claude-code-merge-queue 本地合并队列
这个项目解决的是 parallel Claude Code agents 的最后一公里:Claude 原生 worktree 已经把多个 agent 隔离开,但 build、test、rebase、push、dev checkout 仍然共享本机资源。它用零运行时依赖的 TypeScript CLI,把 land/build/sync/preview/hook/pre-push 串成一套本地交通灯。
它解决什么,凭什么值得拆
项目的核心判断很朴素:Claude Code 的 --worktree 让每个 agent 有自己的目录,但没有解决多个 agent 同时抢同一个 integration branch、同一个 build/test 资源、同一个 dev checkout 的问题。源码入口 src/bin/claude-code-merge-queue.ts:31 把 init、hook、land、sync、promote、preview、build-lock、check-push 都收在一个 CLI 里;配置层 src/lib/config.ts:19 又把 branchPrefix、worktreeSuffix、integrationBranch、checkCommand 等差异外置,所以它不是单仓库脚本,而是可迁移的本地 agent 交通系统。
最值得拆的地方,是它没有用“让 agent 遵守说明”当可靠性基础。landing 队列在 src/land.ts:78 获取跨 worktree FIFO lock,pre-push 在 src/lib/check-push.ts:52 阻断绕过队列的直推,WorktreeCreate hook 在 src/hooks/worktree-create.ts:76 把 Claude 原生 worktree 创建接管成 numbered lanes,sync 在 src/sync.ts:139 负责把主 checkout 快进到刚落地的 commit。它把人类流程约定变成了文件锁、git hook、环境变量和测试门禁。
▸核心数据
本地 Agent 交通灯:三把锁和两个钩子
架构可以分成四层。第一层是 CLI 调度,src/bin/claude-code-merge-queue.ts:196 用一个 switch 分派所有子命令,并且在 src/bin/claude-code-merge-queue.ts:259 直接把 check-push 暴露给 git hook。第二层是配置,src/lib/config.ts:87 定义默认 branchPrefix、worktreeSuffix、symlinks 和 buildOutputDirs,src/lib/config.ts:196 加载用户 repo 的 config 并做校验。第三层是队列锁,src/lib/queue-lock.ts:73 创建 per-repo/per-queue 的 FIFO mutex。第四层是 hook 和同步,src/hooks/worktree-create.ts:179 响应 Claude 的 WorktreeCreate 事件,src/sync.ts:175 fetch 之后只做 ff-only。
▸系统拓扑
src/hooks/worktree-create.ts:179)
→ lane/N + symlinked env/node_modules (src/hooks/worktree-create.ts:119)
→ build-lock queue (src/build-lock.ts:1)
→ land FIFO queue (src/land.ts:78)
→ pre-push gate (src/lib/check-push.ts:76)
→ sync main checkout (src/sync.ts:203)▸CLI 是薄调度,状态在底层模块
CLI 自身不保存队列状态,而是把每个命令委托给模块:src/bin/claude-code-merge-queue.ts:222 跑 land,src/bin/claude-code-merge-queue.ts:224 跑 sync,src/bin/claude-code-merge-queue.ts:253 跑 preview,src/bin/claude-code-merge-queue.ts:255 跑 build-lock。这让可测试性更好,因为 lock、hook、sync 都能在 test 目录里独立构造 scratch repo。
196async function main(): Promise<void> { 197 switch (command) { 198 case "init": 199 return init(); 200 case "hook": { 201 const sub = rest[0]; 202 if (sub === "worktree-create") return runWorktreeCreateHook(); 203 console.error(`claude-code-merge-queue hook: unknown hook "${sub ?? ""}". Only "worktree-create" is supported.`); 204 process.exit(1); 205 return; 206 } 207 case "port": { 208 const root = findRepoRoot(); 209 if (!root || !hasConfig(root)) { 210 console.error("claude-code-merge-queue port: no claude-code-merge-queue.config found — not a lane."); 211 process.exit(1); 212 } 213 const cfg = await loadConfig(root); 214 const port = lanePort(process.cwd(), cfg); 215 if (port === null) { 216 console.error("claude-code-merge-queue port: current directory doesn't look like a lane worktree."); 217 process.exit(1); 218 } 219 console.log(port); 220 return; 221 } 222 case "land": 223 return land(); 224 case "sync": 225 process.exit(await sync()); 226 return; 227 case "promote": 228 process.exit(await promote()); 229 return; 230 case "prune": { 231 // `land` already does this automatically as a side effect of every 232 // successful landing — this is the on-demand escape hatch for 233 // "clean these up right now" instead of waiting for the next lane to 234 // land something. Same safety rules apply: only already-merged 235 // branches, never anything with a live process inside, never the 236 // worktree this command itself is running from. 237 const root = findRepoRoot(); 238 if (!root || !hasConfig(root)) { 239 console.error("claude-code-merge-queue prune: no claude-code-merge-queue.config found — nothing to do."); 240 process.exit(0); 241 } 242 const cfg = await loadConfig(root); 243 const mainTop = resolveMainCheckout(process.cwd()); 244 const pruned = pruneLandedLanes(mainTop, cfg, process.cwd()); 245 if (pruned.length === 0) { 246 console.log("claude-code-merge-queue prune: nothing to clean up — no already-landed sibling lanes found."); 247 } else { 248 const names = pruned.map((p) => p.split("/").pop()).join(", "); 249 console.log(`claude-code-merge-queue prune: removed ${pruned.length} already-landed lane${pruned.length === 1 ? "" : "s"}: ${names}`); 250 } 251 return; 252 } 253 case "preview": 254 return runPreview(rest); 255 case "build-lock": { 256 const parts = rest[0] === "--" ? rest.slice(1) : rest; 257 return buildLock(parts); 258 }
▸配置文件是迁移边界
配置接口在 src/lib/config.ts:19 明确列出所有可迁移维度:lane 命名、端口、integrationBranch、productionBranch、protectedBranches、regenerableFiles、symlinks、buildOutputDirs 和 checkCommand。校验函数 src/lib/config.ts:106 把无效配置一次性列出,而不是让错误延迟到 git push 或 worktree add 才爆炸。
19export interface ClaudeCodeMergeQueueConfig { 20 /** Lane branches are named "<branchPrefix><n>" — lane/1, lane/2, ... */ 21 branchPrefix: string; 22 /** Sibling worktree dirs are named "<repo><worktreeSuffix><n>" — ../myapp-lane-1. */ 23 worktreeSuffix: string; 24 /** First lane's dev-server port. Lane n gets portBase + n. */ 25 portBase: number; 26 /** The shared branch `claude-code-merge-queue land` rebases onto and pushes to. */ 27 integrationBranch: string; 28 /** 29 * The production branch, if you run a two-stage model (agents land on 30 * `integrationBranch`; a human promotes that to `productionBranch` on 31 * their own schedule via `claude-code-merge-queue promote`). `null` means 32 * `integrationBranch` IS production — no separate promotion step, and 33 * `claude-code-merge-queue promote` is a no-op. When set, this branch is automatically 34 * protected by the pre-push hook — you don't need to also list it in 35 * `protectedBranches`. 36 */ 37 productionBranch: string | null; 38 /** 39 * Extra branches the pre-push hook refuses a *direct* push to, beyond 40 * integrationBranch (always protected) and productionBranch (protected 41 * automatically when set). Most repos running the standard two-stage 42 * model don't need this at all. 43 */ 44 protectedBranches: string[]; 45 /** 46 * Files a build tool regenerates on its own (next-env.d.ts, a rewritten 47 * tsconfig "include" array, ...) that should never block a rebase or a 48 * fast-forward. Empty by default — you'll meet your first one the hard 49 * way, and then you add it here once, for good. 50 */ 51 regenerableFiles: string[]; 52 /** 53 * Git-ignored paths copied by reference (symlinked) into every new lane 54 * so it never needs a fresh install or a copy of your secrets. 55 */ 56 symlinks: string[]; 57 /** 58 * Build-output directories `preview` never copies onto the main checkout, 59 * on top of the fixed, always-excluded set (.git, node_modules, .env, 60 * .env.local). `preview` itself doesn't know or care what framework 61 * you're running — it just rsyncs source files onto a checkout your dev 62 * server is watching. The default list covers the common cases; add your 63 * own (".output" for Nuxt, ".svelte-kit" for SvelteKit, ...) rather than 64 * assuming this tool knows your build tool. 65 */ 66 buildOutputDirs: string[]; 67 /** 68 * The command `claude-code-merge-queue check-push` runs (in addition to the branch 69 * protections) before a landing is allowed through — your lint/typecheck/ 70 * test/build, whatever "green" means for this repo. `null` means nothing 71 * runs. That's a real, dangerous state for a tool whose whole pitch is 72 * "tested before merge," so it's only silent if `checksRequired` is 73 * explicitly set to `false` too — otherwise a null checkCommand FAILS the 74 * push rather than landing something nobody verified. 75 */ 76 checkCommand: string | null; 77 /** 78 * When true (the default) and `checkCommand` is null, `check-push` fails 79 * the push instead of landing unverified code. Set to `false` yourself to 80 * deliberately run with no checks — a real repo state (nothing to test 81 * yet), but one that should be a visible, committed, code-reviewable 82 * choice, not a silent default. 83 */ 84 checksRequired: boolean; 85} 86 87export const DEFAULTS: ClaudeCodeMergeQueueConfig = { 88 branchPrefix: "lane/", 89 worktreeSuffix: "-lane-", 90 portBase: 3000, 91 integrationBranch: "main", 92 productionBranch: null, 93 protectedBranches: [], 94 regenerableFiles: [], 95 symlinks: [".env", ".env.local", "node_modules"], 96 buildOutputDirs: ["dist", "build", ".next"], 97 checkCommand: null, 98 checksRequired: true, 99};
核心模块:队列、落地、hook、同步
▸QueueLock:用硬链接原子抢锁
src/lib/queue-lock.ts:73 先用 git common dir 计算 repoKey,确保同一个 repo 的所有 worktree 命中同一个队列目录;src/lib/queue-lock.ts:103 每轮 prune dead tickets;src/lib/queue-lock.ts:140 用 linkSync 把临时文件硬链接成 lock 文件,借文件系统原子性避免两个进程同时持锁。它不是 timeout 锁,而是用 src/lib/queue-lock.ts:88 的 PID liveness 判断 holder 是否还活着。
73export function createQueueLock(queueName: string): QueueLock { 74 const QUEUE_DIR = join( 75 tmpdir(), 76 `claude-code-merge-queue-${queueName}-queue-${createHash("sha1").update(repoKey()).digest("hex").slice(0, 12)}`, 77 ); 78 const TICKETS_DIR = join(QUEUE_DIR, "tickets"); 79 const LOCK_FILE = join(QUEUE_DIR, "lock"); 80 mkdirSync(TICKETS_DIR, { recursive: true }); 81 82 const lane = basename(process.cwd()); 83 const ME = process.pid; 84 const TICKET_TS = Date.now(); 85 const TICKET_NAME = `${TICKET_TS}-${ME}`; 86 const TICKET_FILE = join(TICKETS_DIR, TICKET_NAME); 87 88 function alive(pid: number): boolean { 89 if (!pid || pid === ME) return pid === ME; 90 try { 91 process.kill(pid, 0); 92 return true; 93 } catch (e) { 94 return (e as NodeJS.ErrnoException).code === "EPERM"; // exists but owned by someone else 95 } 96 } 97 98 function pidOf(name: string): number { 99 const dash = name.lastIndexOf("-"); 100 return dash === -1 ? 0 : Number(name.slice(dash + 1)); 101 } 102 103 function pruneDeadTickets(): string[] { 104 let names: string[]; 105 try { 106 names = readdirSync(TICKETS_DIR); 107 } catch { 108 return []; 109 } 110 const live: string[] = []; 111 for (const name of names) { 112 if (alive(pidOf(name))) { 113 live.push(name); 114 } else { 115 try { 116 unlinkSync(join(TICKETS_DIR, name)); 117 } catch { 118 /* someone else cleaned it */ 119 } 120 } 121 } 122 live.sort((a, b) => { 123 const [ta, pa] = a.split("-").map(Number) as [number, number]; 124 const [tb, pb] = b.split("-").map(Number) as [number, number]; 125 return ta - tb || pa - pb; 126 }); 127 return live;
▸Land:把 rebase/push/check 放进同一把门
land 模块先拒绝从 integrationBranch 自己运行,见 src/land.ts:46 到 src/land.ts:50;然后在等待队列之前和拿到锁之后各清理一次 regenerable dirt,见 src/land.ts:64 与 src/land.ts:93。真正落地时,src/land.ts:96 fetch,src/land.ts:99 rebase,src/land.ts:107 push,并在 push 环境里注入 CLAUDE_CODE_MERGE_QUEUE_LANDING。
39export async function land(): Promise<void> { 40 if (!hasConfig()) { 41 console.error("claude-code-merge-queue land: no claude-code-merge-queue.config found at the repo root. Run `claude-code-merge-queue init` first."); 42 process.exit(1); 43 } 44 const cfg = await loadConfig(); 45 46 const branch = execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf8" }).trim(); 47 if (branch === cfg.integrationBranch || cfg.protectedBranches.includes(branch) || branch === "HEAD") { 48 console.error(`claude-code-merge-queue land: refusing to run from '${branch}' — land is for lane branches only.`); 49 process.exit(1); 50 } 51 52 // A rebase refuses to run at all with a dirty tree. Build-tool-regenerated 53 // noise shouldn't block landing — discard ONLY the configured 54 // regenerableFiles, exactly like sync does for the fast-forward on the 55 // other end. Any other dirty file is real work-in-progress: leave it alone 56 // and let the rebase fail loud. 57 // 58 // Checked here AND again right before the rebase itself (not just once): 59 // a build tool can regenerate one of these files at any point, including 60 // during the (possibly long) wait for the landing queue lock below. A 61 // single check up front leaves that whole wait as a window where the 62 // exact same harmless noise reappears and gets misreported as a real 63 // rebase conflict instead of silently discarded like it should be. 64 const regenerable = new Set(cfg.regenerableFiles); 65 function discardRegenerableDirt(): void { 66 const status = execSync("git status --porcelain", { encoding: "utf8" }); 67 const dirty = status 68 .split("\n") 69 .filter(Boolean) 70 .map((line) => line.slice(3).trim()); 71 const blocking = dirty.filter((f) => !regenerable.has(f)); 72 if (dirty.length > 0 && blocking.length === 0) { 73 execSync(`git checkout -- ${dirty.map((f) => `"${f}"`).join(" ")}`); 74 } 75 } 76 discardRegenerableDirt(); 77 78 const lock = createQueueLock("land"); 79 await lock.acquire({ 80 label: branch, 81 onWait: ({ ahead, holder }) => { 82 if (ahead > 0) { 83 console.log(`${DIM}[land-queue] ${branch}: waiting — ${ahead} landing${ahead === 1 ? "" : "s"} ahead…${RESET}`); 84 } else if (holder) { 85 console.log(`${DIM}[land-queue] ${branch}: next up — waiting for '${holder.label ?? holder.lane}' to finish landing…${RESET}`); 86 } 87 }, 88 }); 89 90 let exitCode = 0; 91 try { 92 console.log(`${DIM}[land-queue] ${branch}: lock acquired — landing…${RESET}`); 93 discardRegenerableDirt(); // re-check right before the rebase — see comment above 94 95 console.log(`${DIM}fetching origin/${cfg.integrationBranch}…${RESET}`); 96 execSync(`git fetch origin ${cfg.integrationBranch} --quiet`, { stdio: "inherit" }); 97 98 console.log(`${DIM}rebasing onto origin/${cfg.integrationBranch}…${RESET}`); 99 const rebase = spawnSync("git", ["rebase", `origin/${cfg.integrationBranch}`], { stdio: "inherit" }); 100 if (rebase.status !== 0) { 101 spawnSync("git", ["rebase", "--abort"], { stdio: "ignore" }); 102 console.error(`\n${RED}land: rebase onto origin/${cfg.integrationBranch} conflicted — aborted, working tree left clean.${RESET}`); 103 console.error(`Resolve it yourself (git fetch origin ${cfg.integrationBranch} && git rebase origin/${cfg.integrationBranch}), then re-run 'claude-code-merge-queue land'.`); 104 exitCode = 1; 105 } else { 106 console.log(`${DIM}pushing to ${cfg.integrationBranch} (this is where your CI/checks hook runs)…${RESET}`); 107 const push = spawnSync("git", ["push", "origin", `HEAD:${cfg.integrationBranch}`], { 108 stdio: "inherit", 109 env: { ...process.env, CLAUDE_CODE_MERGE_QUEUE_LANDING: "1" }, 110 });
▸WorktreeCreate hook:把 Claude 的原生隔离接入 lane 规范
hook 的巧妙点在于不重新发明 worktree。src/hooks/worktree-create.ts:76 的 createLane 只是寻找最低可用 lane 编号,然后调用 git worktree add;src/hooks/worktree-create.ts:96 特意以 main checkout 当前 HEAD 为 base,避免 init 刚写出的本地配置还没 push 时,新 lane 丢掉本地 wiring commit。src/hooks/worktree-create.ts:119 负责 symlink .env、node_modules 等 gitignored 路径。
76export function createLane(mainTop: string, cfg: ClaudeCodeMergeQueueConfig): { wt: string; branch: string; lane: number } { 77 // Clean up administrative entries for worktrees whose directories are 78 // already gone (e.g. someone `rm -rf`'d one instead of `git worktree 79 // remove`) so reusing that lane number's branch name doesn't fail with 80 // "already checked out elsewhere." 81 tryGit(["worktree", "prune"], mainTop); 82 83 const repoName = basename(mainTop); 84 let lane = 0; 85 for (;;) { 86 lane += 1; 87 if (lane > MAX_LANE_ATTEMPTS) { 88 throw new Error(`could not claim a lane after ${MAX_LANE_ATTEMPTS} attempts — is mainTop ('${mainTop}') actually the repo root?`); 89 } 90 const wt = join(dirname(mainTop), `${repoName}${cfg.worktreeSuffix}${lane}`); 91 if (existsSync(wt)) continue; // still claimed — try the next lane 92 93 const branch = `${cfg.branchPrefix}${lane}`; 94 const branchExists = tryGit(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], mainTop).ok; 95 96 // Base every new lane on the main checkout's own current HEAD — never on 97 // origin/integrationBranch. The main checkout is kept fast-forwarded by 98 // `sync` precisely so its local HEAD IS the trusted, up-to-date view; 99 // reading origin directly instead can be *behind* local HEAD (a commit 100 // made here but not yet pushed) and silently drop it from every new 101 // lane. That's not a hypothetical: it breaks the literal Quickstart — 102 // `init` writes claude-code-merge-queue.config.mjs/CLAUDE.md/.claude locally, and the 103 // very first lane created before that commit is pushed anywhere would 104 // otherwise come up with none of it. 105 let add; 106 if (branchExists) { 107 add = tryGit(["worktree", "add", wt, branch], mainTop); 108 } else { 109 add = tryGit(["worktree", "add", wt, "-b", branch], mainTop); 110 } 111 112 if (!add.ok) { 113 // Someone else claimed this exact lane between our existsSync check 114 // and `git worktree add` — the same race a `mkdir` guards against. 115 // Try the next one. 116 continue; 117 } 118 119 for (const rel of cfg.symlinks) { 120 const src = join(mainTop, rel); 121 const dest = join(wt, rel); 122 if (!existsSync(src) || existsSync(dest)) continue; 123 try { 124 mkdirSync(dirname(dest), { recursive: true }); 125 symlinkSync(src, dest); 126 } catch { 127 /* best-effort — a missing symlink degrades to "run npm install," not a hard failure */ 128 } 129 } 130 131 return { wt, branch, lane }; 132 }
▸pre-push:把“请走队列”变成硬失败
如果只有 land 命令,agent 仍然可能手写 git push。src/lib/check-push.ts:52 解析 ref update 后逐个远端 ref 检查,src/lib/check-push.ts:76 阻断未设置 landing 环境变量的 integrationBranch 直推,src/lib/check-push.ts:64 同时保护 production/protected branches。它承认 emergency env var 是 convention,但能挡住大多数误操作。
52export function checkPush( 53 refUpdates: RefUpdate[], 54 cfg: Pick<ClaudeCodeMergeQueueConfig, "integrationBranch" | "productionBranch" | "protectedBranches">, 55 env: NodeJS.ProcessEnv, 56): CheckResult { 57 const integrationRef = `refs/heads/${cfg.integrationBranch}`; 58 const protectedBranches = cfg.productionBranch ? [...cfg.protectedBranches, cfg.productionBranch] : cfg.protectedBranches; 59 const protectedRefs = new Set(protectedBranches.map((b) => `refs/heads/${b}`)); 60 61 for (const { remoteRef } of refUpdates) { 62 const branch = remoteRef.replace("refs/heads/", ""); 63 64 if (protectedRefs.has(remoteRef) && !emergencyConfirmed(env)) { 65 return { 66 ok: false, 67 message: [ 68 "", 69 `✋ Direct pushes to '${branch}' are blocked.`, 70 ` This is a protected branch — promote it deliberately, not with a stray push.`, 71 ` Emergency override: CLAUDE_CODE_MERGE_QUEUE_EMERGENCY_PUSH=1 git push …`, 72 "", 73 ].join("\n"), 74 }; 75 } 76 if (remoteRef === integrationRef && env.CLAUDE_CODE_MERGE_QUEUE_LANDING !== "1" && !emergencyConfirmed(env)) { 77 return { 78 ok: false, 79 message: [ 80 "", 81 `✋ Direct pushes to '${cfg.integrationBranch}' are blocked — landing goes through the queue.`, 82 ` Land your work: claude-code-merge-queue land`, 83 ` Genuine emergency: CLAUDE_CODE_MERGE_QUEUE_EMERGENCY_PUSH=1 git push …`, 84 "", 85 ].join("\n"), 86 }; 87 } 88 } 89 return { ok: true }; 90}
关键流程:从 lane 创建到主 checkout 更新
完整路径是:Claude 触发 WorktreeCreate hook,hook 创建 lane worktree;agent 在 lane 中提交;agent 执行 land;land 拿 FIFO 锁、fetch、rebase、push;pre-push hook 运行 checkCommand;push 成功后 land 调 sync,让主 checkout ff-only 到最新。这里每一步都能在源码里找到硬边界:src/hooks/worktree-create.ts:197 输出 worktree path,src/land.ts:79 等队列,src/lib/check-command.ts:60 执行 checkCommand,src/sync.ts:177 只允许 ff-only merge。
▸Landing path
src/land.ts:65)
→ createQueueLock("land") (src/land.ts:78)
→ git fetch + rebase (src/land.ts:95, src/land.ts:99)
→ git push with landing env (src/land.ts:107)
→ sync main checkout (src/land.ts:126)▸Sync path
src/sync.ts:163)
→ fetch origin (src/sync.ts:175)
→ merge --ff-only (src/sync.ts:177)
→ refresh dependencies if lockfile changed (src/sync.ts:75)测试覆盖:它真的测并发和崩溃
测试不是只测纯函数。test/queue-lock.test.ts:32 启动四个真实 worker 验证互斥,test/queue-lock.test.ts:59 用 SIGKILL 杀掉 holder 再证明第二个 worker 能重新拿锁。land 测试在 test/land.test.ts:59 构造真实 git repo + lane + remote,特意让 regenerable file 在等待队列期间被重新写脏,复现过去 bug。worktree 测试在 test/worktree-create.test.ts:84 并发启动多个 hook,确认不会撞 lane。
32test("mutual exclusion: four concurrent workers never overlap while holding the lock", async () => { 33 const dir = mkdtempSync(join(tmpdir(), "claude-code-merge-queue-lock-test-")); 34 const resultsFile = join(dir, "results.ndjson"); 35 const queueName = `test-mutex-${process.pid}-${Date.now()}`; 36 37 const workers = [0, 1, 2, 3].map(() => spawnWorker(queueName, resultsFile, 150)); 38 await Promise.all(workers.map(waitExit)); 39 40 const events = readEvents(resultsFile); 41 const intervals = new Map<number, { start: number; end: number }>(); 42 for (const e of events) { 43 const cur = intervals.get(e.pid) ?? { start: 0, end: 0 }; 44 cur[e.event === "start" ? "start" : "end"] = e.t; 45 intervals.set(e.pid, cur); 46 } 47 assert.equal(intervals.size, 4, "expected all four workers to record start+end"); 48 49 const sorted = [...intervals.values()].sort((a, b) => a.start - b.start); 50 for (let i = 1; i < sorted.length; i++) { 51 const prev = sorted[i - 1]!; 52 const cur = sorted[i]!; 53 assert.ok(cur.start >= prev.end, `worker holding [${prev.start},${prev.end}] overlapped worker starting at ${cur.start}`); 54 } 55 56 rmSync(dir, { recursive: true, force: true }); 57}); 58 59test("crash safety: a killed holder is reclaimed, not a permanent deadlock", async () => { 60 const dir = mkdtempSync(join(tmpdir(), "claude-code-merge-queue-lock-test-")); 61 const resultsFile = join(dir, "results.ndjson"); 62 const queueName = `test-crash-${process.pid}-${Date.now()}`; 63 64 // Worker A holds the lock for far longer than the test's patience — it 65 // should never release it gracefully. It gets SIGKILLed instead. 66 const holder = spawnWorker(queueName, resultsFile, 60_000); 67 68 const deadline = Date.now() + 5_000; 69 while (Date.now() < deadline) { 70 if (existsSync(resultsFile) && readFileSync(resultsFile, "utf8").includes('"start"')) break; 71 await new Promise((r) => setTimeout(r, 50)); 72 } 73 assert.ok(existsSync(resultsFile) && readFileSync(resultsFile, "utf8").includes('"start"'), "holder never acquired the lock"); 74 75 execSync(`kill -9 ${holder.pid}`); 76 77 // Worker B should still be able to acquire the SAME queue — if the lock 78 // weren't reclaimed via PID-liveness, this would hang until the test 79 // runner's own timeout, not queue-lock's (there is no queue-lock timeout). 80 const second = spawnWorker(queueName, resultsFile, 10); 81 const code = await waitExit(second); 82 assert.equal(code, 0, "second worker should acquire and finish cleanly after the holder was killed"); 83 84 const events = readEvents(resultsFile).filter((e) => e.pid === second.pid); 85 assert.equal(events.length, 2, "second worker should have recorded both start and end"); 86 87 rmSync(dir, { recursive: true, force: true }); 88});
▸worktree 与 hook wiring 也有回归测试
test/worktree-create.test.ts:61 验证新 lane 基于 main checkout 的当前 HEAD,而不是 stale origin;test/wire-hooks.test.ts:30 确认写入 Claude settings 时不会丢掉已有 hook;test/wire-hooks.test.ts:90 验证合并 Husky pre-push 时保留用户自定义逻辑。这些测试说明作者把真实事故转成了持久 guardrail。
61test("createLane bases a new lane on the main checkout's own HEAD, not a stale origin/integrationBranch", () => { 62 const mainTop = makeScratchRepo(); 63 const remote = mkdtempSync(join(tmpdir(), "claude-code-merge-queue-wt-remote-")); 64 try { 65 execFileSync("git", ["init", "-q", "--bare", remote]); 66 execFileSync("git", ["branch", "-M", "main"], { cwd: mainTop }); 67 execFileSync("git", ["remote", "add", "origin", remote], { cwd: mainTop }); 68 execFileSync("git", ["push", "-q", "origin", "main"], { cwd: mainTop }); 69 70 // A commit made locally but never pushed — e.g. `claude-code-merge-queue init`'s own 71 // wiring commit, right after Quickstart, before anyone has pushed it. 72 execFileSync("node", ["-e", "require('fs').writeFileSync('claude-code-merge-queue.config.mjs', 'x')"], { cwd: mainTop }); 73 execFileSync("git", ["add", "-A"], { cwd: mainTop }); 74 execFileSync("git", ["commit", "-q", "-m", "local-only wiring commit"], { cwd: mainTop }); 75 76 const { wt } = createLane(mainTop, { ...DEFAULTS, integrationBranch: "main" }); 77 assert.ok(existsSync(join(wt, "claude-code-merge-queue.config.mjs")), "new lane must inherit the unpushed local commit, not fall back to stale origin/main"); 78 } finally { 79 cleanupRepoAndLanes(mainTop); 80 rmSync(remote, { recursive: true, force: true }); 81 } 82}); 83 84test("concurrent WorktreeCreate hook invocations never collide on the same lane", async () => { 85 const mainTop = makeScratchRepo(); 86 try { 87 const N = 4; 88 const children = Array.from({ length: N }, () => 89 spawn("node", ["--import", "tsx", WORKER], { stdio: ["pipe", "pipe", "inherit"] }), 90 ); 91 for (const child of children) { 92 child.stdin.end(JSON.stringify({ cwd: mainTop })); 93 } 94 95 const results = await Promise.all( 96 children.map( 97 (child) => 98 new Promise<{ code: number; stdout: string }>((resolve) => { 99 let stdout = ""; 100 child.stdout.on("data", (d) => (stdout += d)); 101 child.on("exit", (code) => resolve({ code: code ?? 1, stdout: stdout.trim() })); 102 }), 103 ), 104 ); 105 106 for (const r of results) { 107 assert.equal(r.code, 0, `worker should exit 0, got ${r.code}`); 108 } 109 const paths = results.map((r) => r.stdout); 110 assert.equal(new Set(paths).size, N, `all ${N} claimed lanes should be distinct paths, got: ${paths.join(", ")}`); 111 for (const p of paths) { 112 assert.ok(existsSync(p), `claimed worktree ${p} should exist on disk`); 113 } 114 } finally { 115 cleanupRepoAndLanes(mainTop); 116 } 117});
技术亮点之外,别忽略这些
▸亮点:同一套 liveness 模式复用三次
queue lock 在 src/lib/queue-lock.ts:88 用 PID 判断 ticket/holder;ephemeral resource 在 src/lib/ephemeral.ts:56 用 ClaimRegistry 保存 claim,并在 src/lib/ephemeral.ts:110 先清理 orphan 再创建新资源;WorktreeCreate hook 则用 “worktree path 是否存在 + git worktree add 原子性” 代替 PID。三个地方的共同点是:不靠 magic timeout。
56export class ClaimRegistry { 57 private readonly dir: string; 58 59 constructor(registryDir: string) { 60 this.dir = registryDir; 61 mkdirSync(this.dir, { recursive: true }); 62 } 63 64 record(claim: Claim): void { 65 writeFileSync(join(this.dir, claim.id), JSON.stringify(claim)); 66 } 67 68 release(id: string): void { 69 try { 70 unlinkSync(join(this.dir, id)); 71 } catch { 72 /* already gone */ 73 } 74 } 75 76 /** Every claim whose owning PID is no longer alive — safe to destroy. */ 77 orphans(): Claim[] { 78 let names: string[]; 79 try { 80 names = readdirSync(this.dir); 81 } catch { 82 return []; 83 } 84 const found: Claim[] = []; 85 for (const name of names) { 86 try { 87 const claim = JSON.parse(readFileSync(join(this.dir, name), "utf8")) as Claim; 88 if (!alive(claim.pid)) found.push(claim); 89 } catch { 90 /* unreadable claim file — leave it, don't guess */ 91 } 92 } 93 return found; 94 } 95} 96 97/** 98 * Run `fn` against a freshly created resource, prune any orphans left by a 99 * previous crashed run first, and always release the claim — including on 100 * a thrown error. If THIS process is killed before the `finally` runs, the 101 * resource isn't leaked forever: the next call to `withEphemeralResource` 102 * (in the next test run, from any lane) prunes it via `orphans()` before 103 * creating its own. 104 */ 105export async function withEphemeralResource<T>( 106 provider: EphemeralResourceProvider<T>, 107 registry: ClaimRegistry, 108 fn: (resource: T) => Promise<void>, 109): Promise<void> { 110 for (const orphan of registry.orphans()) { 111 await provider.destroyOrphan(orphan); 112 registry.release(orphan.id); 113 } 114 115 const id = `${Date.now()}-${process.pid}`; 116 const claim: Claim = { id, pid: process.pid, createdAt: Date.now() }; 117 registry.record(claim); 118 119 const resource = await provider.create(); 120 try { 121 await fn(resource); 122 } finally { 123 await provider.destroy(resource); 124 registry.release(id); 125 } 126}
▸风险:本地工具不是安全边界
pre-push gate 能挡误操作,但 src/lib/check-push.ts:48 暴露 emergency env var,README 也承认它不是 adversarial security boundary。sync 的 src/sync.ts:91 会在 lockfile changed 时跑 package manager install,这对共享 node_modules 是必要的,但 src/sync.ts:81 也特意检查 dependency removal,避免把旧 lane 依赖删掉。