1. 目标
为任务创建独立的 git worktree(隔离目录 + 专属分支),让智能体在隔离目录中工作, 不污染主工作区;任务与目录的绑定关系持久化,可跨会话查询、释放、清理。
完成后的效果:
> /worktree create fix-login
Worktree created for task fix-login
path : /repo/.cat-code/worktrees/fix-login
branch : task/fix-login (base: HEAD)
> /worktree list
Active worktrees:
fix-login -> /repo/.cat-code/worktrees/fix-login (branch task/fix-login)
> /worktree release fix-login
Worktree released: fix-login
> /worktree list
No active worktrees.
Recently released:
fix-login (branch task/fix-login)在此之前,所有 agent(主 agent、s15 的 teammate、s17 的自治认领者)都挤在同一个工作 目录里读写文件;在此之后,每个任务可以拥有自己的工作副本——目录、分支、绑定记录 三件套齐备,且全部可审计、可自愈。
2. 为什么需要
2.1 现实问题:多智能体同目录踩踏
阶段四的前三站搭好了多智能体的骨架:
- s15 让主 agent 能派生 teammate 并互发消息;
- s16 加了关机握手与计划审批,teammate 不再是脱缰野马;
- s17 的空闲循环会把 s12 的就绪任务自动派发给空闲 teammate。
但有一个隐患随并行度放大:所有 teammate 共享同一个工作目录。两个 teammate
同时认领「改登录页样式」和「修登录接口超时」,都要碰 LoginView.kt——后写覆盖先写,
或者更糟:两个 agent 交替编辑同一文件,各自看到的上下文都不是对方改完的样子。
文件系统级别的冲突,消息总线解决不了。
Claude Code 的解法是 worktree 隔离:每个任务一个 git worktree,共享 .git 对象库
(创建成本远低于 clone),目录物理隔离,分支逻辑隔离。任务完成后再决定合并还是丢弃。
s18 复刻的正是这套机制的地基部分。
2.2 设计原则(取舍依据)
-
真实 git worktree,不做目录复制的伪隔离。 复刻对象是 Claude Code 的真实机制。
git worktree add共享对象库,毫秒级创建; 复制目录既不共享历史也无法 merge,失去了用 git 的全部意义。代价是要处理 git 进程 调用的一整类失败面(超时、非 0 退出、非 git 目录)——这正是WorktreeManager存在的原因。 -
独立
WorktreeRecord,不改TaskRecord。 绑定关系以 taskId 字符串单向引用任务,task包不感知 worktree 的存在。这与 CLAUDE.md「包依赖单向」的关键设计决策一致:worktree依赖不了task(事实上 根本没 import),s20 综合集成时由上层把两者缝起来。 -
Store 镜像 s12
TaskStore模式。 JSON 单文件、内存缓存 + 写穿透、临时文件原子 move、损坏 fail-open、@Synchronized线程安全——s12 已经验证过的模式直接复用,不重新发明。好处不只是省代码:审查者 只需要 diff 两个 store 的语义差异(key 从 id 变 taskId),认知成本极低。 -
幂等与自愈优先于严格报错。 agent 环境里「状态不一致」是常态:用户手动
rm -rf了 worktree 目录、git 侧被git worktree prune清过、分支被删了……如果 manager 遇到不一致就抛异常,孤儿 状态会永远卡死同名任务的重建。所以本 Index 的原则是:能自愈就自愈,最后落盘 的状态永远朝「可用」方向收敛,中间失败 warn 记录。 -
本 Index 不改
AutoClaimer。 严格迭代,不做预留。s17 的认领器现在不知道 worktree 的存在,s20 综合集成时才在 派发/完成两个时点接入create/release。spec 里写明衔接点即可。
3. 核心设计与实现
3.1 架构全景
┌────────────────────────────────────────────────────────────┐
│ REPL 层 │
│ WorktreeCommand ── /worktree list|create|release|prune │
└──────────────────────┬─────────────────────────────────────┘
│ ReplContext.worktreeManager
┌──────────────────────▼─────────────────────────────────────┐
│ worktree 包 │
│ WorktreeManager ──── ProcessBuilder ──→ git worktree add │
│ │ git worktree rm │
│ │ 持久化 git branch -D │
│ ▼ │
│ WorktreeStore ──── JSON ──→ ~/.cat-code/worktrees.json │
│ │
│ WorktreeRecord(数据) WorktreeConfig(路径配置) │
└────────────────────────────────────────────────────────────┘依赖方向:repl → worktree → JDK/序列化库。worktree 不依赖 task/team/
autonomous——这是与 s15-s17 最大的结构差异,后三者互相编织,而 s18 刻意保持独立,
等 s20 收口。
模块职责一句话:
| 类 | 职责 | 不做什么 |
|---|---|---|
WorktreeRecord | 绑定关系的数据模型 | 不知道 git |
WorktreeConfig | 存储文件/worktree 根目录的路径配置 | 不读写磁盘 |
WorktreeStore | 绑定记录的 JSON 持久化 | 不知道 git |
WorktreeManager | 唯一触碰 git 的类,绑定生命周期 | 不解析用户输入 |
WorktreeCommand | REPL 子命令解析与打印 | 不直接调 git |
3.2 WorktreeRecord —— 绑定关系的数据模型
src/main/kotlin/com/sepcai/code/worktree/WorktreeRecord.kt:11-31:
@Serializable
data class WorktreeRecord(
val taskId: String,
val path: String,
val branch: String,
val baseRef: String,
val status: WorktreeStatus = WorktreeStatus.ACTIVE,
val createdAt: Long = System.currentTimeMillis(),
val releasedAt: Long? = null
)几个容易忽略的设计点:
path存绝对路径字符串而非Path:@Serializable直接可用,且 JSON 里可读。 反序列化后Path.of(record.path)还原。baseRef冗余记录创建基点:git 侧能从分支 reflog 推出来,但 reflog 会过期、 记录里留一份让审计(和 s20 的合并策略)不依赖 git 内部状态。releasedAt可空而非默认值 0:ACTIVE时语义上「不存在释放时间」,null 比 哨兵值诚实。/worktree list按releasedAt ?: 0L排序时 null 自然沉底。- RELEASED 记录保留:不删除,留作审计轨迹(谁创建过、什么时候释放的)。
save的同 taskId 替换语义保证同名任务重建时旧 RELEASED 被新 ACTIVE 覆盖。
状态枚举极简(WorktreeRecord.kt:5-6):
enum class WorktreeStatus { ACTIVE, RELEASED }没有 MERGED/DIRTY 等中间态——合并与否是 git 分支层面的事(分支在不在、有没有领先 主干的 commit),不建模进绑定状态。这是 YAGNI 的胜利:两个状态足以支撑全部生命周期。
3.3 WorktreeConfig —— 路径配置
src/main/kotlin/com/sepcai/code/worktree/WorktreeConfig.kt:10-36,完全镜像 s12
TaskConfig 的 ~ 展开模式:
data class WorktreeConfig(
val storeFile: String = DEFAULT_STORE_FILE,
val worktreeDirName: String = DEFAULT_WORKTREE_DIR
) {
fun resolvedStoreFile(): Path { /* ~ 展开 */ }
companion object {
const val DEFAULT_STORE_FILE = "~/.cat-code/worktrees.json"
const val DEFAULT_WORKTREE_DIR = ".cat-code/worktrees"
}
}一个值得说的取舍:worktree 目录放在 repo 内(.cat-code/worktrees/)而非 repo 外。
git 社区惯例是把 worktree 放仓库外(../repo-worktrees/),避免污染 git status。
但放 repo 内有三个理由:
git worktree list的路径一眼可辨归属;- 相对 repoRoot 解析,配置里不用存绝对前缀;
- 污染问题用
.git/info/exclude解决(见 3.5 的ensureExcluded)——本地排除文件, 不动.gitignore,不污染团队共享的忽略规则。这也是 Claude Code 处理.claude/worktrees/的同款手法。
3.4 WorktreeStore —— 镜像 TaskStore 的持久化
src/main/kotlin/com/sepcai/code/worktree/WorktreeStore.kt:18-101。与 s12 TaskStore
的语义对照表:
| 维度 | TaskStore (s12) | WorktreeStore (s18) |
|---|---|---|
| 主键 | id | taskId |
| 磁盘格式 | {"tasks": [...]} | {"worktrees": [...]} |
| 排序 | createdAt → id | createdAt → taskId |
| 查找 | findById | findByTaskId(含 RELEASED) |
| 写策略 | 缓存 + 写穿透 + 原子 move | 同左 |
| 损坏文件 | fail-open + warn | 同左 |
| 线程安全 | @Synchronized | 同左 |
核心写路径(WorktreeStore.kt:88-100),原子 move 的注释直接沿用了 s12 的解释,
因为「为什么」完全一样:
// 先写临时文件再原子 move,避免中途崩溃留下损坏文件(损坏文件会导致下次 fail-open 到空)
private fun writeToDisk() {
Files.createDirectories(file.parent)
val tmp = file.resolveSibling("${file.fileName}$TMP_SUFFIX")
try {
Files.writeString(tmp, json.encodeToString(WorktreeStoreData(cache)))
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
} catch (e: Exception) {
logger.error(e) { "Failed to write worktree store: $file" }
Files.deleteIfExists(tmp)
}
}update 保留 createdAt(WorktreeStore.kt:47-56)是另一个沿用点:release 时
record.copy(status = RELEASED, releasedAt = now) 走 update,创建时间不被刷新,
审计轨迹保持「创建→释放」的真实时间跨度。
3.5 WorktreeManager —— 唯一触碰 git 的类
src/main/kotlin/com/sepcai/code/worktree/WorktreeManager.kt:37-203,本 Index 的心脏。
五个公开操作 + 两个私有支撑,逐个拆。
create:幂等创建(WorktreeManager.kt:73-104)
fun create(taskId: String, baseRef: String = "HEAD"): WorktreeRecord {
store.findByTaskId(taskId)?.let { existing ->
if (existing.status == WorktreeStatus.ACTIVE && Files.isDirectory(Path.of(existing.path))) {
logger.debug { "Worktree already active for task $taskId: ${existing.path}" }
return existing
}
}
val sanitized = sanitize(taskId)
val branch = "task/$sanitized"
val path = repoRoot.resolve(config.worktreeDirName).resolve(sanitized)
ensureExcluded()
// 分支已存在(上次 release 保留了分支)→ 不带 -b 直接检出
val branchExists = runCatching { runGit("rev-parse", "--verify", branch) }.isSuccess
if (branchExists) {
runGit("worktree", "add", path.toString(), branch)
} else {
runGit("worktree", "add", path.toString(), "-b", branch, baseRef)
}
val record = WorktreeRecord(taskId = taskId, path = path.toString(), branch = branch, baseRef = baseRef)
store.save(record)
return record
}三层防御,每层对应一类真实故障:
- 幂等短路:已有 ACTIVE 且目录还在 → 返回原记录。自治循环里 tick 重入、用户
重复敲命令都是常态,重复执行
git worktree add到同一路径会直接报错。 - 分支已存在降级:
release(deleteBranch = false)保留的分支,重建时git worktree add -b会因分支已存在而失败——退化为不带-b的检出,复用旧分支 (上面的提交不丢)。用rev-parse --verify探测而不是解析branch --list输出,避免文本解析。 - 失败不落盘:
runGit抛异常时store.save根本不会执行——git 侧与 store 侧 永不出现「store 说 ACTIVE 但目录不存在」的创建期不一致(运行期不一致由 prune 兜底)。
sanitize:taskId 消毒(WorktreeManager.kt:59-62)
fun sanitize(taskId: String): String {
val cleaned = taskId.replace(ILLEGAL_CHARS, "-") // [^A-Za-z0-9._-] → -
return cleaned.ifBlank { FALLBACK_NAME } // "task"
}taskId 是自由文本(s12 的任务标题 id 可能是 feat: 登录/改造),直接拼进路径就是
路径逃逸(../../etc),拼进分支名就是 git 拒绝。白名单字符集 [A-Za-z0-9._-]
同时满足目录名与分支名的合法子集。消毒在 manager 而非 record 层做,WorktreeRecord.taskId
保留原始 id(展示/审计用),只有 path/branch 用消毒结果。
release:自愈式释放(WorktreeManager.kt:106-147)
fun release(taskId: String, deleteBranch: Boolean = true): Boolean {
val record = store.findByTaskId(taskId)
if (record == null || record.status != WorktreeStatus.ACTIVE) return false
val path = Path.of(record.path)
try {
if (Files.isDirectory(path)) {
try {
runGit("worktree", "remove", record.path)
} catch (e: WorktreeException) {
// 目录内有改动时普通 remove 拒绝 → --force 兜底
logger.warn { "Plain worktree remove failed, retrying with --force: ${e.stderr}" }
runGit("worktree", "remove", "--force", record.path)
}
} else {
// 目录已被外部删除 → prune 对齐 git 元数据
runGit("worktree", "prune")
}
} catch (e: WorktreeException) {
logger.warn(e) { "Failed to remove worktree ${record.path}, marking RELEASED anyway" }
}
if (deleteBranch) {
try {
runGit("branch", "-D", record.branch)
} catch (e: WorktreeException) {
logger.warn { "Failed to delete branch ${record.branch}: ${e.stderr}" }
}
}
store.update(record.copy(status = WorktreeStatus.RELEASED, releasedAt = System.currentTimeMillis()))
return true
}这段是本 Index「自愈优先」原则最集中的体现,三个分支各治一种脏状态:
| 脏状态 | 治理 |
|---|---|
worktree 目录里有未提交改动(git worktree remove 拒绝) | --force 兜底重试 |
目录被用户 rm -rf 了 | git worktree prune 清 git 侧元数据 |
| 分支已不存在 / 删分支失败 | warn 后继续,不阻塞 |
为什么最后无条件置 RELEASED:绑定状态机的终态必须可达。如果因为删目录失败就
不置 RELEASED,这条 ACTIVE 记录会永远挡在同名任务的 create 面前(幂等短路会返回
一个指向已损坏目录的记录),只能人工编辑 JSON 解锁。agent 基础设施的正确取舍是
「记录朝可用方向收敛,现场留给日志」。
--force 兜底有个刻意的不安全:它会丢弃 worktree 里的未提交改动。但 release 的语义
本来就是「这个任务的工作副本不要了」,且分支默认删除前还有一次 deleteBranch=false
的显式保留选项——想留现场的用户/上层(s20)有出口。
prune:孤儿清理(WorktreeManager.kt:149-159)
fun prune(): List<String> {
val orphans = store.loadAll().filter {
it.status == WorktreeStatus.ACTIVE && !Files.isDirectory(Path.of(it.path))
}
orphans.forEach { release(it.taskId, deleteBranch = true) }
...
return orphans.map { it.taskId }
}不重新实现清理逻辑,直接复用 release——孤儿就是「目录没了的 ACTIVE」,而 release
恰好有「目录没了走 prune 元数据」的分支。组合优于复制。
runGit:进程调用封装(WorktreeManager.kt:172-187)
private fun runGit(vararg args: String): String {
val command = listOf("git", "-C", repoRoot.toString()) + args
val process = ProcessBuilder(command).start()
// waitFor 前读尽两个流,防止管道缓冲写满死锁
val stdout = process.inputStream.bufferedReader().readText()
val stderr = process.errorStream.bufferedReader().readText()
if (!process.waitFor(GIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
process.destroyForcibly()
throw WorktreeException(command.joinToString(" "), -1, "timed out after ${GIT_TIMEOUT_SECONDS}s")
}
if (process.exitValue() != 0) {
throw WorktreeException(command.joinToString(" "), process.exitValue(), stderr.trim())
}
return stdout.trim()
}两个进程编程的经典坑都在这里处理:
- 先读流再 waitFor:子进程 stdout/stderr 管道缓冲(Linux 64KB)写满后会阻塞在
write 上,如果父进程先
waitFor()就死锁——父等子退出,子等父读管道。readText()阻塞读尽再 waitFor 是最简单的正确顺序(git worktree 输出都很小,不需要流式消费)。 - 超时兜底 30s:git 命令正常都在毫秒级,30s 意味着挂死(如网络文件系统上的
repo),
destroyForcibly()+ 异常比无限等待好。
WorktreeException(WorktreeManager.kt:15-19)带齐 command/exitCode/stderr 三件套,
命令层打印 stderr 给用户,日志里留完整命令行。
ensureExcluded:本地排除(WorktreeManager.kt:189-202)
private fun ensureExcluded() {
val gitDir = repoRoot.resolve(".git")
if (!Files.isDirectory(gitDir)) return // .git 是文件 → repoRoot 本身是 worktree,跳过
val exclude = gitDir.resolve("info/exclude")
...
if (!content.lines().any { it.trim() == EXCLUDE_ENTRY }) {
Files.writeString(exclude, content + EXCLUDE_ENTRY + "\n")
}
}把 .cat-code/worktrees/ 追加进 .git/info/exclude,worktree 目录就不会出现在
git status 的 untracked 列表里。选 info/exclude 而非 .gitignore 的理由:
这是本机工具的运行产物,不该进团队共享的忽略规则。幂等检查按行匹配,重复 create
不会刷屏追加。.git 为文件的情况(repoRoot 本身是别人的 worktree)直接跳过——
那时 exclude 文件在主仓库的 gitDir 里,不该在这里追。
3.6 WorktreeCommand —— REPL 入口
src/main/kotlin/com/sepcai/code/repl/commands/WorktreeCommand.kt:17-105,完全遵循
s17 AutoCommand 的命令模式:ReplCommand 接口 + 子命令 when 分派 + USAGE 常量。
一个展示层的小设计(WorktreeCommand.kt:41-60):list 把 ACTIVE 全列,RELEASED
只取最近 5 条(RECENT_RELEASED_LIMIT)——RELEASED 是审计轨迹,全列会随着使用
膨胀刷屏;最近几条足够回答「刚才那个任务释放了没」。全部释放记录仍在
~/.cat-code/worktrees.json 里可查。
接线三处(src/main/kotlin/com/sepcai/code/repl/):
ReplCommand.kt:106——ReplContext增加worktreeManager: WorktreeManager(s18 注释);ReplLoop.kt启动段 ——repoRoot = Path.of("").toAbsolutePath()(cwd 即仓库), store 落在~/.cat-code/worktrees.json;ReplLoop.kt命令注册 ——register(WorktreeCommand(), "/worktree")。
cwd 不是 git 仓库时(比如用户在临时目录里起了 REPL),/worktree create 会走
WorktreeException → 命令层打印 Failed to create worktree for x: ...,其他子命令
(list/prune/release)不受影响——绑定记录的读写不依赖 git。
3.7 端到端:一次完整生命周期
把「创建 → 工作 → 释放」走一遍,看 git 侧与 store 侧各自留下什么。以下命令输出取自
真实临时仓库(与测试同款的 git init + 空初始 commit 环境):
① 创建前:仓库只有一个分支,store 文件不存在。
$ git -C /tmp/repo branch --list
* master② manager.create("fix-login"):
git 侧——worktree 目录与分支同时出现:
$ git -C /tmp/repo worktree list
/tmp/repo master
/tmp/repo/.cat-code/worktrees/fix-login task/fix-login
$ git -C /tmp/repo branch --list
* master
task/fix-login
$ cat /tmp/repo/.git/info/exclude
.cat-code/worktrees/store 侧——~/.cat-code/worktrees.json(测试里在临时目录)写入 ACTIVE 记录:
{"worktrees":[{"taskId":"fix-login",
"path":"/tmp/repo/.cat-code/worktrees/fix-login",
"branch":"task/fix-login","baseRef":"HEAD",
"status":"ACTIVE","createdAt":1786464000000,"releasedAt":null}]}③ 智能体在 worktree 里工作:teammate 在 .cat-code/worktrees/fix-login/ 里改文件、
提交到 task/fix-login 分支。主仓库的 git status 干干净净(info/exclude 生效),
master 分支一动不动——隔离的意义就在这一步:无论 teammate 怎么折腾,主工作区零感知。
④ manager.release("fix-login")(任务完成/放弃):
$ git -C /tmp/repo worktree list
/tmp/repo master
$ git -C /tmp/repo branch --list
* master目录与分支都消失,store 里的记录变为终态:
{"taskId":"fix-login","status":"RELEASED",
"createdAt":1786464000000,"releasedAt":1786467600000, ...}createdAt 被 update 保留(WorktreeStore.kt:50),releasedAt 补上——一条完整的
「何时创建、何时释放」审计轨迹。若 release 时选了 deleteBranch=false,第④步后
task/fix-login 分支仍在,上面的 commit 留待合并;同名任务再次 create 时会直接检出
这条旧分支继续工作(3.5 的分支复用路径)。
⑤ 异常路径对照:如果③之后用户手动 rm -rf 了 worktree 目录再 release——
WorktreeManager.kt:120-122 检测到目录不存在,改走 git worktree prune 清掉
.git/worktrees/<id> 元数据,分支照删,记录照常 RELEASED。从 store 视角看,结局与
正常路径完全一致——这就是「自愈优先」要的效果:殊途同归,终态可达。
3.8 与前后 Index 的衔接
s12 TaskStore ──┐ (taskId 字符串弱引用,编译期无依赖)
├──→ s18 WorktreeManager
s17 AutoClaimer ┘ │
│ s20 衔接点(本 Index 不实现):
│ ① tick ②派发前 manager.create(task.id)
│ 派发消息里带 worktree 路径
│ ② tick ①完成检测时 manager.release(task.id)
▼
s20 Comprehensive Agent 统一收口关键衔接设计:AutoClaimer 派发任务时现在是把 formatTask(task) 文本经 MessageBus
发给 teammate(s17 AutoClaimer.kt:105-106)。s20 只需要在文本里加一行
Worktree: <path>,teammate 的文件工具就能以该目录为根工作——消息协议不用变。
4. 错误处理总表
| 场景 | 行为 | 代码位置 |
|---|---|---|
| cwd 不是 git 仓库 | create 抛 WorktreeException,不落盘;命令层打印友好错误 | WorktreeManager.kt:172-187 + WorktreeCommand.kt:76-79 |
| git 命令非 0 退出 | WorktreeException(command, exitCode, stderr) | WorktreeManager.kt:182-184 |
| git 进程 30s 超时 | destroyForcibly() + 异常(exitCode=-1) | WorktreeManager.kt:178-181 |
| taskId 含路径分隔符/空格 | 消毒为合法目录/分支名片段 | WorktreeManager.kt:59-62 |
| 重复 create 同 taskId | 幂等返回原 ACTIVE 记录 | WorktreeManager.kt:74-79 |
| 分支已存在(保留分支的 release 后重建) | 不带 -b 直接检出旧分支 | WorktreeManager.kt:88-93 |
| release 时目录有未提交改动 | 普通 remove 失败 → --force 兜底 | WorktreeManager.kt:113-118 |
| release 时目录已被外部删除 | git worktree prune 对齐元数据 | WorktreeManager.kt:120-122 |
| release 时删分支失败 | warn 后继续,记录仍置 RELEASED | WorktreeManager.kt:127-132 |
| store 文件损坏 | fail-open 为空 + warn | WorktreeStore.kt:76-86 |
| repoRoot 本身是 worktree(.git 为文件) | 跳过 info/exclude 写入 | WorktreeManager.kt:191 |
5. 测试策略
5.1 测试类 × 覆盖点
| 测试类 | 用例数 | 覆盖点 |
|---|---|---|
WorktreeConfigTest | 3 | ~ 展开 / 绝对路径原样 / 默认常量值 |
WorktreeStoreTest | 8 | 落盘 / 排序确定性 / update 保留 createdAt / 同 id 替换 / delete 有无 / 重载存活 / 损坏 fail-open |
WorktreeManagerTest | 11 | create 三件套(目录+分支+记录)/ 幂等 / 非 git 目录抛异常不落盘 / release 删目录删分支 / 保留分支+重建复用 / 无绑定 release=false / 外部删目录自愈 / prune 孤儿 / sanitize / create 消毒 / info/exclude 写入 |
WorktreeCommandTest | 5 | 空态 / create→list 联动 / release 后列表 / 未知子命令 usage / 非 git 目录友好错误 |
合计 27 个用例,./gradlew test 全量绿(含为 ReplContext 新字段修补的 7 个既有
命令测试)。
5.2 真实 git 临时仓库,不引入 mock
WorktreeManagerTest 的每个用例都在 @TempDir 风格的临时目录里建真仓库
(WorktreeManagerTest.kt:23-36):
fun initRepo(): Path {
val dir = Files.createTempDirectory("worktree-manager-test")
fun git(vararg args: String) { ... }
git("init")
git("-c", "user.email=test@example.com", "-c", "user.name=Test",
"commit", "--allow-empty", "-m", "init")
return dir
}取舍记录:
- 为什么不用 mock/fake:worktree 的行为是 git 的行为——「分支已存在时 add 该 怎么调」「remove 拒绝时 force 管不管用」,mock 出来的 git 只是在复述我的假设, 测不出假设错了。真实 git 让每个用例都是端到端的。
- 确定性怎么保证:
--allow-empty初始 commit 不需要任何文件内容;user.email/name用-c内联传入,不依赖全局 git config;不断言默认分支名 (master/main 因 git 版本而异),一律用 HEAD。 - 代价:每个用例拉起若干 git 进程,manager 测试 11 例总耗时约 2s——可接受。
5.3 测试设计上的两个坑
- sanitize 的空串边界:初版测试断言
sanitize(" ") == "task",实际消毒结果是"--"(两个空格各自变-),ifBlank只对空串/全空白生效——而"--"不是空白。 修测试而非修实现:"--"作为目录/分支名片段完全合法,只有空串需要兜底。 这是「实现语义优先于测试直觉」的例子。 - ReplContext 加字段的涟漪:
ReplContext是 7 个命令测试各自完整构造的聚合 data class,加一个必填字段就要改 7 处。s17 已为此建了TestTeamSupport.kt共享 holder(makeTeamFields()),本次把worktreeManager加进同一个 holder, 每个测试构造器只加一行。聚合上下文的字段膨胀是项目里反复出现的模式,这个 holder 还会服务 s19/s20。
6. 开发过程记录
- Brainstorm 阶段用户授权自主决策:本次用户明确「自行选择合适的方案,不需要 询问」。关键自主决策:真实 git CLI(非目录复制伪隔离)、独立 WorktreeRecord (不改 TaskRecord)、本 Index 不动 AutoClaimer(衔接留 s20)。全部写入 spec 固化。
- 计划自审查出三处问题:① prune 的分支删除语义在 spec 草稿里有未决疑问 (已固化为 deleteBranch=true);② ReplContext 加必填字段会让 ReplLoop 先编译失败, 计划中把 ReplLoop 构造接线提前到与 TestTeamSupport 修改同一步;③ sanitize 空串 用例的预期值写错(见 5.3 坑 1)。
- 分支复用是审查中补的设计点:初版 create 无脑
worktree add -b,release 保留 分支的场景下重建必失败。补了rev-parse --verify探测 + 降级检出,测试 「release with deleteBranch=false keeps branch; recreate reuses it」锁定该行为。 - TDD 节奏:四个实现 commit(record/config → store → manager → command+接线) 全部先红后绿;spec/plan 两个 docs commit 在前,博客+状态收尾 commit 在后, 与 CLAUDE.md 工作流一致。
7. 下一站
- s19 MCP Plugin:多传输工具池。与本 Index 无直接依赖,但 s19 完成后工具面 扩大,worktree 内的文件操作工具链更完整。
- s20 Comprehensive Agent:本 Index 的直接消费者。AutoClaimer 派发前
create、完成检测时release,派发消息携带 worktree 路径;WorktreeRecord.branch支撑「任务完成后 merge 回主干」的策略决策(ff-only?人工审?s20 再定)。 到那时,「一个任务 → 一个 teammate → 一个 worktree → 一个分支」的完整隔离链 才算闭环。


