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/sync.js ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * sync.ts — fast-forward the MAIN checkout to its upstream branch.
3
+ *
4
+ * Your dev server (or whatever's watching the filesystem) runs on the MAIN
5
+ * checkout, which tracks the integration branch. Lanes land onto that branch
6
+ * via a push, but the MAIN checkout's working tree only advances on a pull —
7
+ * so it serves stale files until something fast-forwards it. `land` runs
8
+ * this immediately after every successful push, so the dev server picks up
9
+ * landed work with zero manual `git pull`.
10
+ *
11
+ * Safe by construction:
12
+ * - Fast-forward ONLY. If the checkout has diverged from its upstream, it
13
+ * warns and leaves it untouched — never a force, never a merge commit.
14
+ * - Retries transient index.lock contention (two lanes landing near-simultaneously).
15
+ * - If a fast-forward is blocked only by a locally-modified *regenerable*
16
+ * file (configured in claude-code-local-merge.config), it discards that file and
17
+ * retries. Any other dirty file → warn and skip.
18
+ */
19
+ import { execFileSync, spawnSync } from "node:child_process";
20
+ import { loadConfig } from "./lib/config.js";
21
+ import { resolveMainCheckout } from "./lib/main-checkout.js";
22
+ import { detectPackageManager } from "./lib/check-command.js";
23
+ const LOCK_RETRIES = 3;
24
+ // Keyed by detectPackageManager's return value. bun writes either lockfile
25
+ // name depending on version, so both are checked.
26
+ const LOCKFILES = {
27
+ npm: ["package-lock.json"],
28
+ pnpm: ["pnpm-lock.yaml"],
29
+ yarn: ["yarn.lock"],
30
+ bun: ["bun.lockb", "bun.lock"],
31
+ };
32
+ /**
33
+ * The main checkout's node_modules is the one every lane symlinks from
34
+ * (see claude-code-local-merge.config's `symlinks`). Fast-forwarding its git state does
35
+ * nothing to that directory — if the range we just pulled in changed the
36
+ * lockfile, every lane is now silently running on stale dependencies until
37
+ * someone happens to run `npm install` here by hand. Do it automatically,
38
+ * the same moment the git state lands, so the gap never opens.
39
+ */
40
+ function refreshDependenciesIfChanged(root, before, after) {
41
+ const pm = detectPackageManager(root);
42
+ const lockfiles = LOCKFILES[pm] ?? [];
43
+ const changed = git(root, ["diff", "--name-only", before, after], { allowFail: true }).out.split("\n");
44
+ if (!lockfiles.some((f) => changed.includes(f)))
45
+ return;
46
+ console.log(`claude-code-local-merge sync: lockfile changed — running "${pm} install" so the shared node_modules (symlinked into every lane) stays in sync…`);
47
+ const result = spawnSync(pm, ["install"], { cwd: root, stdio: "inherit" });
48
+ if (result.status !== 0) {
49
+ console.error(`claude-code-local-merge sync: "${pm} install" failed (exit ${result.status ?? 1}) — shared node_modules may be stale. Run it manually in ${root}.`);
50
+ }
51
+ else {
52
+ console.log("claude-code-local-merge sync: dependencies refreshed.");
53
+ }
54
+ }
55
+ function git(cwd, args, { allowFail = false } = {}) {
56
+ try {
57
+ return {
58
+ ok: true,
59
+ out: execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }),
60
+ };
61
+ }
62
+ catch (e) {
63
+ if (!allowFail)
64
+ throw e;
65
+ const err = e;
66
+ return { ok: false, out: `${err.stdout ?? ""}${err.stderr ?? ""}` };
67
+ }
68
+ }
69
+ function sleep(ms) {
70
+ const end = Date.now() + ms;
71
+ while (Date.now() < end) {
72
+ /* tiny synchronous backoff for index.lock */
73
+ }
74
+ }
75
+ /**
76
+ * Fast-forwards the MAIN checkout. Returns a process exit code; never throws.
77
+ *
78
+ * Accepts an already-loaded config, for `land` calling this immediately
79
+ * after a push that ITSELF introduced or changed claude-code-local-merge.config.mjs: the
80
+ * MAIN checkout hasn't been fast-forwarded yet at that exact moment (that's
81
+ * this function's whole job), so loading fresh from MAIN would silently
82
+ * fall back to DEFAULTS and could reject a perfectly good sync — the same
83
+ * bootstrap gap createLane had to be fixed for. The lane's own config,
84
+ * which just successfully rebased onto and pushed to the real
85
+ * integrationBranch, is the more trustworthy answer at that moment. A bare
86
+ * `claude-code-local-merge sync` (no caller-provided config) still loads fresh from
87
+ * MAIN, same as before.
88
+ */
89
+ export async function sync(providedCfg) {
90
+ let MAIN;
91
+ try {
92
+ MAIN = resolveMainCheckout(process.cwd());
93
+ }
94
+ catch {
95
+ console.error("claude-code-local-merge sync: not inside a git repo — nothing to do.");
96
+ return 0;
97
+ }
98
+ const cfg = providedCfg ?? (await loadConfig(MAIN));
99
+ const regenerable = new Set(cfg.regenerableFiles);
100
+ const branchRes = git(MAIN, ["rev-parse", "--abbrev-ref", "HEAD"], { allowFail: true });
101
+ const branch = branchRes.out.trim();
102
+ if (!branch || branch === "HEAD") {
103
+ console.error("claude-code-local-merge sync: the checkout is detached or unresolved — left untouched.");
104
+ return 0;
105
+ }
106
+ // The main checkout is meant to stay parked on integrationBranch permanently
107
+ // (that's what makes "fast-forward it" a safe, unattended operation). If
108
+ // it's on something else — someone switched branches in it by hand, or ran
109
+ // `land` from a single non-worktree checkout instead of a lane worktree —
110
+ // fast-forwarding "whatever HEAD happens to be" silently does the wrong
111
+ // thing. Say so plainly instead of surfacing a raw git error later.
112
+ if (branch !== cfg.integrationBranch) {
113
+ console.error(`claude-code-local-merge sync: this checkout is on '${branch}', not the configured integrationBranch ` +
114
+ `('${cfg.integrationBranch}'). sync only fast-forwards the main checkout — run it from ` +
115
+ `there, or check out '${cfg.integrationBranch}' here first. Left untouched.`);
116
+ return 0;
117
+ }
118
+ const upstream = `origin/${branch}`;
119
+ const before = git(MAIN, ["rev-parse", "--short", "HEAD"], { allowFail: true }).out.trim();
120
+ git(MAIN, ["fetch", "origin", "--quiet"], { allowFail: true });
121
+ const tryFastForward = () => git(MAIN, ["merge", "--ff-only", upstream], { allowFail: true });
122
+ let res = tryFastForward();
123
+ // Retry transient lock contention (another lane landing at the same instant).
124
+ for (let i = 0; i < LOCK_RETRIES && !res.ok && /index\.lock|Unable to create|another git process/i.test(res.out); i++) {
125
+ sleep(400);
126
+ res = tryFastForward();
127
+ }
128
+ // Blocked by a locally-modified regenerable file? Discard it and retry once.
129
+ if (!res.ok && /would be overwritten by merge/i.test(res.out)) {
130
+ const files = res.out
131
+ .split("\n")
132
+ .map((l) => l.trim())
133
+ .filter((l) => l && !/would be overwritten|please commit|aborting|^error:/i.test(l));
134
+ const blocking = files.filter((f) => !regenerable.has(f));
135
+ if (blocking.length === 0 && files.length > 0) {
136
+ git(MAIN, ["checkout", "--", ...files], { allowFail: true });
137
+ res = tryFastForward();
138
+ }
139
+ else {
140
+ console.error(`claude-code-local-merge sync: ${branch} has local changes blocking fast-forward (${blocking.join(", ")}). Left untouched — resolve in the checkout.`);
141
+ return 0;
142
+ }
143
+ }
144
+ if (res.ok) {
145
+ const after = git(MAIN, ["rev-parse", "--short", "HEAD"], { allowFail: true }).out.trim();
146
+ if (before === after) {
147
+ console.log(`claude-code-local-merge sync: ${branch} already current at ${after}.`);
148
+ }
149
+ else {
150
+ console.log(`claude-code-local-merge sync: fast-forwarded ${branch} ${before} → ${after} — the dev server will pick it up.`);
151
+ refreshDependenciesIfChanged(MAIN, before, after);
152
+ }
153
+ return 0;
154
+ }
155
+ if (/Not possible to fast-forward|diverging|non-fast-forward/i.test(res.out)) {
156
+ console.error(`claude-code-local-merge sync: local ${branch} has DIVERGED from ${upstream} (something was committed directly on the checkout). Left untouched — reconcile it manually.`);
157
+ return 0;
158
+ }
159
+ console.error(`claude-code-local-merge sync: could not fast-forward ${branch} — left untouched.\n${res.out.trim()}`);
160
+ return 0;
161
+ }
@@ -0,0 +1,67 @@
1
+ // claude-code-local-merge.config.mjs — lives at your repo root. `claude-code-local-merge init` writes a
2
+ // copy of this for you; edit the values below for your project.
3
+ //
4
+ // Worktree isolation is Claude Code's job (native `--worktree` /
5
+ // `isolation: "worktree"`) — this file is what the WorktreeCreate hook
6
+ // (see hooks/claude-settings.example.json) reads to name and shape the lane
7
+ // it creates, and what everything downstream (build queue, landing queue,
8
+ // preview) reads too.
9
+
10
+ /** @type {import("claude-code-local-merge").ClaudeCodeLocalMergeConfig} */
11
+ export default {
12
+ // Lane branches: lane/1, lane/2, ...
13
+ branchPrefix: "lane/",
14
+
15
+ // Sibling worktree dirs: ../<your-repo>-lane-1, -lane-2, ...
16
+ worktreeSuffix: "-lane-",
17
+
18
+ // Lane 1 gets this port, lane 2 gets portBase + 2, and so on — handy if
19
+ // each lane also runs its own throwaway dev server.
20
+ portBase: 3000,
21
+
22
+ // The branch `claude-code-local-merge land` rebases onto and pushes to. Agents land
23
+ // here continuously and autonomously — see the CLAUDE.md workflow section
24
+ // `claude-code-local-merge init` writes.
25
+ integrationBranch: "main",
26
+
27
+ // Set this if you run a two-stage model: agents land on integrationBranch,
28
+ // a human ships to productionBranch on their own schedule with
29
+ // `claude-code-local-merge promote`. null (the default) means integrationBranch IS
30
+ // production — no separate promotion step. Example: integrationBranch
31
+ // "dev", productionBranch "main". Automatically protected by the pre-push
32
+ // hook when set — you don't need to also list it below.
33
+ productionBranch: null,
34
+
35
+ // Extra branches the pre-push hook refuses a *direct* push to, beyond
36
+ // integrationBranch and productionBranch. Most repos need nothing here.
37
+ protectedBranches: [],
38
+
39
+ // Files your build tool rewrites on its own that should never block a
40
+ // rebase or a fast-forward. Next.js projects typically want
41
+ // ["next-env.d.ts"] here at minimum — add to this list the first time a
42
+ // regenerated file blocks a landing, and never think about it again.
43
+ regenerableFiles: [],
44
+
45
+ // Git-ignored paths symlinked into every new lane so it needs no fresh
46
+ // install and no copy of your secrets.
47
+ symlinks: [".env", ".env.local", "node_modules"],
48
+
49
+ // Build-output dirs `claude-code-local-merge preview` never copies onto your dev
50
+ // checkout. preview is framework-agnostic (it's an rsync, not a build
51
+ // step) — this is the one place your framework's name shows up. Add
52
+ // ".output" for Nuxt, ".svelte-kit" for SvelteKit, etc.
53
+ buildOutputDirs: ["dist", "build", ".next"],
54
+
55
+ // The command that actually gates a landing — your lint/typecheck/test/
56
+ // build. `claude-code-local-merge init` tries to detect this from package.json
57
+ // (check:push, check, ci, or test, in that order) and fills it in for
58
+ // you. null means nothing runs, which is only allowed if checksRequired
59
+ // is also false — see below.
60
+ checkCommand: "npm run check",
61
+
62
+ // true (the default): a null checkCommand FAILS every push rather than
63
+ // landing unverified code. Set to false yourself to deliberately run
64
+ // with no checks — a real state for a repo with nothing to test yet, but
65
+ // one that should be a visible, committed choice, not a silent default.
66
+ checksRequired: true,
67
+ };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * A complete, runnable EphemeralResourceProvider — a scratch directory per
3
+ * test run instead of a database branch, so this example works with zero
4
+ * external services and zero cost. Swap `create`/`destroy`/`destroyOrphan`
5
+ * for calls to your actual provider (a Neon branch-create/delete API, a
6
+ * `CREATE DATABASE ... TEMPLATE ...`, a Docker container) and everything
7
+ * else — the claim registry, the orphan pruning, the finally-block release —
8
+ * carries over unchanged.
9
+ *
10
+ * Run it: node --import tsx examples/ephemeral-tmp-dir.example.ts
11
+ * Simulate a crash: node --import tsx examples/ephemeral-tmp-dir.example.ts --crash
12
+ * then run it again without --crash and watch it prune the orphan first.
13
+ */
14
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { ClaimRegistry, withEphemeralResource, type EphemeralResourceProvider } from "../src/lib/ephemeral.js";
18
+
19
+ const REGISTRY_DIR = join(tmpdir(), "claude-code-local-merge-example-ephemeral-registry");
20
+ const RESOURCE_ROOT = join(tmpdir(), "claude-code-local-merge-example-ephemeral-resources");
21
+ mkdirSync(RESOURCE_ROOT, { recursive: true });
22
+
23
+ const tmpDirProvider: EphemeralResourceProvider<string> = {
24
+ async create() {
25
+ const dir = mkdtempSync(join(RESOURCE_ROOT, "run-"));
26
+ console.log(` created scratch dir: ${dir}`);
27
+ return dir;
28
+ },
29
+ async destroy(dir) {
30
+ rmSync(dir, { recursive: true, force: true });
31
+ console.log(` destroyed scratch dir: ${dir}`);
32
+ },
33
+ async destroyOrphan(claim) {
34
+ // The claim only tells us WHO made it and WHEN, not which directory it
35
+ // owned — in a real provider you'd store that mapping yourself (e.g. the
36
+ // resource's ID inside the claim record). For this example, orphaned
37
+ // "run-*" directories are swept by age instead.
38
+ console.log(` pruning orphan left by dead pid ${claim.pid} (claimed at ${new Date(claim.createdAt).toISOString()})`);
39
+ for (const name of readdirSync(RESOURCE_ROOT)) {
40
+ const path = join(RESOURCE_ROOT, name);
41
+ if (existsSync(join(path, ".orphan-sweep-safe"))) {
42
+ rmSync(path, { recursive: true, force: true });
43
+ }
44
+ }
45
+ },
46
+ };
47
+
48
+ const registry = new ClaimRegistry(REGISTRY_DIR);
49
+
50
+ if (process.argv.includes("--crash")) {
51
+ // Simulate a run that claims a resource and then dies before its finally
52
+ // block runs — no rmSync, no registry.release(). The NEXT run should find
53
+ // and prune this.
54
+ const dir = mkdtempSync(join(RESOURCE_ROOT, "run-"));
55
+ writeFileSync(join(dir, ".orphan-sweep-safe"), "");
56
+ registry.record({ id: `${Date.now()}-${process.pid}`, pid: process.pid, createdAt: Date.now() });
57
+ console.log(`simulated crash: claimed ${dir} and exiting without cleanup (pid ${process.pid})`);
58
+ process.exit(1);
59
+ }
60
+
61
+ await withEphemeralResource(tmpDirProvider, registry, async (dir) => {
62
+ writeFileSync(join(dir, "example.txt"), "this file only exists for the run's lifetime\n");
63
+ console.log(` running your tests against ${dir}…`);
64
+ });
65
+
66
+ console.log("done — resource was created, used, and torn down; any prior orphan was pruned first.");
@@ -0,0 +1,14 @@
1
+ {
2
+ "hooks": {
3
+ "WorktreeCreate": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "npx claude-code-local-merge hook worktree-create"
9
+ }
10
+ ]
11
+ }
12
+ ]
13
+ }
14
+ }
package/hooks/pre-push ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env sh
2
+ # Claude Code Local Merge — landing-queue enforcement + your checks, gating every push.
3
+ #
4
+ # Copy this file to .husky/pre-push (recommended — Husky wires it in on
5
+ # your package manager's install step, for every clone and every lane
6
+ # worktree) or straight to
7
+ # .git/hooks/pre-push (works, but isn't versioned or shared with the team).
8
+ # If you already have a pre-push hook, append this block instead of
9
+ # replacing the file. `claude-code-local-merge init` does all of this for you.
10
+ #
11
+ # `claude-code-local-merge check-push` does two things, in order:
12
+ # 1. Blocks a direct push to your integration/protected/production
13
+ # branches that didn't go through `claude-code-local-merge land` / `promote`.
14
+ # 2. Runs `checkCommand` from claude-code-local-merge.config.mjs (your lint/typecheck/
15
+ # test/build) and fails the push if it fails — or if checkCommand
16
+ # isn't set and checksRequired wasn't deliberately turned off.
17
+ #
18
+ # See src/land.ts and src/lib/check-push.ts for why a convention alone
19
+ # isn't enough to guarantee either of those.
20
+
21
+ if ! npx claude-code-local-merge check-push; then
22
+ exit 1
23
+ fi
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "claude-code-merge-queue",
3
+ "version": "0.1.14",
4
+ "description": "The local, zero-cost merge queue for parallel Claude Code agents. Plugs into Claude Code's native worktree isolation; one build at a time, one landing at a time, zero races.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Jesse Heaslip",
8
+ "homepage": "https://github.com/funador/claude-code-local-merge",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/funador/claude-code-local-merge.git"
12
+ },
13
+ "keywords": [
14
+ "claude-code",
15
+ "ai-agents",
16
+ "git-worktree",
17
+ "parallel-agents",
18
+ "coding-agent",
19
+ "merge-queue",
20
+ "monorepo-tooling"
21
+ ],
22
+ "bin": {
23
+ "claude-code-local-merge": "dist/bin/claude-code-local-merge.js"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "hooks",
28
+ "examples",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "scripts": {
36
+ "build": "tsc -p tsconfig.json && chmod +x dist/bin/claude-code-local-merge.js",
37
+ "prepare": "npm run build",
38
+ "pretest": "npm run build",
39
+ "test": "node --import tsx --test test/*.test.ts"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^22.10.0",
43
+ "tsx": "^4.19.0",
44
+ "typescript": "^5.7.0"
45
+ }
46
+ }