Skip to content

第 9 章 · Worktree 隔离与并行交付

本章目标:理解 FirstMate 为什么坚持「每个任务一个干净 git worktree」的隔离模型,掌握 treehouse worktree 池、base-freshness 新鲜度边界与 fail-closed teardown 的完整生命周期。

9.1 为什么需要 Worktree 隔离

想象一下没有隔离的场景:你让第一副手同时派三个船员去改同一个仓库——修登录 bug、加暗色模式、重构工具函数。如果他们都在你的项目目录里直接工作,会发生什么?

text
无隔离的并行:
projects/myapp/          ← 所有船员共用同一份工作区
├── src/login.ts         船员 A 正在修改(半成品)
├── src/theme.css        船员 B 也在这里改(互相踩踏)
├── src/utils.ts         船员 C 重构到一半,A 的测试跑不起来
└── ???                  谁改了什么?git diff 一团糟

未提交的改动混在一起,无法拆分、无法回滚、甚至无法编译。并行的收益瞬间变成并行的事故。

Git 其实早就给出了答案:git worktree。它允许同一个仓库在磁盘上同时检出多个工作区,每个工作区有独立的文件和独立的 HEAD,但共享同一份 .git 对象库——既隔离又省空间:

bash
# 原生 git worktree 演示:同一仓库两个独立工作区
cd myapp                          # 主检出留在 main 分支
git worktree add ../myapp-fix -b fix/login-bug   # 新工作区 + 新分支
git worktree add ../myapp-dark -b feat/dark-mode # 再来一个
git worktree list
# /Users/captain/code/myapp        abc1234 [main]
# /Users/captain/code/myapp-fix    def5678 [fix/login-bug]
# /Users/captain/code/myapp-dark   9abc123 [feat/dark-mode]

cd ../myapp-fix    # 这里随便折腾,完全不影响其他工作区

FirstMate 把这个机制制度化:crewmate 从不故意触碰你的项目主检出(primary checkout),每个任务都在自己的干净 worktree 里完成。这就是架构文档里「Worktrees, not branches in your checkout」的含义。

9.2 Treehouse:Worktree 池

为每个任务手动 git worktree add 太繁琐,而且要处理清理、复用、新鲜度等问题。FirstMate 配套使用 treehouse 来集中管理一批干净的 task worktree

text
任务与 worktree 的对应关系:

projects/myapp/            ← 你的项目克隆(firstmate 只读;crewmate 不在此工作)
     │  fm-spawn.sh
     ├── treehouse worktree #1 ──→ fm-task1(ship:修登录 bug)
     ├── treehouse worktree #2 ──→ fm-task2(scout:调查性能回归)
     └── treehouse worktree #3 ──→ fm-task3(ship:暗色模式)

各后端的 worktree 提供方:
tmux / herdr / zellij / cmux  →  treehouse 统一供给
orca                          →  Orca 自建 worktree(记录 orca_worktree_id=,
                                  经常规 teardown 检查后用 orca worktree rm 移除)

要点有三:

  1. 池化供给:tmux、herdr、zellij、cmux 四种会话后端都由 treehouse 提供 worktree;只有实验性的 Orca 后端自己管理工作树生命周期。
  2. 一任务一树:每个 crewmate 会话绑定一个专属 worktree,互不可见、互不污染。
  3. 用完归还:任务结束经 teardown 流程把 worktree 归还池子,供后续任务复用。

9.3 启动闸门:fm-spawn.sh 的两道检查

隔离不是靠自觉,而是靠脚本强制。bin/fm-spawn.sh 在派出任何 ship 或 scout 任务前有两道硬性检查。

第一道:真实且独立的 worktree 根。除非解析出的任务路径是一个真实的 git worktree 根、并且不同于项目主检出,否则拒绝启动。

第二道:base-freshness 新鲜度边界。每个全新的 ship/scout 任务,其干净 worktree 必须先对齐 origin 解析出的默认分支的最新 fetched tip,任何不安全或无法验证的基础都会阻止启动:

text
base-freshness 流程(由 fm-spawn.sh 负责):

1. fetch origin,解析默认分支(origin/HEAD)
2. 将任务 worktree 的基础对齐到该 fetched tip
3. 对齐成功       → worker 才允许启动
4. 不安全/无法验证 → spawn 停止,绝不带病开工

为什么这么严?因为过时的基线意味着船员基于旧代码开发,PR 一开就是一堆无谓冲突甚至逻辑错误。「先保鲜、再开工」把这类事故扼杀在起点。精确的拒绝机制由脚本头注释拥有,回归覆盖见仓库中的 tests/fm-spawn-pool-base-freshen.test.sh

9.4 Worktree Tangle:唯一的健康判据是分支状态

有一个特殊情况值得单独讲:firstmate 仓库自己也可能被派活(船队可以调度船员维护 firstmate 本身)。这时 FM_ROOT(firstmate 自己的运营检出)和船员的临时 worktree 都是同一个仓库的 linked worktree,「是否 linked」不再能区分健康与否。

架构文档给出的判别标准是分支状态

text
健康的拓扑:
FM_ROOT(主检出)                  → 停留在默认分支        ✅ 健康
linked worktree / secondmate home → 处于 detached HEAD    ✅ 健康

