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/dist/land.js ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * land.ts — the ONLY sanctioned way for a lane to land onto the integration
3
+ * branch.
4
+ *
5
+ * Left to behavioral convention alone ("only one lane rebases-and-pushes at
6
+ * a time"), several lanes going green around the same time all rebase and
7
+ * push at once: pushes race, the loser's checks run against an already-stale
8
+ * remote, and when something breaks everyone ends up mid-push fixing the
9
+ * same failure. This makes "land" a single cross-worktree FIFO queue
10
+ * (queue-lock.ts — the same crash-safe mechanics build-lock uses) so only
11
+ * one lane is ever fetching, rebasing, pushing, and checking at a time.
12
+ *
13
+ * A failed attempt releases the lock rather than holding it hostage — the
14
+ * next lane in line lands next while the failed lane fixes and re-runs
15
+ * `claude-code-local-merge land` (re-entering the back of the queue). That keeps one
16
+ * broken lane from blocking every OTHER lane's unrelated, ready-to-land
17
+ * work, while still guaranteeing no two lanes are ever mid-push at once.
18
+ *
19
+ * This is only half the guarantee, though — a convention that says "always
20
+ * run `claude-code-local-merge land`" is exactly the kind of rule a confused agent (or a
21
+ * human under time pressure) eventually skips by hand-rolling `git push`.
22
+ * The other half lives in hooks/pre-push: it hard-rejects a direct push to
23
+ * the integration branch that didn't set CLAUDE_CODE_LOCAL_MERGE_LANDING=1, which this
24
+ * script sets right before its own push and nothing else legitimately would.
25
+ * Wire that hook up (see the README) and the queue isn't a convention
26
+ * anymore — it's the only door.
27
+ *
28
+ * Usage: claude-code-local-merge land (run from a lane worktree, on its own branch)
29
+ */
30
+ import { execSync, spawnSync } from "node:child_process";
31
+ import { createQueueLock } from "./lib/queue-lock.js";
32
+ import { hasConfig, loadConfig } from "./lib/config.js";
33
+ import { resolveMainCheckout } from "./lib/main-checkout.js";
34
+ import { pruneLandedLanes } from "./lib/prune-lanes.js";
35
+ import { sync } from "./sync.js";
36
+ const DIM = "\x1b[2m", RESET = "\x1b[0m", RED = "\x1b[31m", GREEN = "\x1b[32m";
37
+ export async function land() {
38
+ if (!hasConfig()) {
39
+ console.error("claude-code-local-merge land: no claude-code-local-merge.config found at the repo root. Run `claude-code-local-merge init` first.");
40
+ process.exit(1);
41
+ }
42
+ const cfg = await loadConfig();
43
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf8" }).trim();
44
+ if (branch === cfg.integrationBranch || cfg.protectedBranches.includes(branch) || branch === "HEAD") {
45
+ console.error(`claude-code-local-merge land: refusing to run from '${branch}' — land is for lane branches only.`);
46
+ process.exit(1);
47
+ }
48
+ // A rebase refuses to run at all with a dirty tree. Build-tool-regenerated
49
+ // noise shouldn't block landing — discard ONLY the configured
50
+ // regenerableFiles, exactly like sync does for the fast-forward on the
51
+ // other end. Any other dirty file is real work-in-progress: leave it alone
52
+ // and let the rebase fail loud.
53
+ //
54
+ // Checked here AND again right before the rebase itself (not just once):
55
+ // a build tool can regenerate one of these files at any point, including
56
+ // during the (possibly long) wait for the landing queue lock below. A
57
+ // single check up front leaves that whole wait as a window where the
58
+ // exact same harmless noise reappears and gets misreported as a real
59
+ // rebase conflict instead of silently discarded like it should be.
60
+ const regenerable = new Set(cfg.regenerableFiles);
61
+ function discardRegenerableDirt() {
62
+ const status = execSync("git status --porcelain", { encoding: "utf8" });
63
+ const dirty = status
64
+ .split("\n")
65
+ .filter(Boolean)
66
+ .map((line) => line.slice(3).trim());
67
+ const blocking = dirty.filter((f) => !regenerable.has(f));
68
+ if (dirty.length > 0 && blocking.length === 0) {
69
+ execSync(`git checkout -- ${dirty.map((f) => `"${f}"`).join(" ")}`);
70
+ }
71
+ }
72
+ discardRegenerableDirt();
73
+ const lock = createQueueLock("land");
74
+ await lock.acquire({
75
+ label: branch,
76
+ onWait: ({ ahead, holder }) => {
77
+ if (ahead > 0) {
78
+ console.log(`${DIM}[land-queue] ${branch}: waiting — ${ahead} landing${ahead === 1 ? "" : "s"} ahead…${RESET}`);
79
+ }
80
+ else if (holder) {
81
+ console.log(`${DIM}[land-queue] ${branch}: next up — waiting for '${holder.label ?? holder.lane}' to finish landing…${RESET}`);
82
+ }
83
+ },
84
+ });
85
+ let exitCode = 0;
86
+ try {
87
+ console.log(`${DIM}[land-queue] ${branch}: lock acquired — landing…${RESET}`);
88
+ discardRegenerableDirt(); // re-check right before the rebase — see comment above
89
+ console.log(`${DIM}fetching origin/${cfg.integrationBranch}…${RESET}`);
90
+ execSync(`git fetch origin ${cfg.integrationBranch} --quiet`, { stdio: "inherit" });
91
+ console.log(`${DIM}rebasing onto origin/${cfg.integrationBranch}…${RESET}`);
92
+ const rebase = spawnSync("git", ["rebase", `origin/${cfg.integrationBranch}`], { stdio: "inherit" });
93
+ if (rebase.status !== 0) {
94
+ spawnSync("git", ["rebase", "--abort"], { stdio: "ignore" });
95
+ console.error(`\n${RED}land: rebase onto origin/${cfg.integrationBranch} conflicted — aborted, working tree left clean.${RESET}`);
96
+ console.error(`Resolve it yourself (git fetch origin ${cfg.integrationBranch} && git rebase origin/${cfg.integrationBranch}), then re-run 'claude-code-local-merge land'.`);
97
+ exitCode = 1;
98
+ }
99
+ else {
100
+ console.log(`${DIM}pushing to ${cfg.integrationBranch} (this is where your CI/checks hook runs)…${RESET}`);
101
+ const push = spawnSync("git", ["push", "origin", `HEAD:${cfg.integrationBranch}`], {
102
+ stdio: "inherit",
103
+ env: { ...process.env, CLAUDE_CODE_LOCAL_MERGE_LANDING: "1" },
104
+ });
105
+ if (push.status !== 0) {
106
+ console.error(`\n${RED}land: push to ${cfg.integrationBranch} failed — see output above.${RESET}`);
107
+ console.error(`Fix the failure, then re-run 'claude-code-local-merge land'.`);
108
+ exitCode = 1;
109
+ }
110
+ else {
111
+ console.log(`${GREEN}✓ ${branch} landed on ${cfg.integrationBranch}.${RESET}`);
112
+ // Landing isn't "done" until the checkout that actually serves your
113
+ // dev server can see it — call sync in-process rather than shelling
114
+ // back out to the CLI, so this doesn't depend on `claude-code-local-merge` being
115
+ // resolvable on PATH. Pass this lane's own already-loaded cfg through
116
+ // rather than letting sync() reload from MAIN — MAIN hasn't been
117
+ // fast-forwarded yet at this exact moment (that's what sync is about
118
+ // to do), so if this push just introduced or changed
119
+ // claude-code-local-merge.config.mjs itself, a fresh MAIN-side load would silently
120
+ // fall back to DEFAULTS instead of the real config.
121
+ exitCode = await sync(cfg);
122
+ // Housekeeping, never a reason to fail this landing: sweep sibling
123
+ // lanes whose OWN branch already made it upstream (nothing created
124
+ // ever tears a worktree down on the way out) so they don't
125
+ // accumulate on disk forever waiting for someone to remember.
126
+ try {
127
+ const mainTop = resolveMainCheckout(process.cwd());
128
+ const pruned = pruneLandedLanes(mainTop, cfg, process.cwd());
129
+ if (pruned.length > 0) {
130
+ const names = pruned.map((p) => p.split("/").pop()).join(", ");
131
+ console.log(`${DIM}pruned ${pruned.length} already-landed lane${pruned.length === 1 ? "" : "s"}: ${names}${RESET}`);
132
+ }
133
+ }
134
+ catch {
135
+ /* best-effort — never block a successful landing over cleanup */
136
+ }
137
+ }
138
+ }
139
+ }
140
+ finally {
141
+ lock.release();
142
+ }
143
+ process.exit(exitCode);
144
+ }
@@ -0,0 +1,29 @@
1
+ import type { ClaudeCodeLocalMergeConfig } from "./config.js";
2
+ /**
3
+ * Which package manager actually installed this project — detected from its
4
+ * lockfile, since that's the one signal that's always there regardless of
5
+ * what's on PATH. Defaulting straight to npm regardless of the real answer
6
+ * isn't a hypothetical: a pnpm workspace's scripts can rely on pnpm-specific
7
+ * behavior (workspace: protocol deps, `--filter`), and `npm run` may not
8
+ * even be installed on a pnpm/yarn/bun-only machine.
9
+ */
10
+ export declare function detectPackageManager(root: string): "npm" | "pnpm" | "yarn" | "bun";
11
+ /** Look at package.json's own scripts for something to run — best-effort, never throws. */
12
+ export declare function detectCheckCommand(root: string): string | null;
13
+ /**
14
+ * Run the configured check command. Returns the exit code to propagate.
15
+ *
16
+ * A null checkCommand is only ever silent if checksRequired was explicitly
17
+ * turned off — the default is to FAIL the push, because a merge queue that
18
+ * lets unverified code through by default isn't one, it's a false sense of
19
+ * safety.
20
+ *
21
+ * Always runs from `root`, not whatever the caller's cwd happens to be. In
22
+ * the real git-push flow that's moot — git always resets cwd to the repo
23
+ * root before running a hook — but `check-push` is also a directly
24
+ * user-runnable command, and silently depending on git's hook behavior for
25
+ * correctness (instead of just being correct on its own) is exactly the
26
+ * kind of implicit assumption that bites the first time someone runs it by
27
+ * hand from a subdirectory.
28
+ */
29
+ export declare function runCheckCommand(cfg: Pick<ClaudeCodeLocalMergeConfig, "checkCommand" | "checksRequired">, root: string): number;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The part of the merge queue that actually earns the name: running your
3
+ * lint/typecheck/test/build before a landing is allowed through. Without
4
+ * this, "merge queue" is just "push queue" — serialized, but blind.
5
+ */
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { spawnSync } from "node:child_process";
8
+ import { join } from "node:path";
9
+ // Priority order: prefer a script that already bundles multiple checks
10
+ // (a repo's own "check" or CI script) over a narrower "test" script, but
11
+ // take whatever exists rather than assume one specific name.
12
+ const CANDIDATE_SCRIPTS = ["check:push", "check", "ci", "test"];
13
+ /**
14
+ * Which package manager actually installed this project — detected from its
15
+ * lockfile, since that's the one signal that's always there regardless of
16
+ * what's on PATH. Defaulting straight to npm regardless of the real answer
17
+ * isn't a hypothetical: a pnpm workspace's scripts can rely on pnpm-specific
18
+ * behavior (workspace: protocol deps, `--filter`), and `npm run` may not
19
+ * even be installed on a pnpm/yarn/bun-only machine.
20
+ */
21
+ export function detectPackageManager(root) {
22
+ if (existsSync(join(root, "pnpm-lock.yaml")) || existsSync(join(root, "pnpm-workspace.yaml")))
23
+ return "pnpm";
24
+ if (existsSync(join(root, "yarn.lock")))
25
+ return "yarn";
26
+ if (existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bun.lock")))
27
+ return "bun";
28
+ return "npm";
29
+ }
30
+ /** Look at package.json's own scripts for something to run — best-effort, never throws. */
31
+ export function detectCheckCommand(root) {
32
+ const pkgPath = join(root, "package.json");
33
+ if (!existsSync(pkgPath))
34
+ return null;
35
+ try {
36
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
37
+ const found = CANDIDATE_SCRIPTS.find((name) => pkg.scripts?.[name]);
38
+ return found ? `${detectPackageManager(root)} run ${found}` : null;
39
+ }
40
+ catch {
41
+ return null;
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
+ */
60
+ export function runCheckCommand(cfg, root) {
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-local-merge.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-local-merge check-push: no checkCommand configured (checksRequired: false — running with no checks, on purpose).");
75
+ return 0;
76
+ }
77
+ console.log(`claude-code-local-merge check-push: running "${cfg.checkCommand}"…`);
78
+ const result = spawnSync(cfg.checkCommand, { shell: true, stdio: "inherit", cwd: root });
79
+ if (result.status !== 0) {
80
+ console.error(`\n✋ checkCommand failed (exit ${result.status ?? 1}) — landing blocked.`);
81
+ }
82
+ return result.status ?? 1;
83
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The enforcement half of the landing queue. `land.ts` (and `promote.ts`)
3
+ * are *conventions* — "always land through here" — and conventions are
4
+ * exactly the kind of rule a confused agent, or a human moving fast,
5
+ * eventually skips by hand-rolling `git push`. This is the mechanism that
6
+ * makes the convention unnecessary to trust: it reads the same ref-update
7
+ * lines git's pre-push hook always gets on stdin, and rejects a direct push
8
+ * to the integration branch or any protected/production branch by default.
9
+ *
10
+ * `land.ts` sets CLAUDE_CODE_LOCAL_MERGE_LANDING=1 right before its own push — that's
11
+ * the only thing that legitimately unblocks the integration branch on the
12
+ * normal path. `promote.ts` pushes to productionBranch with `--no-verify`,
13
+ * which skips this hook entirely (same as git always does for --no-verify).
14
+ *
15
+ * Every branch here (including the integration branch, which used to have
16
+ * NO override at all) also has a genuine emergency hatch: set
17
+ * CLAUDE_CODE_LOCAL_MERGE_EMERGENCY_PUSH=1 and push. One env var, no prompts, no second
18
+ * factor to remember — the same trust model as CLAUDE_CODE_LOCAL_MERGE_LANDING=1 above
19
+ * it. This is a convention, not a hard guarantee: it stops mistakes and
20
+ * stray pushes, not a truly adversarial agent that sets it itself. Worth
21
+ * knowing, not worth building 18 hoops to (marginally) defend against.
22
+ */
23
+ import type { ClaudeCodeLocalMergeConfig } from "./config.js";
24
+ export interface RefUpdate {
25
+ localRef: string;
26
+ localSha: string;
27
+ remoteRef: string;
28
+ remoteSha: string;
29
+ }
30
+ export interface CheckResult {
31
+ ok: boolean;
32
+ message?: string;
33
+ }
34
+ export declare function parseRefUpdates(stdin: string): RefUpdate[];
35
+ export declare function checkPush(refUpdates: RefUpdate[], cfg: Pick<ClaudeCodeLocalMergeConfig, "integrationBranch" | "productionBranch" | "protectedBranches">, env: NodeJS.ProcessEnv): CheckResult;
@@ -0,0 +1,46 @@
1
+ export function parseRefUpdates(stdin) {
2
+ return stdin
3
+ .split("\n")
4
+ .map((l) => l.trim())
5
+ .filter(Boolean)
6
+ .map((line) => {
7
+ const [localRef, localSha, remoteRef, remoteSha] = line.split(/\s+/);
8
+ return { localRef: localRef ?? "", localSha: localSha ?? "", remoteRef: remoteRef ?? "", remoteSha: remoteSha ?? "" };
9
+ });
10
+ }
11
+ function emergencyConfirmed(env) {
12
+ return env.CLAUDE_CODE_LOCAL_MERGE_EMERGENCY_PUSH === "1";
13
+ }
14
+ export function checkPush(refUpdates, cfg, env) {
15
+ const integrationRef = `refs/heads/${cfg.integrationBranch}`;
16
+ const protectedBranches = cfg.productionBranch ? [...cfg.protectedBranches, cfg.productionBranch] : cfg.protectedBranches;
17
+ const protectedRefs = new Set(protectedBranches.map((b) => `refs/heads/${b}`));
18
+ for (const { remoteRef } of refUpdates) {
19
+ const branch = remoteRef.replace("refs/heads/", "");
20
+ if (protectedRefs.has(remoteRef) && !emergencyConfirmed(env)) {
21
+ return {
22
+ ok: false,
23
+ message: [
24
+ "",
25
+ `✋ Direct pushes to '${branch}' are blocked.`,
26
+ ` This is a protected branch — promote it deliberately, not with a stray push.`,
27
+ ` Emergency override: CLAUDE_CODE_LOCAL_MERGE_EMERGENCY_PUSH=1 git push …`,
28
+ "",
29
+ ].join("\n"),
30
+ };
31
+ }
32
+ if (remoteRef === integrationRef && env.CLAUDE_CODE_LOCAL_MERGE_LANDING !== "1" && !emergencyConfirmed(env)) {
33
+ return {
34
+ ok: false,
35
+ message: [
36
+ "",
37
+ `✋ Direct pushes to '${cfg.integrationBranch}' are blocked — landing goes through the queue.`,
38
+ ` Land your work: claude-code-local-merge land`,
39
+ ` Genuine emergency: CLAUDE_CODE_LOCAL_MERGE_EMERGENCY_PUSH=1 git push …`,
40
+ "",
41
+ ].join("\n"),
42
+ };
43
+ }
44
+ }
45
+ return { ok: true };
46
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The actual "baked into Claude" mechanism — not a hook, not magic. Claude
3
+ * Code reads CLAUDE.md automatically at the start of every session and
4
+ * follows it as standing instructions. So instead of expecting a human to
5
+ * remember to type `claude-code-local-merge land` after every change, this snippet tells
6
+ * the agent itself to do it — the same way a real production setup that
7
+ * runs several parallel Claude Code agents keeps its own human out of the
8
+ * loop for anything except the final promotion to production.
9
+ *
10
+ * `claude-code-local-merge init` writes this into CLAUDE.md (creating it if missing,
11
+ * appending — once, idempotently, via MARKER — if one already exists) so
12
+ * every session in the repo picks it up with zero extra setup per agent.
13
+ */
14
+ import type { ClaudeCodeLocalMergeConfig } from "./config.js";
15
+ export declare const MARKER = "<!-- claude-code-local-merge:workflow -->";
16
+ export declare function claudeMdSnippet(cfg: ClaudeCodeLocalMergeConfig): string;
@@ -0,0 +1,18 @@
1
+ export const MARKER = "<!-- claude-code-local-merge:workflow -->";
2
+ export function claudeMdSnippet(cfg) {
3
+ const promoteBlock = cfg.productionBranch
4
+ ? `- **\`${cfg.productionBranch}\` is production, and it is not your call.** Only a human runs \`claude-code-local-merge promote\` (${cfg.integrationBranch} → ${cfg.productionBranch}), on their own schedule. Never run it yourself, never suggest running it as part of "finishing" a task — landing on \`${cfg.integrationBranch}\` is the finish line for you.`
5
+ : `- There's no separate production branch configured — \`${cfg.integrationBranch}\` already is it. Landing is the whole deal.`;
6
+ return `${MARKER}
7
+ ## Claude Code Local Merge workflow
8
+
9
+ This repo uses [Claude Code Local Merge](https://github.com/funador/claude-code-local-merge) to run parallel Claude Code agents without them racing each other. It's not a suggestion — follow it by default, every session, without being asked:
10
+
11
+ - **You're probably already in a lane.** If you were started with \`claude --worktree <name>\`, Claude Code Local Merge's \`WorktreeCreate\` hook already isolated you into your own git worktree and branch. Just work.
12
+ - **Land the moment your change is green — don't ask first.** Run \`claude-code-local-merge land\` yourself once tests/build/lint pass. This is pre-authorized, standard workflow, not a decision that needs sign-off. It rebases onto \`${cfg.integrationBranch}\`, pushes through the FIFO landing queue, and syncs the dev checkout — that whole sequence is "done," not "pushed to a branch and waiting."
13
+ - **Never push \`${cfg.integrationBranch}\` directly.** \`git push origin HEAD:${cfg.integrationBranch}\` is rejected by the pre-push hook anyway — always go through \`claude-code-local-merge land\`.
14
+ - **A failed \`claude-code-local-merge land\` is not a stopping point — landed is the only "done."** A rebase conflict aborts safely and leaves your working tree clean; that's expected, not an error to report and hand back. Resolve the conflicting diff yourself the same way you'd fix any other bug, \`git add\` the result, \`git rebase --continue\`, then re-run \`claude-code-local-merge land\` — repeat until it actually lands. Don't say a change is finished, complete, or ready while it's still sitting unlanded in your lane; \`checkCommand\` still gates the real push regardless of how many attempts it took, so a wrong resolution gets caught there, not by a human reading it first.
15
+ - **Use \`claude-code-local-merge build-lock -- <cmd>\` for anything heavy** (a full build, a slow test suite) so parallel lanes take turns instead of thrashing the machine together.
16
+ ${promoteBlock}
17
+ `;
18
+ }
@@ -0,0 +1,92 @@
1
+ export interface ClaudeCodeLocalMergeConfig {
2
+ /** Lane branches are named "<branchPrefix><n>" — lane/1, lane/2, ... */
3
+ branchPrefix: string;
4
+ /** Sibling worktree dirs are named "<repo><worktreeSuffix><n>" — ../myapp-lane-1. */
5
+ worktreeSuffix: string;
6
+ /** First lane's dev-server port. Lane n gets portBase + n. */
7
+ portBase: number;
8
+ /** The shared branch `claude-code-local-merge land` rebases onto and pushes to. */
9
+ integrationBranch: string;
10
+ /**
11
+ * The production branch, if you run a two-stage model (agents land on
12
+ * `integrationBranch`; a human promotes that to `productionBranch` on
13
+ * their own schedule via `claude-code-local-merge promote`). `null` means
14
+ * `integrationBranch` IS production — no separate promotion step, and
15
+ * `claude-code-local-merge promote` is a no-op. When set, this branch is automatically
16
+ * protected by the pre-push hook — you don't need to also list it in
17
+ * `protectedBranches`.
18
+ */
19
+ productionBranch: string | null;
20
+ /**
21
+ * Extra branches the pre-push hook refuses a *direct* push to, beyond
22
+ * integrationBranch (always protected) and productionBranch (protected
23
+ * automatically when set). Most repos running the standard two-stage
24
+ * model don't need this at all.
25
+ */
26
+ protectedBranches: string[];
27
+ /**
28
+ * Files a build tool regenerates on its own (next-env.d.ts, a rewritten
29
+ * tsconfig "include" array, ...) that should never block a rebase or a
30
+ * fast-forward. Empty by default — you'll meet your first one the hard
31
+ * way, and then you add it here once, for good.
32
+ */
33
+ regenerableFiles: string[];
34
+ /**
35
+ * Git-ignored paths copied by reference (symlinked) into every new lane
36
+ * so it never needs a fresh install or a copy of your secrets.
37
+ */
38
+ symlinks: string[];
39
+ /**
40
+ * Build-output directories `preview` never copies onto the main checkout,
41
+ * on top of the fixed, always-excluded set (.git, node_modules, .env,
42
+ * .env.local). `preview` itself doesn't know or care what framework
43
+ * you're running — it just rsyncs source files onto a checkout your dev
44
+ * server is watching. The default list covers the common cases; add your
45
+ * own (".output" for Nuxt, ".svelte-kit" for SvelteKit, ...) rather than
46
+ * assuming this tool knows your build tool.
47
+ */
48
+ buildOutputDirs: string[];
49
+ /**
50
+ * The command `claude-code-local-merge check-push` runs (in addition to the branch
51
+ * protections) before a landing is allowed through — your lint/typecheck/
52
+ * test/build, whatever "green" means for this repo. `null` means nothing
53
+ * runs. That's a real, dangerous state for a tool whose whole pitch is
54
+ * "tested before merge," so it's only silent if `checksRequired` is
55
+ * explicitly set to `false` too — otherwise a null checkCommand FAILS the
56
+ * push rather than landing something nobody verified.
57
+ */
58
+ checkCommand: string | null;
59
+ /**
60
+ * When true (the default) and `checkCommand` is null, `check-push` fails
61
+ * the push instead of landing unverified code. Set to `false` yourself to
62
+ * deliberately run with no checks — a real repo state (nothing to test
63
+ * yet), but one that should be a visible, committed, code-reviewable
64
+ * choice, not a silent default.
65
+ */
66
+ checksRequired: boolean;
67
+ }
68
+ export declare const DEFAULTS: ClaudeCodeLocalMergeConfig;
69
+ /**
70
+ * Fail loud on a malformed config instead of silently misbehaving three
71
+ * commands later. Returns a list of human-readable problems — empty means
72
+ * valid.
73
+ */
74
+ export declare function validateConfig(cfg: ClaudeCodeLocalMergeConfig): string[];
75
+ /**
76
+ * The repo's actual current branch, so `init` doesn't blindly assume "main"
77
+ * — plenty of real repos still default to "master" (or something else
78
+ * entirely), and a generated config pointing at a branch that doesn't exist
79
+ * is exactly the kind of out-of-the-box friction this tool exists to avoid.
80
+ * Returns null (letting the caller fall back to DEFAULTS) if there's no
81
+ * commit yet or HEAD is detached.
82
+ */
83
+ export declare function detectCurrentBranch(cwd?: string): string | null;
84
+ export declare function findRepoRoot(cwd?: string): string | null;
85
+ export declare function configPath(cwd?: string): string | null;
86
+ export declare function hasConfig(cwd?: string): boolean;
87
+ /**
88
+ * Load claude-code-local-merge.config.(m)js from the current repo, merged over DEFAULTS.
89
+ * Throws with every problem listed if the merged config is invalid — a
90
+ * config that's silently wrong is worse than a command that refuses to run.
91
+ */
92
+ export declare function loadConfig(cwd?: string): Promise<ClaudeCodeLocalMergeConfig>;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * claude-code-local-merge.config.ts (or .js — plain JS is fine too) is the only thing
3
+ * that changes between repos. Every command in here reads its knobs from
4
+ * here instead of hardcoding a branch name, a repo name, or a command —
5
+ * that's the difference between "a tool we wrote for one repo" and "a tool
6
+ * anyone can point at theirs."
7
+ *
8
+ * `claude-code-local-merge init` writes a starter config into the repo you run it from.
9
+ * Worktree isolation itself is Claude Code's job now (native `--worktree` /
10
+ * `isolation: worktree`) — this config is read by the WorktreeCreate hook
11
+ * that plugs Claude Code Local Merge's lane numbering into that, and by everything
12
+ * downstream of it (the build queue, the landing queue, preview).
13
+ */
14
+ import { existsSync } from "node:fs";
15
+ import { execFileSync } from "node:child_process";
16
+ import { join } from "node:path";
17
+ import { pathToFileURL } from "node:url";
18
+ export const DEFAULTS = {
19
+ branchPrefix: "lane/",
20
+ worktreeSuffix: "-lane-",
21
+ portBase: 3000,
22
+ integrationBranch: "main",
23
+ productionBranch: null,
24
+ protectedBranches: [],
25
+ regenerableFiles: [],
26
+ symlinks: [".env", ".env.local", "node_modules"],
27
+ buildOutputDirs: ["dist", "build", ".next"],
28
+ checkCommand: null,
29
+ checksRequired: true,
30
+ };
31
+ /**
32
+ * Fail loud on a malformed config instead of silently misbehaving three
33
+ * commands later. Returns a list of human-readable problems — empty means
34
+ * valid.
35
+ */
36
+ export function validateConfig(cfg) {
37
+ const problems = [];
38
+ const nonEmptyString = (v) => typeof v === "string" && v.trim().length > 0;
39
+ if (!nonEmptyString(cfg.branchPrefix))
40
+ problems.push("branchPrefix must be a non-empty string.");
41
+ if (!nonEmptyString(cfg.worktreeSuffix))
42
+ problems.push("worktreeSuffix must be a non-empty string.");
43
+ if (typeof cfg.portBase !== "number" || !Number.isInteger(cfg.portBase) || cfg.portBase <= 0) {
44
+ problems.push("portBase must be a positive integer.");
45
+ }
46
+ if (!nonEmptyString(cfg.integrationBranch))
47
+ problems.push("integrationBranch must be a non-empty string.");
48
+ if (cfg.productionBranch !== null && !nonEmptyString(cfg.productionBranch)) {
49
+ problems.push("productionBranch must be null or a non-empty string.");
50
+ }
51
+ if (cfg.productionBranch !== null && cfg.productionBranch === cfg.integrationBranch) {
52
+ problems.push("productionBranch and integrationBranch are the same branch — that's a no-op two-stage model. Set productionBranch to null instead.");
53
+ }
54
+ if (!Array.isArray(cfg.protectedBranches) || !cfg.protectedBranches.every(nonEmptyString)) {
55
+ problems.push("protectedBranches must be an array of non-empty strings.");
56
+ }
57
+ else if (cfg.protectedBranches.includes(cfg.integrationBranch)) {
58
+ problems.push("protectedBranches contains integrationBranch — that branch is where claude-code-local-merge land pushes; it can't also be blocked.");
59
+ }
60
+ if (!Array.isArray(cfg.regenerableFiles) || !cfg.regenerableFiles.every((v) => typeof v === "string")) {
61
+ problems.push("regenerableFiles must be an array of strings.");
62
+ }
63
+ if (!Array.isArray(cfg.symlinks) || !cfg.symlinks.every((v) => typeof v === "string")) {
64
+ problems.push("symlinks must be an array of strings.");
65
+ }
66
+ if (!Array.isArray(cfg.buildOutputDirs) || !cfg.buildOutputDirs.every((v) => typeof v === "string")) {
67
+ problems.push("buildOutputDirs must be an array of strings.");
68
+ }
69
+ if (cfg.checkCommand !== null && !nonEmptyString(cfg.checkCommand)) {
70
+ problems.push("checkCommand must be null or a non-empty string.");
71
+ }
72
+ if (typeof cfg.checksRequired !== "boolean") {
73
+ problems.push("checksRequired must be a boolean.");
74
+ }
75
+ return problems;
76
+ }
77
+ /**
78
+ * The repo's actual current branch, so `init` doesn't blindly assume "main"
79
+ * — plenty of real repos still default to "master" (or something else
80
+ * entirely), and a generated config pointing at a branch that doesn't exist
81
+ * is exactly the kind of out-of-the-box friction this tool exists to avoid.
82
+ * Returns null (letting the caller fall back to DEFAULTS) if there's no
83
+ * commit yet or HEAD is detached.
84
+ */
85
+ export function detectCurrentBranch(cwd = process.cwd()) {
86
+ try {
87
+ const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
88
+ cwd,
89
+ encoding: "utf8",
90
+ stdio: ["ignore", "pipe", "pipe"],
91
+ }).trim();
92
+ return branch && branch !== "HEAD" ? branch : null;
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
98
+ export function findRepoRoot(cwd = process.cwd()) {
99
+ try {
100
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
101
+ cwd,
102
+ encoding: "utf8",
103
+ }).trim();
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }
109
+ function candidatePaths(root) {
110
+ return [join(root, "claude-code-local-merge.config.mjs"), join(root, "claude-code-local-merge.config.js")];
111
+ }
112
+ export function configPath(cwd = process.cwd()) {
113
+ const root = findRepoRoot(cwd);
114
+ if (!root)
115
+ return null;
116
+ return candidatePaths(root).find((p) => existsSync(p)) ?? null;
117
+ }
118
+ export function hasConfig(cwd = process.cwd()) {
119
+ return configPath(cwd) !== null;
120
+ }
121
+ /**
122
+ * Load claude-code-local-merge.config.(m)js from the current repo, merged over DEFAULTS.
123
+ * Throws with every problem listed if the merged config is invalid — a
124
+ * config that's silently wrong is worse than a command that refuses to run.
125
+ */
126
+ export async function loadConfig(cwd = process.cwd()) {
127
+ const p = configPath(cwd);
128
+ if (!p)
129
+ return { ...DEFAULTS };
130
+ const mod = (await import(pathToFileURL(p).href));
131
+ const cfg = { ...DEFAULTS, ...(mod.default ?? {}) };
132
+ const problems = validateConfig(cfg);
133
+ if (problems.length > 0) {
134
+ throw new Error(`Invalid claude-code-local-merge.config at ${p}:\n${problems.map((p2) => ` - ${p2}`).join("\n")}`);
135
+ }
136
+ return cfg;
137
+ }
@@ -0,0 +1,40 @@
1
+ export interface EphemeralResourceProvider<T> {
2
+ /** Create one resource for this run and return whatever callers need to use it. */
3
+ create(): Promise<T>;
4
+ /** Tear down a resource this same process created. */
5
+ destroy(handle: T): Promise<void>;
6
+ /**
7
+ * Tear down a resource some OTHER (now-dead) process created and never got
8
+ * to destroy — a crash, a SIGKILL, a CI runner that got cancelled. Given
9
+ * only what was recorded at claim time (see ClaimRegistry below).
10
+ */
11
+ destroyOrphan(claim: Claim): Promise<void>;
12
+ }
13
+ export interface Claim {
14
+ id: string;
15
+ pid: number;
16
+ createdAt: number;
17
+ }
18
+ /**
19
+ * A tiny on-disk registry of "what's claimed, by which PID." Not a database
20
+ * — just enough bookkeeping to answer "is this orphaned?" the same way
21
+ * queue-lock.ts answers it for locks: check whether the claiming PID is
22
+ * still alive, with no timeout to tune.
23
+ */
24
+ export declare class ClaimRegistry {
25
+ private readonly dir;
26
+ constructor(registryDir: string);
27
+ record(claim: Claim): void;
28
+ release(id: string): void;
29
+ /** Every claim whose owning PID is no longer alive — safe to destroy. */
30
+ orphans(): Claim[];
31
+ }
32
+ /**
33
+ * Run `fn` against a freshly created resource, prune any orphans left by a
34
+ * previous crashed run first, and always release the claim — including on
35
+ * a thrown error. If THIS process is killed before the `finally` runs, the
36
+ * resource isn't leaked forever: the next call to `withEphemeralResource`
37
+ * (in the next test run, from any lane) prunes it via `orphans()` before
38
+ * creating its own.
39
+ */
40
+ export declare function withEphemeralResource<T>(provider: EphemeralResourceProvider<T>, registry: ClaimRegistry, fn: (resource: T) => Promise<void>): Promise<void>;