hooks + safety gates 深度解析
WorktreeCreate、Husky pre-push、preflight script 如何把约定变成默认路径。
导入链与契约
Claude hook 契约
WorktreeCreate hook 是 Claude Code 提供的阻塞式创建事件。项目在 src/hooks/worktree-create.ts:179 读取 stdin,src/hooks/worktree-create.ts:189 解析 main checkout,最后 src/hooks/worktree-create.ts:197 创建 lane 并输出绝对路径。这个输出就是 Claude 接下来进入的 worktree。
init wiring 负责把 hook 装进用户 repo。src/lib/wire-hooks.ts:45 写入或合并 .claude/settings.json,src/lib/wire-hooks.ts:99 处理 Husky pre-push,src/lib/wire-hooks.ts:144 设置 core.hooksPath,避免 pre-push 文件存在但 git 不执行。
179export async function runWorktreeCreateHook(): Promise<void> { 180 let input: HookInput = {}; 181 try { 182 input = JSON.parse(await readStdin()) as HookInput; 183 } catch { 184 /* no/invalid stdin — fall back to process.cwd() below */ 185 } 186 const fromCwd = input.cwd ?? process.cwd(); 187 188 try { 189 const mainTop = resolveMainCheckout(fromCwd); 190 if (expectsLocalInstall(mainTop) && isEphemeralNpxCopy(fileURLToPath(import.meta.url))) { 191 throw new Error( 192 "running from npx's ephemeral cache, not this project's own installed dependency — " + 193 "node_modules is missing or broken. Run `npm install` in the main checkout and try again.", 194 ); 195 } 196 const cfg = hasConfig(mainTop) ? await loadConfig(mainTop) : { ...DEFAULTS }; 197 const { wt } = createLane(mainTop, cfg); 198 process.stdout.write(wt + "\n"); 199 process.exit(0); 200 } catch (err) { 201 process.stderr.write(`claude-code-merge-queue worktree-create hook failed: ${err instanceof Error ? err.message : String(err)}\n`); 202 process.exit(1); 203 } 204}
45export function wireClaudeSettings(root: string): WireResult { 46 const dir = join(root, ".claude"); 47 const path = join(dir, "settings.json"); 48 49 if (!existsSync(path)) { 50 mkdirSync(dir, { recursive: true }); 51 const settings: ClaudeSettings = { 52 hooks: { WorktreeCreate: [{ hooks: [{ type: "command", command: HOOK_COMMAND }] }] }, 53 }; 54 writeFileSync(path, JSON.stringify(settings, null, 2) + "\n"); 55 return "created"; 56 } 57 58 let settings: ClaudeSettings; 59 try { 60 settings = JSON.parse(readFileSync(path, "utf8")) as ClaudeSettings; 61 } catch { 62 return "unparseable"; // leave it alone — don't guess at broken JSON 63 } 64 65 settings.hooks ??= {}; 66 settings.hooks.WorktreeCreate ??= []; 67 const alreadyWired = settings.hooks.WorktreeCreate.some((group) => group.hooks?.some((h) => h.command?.includes(HOOK_COMMAND))); 68 if (alreadyWired) return "already-wired"; 69 70 settings.hooks.WorktreeCreate.push({ hooks: [{ type: "command", command: HOOK_COMMAND }] }); 71 writeFileSync(path, JSON.stringify(settings, null, 2) + "\n"); 72 return "merged"; 73}
核心实现逐段拆
lane 创建策略
createLane 的原子性来自 git 自己。src/hooks/worktree-create.ts:84 从 lane 1 开始递增,src/hooks/worktree-create.ts:91 跳过已存在路径,src/hooks/worktree-create.ts:107 或 src/hooks/worktree-create.ts:109 调 git worktree add。若竞争者刚抢走同一 lane,src/hooks/worktree-create.ts:112 捕获失败并继续下一轮。
hook 还保护 npx 陷阱。src/hooks/worktree-create.ts:150 检测 _npx cache path,src/hooks/worktree-create.ts:163 只有当宿主 package.json 声明依赖时才期待本地安装,src/hooks/worktree-create.ts:190 在本地安装应存在却跑到 ephemeral npx copy 时直接报错。
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 }
150export function isEphemeralNpxCopy(selfPath: string): boolean { 151 return selfPath.includes(`${sep}_npx${sep}`); 152} 153 154// The guard above only makes sense for a host project that's an npm project 155// with claude-code-merge-queue as a real dependency — hola, say. A non-Node host repo 156// (a Haskell/Lua/Rust/whatever project with no package.json at all) has 157// nowhere to install claude-code-merge-queue INTO; npx's ephemeral cache is the only way 158// it can ever run claude-code-merge-queue commands, not a fallback masking a broken 159// local install. Only expect a local install — and therefore only treat 160// ephemeral execution as suspicious — when the host's own package.json 161// actually lists claude-code-merge-queue as a dependency. No package.json, or one that 162// doesn't mention claude-code-merge-queue: ephemeral execution is completely normal. 163export function expectsLocalInstall(mainTop: string): boolean { 164 let pkg: { dependencies?: Record<string, string>; devDependencies?: Record<string, string> }; 165 try { 166 pkg = JSON.parse(readFileSync(join(mainTop, "package.json"), "utf8")) as typeof pkg; 167 } catch { 168 return false; // no package.json, or unreadable/invalid — nothing "expected" to be there 169 } 170 return Boolean(pkg.dependencies?.["claude-code-merge-queue"] || pkg.devDependencies?.["claude-code-merge-queue"]); 171} 172 173async function readStdin(): Promise<string> { 174 const chunks: Buffer[] = []; 175 for await (const chunk of process.stdin) chunks.push(chunk as Buffer); 176 return Buffer.concat(chunks).toString("utf8"); 177} 178 179export async function runWorktreeCreateHook(): Promise<void> { 180 let input: HookInput = {}; 181 try { 182 input = JSON.parse(await readStdin()) as HookInput; 183 } catch { 184 /* no/invalid stdin — fall back to process.cwd() below */ 185 } 186 const fromCwd = input.cwd ?? process.cwd(); 187 188 try { 189 const mainTop = resolveMainCheckout(fromCwd); 190 if (expectsLocalInstall(mainTop) && isEphemeralNpxCopy(fileURLToPath(import.meta.url))) { 191 throw new Error( 192 "running from npx's ephemeral cache, not this project's own installed dependency — " + 193 "node_modules is missing or broken. Run `npm install` in the main checkout and try again.", 194 ); 195 } 196 const cfg = hasConfig(mainTop) ? await loadConfig(mainTop) : { ...DEFAULTS }; 197 const { wt } = createLane(mainTop, cfg); 198 process.stdout.write(wt + "\n"); 199 process.exit(0); 200 } catch (err) { 201 process.stderr.write(`claude-code-merge-queue worktree-create hook failed: ${err instanceof Error ? err.message : String(err)}\n`); 202 process.exit(1); 203 } 204}
关键流程
增量 wiring
wiring 逻辑是 additive/idempotent。src/lib/wire-hooks.ts:49 缺 settings 就创建;src/lib/wire-hooks.ts:65 保留现有 hooks 并补 WorktreeCreate;src/lib/wire-hooks.ts:112 检查已有 pre-push 是否包含 marker;src/lib/wire-hooks.ts:115 追加功能片段而不是复制整段 shebang 和说明。
CLI init 把这些 wiring 串起来。src/bin/claude-code-merge-queue.ts:31 进入 init,src/bin/claude-code-merge-queue.ts:45 先保护已有 config,src/bin/claude-code-merge-queue.ts:99 写 Claude settings,src/bin/claude-code-merge-queue.ts:118 写 Husky,src/bin/claude-code-merge-queue.ts:166 写 package scripts。
99export function wireHuskyPrePush(root: string): WireResult { 100 const huskyDir = join(root, ".husky"); 101 if (!existsSync(huskyDir)) return "no-husky"; 102 103 const path = join(huskyDir, "pre-push"); 104 const template = shippedPrePushTemplate(); 105 106 if (!existsSync(path)) { 107 writeFileSync(path, template); 108 chmodSync(path, 0o755); 109 return "created"; 110 } 111 112 const existing = readFileSync(path, "utf8"); 113 if (existing.includes(PRE_PUSH_MARKER)) return "already-wired"; 114 115 const marker = "# --- Claude Code Merge Queue (appended by `claude-code-merge-queue init`) — see node_modules/claude-code-merge-queue/hooks/pre-push for the full comments ---"; 116 appendFileSync(path, `\n${marker}\n${functionalSnippet(template)}`); 117 chmodSync(path, 0o755); 118 return "merged"; 119} 120 121export type HooksPathResult = "set" | "already-set" | "custom-path"; 122 123/** 124 * A `.husky/pre-push` file on disk enforces nothing on its own — git only 125 * runs it if `core.hooksPath` points somewhere that resolves to it, which is 126 * normally set as a side effect of the package manager's install step 127 * (husky's own `prepare` script). On a freshly cloned repo where nobody's 128 * run that install yet — the exact state Quickstart leaves you in right 129 * after `init` — the file is silently inert and a direct push sails through 130 * uncontested. Since Claude Code Merge Queue is the one promising "pushes are gated now," 131 * it sets this itself instead of depending on a step that may not have 132 * happened yet, mirroring exactly what `husky install` itself does. 133 * 134 * Husky v9 changed its own convention mid-flight: v6–v8 point 135 * core.hooksPath directly at `.husky` (hook files run as-is); v9 points it 136 * at `.husky/_`, a generated wrapper directory that then execs the real 137 * `.husky/<hookname>` file. Both are legitimate, already-correct setups — 138 * only treat something OTHER than either as a deliberate custom path worth 139 * warning about. `.husky/_` doesn't exist yet on the fresh-clone/no-install 140 * case this function exists for, so `.husky` remains the right thing to set 141 * when nothing's configured at all; if the project turns out to be v9, 142 * husky's own next real install corrects it to `.husky/_`. 143 */ 144export function ensureHooksPath(root: string): HooksPathResult { 145 let current: string | null; 146 try { 147 current = execFileSync("git", ["config", "core.hooksPath"], { cwd: root, encoding: "utf8" }).trim(); 148 } catch { 149 current = null; // unset 150 } 151 152 if (current === ".husky" || current === ".husky/_") return "already-set"; 153 if (current) return "custom-path"; // respect an existing deliberate setup — don't override it 154 155 execFileSync("git", ["config", "core.hooksPath", ".husky"], { cwd: root }); 156 return "set"; 157}
31async function init(): Promise<void> { 32 const root = findRepoRoot(); 33 if (!root) { 34 console.error("claude-code-merge-queue init: not inside a git repo."); 35 process.exit(1); 36 } 37 const { writeFileSync, readFileSync: read, existsSync, appendFileSync } = await import("node:fs"); 38 const { join } = await import("node:path"); 39 40 // What actually got newly written or modified this run — only these need 41 // committing. If everything below was already wired from a previous run, 42 // there's nothing new to tell you to commit. 43 const writtenFiles: string[] = []; 44 45 if (hasConfig(root)) { 46 console.log("claude-code-merge-queue init: claude-code-merge-queue.config.mjs already exists — leaving it alone."); 47 } else { 48 writtenFiles.push("claude-code-merge-queue.config.mjs"); 49 const detectedBranch = detectCurrentBranch(root); 50 const detectedCheck = detectCheckCommand(root); 51 const generated = { 52 ...DEFAULTS, 53 ...(detectedBranch ? { integrationBranch: detectedBranch } : {}), 54 ...(detectedCheck ? { checkCommand: detectedCheck } : {}), 55 }; 56 const template = `// claude-code-merge-queue.config.mjs — generated by \`claude-code-merge-queue init\`. Edit freely. 57// Its presence here is what turns Claude Code Merge Queue ON for this repo. 58 59/** @type {import("claude-code-merge-queue").ClaudeCodeMergeQueueConfig} */ 60export default ${JSON.stringify(generated, null, 2)}; 61`; 62 writeFileSync(join(root, "claude-code-merge-queue.config.mjs"), template); 63 console.log(`claude-code-merge-queue init: wrote ${join(root, "claude-code-merge-queue.config.mjs")}`); 64 if (detectedBranch && detectedBranch !== DEFAULTS.integrationBranch) { 65 console.log(` (detected current branch "${detectedBranch}" — set as integrationBranch instead of the "${DEFAULTS.integrationBranch}" default)`); 66 } else if (!detectedBranch) { 67 console.log(""); 68 console.log(` ⚠️ Couldn't detect the current branch (detached HEAD?) — integrationBranch`); 69 console.log(` defaulted to "${DEFAULTS.integrationBranch}". Verify that's actually right`); 70 console.log(` in claude-code-merge-queue.config.mjs before committing it.`); 71 } 72 if (detectedCheck) { 73 console.log(` (detected "${detectedCheck}" from package.json — set as checkCommand)`); 74 } else { 75 console.log(""); 76 console.log(" ⚠️ No checkCommand detected (no check:push/check/ci/test script found in"); 77 console.log(" package.json). Every push is BLOCKED until you set one — or set"); 78 console.log(" checksRequired: false to deliberately run with no checks."); 79 } 80 }
边界与失败
wire-hooks 的测试关注“不要覆盖用户已有配置”。test/wire-hooks.test.ts:30 先写一个 PreToolUse hook,再验证 merge 后仍存在;test/wire-hooks.test.ts:90 写自定义 pre-push,再确认追加块没有删 custom logic;test/wire-hooks.test.ts:130 验证 fresh clone 时 core.hooksPath 会被设置。
worktree 测试覆盖并发和路径解析。test/worktree-create.test.ts:84 同时启动四个 hook worker,test/worktree-create.test.ts:109 检查路径全不同;test/worktree-create.test.ts:119 从已创建 lane 内再次触发 hook,防止 mainTop 解析回归成无限循环。
30test("wireClaudeSettings merges into an existing settings.json without dropping other hooks", () => { 31 const dir = scratchDir(); 32 try { 33 mkdirSync(join(dir, ".claude")); 34 writeFileSync( 35 join(dir, ".claude", "settings.json"), 36 JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: "command", command: "echo hi" }] }] } }), 37 ); 38 assert.equal(wireClaudeSettings(dir), "merged"); 39 const written = JSON.parse(readFileSync(join(dir, ".claude", "settings.json"), "utf8")); 40 assert.equal(written.hooks.PreToolUse[0].hooks[0].command, "echo hi", "existing hook must survive"); 41 assert.equal(written.hooks.WorktreeCreate[0].hooks[0].command, "npx claude-code-merge-queue hook worktree-create"); 42 } finally { 43 rmSync(dir, { recursive: true, force: true }); 44 } 45}); 46 47test("wireClaudeSettings is idempotent", () => { 48 const dir = scratchDir(); 49 try { 50 wireClaudeSettings(dir); 51 assert.equal(wireClaudeSettings(dir), "already-wired"); 52 } finally { 53 rmSync(dir, { recursive: true, force: true }); 54 } 55}); 56 57test("wireClaudeSettings leaves unparseable JSON untouched", () => { 58 const dir = scratchDir(); 59 try { 60 mkdirSync(join(dir, ".claude")); 61 writeFileSync(join(dir, ".claude", "settings.json"), "{ not valid json"); 62 assert.equal(wireClaudeSettings(dir), "unparseable"); 63 assert.equal(readFileSync(join(dir, ".claude", "settings.json"), "utf8"), "{ not valid json"); 64 } finally { 65 rmSync(dir, { recursive: true, force: true }); 66 } 67}); 68 69test("wireHuskyPrePush does nothing when there's no .husky directory", () => { 70 const dir = scratchDir(); 71 try { 72 assert.equal(wireHuskyPrePush(dir), "no-husky"); 73 assert.ok(!existsSync(join(dir, ".husky"))); 74 } finally { 75 rmSync(dir, { recursive: true, force: true }); 76 } 77}); 78 79test("wireHuskyPrePush creates pre-push when .husky exists but the hook doesn't", () => { 80 const dir = scratchDir(); 81 try { 82 mkdirSync(join(dir, ".husky")); 83 assert.equal(wireHuskyPrePush(dir), "created"); 84 assert.match(readFileSync(join(dir, ".husky", "pre-push"), "utf8"), /claude-code-merge-queue check-push/); 85 } finally { 86 rmSync(dir, { recursive: true, force: true }); 87 } 88}); 89 90test("wireHuskyPrePush appends to an existing custom pre-push without dropping it", () => { 91 const dir = scratchDir(); 92 try { 93 mkdirSync(join(dir, ".husky")); 94 writeFileSync(join(dir, ".husky", "pre-push"), "#!/usr/bin/env sh\necho custom logic\n"); 95 assert.equal(wireHuskyPrePush(dir), "merged"); 96 const content = readFileSync(join(dir, ".husky", "pre-push"), "utf8"); 97 assert.match(content, /echo custom logic/); 98 assert.match(content, /claude-code-merge-queue check-push/); 99 } finally { 100 rmSync(dir, { recursive: true, force: true }); 101 } 102}); 103 104test("wireHuskyPrePush's appended block has no duplicate shebang or self-referential 'copy this file' prose", () => { 105 const dir = scratchDir(); 106 try { 107 mkdirSync(join(dir, ".husky")); 108 writeFileSync(join(dir, ".husky", "pre-push"), "#!/usr/bin/env sh\necho custom logic\n"); 109 wireHuskyPrePush(dir); 110 const content = readFileSync(join(dir, ".husky", "pre-push"), "utf8"); 111 const shebangCount = content.split("\n").filter((l) => l.startsWith("#!")).length; 112 assert.equal(shebangCount, 1, "only the file's own original shebang should be present"); 113 assert.doesNotMatch(content, /Copy this file to \.husky/); 114 } finally { 115 rmSync(dir, { recursive: true, force: true }); 116 } 117});
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}); 118 119test("the hook resolves the main checkout correctly when invoked from INSIDE an already-created lane worktree, not just from mainTop itself", async () => { 120 // `git rev-parse --git-common-dir` returns the relative string ".git" from 121 // the main checkout, but an ABSOLUTE path when run from a linked worktree. 122 // A previous, separate path-join implementation only handled the relative 123 // case correctly; from inside a real linked worktree it produced a 124 // nonsense path, which meant createLane's lane-claim loop found no 125 // existing lane ever "in the way" and no `git worktree add` ever 126 // succeeding either — an unbounded loop that burned CPU indefinitely 127 // instead of failing. This reproduces exactly that invocation shape. 128 const mainTop = makeScratchRepo(); 129 try { 130 const first = createLane(mainTop, DEFAULTS); 131 132 const child = spawn("node", ["--import", "tsx", WORKER], { stdio: ["pipe", "pipe", "inherit"] }); 133 child.stdin.end(JSON.stringify({ cwd: first.wt })); // cwd is INSIDE the lane, not mainTop 134 135 const result = await Promise.race([ 136 new Promise<{ code: number; stdout: string }>((resolve) => { 137 let stdout = ""; 138 child.stdout.on("data", (d) => (stdout += d)); 139 child.on("exit", (code) => resolve({ code: code ?? 1, stdout: stdout.trim() })); 140 }), 141 new Promise<never>((_, reject) => 142 setTimeout(() => { 143 child.kill("SIGKILL"); 144 reject(new Error("hook did not exit within 10s — likely regressed into the unbounded lane-claim loop")); 145 }, 10_000), 146 ), 147 ]); 148 149 assert.equal(result.code, 0, "hook should exit 0"); 150 assert.equal(result.stdout, expectedLanePath(mainTop, 2), "should claim lane 2, resolved back to the true mainTop"); 151 assert.ok(existsSync(result.stdout)); 152 } finally { 153 cleanupRepoAndLanes(mainTop); 154 } 155});
关联模块
pre-push 的安全门在 src/lib/check-push.ts:52,checkCommand 的执行在 src/lib/check-command.ts:60。两者被 CLI 的 src/bin/claude-code-merge-queue.ts:259 check-push 分支组合起来:先看 ref 是否允许,再跑项目自己的 lint/test/build。
37export function parseRefUpdates(stdin: string): RefUpdate[] { 38 return stdin 39 .split("\n") 40 .map((l) => l.trim()) 41 .filter(Boolean) 42 .map((line) => { 43 const [localRef, localSha, remoteRef, remoteSha] = line.split(/\s+/); 44 return { localRef: localRef ?? "", localSha: localSha ?? "", remoteRef: remoteRef ?? "", remoteSha: remoteSha ?? "" }; 45 }); 46} 47 48function emergencyConfirmed(env: NodeJS.ProcessEnv): boolean { 49 return env.CLAUDE_CODE_MERGE_QUEUE_EMERGENCY_PUSH === "1"; 50} 51 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}
24export function detectPackageManager(root: string): "npm" | "pnpm" | "yarn" | "bun" { 25 if (existsSync(join(root, "pnpm-lock.yaml")) || existsSync(join(root, "pnpm-workspace.yaml"))) return "pnpm"; 26 if (existsSync(join(root, "yarn.lock"))) return "yarn"; 27 if (existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bun.lock"))) return "bun"; 28 return "npm"; 29} 30 31/** Look at package.json's own scripts for something to run — best-effort, never throws. */ 32export function detectCheckCommand(root: string): string | null { 33 const pkgPath = join(root, "package.json"); 34 if (!existsSync(pkgPath)) return null; 35 try { 36 const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { scripts?: Record<string, string> }; 37 const found = CANDIDATE_SCRIPTS.find((name) => pkg.scripts?.[name]); 38 return found ? `${detectPackageManager(root)} run ${found}` : null; 39 } catch { 40 return null; 41 } 42} 43 44/** 45 * Run the configured check command. Returns the exit code to propagate. 46 * 47 * A null checkCommand is only ever silent if checksRequired was explicitly 48 * turned off — the default is to FAIL the push, because a merge queue that 49 * lets unverified code through by default isn't one, it's a false sense of 50 * safety. 51 * 52 * Always runs from `root`, not whatever the caller's cwd happens to be. In 53 * the real git-push flow that's moot — git always resets cwd to the repo 54 * root before running a hook — but `check-push` is also a directly 55 * user-runnable command, and silently depending on git's hook behavior for 56 * correctness (instead of just being correct on its own) is exactly the 57 * kind of implicit assumption that bites the first time someone runs it by 58 * hand from a subdirectory. 59 */ 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}
源码判断
这组 hook 的价值在于把流程入口前移到 lane 创建和 push 之前,而不是等冲突发生后再要求 agent 修。风险也很现实:src/lib/check-push.ts:48 的 emergency env var 说明它不是恶意环境下的强安全边界,但对“误操作”和“忘记流程”的防护已经足够实用。