MergeQueue
§ 00 · Source-level Observatory

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 串成一套本地交通灯。

★ 295
GitHub Stars
4.5K
TS LOC
31
TS/Test Files
2026-07
Created
Claude lanes │ WorktreeCreate hook ▼ lane/1 lane/2 lane/3 │ build-lock / land queue ▼ integrationBranch → sync → main checkout
§ 00 · OVERVIEW

它解决什么,凭什么值得拆

项目的核心判断很朴素: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、环境变量和测试门禁。

▸核心数据

架构模式
Node CLI + Git worktree + FIFO lock + Husky pre-push hook。关键状态放在 git、tmpdir 和配置文件里,没有服务端。
核心抽象
lane、integrationBranch、QueueLock、WorktreeCreate hook、checkCommand、ephemeral resource claim。
测试信号
test/ 目录覆盖 queue-lock、land、sync、worktree-create、wire-hooks、check-push 等关键路径。
下游依赖
运行时零依赖;开发依赖 TypeScript/tsx/@types/node。用户项目只需要 Git、Node、可选 Husky。
§ 01 · ARCHITECTURE

本地 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。

▸系统拓扑

claude --worktree → WorktreeCreate hook (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。

src/bin/claude-code-merge-queue.ts:196 · L196-258TypeScript
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    }
深度阅读 · FIFO queue-lock

▸配置文件是迁移边界

配置接口在 src/lib/config.ts:19 明确列出所有可迁移维度:lane 命名、端口、integrationBranch、productionBranch、protectedBranches、regenerableFiles、symlinks、buildOutputDirs 和 checkCommand。校验函数 src/lib/config.ts:106 把无效配置一次性列出,而不是让错误延迟到 git push 或 worktree add 才爆炸。

src/lib/config.ts:19 · L19-99TypeScript
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};
§ 02 · CORE MODULES

核心模块:队列、落地、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 是否还活着。

src/lib/queue-lock.ts:73 · L73-127TypeScript
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。

src/land.ts:39 · L39-110TypeScript
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      });
深度阅读 · land + sync

▸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 路径。

src/hooks/worktree-create.ts:76 · L76-132TypeScript
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,但能挡住大多数误操作。

src/lib/check-push.ts:52 · L52-90TypeScript
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}
深度阅读 · hooks + safety gates
§ 03 · KEY FLOW

关键流程:从 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

lane branch → discard regenerable dirt (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

main checkout → verify current branch == integrationBranch (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)
§ 04 · TESTS & VALIDATION

测试覆盖:它真的测并发和崩溃

测试不是只测纯函数。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。

test/queue-lock.test.ts:32 · L32-88TypeScript
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。

test/worktree-create.test.ts:61 · L61-117TypeScript
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});
§ 05 · HIGHLIGHTS

技术亮点之外,别忽略这些

▸亮点:同一套 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。

src/lib/ephemeral.ts:56 · L56-126TypeScript
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 依赖删掉。

强项
把并发 agent 的冲突点缩成本地可验证机制:FIFO lock、pre-push、ff-only sync、worktree hook。
限制
只协调一台机器;两台机器同时推仍回到 git 非 fast-forward 语义。
强项
大量测试使用真实 git repo、真实 child process、真实 hook wiring,覆盖工程事故而非只测 happy path。
限制
如果 checkCommand 是空壳,队列只能保证“跑过检查”,不能保证检查真的有价值。
Generated · 2026-07-07Source · funador/claude-code-merge-queue↑ 返回索引