猫咖
首页博客工具
搜索
语言
切换网站风格
选择主题颜色
点击特效
主题

猫咖 · 持续更新中,源码见 GitHub。

Index 18: Worktree Isolation —— 任务-目录绑定

2026年8月13日
AI智能体Kotlin后端

系列

使用Kotlin从0开发一个ClaudeCode

系列

使用Kotlin从0开发一个ClaudeCode

进度 18 / 21

使用Kotlin从0开发一个ClaudeCode

上一篇

Index 17: Autonomous Agents — 空闲循环 / 自动认领

下一篇

Index 19: MCP Plugin —— 多传输 / 通道路由 / 工具池组装

1. 目标

为任务创建独立的 git worktree(隔离目录 + 专属分支),让智能体在隔离目录中工作, 不污染主工作区;任务与目录的绑定关系持久化,可跨会话查询、释放、清理。

完成后的效果:

TEXT
> /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 设计原则(取舍依据)

  1. 真实 git worktree,不做目录复制的伪隔离。 复刻对象是 Claude Code 的真实机制。git worktree add 共享对象库,毫秒级创建; 复制目录既不共享历史也无法 merge,失去了用 git 的全部意义。代价是要处理 git 进程 调用的一整类失败面(超时、非 0 退出、非 git 目录)——这正是 WorktreeManager 存在的原因。

  2. 独立 WorktreeRecord,不改 TaskRecord。 绑定关系以 taskId 字符串单向引用任务,task 包不感知 worktree 的存在。这与 CLAUDE.md「包依赖单向」的关键设计决策一致:worktree 依赖不了 task(事实上 根本没 import),s20 综合集成时由上层把两者缝起来。

  3. Store 镜像 s12 TaskStore 模式。 JSON 单文件、内存缓存 + 写穿透、临时文件原子 move、损坏 fail-open、@Synchronized 线程安全——s12 已经验证过的模式直接复用,不重新发明。好处不只是省代码:审查者 只需要 diff 两个 store 的语义差异(key 从 id 变 taskId),认知成本极低。

  4. 幂等与自愈优先于严格报错。 agent 环境里「状态不一致」是常态:用户手动 rm -rf 了 worktree 目录、git 侧被 git worktree prune 清过、分支被删了……如果 manager 遇到不一致就抛异常,孤儿 状态会永远卡死同名任务的重建。所以本 Index 的原则是:能自愈就自愈,最后落盘 的状态永远朝「可用」方向收敛,中间失败 warn 记录。

  5. 本 Index 不改 AutoClaimer。 严格迭代,不做预留。s17 的认领器现在不知道 worktree 的存在,s20 综合集成时才在 派发/完成两个时点接入 create/release。spec 里写明衔接点即可。

3. 核心设计与实现

3.1 架构全景

TEXT
┌────────────────────────────────────────────────────────────┐
│ 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 的类,绑定生命周期不解析用户输入
WorktreeCommandREPL 子命令解析与打印不直接调 git

3.2 WorktreeRecord —— 绑定关系的数据模型

src/main/kotlin/com/sepcai/code/worktree/WorktreeRecord.kt:11-31:

KOTLIN
@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):

KOTLIN
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 的 ~ 展开模式:

KOTLIN
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 内有三个理由:

  1. git worktree list 的路径一眼可辨归属;
  2. 相对 repoRoot 解析,配置里不用存绝对前缀;
  3. 污染问题用 .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)
主键idtaskId
磁盘格式{"tasks": [...]}{"worktrees": [...]}
排序createdAt → idcreatedAt → taskId
查找findByIdfindByTaskId(含 RELEASED)
写策略缓存 + 写穿透 + 原子 move同左
损坏文件fail-open + warn同左
线程安全@Synchronized同左

核心写路径(WorktreeStore.kt:88-100),原子 move 的注释直接沿用了 s12 的解释, 因为「为什么」完全一样:

KOTLIN
// 先写临时文件再原子 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)