唯一病态(worktree tangle):
FM_ROOT                           → 检出了命名的非默认分支 ❌ tangle

一旦出现 tangle,两处都会报警:

  • bin/fm-session-start.sh 通过 bootstrap 在会话启动时报告一行 TANGLE: 信息;
  • bin/fm-guard.sh 在下一次「可变更舰队动作」时打印修复命令。

若另一个活跃会话正持有舰队锁,两处都会保留告警但切换为只读措辞、不给修复命令。分类逻辑由 fm-tangle-lib.sh 完成:先从 origin/HEAD 解析默认分支,再依次回退到本地 mainmaster。此外,ship brief 还会让船员自己在创建 fm/<id> 前验证当前路径与工作区根,若发现自己落在了主检出里就立即以 blocked 状态停止:

bash
# ship brief 要求船员开工前的自检(防止误入主检出)
pwd -P                         # 当前物理路径
git rev-parse --show-toplevel  # 当前仓库工作区根
# 若结果指向 primary checkout → 以 blocked 状态停止,绝不继续

9.5 Fail-Closed Teardown:未落地的工作不许动

任务收尾(teardown)是隔离生命周期的最后一环,也是 hard rule 3(Never tear down unlanded work)的主战场。规则原文非常明确:

text
Hard rule 3 — Never tear down unlanded work:
- Uncommitted changes are never landed,
  and bin/fm-teardown.sh owns the complete landed-work test.
- Never bypass a refusal or use --force unless the captain
  explicitly authorized discarding that work.
- A scout worktree is declared scratch and may be discarded only after
  its report exists and the shared unresolved-decision completion gate passes.

落到执行层面,teardown 是 fail-closed(失败即关闭) 的:

text
ship worktree 的 teardown 判定链:

脏 worktree(有 uncommitted changes)→ 拒绝 teardown
已提交但尚未 landing 的工作          → 拒绝归还,必须先落地
已落地(landed)的工作               → 归还 worktree 入池

也就是说:脏的不许碰,没落地的不许删。架构文档指出 bin/fm-teardown.sh 的头部注释拥有完整的 landed-work 判定证据、PR 发现回退逻辑和陈旧锁恢复流程。只有船长明确授权丢弃该工作时,才允许绕过拒绝使用 --force——而且这个授权必须针对「这一份工作」,不能泛化成常设许可。

scout worktree 更特殊一点:它的产出是一份调查报告而不是代码变更。只有当报告已经存在(data/<id>/report.md)、且共享的 unresolved-decision 完成闸门通过之后,它才能被声明为 scratch 并丢弃。这保证了调查结论不会随着 worktree 清理而凭空消失:

text
scout worktree 丢弃前置条件:

data/<id>/report.md 存在?         ──否──→ 不能丢弃
        │是
unresolved-decision 完成闸门通过? ──否──→ 不能丢弃
        │是
声明为 scratch,安全丢弃 ✅

本章小结

  • 并行改动同一份工作区必然互踩;FirstMate 制度化了 git worktree:crewmate 从不触碰项目主检出,每任务一个干净 worktree。
  • tmux/herdr/zellij/cmux 由 treehouse 池统一供给 worktree;Orca 后端自建并以 orca worktree rm 管理。
  • fm-spawn.sh 设两道闸门:任务路径必须是独立于主检出的真实 worktree 根;基线必须对齐 origin 默认分支最新 fetched tip(base-freshness),不安全即停止。
  • 健康判据是分支状态而非是否 linked worktree:FM_ROOT 停在默认分支、worktree 处于 detached HEAD 即健康;主检出处检出非默认分支即为 tangle,session-start 报 TANGLE: 行、fm-guard 打印修复命令。
  • teardown 遵循 fail-closed:脏 worktree 拒绝、未落地工作拒绝归还;绕过需要船长针对该工作的明确授权;scout worktree 须报告存在且决策闸门通过后方可按 scratch 丢弃。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. FirstMate 架构中,crewmate 与项目主检出(primary checkout)的关系是?

2. 关于 tmux、herdr、zellij、cmux 四个后端与 worktree 的关系,正确的是?

3. fm-spawn.sh 的 base-freshness 边界要求什么?

4. 一个 scout worktree 满足什么条件才可以被声明为 scratch 并丢弃?

🛠️ 动手实践

  1. 在你自己的任意仓库上练习原生 git worktree:创建两个 worktree 分别修改不同的文件,用 git worktree list 观察结构,然后在其中一个提交并合并回主分支,体会「同一仓库、多份独立工作区」的隔离效果。
  2. 阅读 FirstMate 仓库中 bin/fm-teardown.sh 的头部注释,梳理它判定 landed-work 的证据清单与 PR 发现回退逻辑;对照本章 9.5 的 fail-closed 链路,列出哪些状态会被拒绝归还 worktree。
  3. 在测试环境人为制造一次 worktree tangle:在 firstmate 运营检出(FM_ROOT)上检出一个命名非默认分支,重新启动会话并观察 bootstrap 阶段的 TANGLE: 报告内容,最后切回默认分支消除告警。