MergeQueue
§ · Nested detail page

hooks + safety gates 深度解析

WorktreeCreate、Husky pre-push、preflight script 如何把约定变成默认路径。

§ 00 · DETAIL

导入链与契约

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 不执行。

src/hooks/worktree-create.ts:179 · L179-204TypeScript
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}
src/lib/wire-hooks.ts:45 · L45-73TypeScript
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}
§ 01 · DETAIL

核心实现逐段拆

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 时直接报错。

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  }
src/hooks/worktree-create.ts:150 · L150-204TypeScript
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}
§ 02 · DETAIL

关键流程

增量 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。

init → config/CLAUDE.md → WorktreeCreate hook → Husky pre-push → preflight script → package scripts
src/lib/wire-hooks.ts:99 · L99-157TypeScript
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}
src/bin/claude-code-merge-queue.ts:31 · L31-80TypeScript
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  }
§ 03 · DETAIL

边界与失败

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 解析回归成无限循环。

test/wire-hooks.test.ts:30 · L30-117TypeScript
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});
test/worktree-create.test.ts:84 · L84-155TypeScript
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});
§ 04 · DETAIL

关联模块

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。

src/lib/check-push.ts:37 · L37-90TypeScript
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}
src/lib/check-command.ts:24 · L24-84TypeScript
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}
§ 05 · DETAIL

源码判断

这组 hook 的价值在于把流程入口前移到 lane 创建和 push 之前,而不是等冲突发生后再要求 agent 修。风险也很现实:src/lib/check-push.ts:48 的 emergency env var 说明它不是恶意环境下的强安全边界,但对“误操作”和“忘记流程”的防护已经足够实用。

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