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.
- package/LICENSE +21 -0
- package/README.md +260 -0
- package/dist/bin/claude-code-local-merge.d.ts +2 -0
- package/dist/bin/claude-code-local-merge.js +316 -0
- package/dist/bin/lanekeeper.d.ts +2 -0
- package/dist/bin/lanekeeper.js +307 -0
- package/dist/bin/localmerge.d.ts +2 -0
- package/dist/bin/localmerge.js +316 -0
- package/dist/bin/mergequeue.d.ts +2 -0
- package/dist/bin/mergequeue.js +307 -0
- package/dist/build-lock.d.ts +1 -0
- package/dist/build-lock.js +70 -0
- package/dist/hooks/worktree-create.d.ts +15 -0
- package/dist/hooks/worktree-create.js +192 -0
- package/dist/land.d.ts +1 -0
- package/dist/land.js +144 -0
- package/dist/lib/check-command.d.ts +29 -0
- package/dist/lib/check-command.js +83 -0
- package/dist/lib/check-push.d.ts +35 -0
- package/dist/lib/check-push.js +46 -0
- package/dist/lib/claude-md-snippet.d.ts +16 -0
- package/dist/lib/claude-md-snippet.js +18 -0
- package/dist/lib/config.d.ts +92 -0
- package/dist/lib/config.js +137 -0
- package/dist/lib/ephemeral.d.ts +40 -0
- package/dist/lib/ephemeral.js +100 -0
- package/dist/lib/lane-port.d.ts +3 -0
- package/dist/lib/lane-port.js +25 -0
- package/dist/lib/main-checkout.d.ts +1 -0
- package/dist/lib/main-checkout.js +19 -0
- package/dist/lib/prune-lanes.d.ts +36 -0
- package/dist/lib/prune-lanes.js +196 -0
- package/dist/lib/queue-lock.d.ts +26 -0
- package/dist/lib/queue-lock.js +212 -0
- package/dist/lib/tty-confirm.d.ts +1 -0
- package/dist/lib/tty-confirm.js +44 -0
- package/dist/lib/wire-hooks.d.ts +62 -0
- package/dist/lib/wire-hooks.js +230 -0
- package/dist/preview.d.ts +1 -0
- package/dist/preview.js +119 -0
- package/dist/promote.d.ts +1 -0
- package/dist/promote.js +77 -0
- package/dist/sync.d.ts +16 -0
- package/dist/sync.js +161 -0
- package/examples/claude-code-local-merge.config.mjs +67 -0
- package/examples/ephemeral-tmp-dir.example.ts +66 -0
- package/hooks/claude-settings.example.json +14 -0
- package/hooks/pre-push +23 -0
- 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>;
|