MergeQueue
§ · Nested detail page

land + sync 深度解析

land 如何串行 rebase/push,sync 如何让主 checkout 安全跟上。

§ 00 · DETAIL

导入链与契约

land 的入口约束

land 是唯一被授权落到 integrationBranch 的路径。src/land.ts:39 进入函数后先加载配置,src/land.ts:46 读取当前 branch,src/land.ts:47 直接拒绝从 integrationBranch、protectedBranches 或 detached HEAD 运行。这个约束避免 agent 在主 checkout 上直接 land,确保 lane 语义成立。

它还承认 build tool 会重写文件。src/land.ts:64 读取 regenerableFiles,src/land.ts:65 的 discardRegenerableDirt 只会丢弃白名单文件;src/land.ts:71 把其他 dirty file 当成真实 WIP,不替用户猜。

src/land.ts:39 · L39-78TypeScript
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");
§ 01 · DETAIL

核心实现逐段拆

串行落地主线

落地锁在 src/land.ts:78 获取,等待日志由 src/land.ts:81 的 onWait 提供,agent 或人能看到前面还有几个 landing。拿锁后 src/land.ts:93 再清一次 regenerable dirt,因为等待队列期间文件可能又被构建工具重写;这个二次检查来自真实 bug。

实际 git 流程非常保守:src/land.ts:96 fetch,src/land.ts:99 rebase,失败则 src/land.ts:101 abort;成功后 src/land.ts:107 push 到 integrationBranch,并在 src/land.ts:109 设置 landing env,让 pre-push hook 知道这是合法路径。

