claude-code-merge-queue 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +260 -0
  3. package/dist/bin/claude-code-local-merge.d.ts +2 -0
  4. package/dist/bin/claude-code-local-merge.js +316 -0
  5. package/dist/bin/lanekeeper.d.ts +2 -0
  6. package/dist/bin/lanekeeper.js +307 -0
  7. package/dist/bin/localmerge.d.ts +2 -0
  8. package/dist/bin/localmerge.js +316 -0
  9. package/dist/bin/mergequeue.d.ts +2 -0
  10. package/dist/bin/mergequeue.js +307 -0
  11. package/dist/build-lock.d.ts +1 -0
  12. package/dist/build-lock.js +70 -0
  13. package/dist/hooks/worktree-create.d.ts +15 -0
  14. package/dist/hooks/worktree-create.js +192 -0
  15. package/dist/land.d.ts +1 -0
  16. package/dist/land.js +144 -0
  17. package/dist/lib/check-command.d.ts +29 -0
  18. package/dist/lib/check-command.js +83 -0
  19. package/dist/lib/check-push.d.ts +35 -0
  20. package/dist/lib/check-push.js +46 -0
  21. package/dist/lib/claude-md-snippet.d.ts +16 -0
  22. package/dist/lib/claude-md-snippet.js +18 -0
  23. package/dist/lib/config.d.ts +92 -0
  24. package/dist/lib/config.js +137 -0
  25. package/dist/lib/ephemeral.d.ts +40 -0
  26. package/dist/lib/ephemeral.js +100 -0
  27. package/dist/lib/lane-port.d.ts +3 -0
  28. package/dist/lib/lane-port.js +25 -0
  29. package/dist/lib/main-checkout.d.ts +1 -0
  30. package/dist/lib/main-checkout.js +19 -0
  31. package/dist/lib/prune-lanes.d.ts +36 -0
  32. package/dist/lib/prune-lanes.js +196 -0
  33. package/dist/lib/queue-lock.d.ts +26 -0
  34. package/dist/lib/queue-lock.js +212 -0
  35. package/dist/lib/tty-confirm.d.ts +1 -0
  36. package/dist/lib/tty-confirm.js +44 -0
  37. package/dist/lib/wire-hooks.d.ts +62 -0
  38. package/dist/lib/wire-hooks.js +230 -0
  39. package/dist/preview.d.ts +1 -0
  40. package/dist/preview.js +119 -0
  41. package/dist/promote.d.ts +1 -0
  42. package/dist/promote.js +77 -0
  43. package/dist/sync.d.ts +16 -0
  44. package/dist/sync.js +161 -0
  45. package/examples/claude-code-local-merge.config.mjs +67 -0
  46. package/examples/ephemeral-tmp-dir.example.ts +66 -0
  47. package/hooks/claude-settings.example.json +14 -0
  48. package/hooks/pre-push +23 -0
  49. package/package.json +46 -0
