worktrunkwt list的 Skeleton-First 渲染架构:从 50ms 首帧到渐进式填充
【免费下载链接】worktrunkWorktrunk is a CLI for Git worktree management, designed for parallel AI agent workflows项目地址: https://gitcode.com/GitHub_Trending/wo/worktrunk
worktrunk 的wt list是展示所有 Git worktree 状态的核心命令,其实现围绕一个核心架构决策展开:skeleton-first(骨架优先)渲染——先以约 50ms 的延迟画出一张带占位符的表格,再随着后台 git 操作的完成逐格填充数据。本文以 src/commands/list/CLAUDE.md 这份架构文档为骨架,结合 collect 模块、progressive_table 渲染器 与基准测试代码,完整拆解这一“先首帧、后填充”的设计如何做到 fork 数 O(1)、列数据渐进到位、且退出时终端不跳动。
核心思想:骨架必须尽可能快地渲染
wt list的架构文档开宗明义:
占位表格会立即出现(约 50ms),然后随着数据到达逐格填充。即使用户的 git 操作很慢,也能给出即时反馈。骨架必须尽可能快地渲染。骨架之前的每一步操作都会增加感知延迟。用户是能分辨出 50ms 与 150ms 的差别。
这条原则直接决定了整个命令的代码组织方式:任何新功能默认都应推迟到骨架渲染之后执行,只有“没有该数据骨架就无法渲染”的例外才允许进入关键路径。src/commands/list/collect/mod.rs 的模块 docstring 对这条规则做了更精确的表述:“当添加新功能时,自问:‘这个计算能放在骨架之后做吗?’如果能,就推迟它。”
渲染三阶段拆解
Phase 1:骨架前(Pre-Skeleton)
骨架前阶段只运行固定数量的 git 命令(O(1),与 worktree 数量 N 无关),依靠“每个命令尽可能多地批处理”实现。从 collect/mod.rs 的 docstring 看,稳态运行到达骨架需要经过五个(启用extensions.worktreeConfig=true的仓库为六个)git子进程 fork:
| # | 命令 | 作用 |
|---|---|---|
| 1 | git rev-parse --git-common-dir --is-inside-work-tree --show-toplevel --git-dir --symbolic-full-name HEAD | 一次 fork 拿到五个事实:共享.git、是否在 worktree 内、worktree 根目录、每个 worktree 的.git/worktrees/<name>、当前分支,并填充进程级全局缓存 |
| 2 | git config --list -z(cwd = 发现路径) | 一次性读入合并后的完整配置(system + global + local),NUL 分隔保证含\n或=的值无歧义解析,存入GIT_CONFIG_PRELOAD;之后所有config_last("…")读取都走内存 |
| 3 | git config --list -z(cwd =git_common_dir) | 条件执行。仅在extensions.worktreeConfig=true的仓库中,linked worktree 的--list会漏掉主 worktree 的config.worktree覆盖(尤其是core.bare = true),需要在 prewarm 线程汇合后从 common dir 重新 fork 一次 |
| 4 | git worktree list --porcelain | 每个 worktree 的路径、HEAD SHA、分支、标志位——骨架行数据的直接来源 |
| 5 | git for-each-ref --format=… refs/heads/ | 本地分支尖端(名称、SHA、提交者时间、upstream),用于--branches的分支专属行和 stale 默认分支检查;--remotes会增加一个refs/remotes/的兄弟 fork |
| 6 | git log --no-walk --no-show-signature --format=… SHA₁ … SHA_N | 所有 worktree HEAD + 分支尖端的批量提交元数据 |
看似像 fork 但实际不算的操作:git config worktrunk.default-branch、git config --bool core.bare、git config remote.*.url、Repository::is_bare等都是config_last("…")查找,命中 #2 建立的内存 map(或条件触发的 #3),热路径上不产生子进程。
唯一一次性的网络路径例外:全新 clone 且worktrunk.default-branch未设置时,Repository::default_branch会依次尝试本地refs/remotes/<remote>/HEAD、回落到git ls-remote --symref <remote> HEAD(100ms–2s 网络开销)、再回落到本地推断(init.defaultBranch、常见分支名),结果持久化到worktrunk.default-branch,后续运行全部命中缓存。
#6:批量提交详情 fork
这是骨架前最精巧的一笔,完整命令形态为:
git log --no-walk --no-show-signature \ --format=%H%x00%h%x00%ct%x00%T%x00%s \ SHA₁ SHA₂ … SHA_N各选项与字段的取舍都有明确理由:
--no-walk:不遍历历史。没有它,git log SHA₁ SHA₂会遍历每个起点的祖先链,打印成千上万条提交;有了它,每个命名的 SHA 恰好输出一条记录,把成本从 O(history) 降到 O(N refs)。--no-show-signature:跳过 GPG 验证。若gpg.program已设置且其中任一提交带签名,默认行为会为每个提交 fork 一次gpg,这里显式禁用。--format=%H%x00%h%x00%ct%x00%T%x00%s:每条提交五个字段,NUL 分隔(提交主题里除 NUL 外什么字符都可能出现):%H完整 SHA、%h缩写 SHA、%ct提交者时间(Unix 时间戳)、%Ttree SHA、%s主题(消息首行)。
SHA 集合来自 #4(worktree HEAD)∪ #5(分支尖端)去重。参数长度随 N 增长,LinuxARG_MAX(约 128 KB)把无分块形态限制在约 3000 个 SHA。每个字段各自的用途:
%ct—骨架的排序依据,这是 #6 必须位于骨架前的根本原因:没有它,骨架无法决定行序。%ct再次在骨架后使用 — Age 列(“3 hours ago”)。%s骨架后 — Message 列。%h骨架前 — 每个表面渲染的缩写 SHA:Commit 单元格、detached 行的 Branch 单元格、--format=json。两列都是身份列(没有“稍后填充”的占位符),且列宽按 git 自己选的宽度计算,所以它们在行构建完成后立即折叠上去——搭了%ct已经付费的同一趟 fork,成本为零。%T骨架后 — 预热commit_tree缓存,使逐行的CommittedTreesMatch/WouldMergeAddtree 查找永远不必 forkgit rev-parse <sha>^{tree}。
tree SHA、主题、缩写 SHA 都是“搭车”数据:git 为读取时间戳本来就要解析每个 commit 对象,多几字节并不带来可测量的往返延迟。没有这个批量的话,之后就得为每个 SHA forkgit log -1(外加rev-parse ^{tree})——同样的数据,N 次 fork 换 1 次。若批量失败(例如某个 SHA 在运行期间被删除),错误只提示一次,该次运行的 Age/Message 单元格渲染占位符。
骨架前路径上的非 git 工作也可忽略但值得点名:路径规范化(检测当前 worktree)、项目配置文件.config/wt.toml读取(Repository::url_template从中读list.url,纯 TOML I/O 无子进程)、配置解析、CI 列宽提示——一次读取.git/wt/cache/pr-number/max.json(CI 任务被跳过时省略),因为骨架没有它就无法给 CI 列定宽。
Phase 2:骨架渲染
骨架阶段呈现的内容有明确清单(见 src/commands/list/CLAUDE.md):
- 分支名(来自 worktree 列表)
- 路径(来自 worktree 列表)
- 缩写提交哈希(git 的
%h,来自骨架本来就等待的 commit-details 批处理——骨架需要它来定行序);对 detached 行,这也充当没有分支名可显示的 Branch 单元格 - 占位 gutter 符号(
·),数据到达后替换为@、^、+等真实符号 - 计算列的加载指示器
progressive_table.rs 的模块注释还说明:骨架的 gutter 显示·占位符,数据加载后填充。
Phase 3:骨架后(Post-Skeleton)
骨架出现后,其余工作才运行:previous branch 查找、integration target 计算、URL 模板展开(并行化)、以及全部后台任务(status、diffs、CI、URL 健康检查),结果完成一格更新一格。从 collect/mod.rs 的 docstring 看,骨架后的设置流程为:
Skeleton render ├─ is_builtin_fsmonitor_enabled() (5ms, sequential - gate) ├─ rayon::scope( │ ├─ switch_previous() (5ms) │ ├─ integration_targets() (10ms) │ ├─ start_fsmonitor_daemon × N worktrees (6ms each, all parallel) │ ) // ~10ms total ├─ populate ListItem.commit from cache (sub-ms) Worker thread spawns └─ paint Age/Message columns (workers already running)其中两处设计细节值得注意:
- fsmonitor 检查为什么串行:它决定是否需要启动 daemon,是后续 spawn 的门控;检查本身只有约 5ms。而 daemon 启动放入并行 scope,因为
git fsmonitor--daemon start在发信号后很快返回,等 worker 线程真正执行git status时 daemon 已有时间初始化。 - 为什么需要显式的 Age/Message paint:这两列不挂任何 task——它们的数据来自上面从缓存填充的
ListItem.commit。若不显式重绘,它们会一直停在骨架占位符上,直到该行的某个task结果恰好重绘它,反而滞后于更慢的 task 驱动列。paint 刻意安排在 worker 池 spawn之后(不拖慢长极的 git 子进程)、drain 渲染任何结果之前(保证 Age/Message 先于所有 task 驱动列上屏)。此处读all_items无竞争:worker 线程只通过 channel 发结果,drain 是all_items唯一的修改者且尚未开始。
docstring 还给出了一组实测相位计时(worktrunk 开发仓库,7 worktrees / 6 branches,暖缓存,release 构建,强制--progressive):
| 相位 | 中位数 | git 命令数 |
|---|---|---|
| collect 开始 → 骨架渲染 | ~60ms | 23 |
| 骨架 → spawn worker 线程 | ~41ms | 7 |
| spawn → 并行执行开始 | <100µs | 0 |
| 并行开始 → 首个结果 | <100µs | 0 |
| 首个结果 → 全部 drain(并行工作) | ~436ms | 154 |
| drain 完 → collect 完成(最终渲染) | ~344µs | 0 |
文档坦率地指出:骨架前的 23 个命令远超上文记载的 5–6 个关键路径 fork,“值得一次审计”,多余部分大多来自渗入该相位的 per-worktree 探测。
添加新功能时的默认规则
这是架构文档中最具约束力的一条规范:
默认:推迟到骨架后。只有当骨架没有该数据就根本无法渲染时,才添加骨架前操作。当前例外全部是小规模的本地读取:
- 列宽计算(CI 列的缓存宽度提示)
- 自定义
[list.custom-columns]展开(值来自内存中的配置快照,布局测量必须先于骨架)- picker 的 CI 缓存预热(让列立即画出;picker 随后运行的 live CiStatus 任务会在首帧背后刷新每格)
模板展开和其他文件 I/O 一律等待;新列可以先渲染占位符,数据到达后再填充。
统一采集架构:progressive 与 buffered 共用一套代码
从 collect/mod.rs 看,两种渲染模式共享同一套采集与渲染代码,唯一差别是采集期间是否展示中间更新:
- Progressive(TTY):渲染骨架表格,数据到达时更新行/页脚;非 TTY 则只渲染最终一次。
- Buffered:静默采集,最后渲染最终表格。
两种模式都在collect()内渲染最终表格,保证单一的规范渲染路径。并行模型是扁平化的:所有任务(所有 worktree 和分支)被收集进单一工作队列,由 Rayon 线程池处理(池大小启动时设定,默认 2× CPU 核数,可用RAYON_NUM_THREADS覆盖),避免嵌套并行、保持利用率。任务排序上,本地 git 操作优先、网络任务(CI 状态、URL 健康检查)排最后,使表格先被本地数据快速填满。
磁盘缓存:.git/wt/cache/
采集过程读写.git/wt/cache/下的多组兄弟缓存,共享 src/cache.rs 提供的一套 torn-write 语义与错误策略。collect docstring 给出权威清单:
| 目录 | Key 方案 | 失效策略 |
|---|---|---|
merge-tree-conflicts/、merge-add-probe/、is-ancestor/、has-added-changes/、diff-stats/、ahead-behind/、merge-base/ | SHA 对(如{sha1}-{sha2}.json,字典序排序) | 永不失效——内容寻址,纯函数于两个提交 SHA |
ci-status/ | {branch}.json | TTL 30–60s + HEAD SHA 检查(分支移动时提前失效) |
summary/{branch}/ | {diff_hash}.json | 当前 hash 无文件即 miss;写入时修剪同分支的陈旧兄弟项,使缓存保持在约每分支 1 项 |
三种 key 方案的语义差异:SHA 对是纯函数,无需 TTL 与失效;分支 + TTL + HEAD 用于外部可变状态(CI API、远端 ref);分支 + 文件名内容哈希让“缓存命中”等价于“该 hash 的文件存在”,剪枝在写入时完成,免去了 LRU 扫描。wt config state cache clear可清空这些缓存;CI 缓存的查看/清除走wt config state cache。
性能与 git 内部缓存的关系
src/commands/list/mod.rs 的模块 docstring 补充了用户侧的性能画像:
- 每个 worktree 执行:
git status --porcelain、git rev-list --count <base>..<head>、工作树行 diff(无 untracked 时git diff --shortstat --find-renames HEAD;有 untracked 时需git ls-files --others+ 临时 index 副本 +git add --intent-to-add+ 两次git diff --numstat -z)、git diff --shortstat <base>...<head>、git rev-parse <ref>,外加一次全局git worktree list --porcelain。 - 性能高度依赖 git 自身缓存:index(
.git/index)、commit graph(.git/objects/info/commit-graph,没有它 1000 提交的计数从 ~5ms 退化到 ~50ms)、ref cache、OS 文件系统缓存、pack 文件。 - 文档给出的优化建议:
git commit-graph write --reachable --changed-paths加速提交计数、定期git gc整合 pack 文件、减少跨 worktree 的未提交变更(每个脏 worktree 都增加 diff 开销)。 - 顶层时序画像:时间到骨架约 50ms(实测 46–52ms 稳定,不随 worktree 数 1–8 或缓存状态变化);完成时间约 60–170ms(取决于 worktree 数);首次运行若无缓存的默认分支会增加约 100–300ms 网络查找。
HEAD±列始终启用重命名检测(tracked 删除配 untracked 目标会使移动在行数上中性),main…±比较已提交的 tree、沿用用户配置的重命名策略——这也是 用户文档 中列定义背后的实现细节。
Progressive 渲染器的终端机制
progressive_table.rs 是基于 crossterm 的原地更新渲染器,模块注释交代了三个关键设计约束:
- 不走 anstream 的原因:anstream 的 strip 适配器会丢掉所有转义序列,包括原地重绘赖以工作的光标控制 CSI(
MoveUp、Clear)。因此该模块自己拆分两类输出:内容(行、表头、页脚,颜色与 OSC-8 超链接已由共享渲染代码烘焙)在颜色关闭时剥离;本模块发出的光标控制序列始终写出。 - 颜色判定不重复推导:“颜色关闭”的判定来自
anstream::AutoStream::choice——其他 stdout 表面使用的同一套决议(进程级ColorChoice优先,再 tty + 环境)——在ProgressiveTable::new中读取一次;剥离用anstream::adapter::strip_str,与 anstream 流在Never模式下应用的变换完全一致,使 progressive 输出与 buffered 路径“按构造”而非“按近似”保持一致。 - 行数不变式:增量重绘按物理行寻址,因此表头、每个数据行、加载页脚都必须保持单行;只有最终 summary 可以跨行,由
ProgressiveTable::finalize在所有增量重绘完成后、通过清空并重建最后数据行以下区域的路径安装。
此外,渲染器在表格下方保留PROMPT_RESERVE_LINES = 2行空白行(write_prompt_reserve在最后一次内容写入后发出):表格在整个 progressive 阶段驻留屏幕,退出时终端为 shell 的多行 prompt 腾位置产生的滚动读起来像是定格表格突然跳动;把滚动移到命令开始处(输出预期会滚动的时刻)就消除了这个视觉跳跃。两行足以吸收 3 行以内的 prompt(fish 默认 1 行、starship 默认 2 行、tide/powerlevel10k 典型 3 行),多出的保留行会被后续输出不可见地消耗掉。
基准测试:如何度量骨架时间
架构文档给出的基准命令:
WORKTRUNK_SKELETON_ONLY=1 hyperfine 'wt list'测量纯骨架延迟,目标:<60ms。
仓库内还有配套的 Criterion 基准 benches/time_to_first_output.rs,用WORKTRUNK_FIRST_OUTPUT环境变量在各命令首个用户可见输出处退出,覆盖first_output/remove、first_output/switch、first_output/list三组。其中list变体有个细节:stdout 被管道捕获,所以“首个输出”是采集/渲染准备完成后的第一个缓冲表格行,而不是 progressive 骨架。remove 变体刻意使用CacheState::Cold,因为首次调用会填充.git/wt/cache/{is-ancestor,has-added-changes,merge-add-probe},不做逐次失效的话测到的是暖缓存而非用户看到的冷启动 TTFO。
按相位分解的复现方式(引自 collect docstring):
cargo bench --bench time_to_first_output -- list # 每相位拆解:抓 trace 后跑 benches/CLAUDE.md 中的 phase-duration SQL RUST_LOG=debug ./target/release/wt -C <repo> list --progressive \ 2> >(cargo run -p wt-perf --release -q -- trace > trace.json)代码结构速览
架构文档的 “Code Structure” 一节对应以下文件布局,可作为深入阅读的入口:
| 路径 | 职责 |
|---|---|
| src/commands/list/collect/ | 采集编排:pre/post-skeleton 相位管理、任务定义与执行;mod.rs 的模块 docstring 是相位细节的权威来源 |
| src/commands/list/render.rs | 行格式化、骨架行、单元格渲染 |
| src/commands/list/layout.rs | 列宽计算 |
| src/commands/list/progressive_table.rs | 终端原地更新渲染(crossterm 光标控制) |
| src/commands/list/progressive.rs | RenderTarget检测与 progressive/buffered 分发 |
| src/commands/list/ci_status/ | CI 状态采集(GitHub/GitLab/Gitea/Azure)与 30–60s TTL 缓存 |
| src/commands/list/json_v2.rs | --format=json的 schema 2 输出 |
| src/commands/list/mod.rs | handle_list入口与 TTFO 性能文档 |
命令入口的调用链为:handle_list 先由RenderTarget::detect(format, progressive_flag)判定渲染目标,解析[list] json-schema(默认 schema 2),再进入collect::collect();表格模式在collect()内部完成渲染,JSON 模式则在返回后由json_v2::to_json_envelope或json_output::to_json_items输出。
小结
worktrunkwt list的架构可以概括为四条可迁移的工程决策:
- 固定 fork 数的关键路径:骨架前只允许 O(1) 个 git fork,每个 fork 用
--porcelain/for-each-ref/批量git log --no-walk榨干数据,把 O(N) 的逐 worktree 探测全部推到骨架后; - 数据即行序:批量提交详情既提供 Age/Message/Commit 三列数据,又因
%ct成为排序依据而被迫留在骨架前——“同一趟 fork 搭车多列”是成本为零的优化; - 单一渲染路径 + 模式差异最小化:progressive 与 buffered 共用采集代码,差异仅在“是否展示中间更新”;
- 终端体验的边界处理:prompt 保留行消除退出时滚动、anstream strip 的一致性剥离、单行不变式——渐进渲染的“丝滑”来自这些容易被忽略的收尾细节。
对维护者而言,这份架构文档最实际的价值是那一条默认规则:新功能默认骨架后,例外必须能回答“骨架没有它为何渲染不了”。基准命令WORKTRUNK_SKELETON_ONLY=1 hyperfine 'wt list'与 60ms 目标则提供了可执行的回归护栏。
【免费下载链接】worktrunkWorktrunk is a CLI for Git worktree management, designed for parallel AI agent workflows项目地址: https://gitcode.com/GitHub_Trending/wo/worktrunk
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考