src/land.ts:78 · L78-126TypeScript
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      });
111      if (push.status !== 0) {
112        console.error(`\n${RED}land: push to ${cfg.integrationBranch} failed — see output above.${RESET}`);
113        console.error(`Fix the failure, then re-run 'claude-code-merge-queue land'.`);
114        exitCode = 1;
115      } else {
116        console.log(`${GREEN}✓ ${branch} landed on ${cfg.integrationBranch}.${RESET}`);
117        // Landing isn't "done" until the checkout that actually serves your
118        // dev server can see it — call sync in-process rather than shelling
119        // back out to the CLI, so this doesn't depend on `claude-code-merge-queue` being
120        // resolvable on PATH. Pass this lane's own already-loaded cfg through
121        // rather than letting sync() reload from MAIN — MAIN hasn't been
122        // fast-forwarded yet at this exact moment (that's what sync is about
123        // to do), so if this push just introduced or changed
124        // claude-code-merge-queue.config.mjs itself, a fresh MAIN-side load would silently
125        // fall back to DEFAULTS instead of the real config.
126        exitCode = await sync(cfg);
src/lib/check-command.ts:60 · L60-84TypeScript
60export function runCheckCommand(cfg: Pick<ClaudeCodeMergeQueueConfig, "checkCommand" | "checksRequired">, root: string): number {
61  if (!cfg.checkCommand) {
62    if (cfg.checksRequired) {
63      console.error([
64        "",
65        "✋ No checkCommand configured, and checksRequired is true (the default).",
66        "   This push would land with NOTHING verifying it — no lint, no test, no build.",
67        "   Set checkCommand in claude-code-merge-queue.config.mjs, e.g. \"npm run check\".",
68        "   Or, if you really have nothing to check yet, set checksRequired: false —",
69        "   deliberately, so it's a visible, committed choice, not a silent gap.",
70        "",
71      ].join("\n"));
72      return 1;
73    }
74    console.log("claude-code-merge-queue check-push: no checkCommand configured (checksRequired: false — running with no checks, on purpose).");
75    return 0;
76  }
77
78  console.log(`claude-code-merge-queue check-push: running "${cfg.checkCommand}"…`);
79  const result = spawnSync(cfg.checkCommand, { shell: true, stdio: "inherit", cwd: root });
80  if (result.status !== 0) {
81    console.error(`\n✋ checkCommand failed (exit ${result.status ?? 1}) — landing blocked.`);
82  }
83  return result.status ?? 1;
84}
§ 02 · DETAIL

关键流程

同步主 checkout

push 成功不是结束。src/land.ts:126 直接调用 sync(cfg),把主 checkout 快进。sync 的保护在 src/sync.ts:151 读取当前 branch,src/sync.ts:163 如果不是 integrationBranch 就拒绝行动;src/sync.ts:177 只做 merge --ff-only,避免自动生成 merge commit。

依赖同步是更细的工程判断。src/sync.ts:75 检查 lockfile 是否变化,src/sync.ts:81 如果 package.json 删除了依赖就不自动 install,避免共享 node_modules 把旧 lane 需要的 binary 删除;安全的版本 bump 才在 src/sync.ts:91 自动 install。

land queue → rebase → push → pre-push checkCommand → sync main checkout → optional dependency refresh
src/sync.ts:139 · L139-221TypeScript
139export async function sync(providedCfg?: ClaudeCodeMergeQueueConfig): Promise<number> {
140  let MAIN: string;
141  try {
142    MAIN = resolveMainCheckout(process.cwd());
143  } catch {
144    console.error("claude-code-merge-queue sync: not inside a git repo — nothing to do.");
145    return 0;
146  }
147
148  const cfg = providedCfg ?? (await loadConfig(MAIN));
149  const regenerable = new Set(cfg.regenerableFiles);
150
151  const branchRes = git(MAIN, ["rev-parse", "--abbrev-ref", "HEAD"], { allowFail: true });
152  const branch = branchRes.out.trim();
153  if (!branch || branch === "HEAD") {
154    console.error("claude-code-merge-queue sync: the checkout is detached or unresolved — left untouched.");
155    return 0;
156  }
157  // The main checkout is meant to stay parked on integrationBranch permanently
158  // (that's what makes "fast-forward it" a safe, unattended operation). If
159  // it's on something else — someone switched branches in it by hand, or ran
160  // `land` from a single non-worktree checkout instead of a lane worktree —
161  // fast-forwarding "whatever HEAD happens to be" silently does the wrong
162  // thing. Say so plainly instead of surfacing a raw git error later.
163  if (branch !== cfg.integrationBranch) {
164    console.error(
165      `claude-code-merge-queue sync: this checkout is on '${branch}', not the configured integrationBranch ` +
166        `('${cfg.integrationBranch}'). sync only fast-forwards the main checkout — run it from ` +
167        `there, or check out '${cfg.integrationBranch}' here first. Left untouched.`,
168    );
169    return 0;
170  }
171  const upstream = `origin/${branch}`;
172
173  const before = git(MAIN, ["rev-parse", "--short", "HEAD"], { allowFail: true }).out.trim();
174
175  git(MAIN, ["fetch", "origin", "--quiet"], { allowFail: true });
176
177  const tryFastForward = () => git(MAIN, ["merge", "--ff-only", upstream], { allowFail: true });
178
179  let res = tryFastForward();
180
181  // Retry transient lock contention (another lane landing at the same instant).
182  for (let i = 0; i < LOCK_RETRIES && !res.ok && /index\.lock|Unable to create|another git process/i.test(res.out); i++) {
183    sleep(400);
184    res = tryFastForward();
185  }
186
187  // Blocked by a locally-modified regenerable file? Discard it and retry once.
188  if (!res.ok && /would be overwritten by merge/i.test(res.out)) {
189    const files = res.out
190      .split("\n")
191      .map((l) => l.trim())
192      .filter((l) => l && !/would be overwritten|please commit|aborting|^error:/i.test(l));
193    const blocking = files.filter((f) => !regenerable.has(f));
194    if (blocking.length === 0 && files.length > 0) {
195      git(MAIN, ["checkout", "--", ...files], { allowFail: true });
196      res = tryFastForward();
197    } else {
198      console.error(`claude-code-merge-queue sync: ${branch} has local changes blocking fast-forward (${blocking.join(", ")}). Left untouched — resolve in the checkout.`);
199      return 0;
200    }
201  }
202
203  if (res.ok) {
204    const after = git(MAIN, ["rev-parse", "--short", "HEAD"], { allowFail: true }).out.trim();
205    if (before === after) {
206      console.log(`claude-code-merge-queue sync: ${branch} already current at ${after}.`);
207    } else {
208      console.log(`claude-code-merge-queue sync: fast-forwarded ${branch} ${before} → ${after} — the dev server will pick it up.`);
209      refreshDependenciesIfChanged(MAIN, before, after);
210    }
211    return 0;
212  }
213
214  if (/Not possible to fast-forward|diverging|non-fast-forward/i.test(res.out)) {
215    console.error(`claude-code-merge-queue sync: local ${branch} has DIVERGED from ${upstream} (something was committed directly on the checkout). Left untouched — reconcile it manually.`);
216    return 0;
217  }
218
219  console.error(`claude-code-merge-queue sync: could not fast-forward ${branch} — left untouched.\n${res.out.trim()}`);
220  return 0;
221}
§ 03 · DETAIL

边界与失败

land 测试重现了“等待期间 regenerable file 又变脏”的事故。test/land.test.ts:59 的测试先让一个 worker 持有 land 锁,test/land.test.ts:92 启动真实 CLI land,等它进入等待后在 test/land.test.ts:99 写脏 generated.txt。最后 test/land.test.ts:101 等 land 退出并断言成功。

sync 测试则覆盖 bootstrap gap。test/sync.test.ts:113 证明 bare sync 会因 main checkout 没有新 config 而退回 DEFAULTS;test/sync.test.ts:138 证明 land 传入 already-loaded cfg 可以修掉这个 gap。

test/land.test.ts:59 · L59-114TypeScript
59test("land discards a regenerable file that gets re-dirtied while waiting for the landing queue lock", async () => {
60  // The exact bug this closes: land only ever checked for stray regenerable
61  // dirt ONCE, before joining the queue. A build tool re-touching that same
62  // file during the (possibly long) wait for the lock — the whole point of
63  // the queue existing at all — went uncaught, so the rebase that runs the
64  // moment the lock is actually acquired failed on a "conflict" that was
65  // really just the identical harmless noise land is supposed to discard.
66  //
67  // Both waits below are polled for real evidence, not guessed timeouts:
68  // the holder's own "start" event, and land's own "waiting for the queue"
69  // log line. A sleep-based version of this test passed even against the
70  // unfixed code, because a lucky race let `land` finish before the dirt
71  // was even written — proving nothing.
72  const { base, lane } = makeRepoWithLane();
73  const resultsFile = join(base, "lock-results.ndjson");
74  let holder: ReturnType<typeof spawn> | undefined;
75  let landProcess: ReturnType<typeof spawn> | undefined;
76  try {
77    writeFileSync(join(lane, "lane-work.txt"), "real change\n");
78    git(lane, ["add", "-A"]);
79    git(lane, ["commit", "-q", "-m", "lane work"]);
80
81    // Hold the real "land" queue lock from a separate process, targeting
82    // this same repo, so it resolves to the identical cross-worktree queue.
83    holder = spawn("node", ["--import", "tsx", WORKER, lane, "land", resultsFile, "2000"], {
84      stdio: "ignore",
85    });
86    await pollUntil(
87      () => existsSync(resultsFile) && readFileSync(resultsFile, "utf8").includes('"start"'),
88      "the holder process to actually acquire the lock",
89    );
90
91    let landOutput = "";
92    landProcess = spawn("node", [CLI, "land"], { cwd: lane });
93    landProcess.stdout?.on("data", (d: Buffer) => (landOutput += d.toString()));
94    landProcess.stderr?.on("data", (d: Buffer) => (landOutput += d.toString()));
95    await pollUntil(() => /land-queue/.test(landOutput), "land to actually be queued, waiting on the holder");
96
97    // land is now genuinely blocked in the queue — regenerate the
98    // configured regenerable file while it waits.
99    writeFileSync(join(lane, "generated.txt"), "build output v2\n");
100
101    const exitCode = await waitExit(landProcess);
102    assert.equal(exitCode, 0, `land should succeed by discarding the re-dirtied regenerable file, not fail on a false conflict.\n--- land output ---\n${landOutput}`);
103
104    // Query the bare remote directly rather than the main checkout's
105    // origin/dev — that's just a local tracking ref and only updates on a
106    // fetch this test never asked for.
107    const devLog = execFileSync("git", ["--git-dir", join(base, "remote.git"), "log", "dev", "--oneline"], { encoding: "utf8" });
108    assert.match(devLog, /lane work/, "the lane's real commit must have actually landed");
109  } finally {
110    holder?.kill();
111    landProcess?.kill();
112    rmSync(base, { recursive: true, force: true });
113  }
114});
test/sync.test.ts:113 · L113-180TypeScript
113test("bare sync() falls back to DEFAULTS when MAIN has no config yet — and can wrongly refuse a real integrationBranch that isn't literally \"main\"", async () => {
114  // The exact bootstrap gap: the main checkout doesn't have
115  // claude-code-merge-queue.config.mjs yet (this push just introduced it, and sync is
116  // what's supposed to bring it over) — a bare sync() has no way to know
117  // the real integrationBranch is "dev", not DEFAULTS' "main".
118  const { dir } = makeRepoWithRemote("dev");
119  const cwd = process.cwd();
120  try {
121    process.chdir(dir);
122    let stderr = "";
123    const origError = console.error;
124    console.error = (...args: unknown[]) => {
125      stderr += args.join(" ");
126    };
127    const code = await sync();
128    console.error = origError;
129
130    assert.equal(code, 0);
131    assert.match(stderr, /not the configured integrationBranch \('main'\)/, "falls back to DEFAULTS.integrationBranch, not the project's real one");
132  } finally {
133    process.chdir(cwd);
134    rmSync(dir, { recursive: true, force: true });
135  }
136});
137
138test("sync(providedCfg) uses the caller's already-loaded config instead of re-deriving from MAIN — fixes the bootstrap gap above", async () => {
139  const { dir, remote } = makeRepoWithRemote("dev");
140  const cwd = process.cwd();
141  try {
142    const other = mkdtempSync(join(tmpdir(), "claude-code-merge-queue-sync-other-"));
143    execFileSync("git", ["clone", "--quiet", remote, other]);
144    git(other, ["config", "user.email", "test@test.com"]);
145    git(other, ["config", "user.name", "Test"]);
146    // The bare remote's own HEAD symref reflects git's global default branch
147    // (main/master), not "dev" — explicitly track it rather than relying on
148    // clone to have checked it out.
149    git(other, ["checkout", "-q", "-b", "dev", "origin/dev"]);
150    writeFileSync(join(other, "file.txt"), "v2\n");
151    git(other, ["add", "-A"]);
152    git(other, ["commit", "-q", "-m", "landed elsewhere"]);
153    git(other, ["push", "-q", "origin", "dev"]);
154    const remoteHead = git(other, ["rev-parse", "HEAD"]).trim();
155    rmSync(other, { recursive: true, force: true });
156
157    process.chdir(dir);
158    const code = await sync({ ...DEFAULTS, integrationBranch: "dev" });
159    assert.equal(code, 0);
160    assert.equal(git(dir, ["rev-parse", "HEAD"]).trim(), remoteHead, "should have fast-forwarded using the provided config, not DEFAULTS");
161  } finally {
162    process.chdir(cwd);
163    rmSync(dir, { recursive: true, force: true });
164  }
165});
166
167test("sync runs an install when the fast-forwarded range changed package-lock.json — the shared node_modules every lane symlinks would otherwise go stale", async () => {
168  const { dir, remote } = makeRepoWithRemote();
169  const cwd = process.cwd();
170  const { binDir, marker } = fakePackageManagerBin();
171  const originalPath = process.env.PATH;
172  try {
173    pushLockfileChange(remote, "package-lock.json", "bump a dep");
174
175    process.chdir(dir);
176    process.env.PATH = `${binDir}:${originalPath}`;
177    const code = await sync();
178    assert.equal(code, 0);
179    assert.ok(existsSync(marker), "expected npm install to have run");
180    assert.equal(readFileSync(marker, "utf8").trim(), "install");
§ 04 · DETAIL

关联模块

check-push 和 land 是一对:land 在 src/land.ts:109 设置 landing env,check-push 在 src/lib/check-push.ts:76 验证它。CLI 在 src/bin/claude-code-merge-queue.ts:286 调 checkPush,随后 src/bin/claude-code-merge-queue.ts:291 调 runCheckCommand。

src/bin/claude-code-merge-queue.ts:259 · L259-292TypeScript
259    case "check-push": {
260      const root = findRepoRoot();
261      if (!root || !hasConfig(root)) {
262        // No config means Claude Code Merge Queue isn't enabled for this repo — nothing to enforce.
263        process.exit(0);
264      }
265      const cfg = await loadConfig(root);
266      const stdin = await readStdin();
267      const refUpdates = parseRefUpdates(stdin);
268
269      // git still invokes pre-push (with empty stdin) for a push that
270      // updates zero refs — e.g. re-pushing a branch that's already exactly
271      // up to date on the remote. There's nothing to block (nothing is
272      // actually changing on the remote either way) and nothing meaningful
273      // for checkCommand to verify — running it anyway just produces a
274      // confusing, unrelated failure (a missing devDependency, say) that
275      // looks exactly like a real block but isn't one. Recognize "nothing
276      // to push" explicitly instead of silently falling through both checks.
277      if (refUpdates.length === 0) {
278        console.log("claude-code-merge-queue check-push: nothing being pushed (already up to date) — skipping checks.");
279        process.exit(0);
280      }
281
282      if (process.env.CLAUDE_CODE_MERGE_QUEUE_EMERGENCY_PUSH === "1") {
283        console.log("\n🚨 CLAUDE_CODE_MERGE_QUEUE_EMERGENCY_PUSH is set — bypassing the landing queue.\n");
284      }
285
286      const result = checkPush(refUpdates, cfg, process.env);
287      if (!result.ok) {
288        console.error(result.message);
289        process.exit(1);
290      }
291      process.exit(runCheckCommand(cfg, root));
292      return;
§ 05 · DETAIL

源码判断

这个设计把“测试是 reviewer”落成了流程,但它也明确暴露了信任边界。src/lib/check-command.ts:61 在 checkCommand 为空且 checksRequired 为 true 时失败;如果用户显式关掉 checksRequired,系统尊重这个选择。这是工具该有的姿态:强默认,允许明示放弃。

Generated · 2026-07-07Source · funador/claude-code-merge-queue↑ 返回索引