KOTLIN
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
}

三层防御,每层对应一类真实故障:

  1. 幂等短路:已有 ACTIVE 且目录还在 → 返回原记录。自治循环里 tick 重入、用户 重复敲命令都是常态,重复执行 git worktree add 到同一路径会直接报错。
  2. 分支已存在降级:release(deleteBranch = false) 保留的分支,重建时 git worktree add -b 会因分支已存在而失败——退化为不带 -b 的检出,复用旧分支 (上面的提交不丢)。用 rev-parse --verify 探测而不是解析 branch --list 输出,避免文本解析。
  3. 失败不落盘:runGit 抛异常时 store.save 根本不会执行——git 侧与 store 侧 永不出现「store 说 ACTIVE 但目录不存在」的创建期不一致(运行期不一致由 prune 兜底)。

sanitize:taskId 消毒(WorktreeManager.kt:59-62)

KOTLIN
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)

KOTLIN
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)

KOTLIN
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)

KOTLIN
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)

KOTLIN
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/):

  1. ReplCommand.kt:106 —— ReplContext 增加 worktreeManager: WorktreeManager(s18 注释);
  2. ReplLoop.kt 启动段 —— repoRoot = Path.of("").toAbsolutePath()(cwd 即仓库), store 落在 ~/.cat-code/worktrees.json;
  3. 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 文件不存在。

TEXT
$ git -C /tmp/repo branch --list
* master

② manager.create("fix-login"):

git 侧——worktree 目录与分支同时出现:

TEXT
$ 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 记录:

JSON
{"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")(任务完成/放弃):

TEXT
$ git -C /tmp/repo worktree list
/tmp/repo  master
$ git -C /tmp/repo branch --list
* master

目录与分支都消失,store 里的记录变为终态:

