land + sync 深度解析
land 如何串行 rebase/push,sync 如何让主 checkout 安全跟上。
导入链与契约
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,不替用户猜。
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");
核心实现逐段拆
串行落地主线
落地锁在 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 知道这是合法路径。
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);
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}
关键流程
同步主 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。
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}
边界与失败
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。
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});
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");
关联模块
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。
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;
源码判断
这个设计把“测试是 reviewer”落成了流程,但它也明确暴露了信任边界。src/lib/check-command.ts:61 在 checkCommand 为空且 checksRequired 为 true 时失败;如果用户显式关掉 checksRequired,系统尊重这个选择。这是工具该有的姿态:强默认,允许明示放弃。