@@ -0,0 +1,307 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The mergequeue CLI. Every subcommand reads mergequeue.config from the
4
+ * current repo — see src/lib/config.ts — so none of this is hardcoded to
5
+ * any one project's branch names.
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import { land } from "../land.js";
9
+ import { sync } from "../sync.js";
10
+ import { promote } from "../promote.js";
11
+ import { runPreview } from "../preview.js";
12
+ import { buildLock } from "../build-lock.js";
13
+ import { findRepoRoot, hasConfig, loadConfig, detectCurrentBranch, DEFAULTS } from "../lib/config.js";
14
+ import { checkPush, parseRefUpdates } from "../lib/check-push.js";
15
+ import { runWorktreeCreateHook } from "../hooks/worktree-create.js";
16
+ import { lanePort } from "../lib/lane-port.js";
17
+ import { claudeMdSnippet, MARKER } from "../lib/claude-md-snippet.js";
18
+ import { detectCheckCommand, runCheckCommand } from "../lib/check-command.js";
19
+ import { wireClaudeSettings, wireHuskyPrePush, ensureHooksPath, wirePackageJsonScripts } from "../lib/wire-hooks.js";
20
+ import { resolveMainCheckout } from "../lib/main-checkout.js";
21
+ import { pruneLandedLanes } from "../lib/prune-lanes.js";
22
+ const [, , command, ...rest] = process.argv;
23
+ async function readStdin() {
24
+ const chunks = [];
25
+ for await (const chunk of process.stdin)
26
+ chunks.push(chunk);
27
+ return Buffer.concat(chunks).toString("utf8");
28
+ }
29
+ async function init() {
30
+ const root = findRepoRoot();
31
+ if (!root) {
32
+ console.error("mergequeue init: not inside a git repo.");
33
+ process.exit(1);
34
+ }
35
+ const { writeFileSync, readFileSync: read, existsSync, appendFileSync } = await import("node:fs");
36
+ const { join } = await import("node:path");
37
+ // What actually got newly written or modified this run — only these need
38
+ // committing. If everything below was already wired from a previous run,
39
+ // there's nothing new to tell you to commit.
40
+ const writtenFiles = [];
41
+ if (hasConfig(root)) {
42
+ console.log("mergequeue init: mergequeue.config.mjs already exists — leaving it alone.");
43
+ }
44
+ else {
45
+ writtenFiles.push("mergequeue.config.mjs");
46
+ const detectedBranch = detectCurrentBranch(root);
47
+ const detectedCheck = detectCheckCommand(root);
48
+ const generated = {
49
+ ...DEFAULTS,
50
+ ...(detectedBranch ? { integrationBranch: detectedBranch } : {}),
51
+ ...(detectedCheck ? { checkCommand: detectedCheck } : {}),
52
+ };
53
+ const template = `// mergequeue.config.mjs — generated by \`mergequeue init\`. Edit freely.
54
+ // Its presence here is what turns MergeQueue ON for this repo.
55
+
56
+ /** @type {import("mergequeue").MergeQueueConfig} */
57
+ export default ${JSON.stringify(generated, null, 2)};
58
+ `;
59
+ writeFileSync(join(root, "mergequeue.config.mjs"), template);
60
+ console.log(`mergequeue init: wrote ${join(root, "mergequeue.config.mjs")}`);
61
+ if (detectedBranch && detectedBranch !== DEFAULTS.integrationBranch) {
62
+ console.log(` (detected current branch "${detectedBranch}" — set as integrationBranch instead of the "${DEFAULTS.integrationBranch}" default)`);
63
+ }
64
+ else if (!detectedBranch) {
65
+ console.log("");
66
+ console.log(` ⚠️ Couldn't detect the current branch (detached HEAD?) — integrationBranch`);
67
+ console.log(` defaulted to "${DEFAULTS.integrationBranch}". Verify that's actually right`);
68
+ console.log(` in mergequeue.config.mjs before committing it.`);
69
+ }
70
+ if (detectedCheck) {
71
+ console.log(` (detected "${detectedCheck}" from package.json — set as checkCommand)`);
72
+ }
73
+ else {
74
+ console.log("");
75
+ console.log(" ⚠️ No checkCommand detected (no check:push/check/ci/test script found in");
76
+ console.log(" package.json). Every push is BLOCKED until you set one — or set");
77
+ console.log(" checksRequired: false to deliberately run with no checks.");
78
+ }
79
+ }
80
+ // This is the part that actually makes agents use the workflow without a
81
+ // human reminding them every session — see claude-md-snippet.ts.
82
+ const claudeMdPath = join(root, "CLAUDE.md");
83
+ const cfg = hasConfig(root) ? await loadConfig(root) : DEFAULTS;
84
+ const snippet = claudeMdSnippet(cfg);
85
+ if (!existsSync(claudeMdPath)) {
86
+ writeFileSync(claudeMdPath, `# Project instructions for Claude Code\n\n${snippet}`);
87
+ console.log(`mergequeue init: wrote ${claudeMdPath}`);
88
+ writtenFiles.push("CLAUDE.md");
89
+ }
90
+ else if (!read(claudeMdPath, "utf8").includes(MARKER)) {
91
+ appendFileSync(claudeMdPath, `\n${snippet}`);
92
+ console.log(`mergequeue init: appended the MergeQueue workflow section to ${claudeMdPath}`);
93
+ writtenFiles.push("CLAUDE.md");
94
+ }
95
+ else {
96
+ console.log(`mergequeue init: ${claudeMdPath} already has the MergeQueue workflow section — leaving it alone.`);
97
+ }
98
+ const claudeSettingsResult = wireClaudeSettings(root);
99
+ switch (claudeSettingsResult) {
100
+ case "created":
101
+ console.log(`mergequeue init: wrote ${join(root, ".claude", "settings.json")} with the WorktreeCreate hook.`);
102
+ writtenFiles.push(".claude/settings.json");
103
+ break;
104
+ case "merged":
105
+ console.log(`mergequeue init: added the WorktreeCreate hook to your existing ${join(root, ".claude", "settings.json")}.`);
106
+ writtenFiles.push(".claude/settings.json");
107
+ break;
108
+ case "already-wired":
109
+ console.log("mergequeue init: .claude/settings.json already has the WorktreeCreate hook — leaving it alone.");
110
+ break;
111
+ case "unparseable":
112
+ console.log("mergequeue init: .claude/settings.json exists but isn't valid JSON — left untouched. Wire it manually,");
113
+ console.log(" see node_modules/mergequeue/hooks/claude-settings.example.json.");
114
+ break;
115
+ }
116
+ const prePushResult = wireHuskyPrePush(root);
117
+ switch (prePushResult) {
118
+ case "created":
119
+ console.log("mergequeue init: wrote .husky/pre-push.");
120
+ writtenFiles.push(".husky/pre-push");
121
+ break;
122
+ case "merged":
123
+ console.log("mergequeue init: appended MergeQueue's checks to your existing .husky/pre-push.");
124
+ writtenFiles.push(".husky/pre-push");
125
+ break;
126
+ case "already-wired":
127
+ console.log("mergequeue init: .husky/pre-push already wired — leaving it alone.");
128
+ break;
129
+ case "no-husky":
130
+ console.log("mergequeue init: no .husky/ directory found — pre-push hook NOT wired automatically.");
131
+ console.log(" Install Husky, or copy node_modules/mergequeue/hooks/pre-push to .git/hooks/pre-push");
132
+ console.log(" yourself (note: .git/hooks isn't version-controlled — only Husky's is shared with your team).");
133
+ break;
134
+ }
135
+ if (prePushResult === "created" || prePushResult === "merged" || prePushResult === "already-wired") {
136
+ // A .husky/pre-push file enforces nothing until core.hooksPath actually
137
+ // points at .husky — normally a side effect of the package manager's
138
+ // install step, which may not have run yet (e.g. a fresh clone, right
139
+ // where Quickstart leaves you). Without this, a direct push sails
140
+ // through uncontested with no indication anything's wrong.
141
+ switch (ensureHooksPath(root)) {
142
+ case "set":
143
+ console.log("mergequeue init: set core.hooksPath=.husky so the pre-push hook actually runs (normally set by your package manager's install step, which may not have run yet).");
144
+ break;
145
+ case "already-set":
146
+ break; // the common case once installed — nothing to say
147
+ case "custom-path":
148
+ console.log("mergequeue init: core.hooksPath is set to something other than .husky — leaving it alone.");
149
+ console.log(" Wrote .husky/pre-push, but it won't run until your hooks path points there. Reconcile this yourself.");
150
+ break;
151
+ }
152
+ }
153
+ const scriptsResult = wirePackageJsonScripts(root);
154
+ switch (scriptsResult.result) {
155
+ case "added":
156
+ console.log(`mergequeue init: added "${scriptsResult.added.join('", "')}" to package.json scripts.`);
157
+ writtenFiles.push("package.json");
158
+ break;
159
+ case "already-wired":
160
+ console.log("mergequeue init: package.json already has all five scripts — leaving them alone.");
161
+ break;
162
+ case "no-package-json":
163
+ console.log("mergequeue init: no package.json found — scripts NOT wired automatically.");
164
+ console.log(' Add "land"/"sync"/"promote"/"preview"/"preview:restore" -> "mergequeue <name>" yourself.');
165
+ break;
166
+ case "unparseable":
167
+ console.log("mergequeue init: package.json exists but isn't valid JSON — left untouched. Wire the scripts manually.");
168
+ break;
169
+ }
170
+ console.log("");
171
+ console.log("Next steps:");
172
+ if (writtenFiles.length > 0) {
173
+ console.log(` 1. Commit what it wrote — ${writtenFiles.join(", ")}.`);
174
+ console.log(" 2. claude --worktree <name> — the agent takes it from there.");
175
+ }
176
+ else {
177
+ console.log(" 1. claude --worktree <name> — the agent takes it from there.");
178
+ console.log(" (everything was already wired — nothing new to commit)");
179
+ }
180
+ }
181
+ async function main() {
182
+ switch (command) {
183
+ case "init":
184
+ return init();
185
+ case "hook": {
186
+ const sub = rest[0];
187
+ if (sub === "worktree-create")
188
+ return runWorktreeCreateHook();
189
+ console.error(`mergequeue hook: unknown hook "${sub ?? ""}". Only "worktree-create" is supported.`);
190
+ process.exit(1);
191
+ return;
192
+ }
193
+ case "port": {
194
+ const root = findRepoRoot();
195
+ if (!root || !hasConfig(root)) {
196
+ console.error("mergequeue port: no mergequeue.config found — not a lane.");
197
+ process.exit(1);
198
+ }
199
+ const cfg = await loadConfig(root);
200
+ const port = lanePort(process.cwd(), cfg);
201
+ if (port === null) {
202
+ console.error("mergequeue port: current directory doesn't look like a lane worktree.");
203
+ process.exit(1);
204
+ }
205
+ console.log(port);
206
+ return;
207
+ }
208
+ case "land":
209
+ return land();
210
+ case "sync":
211
+ process.exit(await sync());
212
+ return;
213
+ case "promote":
214
+ process.exit(await promote());
215
+ return;
216
+ case "prune": {
217
+ // `land` already does this automatically as a side effect of every
218
+ // successful landing — this is the on-demand escape hatch for
219
+ // "clean these up right now" instead of waiting for the next lane to
220
+ // land something. Same safety rules apply: only already-merged
221
+ // branches, never anything with a live process inside, never the
222
+ // worktree this command itself is running from.
223
+ const root = findRepoRoot();
224
+ if (!root || !hasConfig(root)) {
225
+ console.error("mergequeue prune: no mergequeue.config found — nothing to do.");
226
+ process.exit(0);
227
+ }
228
+ const cfg = await loadConfig(root);
229
+ const mainTop = resolveMainCheckout(process.cwd());
230
+ const pruned = pruneLandedLanes(mainTop, cfg, process.cwd());
231
+ if (pruned.length === 0) {
232
+ console.log("mergequeue prune: nothing to clean up — no already-landed sibling lanes found.");
233
+ }
234
+ else {
235
+ const names = pruned.map((p) => p.split("/").pop()).join(", ");
236
+ console.log(`mergequeue prune: removed ${pruned.length} already-landed lane${pruned.length === 1 ? "" : "s"}: ${names}`);
237
+ }
238
+ return;
239
+ }
240
+ case "preview":
241
+ return runPreview(rest);
242
+ case "build-lock": {
243
+ const parts = rest[0] === "--" ? rest.slice(1) : rest;
244
+ return buildLock(parts);
245
+ }
246
+ case "check-push": {
247
+ const root = findRepoRoot();
248
+ if (!root || !hasConfig(root)) {
249
+ // No config means MergeQueue isn't enabled for this repo — nothing to enforce.
250
+ process.exit(0);
251
+ }
252
+ const cfg = await loadConfig(root);
253
+ const stdin = await readStdin();
254
+ const refUpdates = parseRefUpdates(stdin);
255
+ // git still invokes pre-push (with empty stdin) for a push that
256
+ // updates zero refs — e.g. re-pushing a branch that's already exactly
257
+ // up to date on the remote. There's nothing to block (nothing is
258
+ // actually changing on the remote either way) and nothing meaningful
259
+ // for checkCommand to verify — running it anyway just produces a
260
+ // confusing, unrelated failure (a missing devDependency, say) that
261
+ // looks exactly like a real block but isn't one. Recognize "nothing
262
+ // to push" explicitly instead of silently falling through both checks.
263
+ if (refUpdates.length === 0) {
264
+ console.log("mergequeue check-push: nothing being pushed (already up to date) — skipping checks.");
265
+ process.exit(0);
266
+ }
267
+ if (process.env.MERGEQUEUE_EMERGENCY_PUSH === "1") {
268
+ console.log("\n🚨 MERGEQUEUE_EMERGENCY_PUSH is set — bypassing the landing queue.\n");
269
+ }
270
+ const result = checkPush(refUpdates, cfg, process.env);
271
+ if (!result.ok) {
272
+ console.error(result.message);
273
+ process.exit(1);
274
+ }
275
+ process.exit(runCheckCommand(cfg, root));
276
+ return;
277
+ }
278
+ case "--version":
279
+ case "-v": {
280
+ const { join, dirname } = await import("node:path");
281
+ const { fileURLToPath } = await import("node:url");
282
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
283
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
284
+ console.log(pkg.version);
285
+ return;
286
+ }
287
+ default:
288
+ console.log(`mergequeue — keep parallel coding agents in their lane.
289
+
290
+ Usage:
291
+ mergequeue init write a starter mergequeue.config.mjs
292
+ mergequeue land rebase + push this lane onto the integration branch (queued)
293
+ mergequeue sync fast-forward the MAIN checkout to its upstream
294
+ mergequeue promote ship the integration branch to production (human-only — never script this)
295
+ mergequeue preview [--restore] swap the MAIN checkout to this lane's working tree, or restore it
296
+ mergequeue build-lock -- <cmd> run <cmd>, serialized across every lane
297
+ mergequeue port print this lane's dev-server port
298
+ mergequeue hook worktree-create (Claude Code WorktreeCreate hook — not for direct use)
299
+ mergequeue check-push (used by the pre-push hook — not for direct use)
300
+ `);
301
+ process.exit(command ? 1 : 0);
302
+ }
303
+ }
304
+ main().catch((err) => {
305
+ console.error(err instanceof Error ? err.message : err);
306
+ process.exit(1);
307
+ });
@@ -0,0 +1 @@
1
+ export declare function buildLock(commandParts: string[]): Promise<void>;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * build-lock.ts — wrap any command so only ONE runs at a time, machine-wide,
3
+ * across every lane of this repo.
4
+ *
5
+ * A full production build is CPU/RAM-heavy. Run one per lane and four
6
+ * parallel agents will thrash a laptop into a death spiral. This doesn't
7
+ * make builds faster — it makes them take turns, via the same cross-worktree
8
+ * FIFO lock everything else in this repo shares (queue-lock.ts).
9
+ *
10
+ * Usage: claude-code-local-merge build-lock -- <shell command>
11
+ *
12
+ * Crash-safe with no timeouts: a lock whose holder PID has died is reclaimed
13
+ * deterministically, so a killed build can't wedge the queue for anyone
14
+ * else. That's also why the child is spawned detached — SIGKILLing this
15
+ * process still leaves an orphaned build running unless something can reach
16
+ * its whole process group, so a caller that wants to hard-kill a build (a CI
17
+ * runner enforcing a deadline, say) needs a group to signal, not just a PID.
18
+ */
19
+ import { spawn } from "node:child_process";
20
+ import { createQueueLock } from "./lib/queue-lock.js";
21
+ export async function buildLock(commandParts) {
22
+ const command = commandParts.join(" ").trim();
23
+ if (!command) {
24
+ console.error("claude-code-local-merge build-lock: no command given. Usage: claude-code-local-merge build-lock -- <command>");
25
+ process.exit(2);
26
+ }
27
+ const lock = createQueueLock("build");
28
+ await lock.acquire({
29
+ label: command,
30
+ onWait: ({ ahead, holder }) => {
31
+ if (ahead > 0) {
32
+ console.log(`\x1b[2m[build-queue] ${lock.lane}: waiting — ${ahead} build${ahead === 1 ? "" : "s"} ahead…\x1b[0m`);
33
+ }
34
+ else if (holder) {
35
+ console.log(`\x1b[2m[build-queue] ${lock.lane}: next up — waiting for the running build to finish…\x1b[0m`);
36
+ }
37
+ },
38
+ });
39
+ console.log(`\x1b[2m[build-queue] ${lock.lane}: lock acquired — building…\x1b[0m`);
40
+ const child = spawn(command, { shell: true, stdio: "inherit", detached: true });
41
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
42
+ process.on(sig, () => {
43
+ try {
44
+ // detached:true made the child its own process-group leader, so a
45
+ // negative pid signals the whole tree, not just the shell wrapper.
46
+ if (child.pid)
47
+ process.kill(-child.pid, sig);
48
+ }
49
+ catch {
50
+ /* noop */
51
+ }
52
+ lock.release();
53
+ process.exit(130);
54
+ });
55
+ }
56
+ child.on("exit", (code, signal) => {
57
+ lock.release();
58
+ if (signal) {
59
+ process.kill(process.pid, signal);
60
+ }
61
+ else {
62
+ process.exit(code ?? 1);
63
+ }
64
+ });
65
+ child.on("error", (err) => {
66
+ console.error(`claude-code-local-merge build-lock: failed to start command: ${err.message}`);
67
+ lock.release();
68
+ process.exit(1);
69
+ });
70
+ }
@@ -0,0 +1,15 @@
1
+ import { type ClaudeCodeLocalMergeConfig } from "../lib/config.js";
2
+ /**
3
+ * Claim the lowest free lane, create its worktree, and symlink the
4
+ * configured git-ignored paths into it. Throws with a human-readable
5
+ * message on failure — the caller turns that into the hook's stderr +
6
+ * non-zero exit.
7
+ */
8
+ export declare function createLane(mainTop: string, cfg: ClaudeCodeLocalMergeConfig): {
9
+ wt: string;
10
+ branch: string;
11
+ lane: number;
12
+ };
13
+ export declare function isEphemeralNpxCopy(selfPath: string): boolean;
14
+ export declare function expectsLocalInstall(mainTop: string): boolean;
15
+ export declare function runWorktreeCreateHook(): Promise<void>;
@@ -0,0 +1,192 @@
1
+ /**
2
+ * The Claude Code WorktreeCreate hook. Claude Code already isolates agents
3
+ * in worktrees natively (`--worktree`, `isolation: "worktree"`) — this hook
4
+ * doesn't compete with that, it plugs into it. A WorktreeCreate hook
5
+ * "replaces default git behavior entirely" (Claude Code's own docs), so
6
+ * this script is responsible for actually creating the worktree; what it
7
+ * adds on top of the native flow is Claude Code Local Merge's numbered-lane convention
8
+ * and a `node_modules` SYMLINK instead of a copy — Claude Code's own
9
+ * `.worktreeinclude` mechanism copies gitignored files in, which is fine
10
+ * for a `.env` file and genuinely expensive for `node_modules`.
11
+ *
12
+ * Per Claude Code's hook contract: print the new worktree's absolute path
13
+ * on stdout and exit 0, or print an error to stderr and exit non-zero to
14
+ * abort creation (WorktreeCreate is the one hook event that can block).
15
+ *
16
+ * There's no long-lived process behind this the way there was behind the
17
+ * old standalone launcher — the hook runs once and exits. So lane claiming
18
+ * can't be PID-liveness-based here; there's no PID to track. Instead the
19
+ * claim IS the worktree: a lane is free iff `<repo><worktreeSuffix><n>`
20
+ * doesn't exist on disk, and `git worktree add` failing on an
21
+ * already-claimed path is the same atomicity guarantee a `mkdir` race gave
22
+ * the old launcher, just delegated to git itself. When Claude Code (or you,
23
+ * or its cleanup sweep) removes a worktree, that lane number is free again
24
+ * automatically — nothing to release by hand.
25
+ */
26
+ import { execFileSync } from "node:child_process";
27
+ import { existsSync, mkdirSync, readFileSync, symlinkSync } from "node:fs";
28
+ import { dirname, join, basename, sep } from "node:path";
29
+ import { fileURLToPath } from "node:url";
30
+ import { loadConfig, hasConfig, DEFAULTS } from "../lib/config.js";
31
+ import { resolveMainCheckout } from "../lib/main-checkout.js";
32
+ // A lane-claim loop with no upper bound is exactly one path-resolution bug
33
+ // away from spinning the CPU forever instead of failing loud — which is
34
+ // precisely what happened here: an earlier, separate implementation of what
35
+ // is now resolveMainCheckout used path.join instead of path.resolve, so
36
+ // invoking this hook from INSIDE an already-created linked worktree (where
37
+ // git reports an ABSOLUTE git-common-dir, not the relative ".git" a fresh
38
+ // checkout reports) produced a nonsense path that never matched an existing
39
+ // lane and never let `git worktree add` succeed either — an infinite loop,
40
+ // confirmed burning 80%+ CPU indefinitely in production use. Fixing the path
41
+ // bug alone doesn't rule out some other future bug in this class; capping
42
+ // the loop means any of them fails loud instead of hanging.
43
+ const MAX_LANE_ATTEMPTS = 1000;
44
+ // Trying a lane number that turns out to already be claimed (the expected,
45
+ // routine race with another concurrent hook invocation) is not an error
46
+ // worth showing anyone — git's own "fatal: cannot lock ref" on that attempt
47
+ // would otherwise leak to the terminal even though the hook recovers and
48
+ // succeeds. Force stdout/stderr into pipes explicitly rather than trusting
49
+ // the ambient default, so a probing attempt is always silent until we
50
+ // decide it's actually fatal.
51
+ function tryGit(args, cwd) {
52
+ try {
53
+ return {
54
+ ok: true,
55
+ out: execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(),
56
+ };
57
+ }
58
+ catch (e) {
59
+ const err = e;
60
+ return { ok: false, out: `${err.stdout ?? ""}${err.stderr ?? ""}` };
61
+ }
62
+ }
63
+ /**
64
+ * Claim the lowest free lane, create its worktree, and symlink the
65
+ * configured git-ignored paths into it. Throws with a human-readable
66
+ * message on failure — the caller turns that into the hook's stderr +
67
+ * non-zero exit.
68
+ */
69
+ export function createLane(mainTop, cfg) {
70
+ // Clean up administrative entries for worktrees whose directories are
71
+ // already gone (e.g. someone `rm -rf`'d one instead of `git worktree
72
+ // remove`) so reusing that lane number's branch name doesn't fail with
73
+ // "already checked out elsewhere."
74
+ tryGit(["worktree", "prune"], mainTop);
75
+ const repoName = basename(mainTop);
76
+ let lane = 0;
77
+ for (;;) {
78
+ lane += 1;
79
+ if (lane > MAX_LANE_ATTEMPTS) {
80
+ throw new Error(`could not claim a lane after ${MAX_LANE_ATTEMPTS} attempts — is mainTop ('${mainTop}') actually the repo root?`);
81
+ }
82
+ const wt = join(dirname(mainTop), `${repoName}${cfg.worktreeSuffix}${lane}`);
83
+ if (existsSync(wt))
84
+ continue; // still claimed — try the next lane
85
+ const branch = `${cfg.branchPrefix}${lane}`;
86
+ const branchExists = tryGit(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], mainTop).ok;
87
+ // Base every new lane on the main checkout's own current HEAD — never on
88
+ // origin/integrationBranch. The main checkout is kept fast-forwarded by
89
+ // `sync` precisely so its local HEAD IS the trusted, up-to-date view;
90
+ // reading origin directly instead can be *behind* local HEAD (a commit
91
+ // made here but not yet pushed) and silently drop it from every new
92
+ // lane. That's not a hypothetical: it breaks the literal Quickstart —
93
+ // `init` writes claude-code-local-merge.config.mjs/CLAUDE.md/.claude locally, and the
94
+ // very first lane created before that commit is pushed anywhere would
95
+ // otherwise come up with none of it.
96
+ let add;
97
+ if (branchExists) {
98
+ add = tryGit(["worktree", "add", wt, branch], mainTop);
99
+ }
100
+ else {
101
+ add = tryGit(["worktree", "add", wt, "-b", branch], mainTop);
102
+ }
103
+ if (!add.ok) {
104
+ // Someone else claimed this exact lane between our existsSync check
105
+ // and `git worktree add` — the same race a `mkdir` guards against.
106
+ // Try the next one.
107
+ continue;
108
+ }
109
+ for (const rel of cfg.symlinks) {
110
+ const src = join(mainTop, rel);
111
+ const dest = join(wt, rel);
112
+ if (!existsSync(src) || existsSync(dest))
113
+ continue;
114
+ try {
115
+ mkdirSync(dirname(dest), { recursive: true });
116
+ symlinkSync(src, dest);
117
+ }
118
+ catch {
119
+ /* best-effort — a missing symlink degrades to "run npm install," not a hard failure */
120
+ }
121
+ }
122
+ return { wt, branch, lane };
123
+ }
124
+ }
125
+ // `.claude/settings.json` invokes this hook via `npx claude-code-local-merge hook
126
+ // worktree-create` rather than a project script, precisely because a raw
127
+ // hook command has no `node_modules/.bin` on its PATH the way `npm run`
128
+ // does — npx's own directory-walking local resolution is what makes that
129
+ // work at all. The problem: npx treats a package it can't resolve locally as
130
+ // license to silently fetch an ephemeral, unpinned copy from the registry
131
+ // and run *that* instead of failing — which is exactly what happens when the
132
+ // host project's own install of claude-code-local-merge is missing or mid-upgrade (npm
133
+ // removes the old version's files before extracting the new one; anything
134
+ // that interrupts that leaves precisely this state). That fallback ran
135
+ // silently for long enough in production to block two lanes from landing
136
+ // before anyone noticed node_modules was broken. Refuse to proceed if this
137
+ // module is executing from npx's ephemeral cache instead of the project's
138
+ // own installed copy, so a broken install fails loud immediately instead of
139
+ // limping along on a stand-in version nobody asked for.
140
+ export function isEphemeralNpxCopy(selfPath) {
141
+ return selfPath.includes(`${sep}_npx${sep}`);
142
+ }
143
+ // The guard above only makes sense for a host project that's an npm project
144
+ // with claude-code-local-merge as a real dependency — hola, say. A non-Node host repo
145
+ // (a Haskell/Lua/Rust/whatever project with no package.json at all) has
146
+ // nowhere to install claude-code-local-merge INTO; npx's ephemeral cache is the only way
147
+ // it can ever run claude-code-local-merge commands, not a fallback masking a broken
148
+ // local install. Only expect a local install — and therefore only treat
149
+ // ephemeral execution as suspicious — when the host's own package.json
150
+ // actually lists claude-code-local-merge as a dependency. No package.json, or one that
151
+ // doesn't mention claude-code-local-merge: ephemeral execution is completely normal.
152
+ export function expectsLocalInstall(mainTop) {
153
+ let pkg;
154
+ try {
155
+ pkg = JSON.parse(readFileSync(join(mainTop, "package.json"), "utf8"));
156
+ }
157
+ catch {
158
+ return false; // no package.json, or unreadable/invalid — nothing "expected" to be there
159
+ }
160
+ return Boolean(pkg.dependencies?.["claude-code-local-merge"] || pkg.devDependencies?.["claude-code-local-merge"]);
161
+ }
162
+ async function readStdin() {
163
+ const chunks = [];
164
+ for await (const chunk of process.stdin)
165
+ chunks.push(chunk);
166
+ return Buffer.concat(chunks).toString("utf8");
167
+ }
168
+ export async function runWorktreeCreateHook() {
169
+ let input = {};
170
+ try {
171
+ input = JSON.parse(await readStdin());
172
+ }
173
+ catch {
174
+ /* no/invalid stdin — fall back to process.cwd() below */
175
+ }
176
+ const fromCwd = input.cwd ?? process.cwd();
177
+ try {
178
+ const mainTop = resolveMainCheckout(fromCwd);
179
+ if (expectsLocalInstall(mainTop) && isEphemeralNpxCopy(fileURLToPath(import.meta.url))) {
180
+ throw new Error("running from npx's ephemeral cache, not this project's own installed dependency — " +
181
+ "node_modules is missing or broken. Run `npm install` in the main checkout and try again.");
182
+ }
183
+ const cfg = hasConfig(mainTop) ? await loadConfig(mainTop) : { ...DEFAULTS };
184
+ const { wt } = createLane(mainTop, cfg);
185
+ process.stdout.write(wt + "\n");
186
+ process.exit(0);
187
+ }
188
+ catch (err) {
189
+ process.stderr.write(`claude-code-local-merge worktree-create hook failed: ${err instanceof Error ? err.message : String(err)}\n`);
190
+ process.exit(1);
191
+ }
192
+ }
package/dist/land.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function land(): Promise<void>;