JSON
{"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 的衔接

TEXT
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 后继续,记录仍置 RELEASEDWorktreeManager.kt:127-132
store 文件损坏fail-open 为空 + warnWorktreeStore.kt:76-86
repoRoot 本身是 worktree(.git 为文件)跳过 info/exclude 写入WorktreeManager.kt:191

5. 测试策略

5.1 测试类 × 覆盖点

测试类用例数覆盖点
WorktreeConfigTest3~ 展开 / 绝对路径原样 / 默认常量值
WorktreeStoreTest8落盘 / 排序确定性 / update 保留 createdAt / 同 id 替换 / delete 有无 / 重载存活 / 损坏 fail-open
WorktreeManagerTest11create 三件套(目录+分支+记录)/ 幂等 / 非 git 目录抛异常不落盘 / release 删目录删分支 / 保留分支+重建复用 / 无绑定 release=false / 外部删目录自愈 / prune 孤儿 / sanitize / create 消毒 / info/exclude 写入
WorktreeCommandTest5空态 / create→list 联动 / release 后列表 / 未知子命令 usage / 非 git 目录友好错误

合计 27 个用例,./gradlew test 全量绿(含为 ReplContext 新字段修补的 7 个既有 命令测试)。

5.2 真实 git 临时仓库,不引入 mock

WorktreeManagerTest 的每个用例都在 @TempDir 风格的临时目录里建真仓库 (WorktreeManagerTest.kt:23-36):

KOTLIN
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 测试设计上的两个坑

  1. sanitize 的空串边界:初版测试断言 sanitize(" ") == "task",实际消毒结果是 "--"(两个空格各自变 -),ifBlank 只对空串/全空白生效——而 "--" 不是空白。 修测试而非修实现:"--" 作为目录/分支名片段完全合法,只有空串需要兜底。 这是「实现语义优先于测试直觉」的例子。
  2. ReplContext 加字段的涟漪:ReplContext 是 7 个命令测试各自完整构造的聚合 data class,加一个必填字段就要改 7 处。s17 已为此建了 TestTeamSupport.kt 共享 holder(makeTeamFields()),本次把 worktreeManager 加进同一个 holder, 每个测试构造器只加一行。聚合上下文的字段膨胀是项目里反复出现的模式,这个 holder 还会服务 s19/s20。

6. 开发过程记录

  1. Brainstorm 阶段用户授权自主决策:本次用户明确「自行选择合适的方案,不需要 询问」。关键自主决策:真实 git CLI(非目录复制伪隔离)、独立 WorktreeRecord (不改 TaskRecord)、本 Index 不动 AutoClaimer(衔接留 s20)。全部写入 spec 固化。
  2. 计划自审查出三处问题:① prune 的分支删除语义在 spec 草稿里有未决疑问 (已固化为 deleteBranch=true);② ReplContext 加必填字段会让 ReplLoop 先编译失败, 计划中把 ReplLoop 构造接线提前到与 TestTeamSupport 修改同一步;③ sanitize 空串 用例的预期值写错(见 5.3 坑 1)。
  3. 分支复用是审查中补的设计点:初版 create 无脑 worktree add -b,release 保留 分支的场景下重建必失败。补了 rev-parse --verify 探测 + 降级检出,测试 「release with deleteBranch=false keeps branch; recreate reuses it」锁定该行为。
  4. 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 → 一个分支」的完整隔离链 才算闭环。

目录

当前章节:1. 目标

  • 1. 1. 目标
  • 2. 2. 为什么需要
  • 3. 2.1 现实问题:多智能体同目录踩踏
  • 4. 2.2 设计原则(取舍依据)
  • 5. 3. 核心设计与实现
  • 6. 3.1 架构全景
  • 7. 3.2 WorktreeRecord —— 绑定关系的数据模型
  • 8. 3.3 WorktreeConfig —— 路径配置
  • 9. 3.4 WorktreeStore —— 镜像 TaskStore 的持久化
  • 10. 3.5 WorktreeManager —— 唯一触碰 git 的类
  • 11. 3.6 WorktreeCommand —— REPL 入口
  • 12. 3.7 端到端:一次完整生命周期
  • 13. 3.8 与前后 Index 的衔接
  • 14. 4. 错误处理总表
  • 15. 5. 测试策略
  • 16. 5.1 测试类 × 覆盖点
  • 17. 5.2 真实 git 临时仓库,不引入 mock
  • 18. 5.3 测试设计上的两个坑
  • 19. 6. 开发过程记录
  • 20. 7. 下一站
回到顶部

相关推荐

查看全部文章
Index 21: Terminal UX —— Claude Code 风格终端交互

Index 21: Terminal UX —— Claude Code 风格终端交互

2026年8月21日

Index 21 将 cat-code 终端交互升级为 Claude Code 风格,解决旧 REPL 黑箱、无中断及输入体验差的问题。通过 JLine3 与 Mordant 实现历史补全、实时工具可见性、Spinner 状态及 Esc 中断。核心采用 UI 与 AgentLoop 解耦的事件流架构,支持内联权限菜单与优雅降级。同时配置 logback 收敛控制台日志,确保 TUI 清爽且功能无损,显著提升可用性。

Index 20: Comprehensive Agent —— 全机制集成(收口)

Index 20: Comprehensive Agent —— 全机制集成(收口)

2026年8月13日

作为收官之作,把 s01-s19 的二十个核心机制整合为一个全面智能体:统一的状态查询与运行周期、自治认领与工作区隔离的贯通,以及 /status 命令的全局可视化。全机制协同运转,标志着 Cat-Code 从零完整复刻 Claude Code 核心能力的收官。

Index 19: MCP Plugin —— 多传输 / 通道路由 / 工具池组装

Index 19: MCP Plugin —— 多传输 / 通道路由 / 工具池组装

2026年8月13日

引入 MCP(Model Context Protocol)插件机制,通过多传输适配与通道路由,把外部 MCP 服务器的工具动态接入智能体的工具池。工具注册从静态编译期扩展为运行时动态组装,让智能体能力随外部服务即插即用。