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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jesse Heaslip
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,260 @@
1
+ <p align="center">
2
+ <img src="assets/banner.svg" alt="Claude Code Local Merge — the local, zero-cost merge queue for parallel Claude Code agents" width="100%" />
3
+ </p>
4
+
5
+ <p align="center">
6
+ <img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue.svg">
7
+ <img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.x-3178c6.svg">
8
+ <img alt="Node" src="https://img.shields.io/badge/node-%3E%3D18-339933.svg">
9
+ <img alt="Runtime deps" src="https://img.shields.io/badge/runtime%20deps-0-brightgreen.svg">
10
+ </p>
11
+
12
+ # Claude Code Local Merge 🚦
13
+
14
+ Claude Code already isolates your agents — `--worktree` (or `isolation:
15
+ "worktree"` on a subagent) gives every session its own git worktree, natively,
16
+ no setup. That part's solved. Claude Code Local Merge is the part that comes after: what
17
+ happens when four isolated agents all try to land, build, and test *at the
18
+ same time*.
19
+
20
+ - šŸ Everyone pushes to the same branch, someone loses the race, and the
21
+ rejected push turns into a rebase, which sometimes turns into *another*
22
+ rejected push.
23
+ - šŸ”„ A full build is heavy. Four of them running at once turn your laptop
24
+ into a space heater.
25
+ - šŸŽ² If your tests hit a shared database, concurrent runs race each other's
26
+ resets. The failures look flaky. They are not flaky. They're just honest.
27
+
28
+ None of that is a skill issue. It's what happens when several fast,
29
+ confident processes share one mutable thing with no traffic control.
30
+
31
+ Telling the agents to "please coordinate" doesn't fix it. An agent (or a
32
+ teammate in a hurry) will violate a documented convention exactly once, at
33
+ exactly the wrong moment, and mean nothing by it.
34
+
35
+ **So don't ask nicely. Make the collision impossible.** 🚦
36
+
37
+ **The local, zero-cost merge queue for parallel Claude Code agents.**
38
+
39
+ ## ⚔ Quickstart
40
+
41
+ ```bash
42
+ npm install --save-dev claude-code-local-merge # or: pnpm add -D / yarn add -D / bun add -d
43
+ npx claude-code-local-merge init
44
+ ```
45
+
46
+ This does the whole setup, not just the config file:
47
+
48
+ - **`claude-code-local-merge.config.mjs`** — `integrationBranch` auto-detected from your
49
+ current branch, `checkCommand` auto-detected from package.json
50
+ (`check:push` / `check` / `ci` / `test`, first match wins).
51
+ - **`CLAUDE.md`** (or appends to yours if you already have one) — the part
52
+ that makes the whole thing hands-off. Claude Code reads it automatically,
53
+ every session, and it tells the agent to land its own work once green,
54
+ without being asked. See "The hands-off part" below.
55
+ - **`.claude/settings.json`** — the `WorktreeCreate` hook wired in (created,
56
+ or merged into your existing settings without touching anything else
57
+ already there).
58
+ - **`.husky/pre-push`** — created or appended to, *if* you already have
59
+ Husky. If you don't, `init` tells you so instead of silently writing to
60
+ the untracked, not-shared-with-your-team `.git/hooks/pre-push`.
61
+ - **`package.json` scripts** — `land`, `sync`, `promote`, `preview`, and
62
+ `preview:restore` added, skipping any you've already defined yourself.
63
+ - **`claude-code-local-merge-preflight.mjs`** + `preland`/`presync` scripts — a
64
+ self-contained safety net npm runs automatically before `land`/`sync`. If
65
+ this tool's own name/bin ever changes again (it has once — `lanekeeper` →
66
+ `claude-code-local-merge`) and a lane hasn't rebased past that point yet, its
67
+ `package.json` still calls the old name, which no longer exists — a bare,
68
+ confusing `sh: lanekeeper: command not found`. This script catches that
69
+ case with an actual diagnosis ("this branch is stale relative to
70
+ origin/&lt;branch&gt; — rebase first") instead. It's deliberately plain
71
+ JS with zero dependency on `claude-code-local-merge` itself, so it keeps working across
72
+ future renames too.
73
+
74
+ **Commit everything it wrote**, then you're running. Two steps, not a setup
75
+ guide.
76
+
77
+ If `init` couldn't detect a `checkCommand` (no matching script in
78
+ package.json), every push is **blocked** until you set one — see 🧰 What's
79
+ in the box below. That's on purpose.
80
+
81
+ From here on: `claude --worktree <name>` to spin up an isolated lane —
82
+ Claude Code Local Merge's hook takes it from there, and CLAUDE.md tells the agent the rest.
83
+ You show up to run `claude-code-local-merge promote` when you actually want to ship. šŸš€
84
+
85
+ ## šŸ†š vs. GitHub's Merge Queue
86
+
87
+ GitHub already ships a merge queue. Two things it costs you that this doesn't:
88
+
89
+ | | GitHub Merge Queue | Claude Code Local Merge |
90
+ |---|---|---|
91
+ | Private repo | **Enterprise Cloud only** | Any plan, any repo |
92
+ | Cost per landing | GitHub Actions minutes, every queue attempt | $0 — runs on your own machine |
93
+ | Requires | A pull request | Nothing — direct rebase + push |
94
+
95
+ Same idea — serialize landings, test before merge, keep history clean — run
96
+ locally instead of in someone else's billed cloud. šŸ’ø
97
+
98
+ ## 🧰 What's in the box
99
+
100
+ | Command | What it does |
101
+ |---|---|
102
+ | `claude-code-local-merge hook worktree-create` | A Claude Code `WorktreeCreate` hook. Plugs Claude Code Local Merge's numbered lanes into Claude's *native* worktree creation — doesn't reinvent it. |
103
+ | `claude-code-local-merge build-lock -- <cmd>` | Runs `<cmd>` — your build — serialized across every lane, machine-wide. |
104
+ | `claude-code-local-merge land` | Rebases and pushes your lane onto the integration branch through a FIFO queue, so two lanes are never mid-push at once. Agents run this themselves — see below. |
105
+ | `claude-code-local-merge sync` | Fast-forwards your main checkout so a dev server actually sees what just landed — and re-installs dependencies if the lockfile changed, so the `node_modules` every lane symlinks from never goes stale. |
106
+ | `claude-code-local-merge promote` | Ships the integration branch to production. **Human-only** — never in an agent's instructions, never automated. |
107
+ | `claude-code-local-merge preview` | Instantly mirrors a lane's live working tree — uncommitted changes included — onto the main checkout, so you can look at it without a build. |
108
+ | `claude-code-local-merge port` | Prints a lane's dev-server port, derived from its own directory name. |
109
+ | `claude-code-local-merge prune` | Removes already-landed sibling lane worktrees on demand — `land` already does this automatically, this is for "clean these up right now" instead of waiting for the next lane to land something. |
110
+
111
+ Plus šŸ”’ a pre-push hook that makes `land` non-optional: a direct `git push`
112
+ straight to the integration branch gets bounced, full stop. Not a lint
113
+ warning. Not a Slack reminder. Rejected — with the actual command to run
114
+ instead. The same hook also runs your actual checks (`checkCommand` —
115
+ lint/typecheck/test/build) before allowing a landing through at all; a
116
+ config with no checkCommand set **fails every push by default** rather than
117
+ landing unverified code silently.
118
+
119
+ Every one of those blocks has a real, deliberate way out — see "The
120
+ emergency hatch" below — but it takes naming the specific branch you mean
121
+ to push, not one generic flag.
122
+
123
+ And 🧪 a documented extension point (`src/lib/ephemeral.ts` +
124
+ `examples/ephemeral-tmp-dir.example.ts`) for the thing every setup guide
125
+ skips: if your tests hit a shared resource — a database, a queue, anything
126
+ stateful — concurrent lanes need their own throwaway copy of it, and a
127
+ crashed run's copy needs to clean itself up without anyone noticing it died.
128
+
129
+ ## āš™ļø Configuration
130
+
131
+ Everything lives in one file — see
132
+ [`examples/claude-code-local-merge.config.mjs`](examples/claude-code-local-merge.config.mjs) for every
133
+ field with comments. The short version:
134
+
135
+ ```js
136
+ export default {
137
+ branchPrefix: "lane/", // lane/1, lane/2, ...
138
+ worktreeSuffix: "-lane-", // ../your-repo-lane-1
139
+ portBase: 3000, // lane n gets portBase + n
140
+ integrationBranch: "main", // where agents land — see below
141
+ productionBranch: null, // set this for a two-stage model — see below
142
+ protectedBranches: [], // extra branches beyond the two above; most repos need none
143
+ regenerableFiles: [], // files a build tool rewrites — never block a rebase on these
144
+ symlinks: [".env", ".env.local", "node_modules"],
145
+ buildOutputDirs: ["dist", "build", ".next"], // preview never copies these onto your checkout
146
+ checkCommand: "npm run check", // what actually gates a landing — see below
147
+ checksRequired: true, // false = deliberately run with none; see below
148
+ };
149
+ ```
150
+
151
+ Nothing here is hardcoded to any framework or branch model. 🧩 A malformed
152
+ config (empty branch names, a negative port, `productionBranch` equal to
153
+ `integrationBranch`, ...) fails loud with every problem listed, the moment
154
+ any command loads it — not a mysterious failure three steps later.
155
+
156
+ ## 🚨 The emergency hatch
157
+
158
+ Every blocked push — the integration branch, `productionBranch`, anything
159
+ in `protectedBranches` — has a real way through it. One env var, no
160
+ prompts, no second factor to remember:
161
+
162
+ ```bash
163
+ CLAUDE_CODE_LOCAL_MERGE_EMERGENCY_PUSH=1 git push origin HEAD:main
164
+ ```
165
+
166
+ This is the one place that's honestly a convention, not a hard guarantee:
167
+ it stops mistakes and stray pushes, not an adversarial agent that sets the
168
+ var itself.
169
+
170
+ ## šŸ™Œ The hands-off part
171
+
172
+ Tests are the reviewer, not a human, at any point in this pipeline.
173
+
174
+ - **`checkCommand` gates landing.** Nothing reaches `integrationBranch`
175
+ without passing it — the only correctness check most changes get.
176
+ - **`claude-code-local-merge promote` is a release decision, not a code
177
+ review.** It means "this already-tested work should ship now," not "I
178
+ read the diff." Your own CI on the production branch is a second
179
+ automated checkpoint — still not a human one.
180
+ - **When something gets through anyway, the fix is a test, not a
181
+ reviewer.** Every miss becomes a permanent guardrail, not a one-off catch.
182
+
183
+ Not for every team — if you want a human looking at every change before it
184
+ ships, this is missing that step on purpose.
185
+
186
+ ## šŸ” The one idea underneath most of it
187
+
188
+ The build queue, the landing queue, and the ephemeral-resource pattern are
189
+ all crash-safe the **same way**:
190
+
191
+ 1. Claim a resource.
192
+ 2. Tag the claim with your process ID.
193
+ 3. Let liveness — not a timeout — decide when a claim is stale.
194
+
195
+ `queue-lock.ts` does it for the build and landing queues; `ephemeral.ts`
196
+ does it for test resources. `kill -9` any of them mid-claim, and the next
197
+ process notices the PID is dead and reclaims it.
198
+
199
+ The `WorktreeCreate` hook is a cousin of the same idea for a one-shot
200
+ script with no process to check liveness against: the claim IS the
201
+ worktree, and `git worktree add` failing on an already-taken path is the
202
+ atomicity guard.
203
+
204
+ No stale locks, no "just restart your laptop," no magic timeout number. āœ…
205
+
206
+ ## šŸ” Know the limits
207
+
208
+ Things a sharp reader should already know before they ask:
209
+
210
+ - **One machine, not a fleet.** The FIFO queue lives in local temp storage —
211
+ it doesn't coordinate across laptops. Two machines landing at once just
212
+ get git's ordinary non-fast-forward rejection (safe, not corrupting — the
213
+ loser re-fetches and retries, same as any team without a queue does today).
214
+ - **Not a security boundary.** Every guardrail here stops mistakes and
215
+ convention drift, not a truly adversarial agent. Shell access always
216
+ means `git push --no-verify`, deleting the hook, or editing the config on
217
+ purpose — nothing local-only can stop that.
218
+ - **Guarantees a check ran — not that it's good.** It enforces that
219
+ `checkCommand` exists and passed, with no way to know if that's a real
220
+ test suite or `echo ok`. "Tests are the reviewer" is only as true as
221
+ what's actually in them.
222
+ - **The `WorktreeCreate` hook is the youngest piece of this stack** — Claude
223
+ Code shipped it Feb 2026. Losing it degrades gracefully: fall back to
224
+ `git worktree add` by hand and you still keep the build queue, landing
225
+ queue, preview, and ephemeral-resource pieces, none of which depend on it.
226
+ - **A slow `checkCommand` is a real throughput ceiling, not a free lunch.**
227
+ The FIFO lock holds for its entire duration — one landing at a time,
228
+ machine-wide. A 3–4 minute suite caps you well under 20 landings/hour
229
+ flat-out, before any queue wait.
230
+ - **Rebase conflicts abort, they never guess.** `git rebase --abort` on any
231
+ conflict, working tree left clean. Normally "you" here is the agent, not
232
+ a human — CLAUDE.md tells it to resolve the conflict and re-run `land`,
233
+ same as any other bug; `checkCommand` still catches a bad resolution.
234
+ - **Auto-pruning checks for a live Claude Code session, via `lsof`.** A
235
+ merged branch alone isn't enough — a brand-new, zero-commit lane is
236
+ *trivially* "merged" too, so pruning also refuses to touch a worktree
237
+ with a live Claude Code process in it. Deliberately narrower than "any
238
+ process at all": an orphaned MCP server or stray build process can
239
+ outlive the session that spawned it and otherwise keep an abandoned lane
240
+ stuck forever (confirmed live). Missing `lsof` fails closed — treats
241
+ liveness as unknown, never removes.
242
+ - **The `WorktreeCreate` hook needs the host project's own real install.**
243
+ It runs via `npx claude-code-local-merge hook worktree-create` (no
244
+ `node_modules/.bin` on PATH for a raw hook, unlike an `npm run` script),
245
+ and npx silently falls back to an ephemeral, unpinned copy if it can't
246
+ resolve the package locally — which happened in production and masked a
247
+ broken install for two lane-landings. The hook now refuses to run at all
248
+ from npx's ephemeral cache, so a broken install fails loud immediately
249
+ instead of limping along on a mismatched stand-in.
250
+
251
+ ## 🧬 Where this came from
252
+
253
+ This is the extracted, generalized shape of tooling built to run several
254
+ parallel Claude Code agents on one real production codebase without them
255
+ tripping over each other. The names have been filed off; the mechanics
256
+ haven't.
257
+
258
+ ## šŸ“„ License
259
+
260
+ MIT. Fork it, rename it, argue with the config shape — that's the point.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The claude-code-local-merge CLI. Every subcommand reads claude-code-local-merge.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, wirePreflightScript, PREFLIGHT_FILENAME } 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("claude-code-local-merge 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("claude-code-local-merge init: claude-code-local-merge.config.mjs already exists — leaving it alone.");
43
+ }
44
+ else {
45
+ writtenFiles.push("claude-code-local-merge.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 = `// claude-code-local-merge.config.mjs — generated by \`claude-code-local-merge init\`. Edit freely.
54
+ // Its presence here is what turns Claude Code Local Merge ON for this repo.
55
+
56
+ /** @type {import("claude-code-local-merge").ClaudeCodeLocalMergeConfig} */
57
+ export default ${JSON.stringify(generated, null, 2)};
58
+ `;
59
+ writeFileSync(join(root, "claude-code-local-merge.config.mjs"), template);
60
+ console.log(`claude-code-local-merge init: wrote ${join(root, "claude-code-local-merge.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 claude-code-local-merge.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(`claude-code-local-merge 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(`claude-code-local-merge init: appended the Claude Code Local Merge workflow section to ${claudeMdPath}`);
93
+ writtenFiles.push("CLAUDE.md");
94
+ }
95
+ else {
96
+ console.log(`claude-code-local-merge init: ${claudeMdPath} already has the Claude Code Local Merge workflow section — leaving it alone.`);
97
+ }
98
+ const claudeSettingsResult = wireClaudeSettings(root);
99
+ switch (claudeSettingsResult) {
100
+ case "created":
101
+ console.log(`claude-code-local-merge 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(`claude-code-local-merge 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("claude-code-local-merge init: .claude/settings.json already has the WorktreeCreate hook — leaving it alone.");
110
+ break;
111
+ case "unparseable":
112
+ console.log("claude-code-local-merge init: .claude/settings.json exists but isn't valid JSON — left untouched. Wire it manually,");
113
+ console.log(" see node_modules/claude-code-local-merge/hooks/claude-settings.example.json.");
114
+ break;
115
+ }
116
+ const prePushResult = wireHuskyPrePush(root);
117
+ switch (prePushResult) {
118
+ case "created":
119
+ console.log("claude-code-local-merge init: wrote .husky/pre-push.");
120
+ writtenFiles.push(".husky/pre-push");
121
+ break;
122
+ case "merged":
123
+ console.log("claude-code-local-merge init: appended Claude Code Local Merge's checks to your existing .husky/pre-push.");
124
+ writtenFiles.push(".husky/pre-push");
125
+ break;
126
+ case "already-wired":
127
+ console.log("claude-code-local-merge init: .husky/pre-push already wired — leaving it alone.");
128
+ break;
129
+ case "no-husky":
130
+ console.log("claude-code-local-merge init: no .husky/ directory found — pre-push hook NOT wired automatically.");
131
+ console.log(" Install Husky, or copy node_modules/claude-code-local-merge/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("claude-code-local-merge 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("claude-code-local-merge 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
+ switch (wirePreflightScript(root, cfg.integrationBranch)) {
154
+ case "created":
155
+ console.log(`claude-code-local-merge init: wrote ${PREFLIGHT_FILENAME} (a self-contained pre-check "land"/"sync" run before landing — see comments in the file).`);
156
+ writtenFiles.push(PREFLIGHT_FILENAME);
157
+ break;
158
+ case "already-exists":
159
+ break; // don't overwrite — re-run init after changing integrationBranch if it needs updating
160
+ }
161
+ const scriptsResult = wirePackageJsonScripts(root);
162
+ switch (scriptsResult.result) {
163
+ case "added":
164
+ console.log(`claude-code-local-merge init: added "${scriptsResult.added.join('", "')}" to package.json scripts.`);
165
+ writtenFiles.push("package.json");
166
+ break;
167
+ case "already-wired":
168
+ console.log("claude-code-local-merge init: package.json already has all five scripts — leaving them alone.");
169
+ break;
170
+ case "no-package-json":
171
+ console.log("claude-code-local-merge init: no package.json found — scripts NOT wired automatically.");
172
+ console.log(' Add "land"/"sync"/"promote"/"preview"/"preview:restore" -> "claude-code-local-merge <name>", plus');
173
+ console.log(` "preland"/"presync" -> "node ${PREFLIGHT_FILENAME} land"/"sync" yourself.`);
174
+ break;
175
+ case "unparseable":
176
+ console.log("claude-code-local-merge init: package.json exists but isn't valid JSON — left untouched. Wire the scripts manually.");
177
+ break;
178
+ }
179
+ console.log("");
180
+ console.log("Next steps:");
181
+ if (writtenFiles.length > 0) {
182
+ console.log(` 1. Commit what it wrote — ${writtenFiles.join(", ")}.`);
183
+ console.log(" 2. claude --worktree <name> — the agent takes it from there.");
184
+ }
185
+ else {
186
+ console.log(" 1. claude --worktree <name> — the agent takes it from there.");
187
+ console.log(" (everything was already wired — nothing new to commit)");
188
+ }
189
+ }
190
+ async function main() {
191
+ switch (command) {
192
+ case "init":
193
+ return init();
194
+ case "hook": {
195
+ const sub = rest[0];
196
+ if (sub === "worktree-create")
197
+ return runWorktreeCreateHook();
198
+ console.error(`claude-code-local-merge hook: unknown hook "${sub ?? ""}". Only "worktree-create" is supported.`);
199
+ process.exit(1);
200
+ return;
201
+ }
202
+ case "port": {
203
+ const root = findRepoRoot();
204
+ if (!root || !hasConfig(root)) {
205
+ console.error("claude-code-local-merge port: no claude-code-local-merge.config found — not a lane.");
206
+ process.exit(1);
207
+ }
208
+ const cfg = await loadConfig(root);
209
+ const port = lanePort(process.cwd(), cfg);
210
+ if (port === null) {
211
+ console.error("claude-code-local-merge port: current directory doesn't look like a lane worktree.");
212
+ process.exit(1);
213
+ }
214
+ console.log(port);
215
+ return;
216
+ }
217
+ case "land":
218
+ return land();
219
+ case "sync":
220
+ process.exit(await sync());
221
+ return;
222
+ case "promote":
223
+ process.exit(await promote());
224
+ return;
225
+ case "prune": {
226
+ // `land` already does this automatically as a side effect of every
227
+ // successful landing — this is the on-demand escape hatch for
228
+ // "clean these up right now" instead of waiting for the next lane to
229
+ // land something. Same safety rules apply: only already-merged
230
+ // branches, never anything with a live process inside, never the
231
+ // worktree this command itself is running from.
232
+ const root = findRepoRoot();
233
+ if (!root || !hasConfig(root)) {
234
+ console.error("claude-code-local-merge prune: no claude-code-local-merge.config found — nothing to do.");
235
+ process.exit(0);
236
+ }
237
+ const cfg = await loadConfig(root);
238
+ const mainTop = resolveMainCheckout(process.cwd());
239
+ const pruned = pruneLandedLanes(mainTop, cfg, process.cwd());
240
+ if (pruned.length === 0) {
241
+ console.log("claude-code-local-merge prune: nothing to clean up — no already-landed sibling lanes found.");
242
+ }
243
+ else {
244
+ const names = pruned.map((p) => p.split("/").pop()).join(", ");
245
+ console.log(`claude-code-local-merge prune: removed ${pruned.length} already-landed lane${pruned.length === 1 ? "" : "s"}: ${names}`);
246
+ }
247
+ return;
248
+ }
249
+ case "preview":
250
+ return runPreview(rest);
251
+ case "build-lock": {
252
+ const parts = rest[0] === "--" ? rest.slice(1) : rest;
253
+ return buildLock(parts);
254
+ }
255
+ case "check-push": {
256
+ const root = findRepoRoot();
257
+ if (!root || !hasConfig(root)) {
258
+ // No config means Claude Code Local Merge isn't enabled for this repo — nothing to enforce.
259
+ process.exit(0);
260
+ }
261
+ const cfg = await loadConfig(root);
262
+ const stdin = await readStdin();
263
+ const refUpdates = parseRefUpdates(stdin);
264
+ // git still invokes pre-push (with empty stdin) for a push that
265
+ // updates zero refs — e.g. re-pushing a branch that's already exactly
266
+ // up to date on the remote. There's nothing to block (nothing is
267
+ // actually changing on the remote either way) and nothing meaningful
268
+ // for checkCommand to verify — running it anyway just produces a
269
+ // confusing, unrelated failure (a missing devDependency, say) that
270
+ // looks exactly like a real block but isn't one. Recognize "nothing
271
+ // to push" explicitly instead of silently falling through both checks.
272
+ if (refUpdates.length === 0) {
273
+ console.log("claude-code-local-merge check-push: nothing being pushed (already up to date) — skipping checks.");
274
+ process.exit(0);
275
+ }
276
+ if (process.env.CLAUDE_CODE_LOCAL_MERGE_EMERGENCY_PUSH === "1") {
277
+ console.log("\n🚨 CLAUDE_CODE_LOCAL_MERGE_EMERGENCY_PUSH is set — bypassing the landing queue.\n");
278
+ }
279
+ const result = checkPush(refUpdates, cfg, process.env);
280
+ if (!result.ok) {
281
+ console.error(result.message);
282
+ process.exit(1);
283
+ }
284
+ process.exit(runCheckCommand(cfg, root));
285
+ return;
286
+ }
287
+ case "--version":
288
+ case "-v": {
289
+ const { join, dirname } = await import("node:path");
290
+ const { fileURLToPath } = await import("node:url");
291
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
292
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
293
+ console.log(pkg.version);
294
+ return;
295
+ }
296
+ default:
297
+ console.log(`claude-code-local-merge — keep parallel coding agents in their lane.
298
+
299
+ Usage:
300
+ claude-code-local-merge init write a starter claude-code-local-merge.config.mjs
301
+ claude-code-local-merge land rebase + push this lane onto the integration branch (queued)
302
+ claude-code-local-merge sync fast-forward the MAIN checkout to its upstream
303
+ claude-code-local-merge promote ship the integration branch to production (human-only — never script this)
304
+ claude-code-local-merge preview [--restore] swap the MAIN checkout to this lane's working tree, or restore it
305
+ claude-code-local-merge build-lock -- <cmd> run <cmd>, serialized across every lane
306
+ claude-code-local-merge port print this lane's dev-server port
307
+ claude-code-local-merge hook worktree-create (Claude Code WorktreeCreate hook — not for direct use)
308
+ claude-code-local-merge check-push (used by the pre-push hook — not for direct use)
309
+ `);
310
+ process.exit(command ? 1 : 0);
311
+ }
312
+ }
313
+ main().catch((err) => {
314
+ console.error(err instanceof Error ? err.message : err);
315
+ process.exit(1);
316
+ });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};