worktrunk `wt list` 的 Skeleton-First 渲染架构:从 50ms 首帧到渐进式填充
2026/9/16 11:53:26 网站建设 项目流程

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:

#命令作用
1git 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>、当前分支,并填充进程级全局缓存
2git config --list -z(cwd = 发现路径)一次性读入合并后的完整配置(system + global + local),NUL 分隔保证含\n=的值无歧义解析,存入GIT_CONFIG_PRELOAD;之后所有config_last("…")读取都走内存
3git config --list -z(cwd =git_common_dir条件执行。仅在extensions.worktreeConfig=true的仓库中,linked worktree 的--list会漏掉主 worktree 的config.worktree覆盖(尤其是core.bare = true),需要在 prewarm 线程汇合后从 common dir 重新 fork 一次
4git worktree list --porcelain每个 worktree 的路径、HEAD SHA、分支、标志位——骨架行数据的直接来源
5git for-each-ref --format=… refs/heads/本地分支尖端(名称、SHA、提交者时间、upstream),用于--branches的分支专属行和 stale 默认分支检查;--remotes会增加一个refs/remotes/的兄弟 fork
6git log --no-walk --no-show-signature --format=… SHA₁ … SHA_N所有 worktree HEAD + 分支尖端的批量提交元数据

看似像 fork 但实际不算的操作:git config worktrunk.default-branchgit config --bool core.baregit config remote.*.urlRepository::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)

其中两处设计细节值得注意:

  1. fsmonitor 检查为什么串行:它决定是否需要启动 daemon,是后续 spawn 的门控;检查本身只有约 5ms。而 daemon 启动放入并行 scope,因为git fsmonitor--daemon start在发信号后很快返回,等 worker 线程真正执行git status时 daemon 已有时间初始化。
  2. 为什么需要显式的 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 开始 → 骨架渲染~60ms23
骨架 → spawn worker 线程~41ms7
spawn → 并行执行开始<100µs0
并行开始 → 首个结果<100µs0
首个结果 → 全部 drain(并行工作)~436ms154
drain 完 → collect 完成(最终渲染)~344µs0

文档坦率地指出:骨架前的 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}.jsonTTL 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 --porcelaingit 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 的原地更新渲染器,模块注释交代了三个关键设计约束:

  1. 不走 anstream 的原因:anstream 的 strip 适配器会丢掉所有转义序列,包括原地重绘赖以工作的光标控制 CSI(MoveUpClear)。因此该模块自己拆分两类输出:内容(行、表头、页脚,颜色与 OSC-8 超链接已由共享渲染代码烘焙)在颜色关闭时剥离;本模块发出的光标控制序列始终写出。
  2. 颜色判定不重复推导:“颜色关闭”的判定来自anstream::AutoStream::choice——其他 stdout 表面使用的同一套决议(进程级ColorChoice优先,再 tty + 环境)——在ProgressiveTable::new中读取一次;剥离用anstream::adapter::strip_str,与 anstream 流在Never模式下应用的变换完全一致,使 progressive 输出与 buffered 路径“按构造”而非“按近似”保持一致。
  3. 行数不变式:增量重绘按物理行寻址,因此表头、每个数据行、加载页脚都必须保持单行;只有最终 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/removefirst_output/switchfirst_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.rsRenderTarget检测与 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.rshandle_list入口与 TTFO 性能文档

命令入口的调用链为:handle_list 先由RenderTarget::detect(format, progressive_flag)判定渲染目标,解析[list] json-schema(默认 schema 2),再进入collect::collect();表格模式在collect()内部完成渲染,JSON 模式则在返回后由json_v2::to_json_envelopejson_output::to_json_items输出。

小结

worktrunkwt list的架构可以概括为四条可迁移的工程决策:

  1. 固定 fork 数的关键路径:骨架前只允许 O(1) 个 git fork,每个 fork 用--porcelain/for-each-ref/批量git log --no-walk榨干数据,把 O(N) 的逐 worktree 探测全部推到骨架后;
  2. 数据即行序:批量提交详情既提供 Age/Message/Commit 三列数据,又因%ct成为排序依据而被迫留在骨架前——“同一趟 fork 搭车多列”是成本为零的优化;
  3. 单一渲染路径 + 模式差异最小化:progressive 与 buffered 共用采集代码,差异仅在“是否展示中间更新”;
  4. 终端体验的边界处理: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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询