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
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The extension point for per-run ephemeral test resources — a throwaway
|
|
3
|
+
* database branch, a scratch bucket, a sandboxed queue, whatever your test
|
|
4
|
+
* suite needs that would otherwise race across concurrent lanes.
|
|
5
|
+
*
|
|
6
|
+
* This file is deliberately NOT wired to any specific provider. Everyone's
|
|
7
|
+
* test setup is different (Neon, a local Postgres with template databases, a
|
|
8
|
+
* Docker container per run, nothing at all) and shipping one company's
|
|
9
|
+
* choice as the default would make this less useful, not more. What's
|
|
10
|
+
* shipped instead is the *shape* — the same claim → use → release, and the
|
|
11
|
+
* same PID-liveness self-heal, that queue-lock.ts and launch.ts already use
|
|
12
|
+
* for the lock and the lane. One pattern, three places, so a crashed run
|
|
13
|
+
* never needs a human to notice and clean up after it.
|
|
14
|
+
*
|
|
15
|
+
* See examples/ephemeral-tmp-dir.example.ts for a complete, runnable
|
|
16
|
+
* implementation (a scratch directory per run, no external service) — copy
|
|
17
|
+
* its shape when you wire this to your own database or resource provider.
|
|
18
|
+
*/
|
|
19
|
+
import { mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
function alive(pid) {
|
|
22
|
+
try {
|
|
23
|
+
process.kill(pid, 0);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
return e.code === "EPERM";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A tiny on-disk registry of "what's claimed, by which PID." Not a database
|
|
32
|
+
* — just enough bookkeeping to answer "is this orphaned?" the same way
|
|
33
|
+
* queue-lock.ts answers it for locks: check whether the claiming PID is
|
|
34
|
+
* still alive, with no timeout to tune.
|
|
35
|
+
*/
|
|
36
|
+
export class ClaimRegistry {
|
|
37
|
+
dir;
|
|
38
|
+
constructor(registryDir) {
|
|
39
|
+
this.dir = registryDir;
|
|
40
|
+
mkdirSync(this.dir, { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
record(claim) {
|
|
43
|
+
writeFileSync(join(this.dir, claim.id), JSON.stringify(claim));
|
|
44
|
+
}
|
|
45
|
+
release(id) {
|
|
46
|
+
try {
|
|
47
|
+
unlinkSync(join(this.dir, id));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* already gone */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Every claim whose owning PID is no longer alive — safe to destroy. */
|
|
54
|
+
orphans() {
|
|
55
|
+
let names;
|
|
56
|
+
try {
|
|
57
|
+
names = readdirSync(this.dir);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
const found = [];
|
|
63
|
+
for (const name of names) {
|
|
64
|
+
try {
|
|
65
|
+
const claim = JSON.parse(readFileSync(join(this.dir, name), "utf8"));
|
|
66
|
+
if (!alive(claim.pid))
|
|
67
|
+
found.push(claim);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
/* unreadable claim file — leave it, don't guess */
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return found;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Run `fn` against a freshly created resource, prune any orphans left by a
|
|
78
|
+
* previous crashed run first, and always release the claim — including on
|
|
79
|
+
* a thrown error. If THIS process is killed before the `finally` runs, the
|
|
80
|
+
* resource isn't leaked forever: the next call to `withEphemeralResource`
|
|
81
|
+
* (in the next test run, from any lane) prunes it via `orphans()` before
|
|
82
|
+
* creating its own.
|
|
83
|
+
*/
|
|
84
|
+
export async function withEphemeralResource(provider, registry, fn) {
|
|
85
|
+
for (const orphan of registry.orphans()) {
|
|
86
|
+
await provider.destroyOrphan(orphan);
|
|
87
|
+
registry.release(orphan.id);
|
|
88
|
+
}
|
|
89
|
+
const id = `${Date.now()}-${process.pid}`;
|
|
90
|
+
const claim = { id, pid: process.pid, createdAt: Date.now() };
|
|
91
|
+
registry.record(claim);
|
|
92
|
+
const resource = await provider.create();
|
|
93
|
+
try {
|
|
94
|
+
await fn(resource);
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
await provider.destroy(resource);
|
|
98
|
+
registry.release(id);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { ClaudeCodeLocalMergeConfig } from "./config.js";
|
|
2
|
+
export declare function laneNumberFromPath(path: string, cfg: Pick<ClaudeCodeLocalMergeConfig, "worktreeSuffix">): number | null;
|
|
3
|
+
export declare function lanePort(path: string, cfg: Pick<ClaudeCodeLocalMergeConfig, "worktreeSuffix" | "portBase">): number | null;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A lane's dev-server port, derived from where it's sitting on disk rather
|
|
3
|
+
* than an injected environment variable. Claude Code's WorktreeCreate hook
|
|
4
|
+
* has no mechanism to hand environment variables to the session that runs
|
|
5
|
+
* in the worktree it creates — only the directory path. So instead of
|
|
6
|
+
* betting on a second, less-certain hook (SessionStart + CLAUDE_ENV_FILE)
|
|
7
|
+
* to smuggle a port in, the worktree's own name already carries it: Lane
|
|
8
|
+
* Keeper names worktrees "<repo><worktreeSuffix><n>", so any script running
|
|
9
|
+
* inside one can read its own lane number straight off `process.cwd()`.
|
|
10
|
+
* Self-describing beats injected, when the information's already sitting
|
|
11
|
+
* right there in the path.
|
|
12
|
+
*/
|
|
13
|
+
import { basename } from "node:path";
|
|
14
|
+
export function laneNumberFromPath(path, cfg) {
|
|
15
|
+
const name = basename(path);
|
|
16
|
+
const idx = name.lastIndexOf(cfg.worktreeSuffix);
|
|
17
|
+
if (idx === -1)
|
|
18
|
+
return null;
|
|
19
|
+
const n = Number(name.slice(idx + cfg.worktreeSuffix.length));
|
|
20
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
21
|
+
}
|
|
22
|
+
export function lanePort(path, cfg) {
|
|
23
|
+
const lane = laneNumberFromPath(path, cfg);
|
|
24
|
+
return lane === null ? null : cfg.portBase + lane;
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function resolveMainCheckout(cwd?: string): string;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the MAIN checkout — the one non-lane checkout your dev server (or
|
|
3
|
+
* CI, or whatever's watching the filesystem) actually runs from — starting
|
|
4
|
+
* from any lane worktree.
|
|
5
|
+
*
|
|
6
|
+
* git-common-dir is ".git" (relative) from the main checkout itself, or an
|
|
7
|
+
* absolute path to <main>/.git from a linked worktree — either way its
|
|
8
|
+
* parent is MAIN. Shared by sync and preview so they agree on the one true
|
|
9
|
+
* answer.
|
|
10
|
+
*/
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { dirname, resolve } from "node:path";
|
|
13
|
+
export function resolveMainCheckout(cwd = process.cwd()) {
|
|
14
|
+
const common = execFileSync("git", ["rev-parse", "--git-common-dir"], {
|
|
15
|
+
cwd,
|
|
16
|
+
encoding: "utf8",
|
|
17
|
+
}).trim();
|
|
18
|
+
return dirname(resolve(cwd, common));
|
|
19
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ClaudeCodeLocalMergeConfig } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* Is a Claude Code session's current working directory inside `dir` right
|
|
4
|
+
* now? `lsof`'s own exit code is NOT reliable for this — it returns
|
|
5
|
+
* non-zero both when nothing matches AND when it merely fails to inspect
|
|
6
|
+
* some unrelated process it lacks permission for (common, e.g. root-owned
|
|
7
|
+
* system daemons), even while correctly printing real matches. `-a` ANDs
|
|
8
|
+
* the two filters together (lsof ORs by default) so this only matches
|
|
9
|
+
* things actually inside `dir`, not every process on the system.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately narrower than "any process at all": a lane keeps
|
|
12
|
+
* accumulating incidental subprocesses whose lifetime doesn't track the
|
|
13
|
+
* Claude Code session that spawned them — an MCP server is the confirmed
|
|
14
|
+
* live case (a lingering `@brightdata/mcp` process kept a fully-landed,
|
|
15
|
+
* already-abandoned lane stuck on disk indefinitely; caffeinate and stray
|
|
16
|
+
* build/watch processes are the same shape of problem). Counting any of
|
|
17
|
+
* those as "still in use" means a lane can never actually get swept once
|
|
18
|
+
* its real agent session exits, which defeats the entire point of pruning.
|
|
19
|
+
* Only a row whose COMMAND column is actually the Claude Code binary
|
|
20
|
+
* counts as "someone's still working here" — matched by prefix since
|
|
21
|
+
* `lsof` truncates COMMAND (macOS: `claude.ex`, so an exact match would be
|
|
22
|
+
* platform-fragile in the other direction).
|
|
23
|
+
*
|
|
24
|
+
* If `lsof` isn't available at all, this fails CLOSED — treats liveness as
|
|
25
|
+
* unknown/possible rather than confirmed-safe, so an unverifiable state
|
|
26
|
+
* never gets treated the same as a verified-idle one.
|
|
27
|
+
*/
|
|
28
|
+
/** Exported so the matching rule itself is unit-testable without spawning real processes. */
|
|
29
|
+
export declare function isClaudeProcessRow(lsofRow: string): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Removes already-landed sibling lane worktrees. Returns the paths it
|
|
32
|
+
* actually removed. Best-effort throughout — any failure for a given
|
|
33
|
+
* worktree (dirty, diverged, busy) just skips that one; this never blocks
|
|
34
|
+
* or fails the `land` it's running as part of.
|
|
35
|
+
*/
|
|
36
|
+
export declare function pruneLandedLanes(mainTop: string, cfg: Pick<ClaudeCodeLocalMergeConfig, "worktreeSuffix" | "branchPrefix" | "integrationBranch" | "regenerableFiles">, currentWorktree: string): string[];
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* After a lane lands, sibling lanes that were ALSO already fully landed have
|
|
3
|
+
* no more reason to keep a worktree around — nothing created one on the way
|
|
4
|
+
* in tore it down on the way out (Claude Code's own worktree lifecycle
|
|
5
|
+
* doesn't either), so directories silently accumulate on disk forever
|
|
6
|
+
* unless something sweeps them. This runs that sweep as a side effect of
|
|
7
|
+
* every successful `land`, never touching the worktree currently running
|
|
8
|
+
* this process or the main checkout itself.
|
|
9
|
+
*
|
|
10
|
+
* Safety:
|
|
11
|
+
* 1. Only prune a worktree whose branch is a git ANCESTOR of
|
|
12
|
+
* origin/<integrationBranch> — the authoritative, already-pushed
|
|
13
|
+
* truth — not the local integration branch, which may not be
|
|
14
|
+
* fast-forwarded yet at the exact moment this runs. That's the literal
|
|
15
|
+
* definition of "nothing to lose": the work already made it upstream
|
|
16
|
+
* under its own name.
|
|
17
|
+
* 2. Never touch a worktree with a LIVE process currently working in it
|
|
18
|
+
* (checked via `lsof -a -d cwd`, see below) — this is NOT redundant
|
|
19
|
+
* with the ancestor check. A brand-new lane that hasn't diverged yet
|
|
20
|
+
* is *trivially* an ancestor of upstream (its tip IS a commit already
|
|
21
|
+
* on the integration branch, just because nothing's been committed
|
|
22
|
+
* there yet) — structurally identical, in the git graph alone, to a
|
|
23
|
+
* lane whose own real work already landed. Only a liveness check can
|
|
24
|
+
* tell "someone's about to start working here" apart from "this is
|
|
25
|
+
* truly done." Confirmed live: a fresh, zero-commit lane got swept by
|
|
26
|
+
* another lane's land before its own first commit.
|
|
27
|
+
* 3. `git worktree remove` (no `--force`) refuses on its own if the
|
|
28
|
+
* worktree has uncommitted changes — dirty work is never discarded
|
|
29
|
+
* just because its branch happens to be merged. The ONE exception,
|
|
30
|
+
* matching `sync`/`land`: files listed in `regenerableFiles`
|
|
31
|
+
* (next-env.d.ts and the like) are discarded first and the removal
|
|
32
|
+
* retried, since a build tool rewriting its own output shouldn't be
|
|
33
|
+
* the thing that leaves an otherwise fully-landed lane stuck forever.
|
|
34
|
+
* Any OTHER dirty file blocks pruning exactly as before — real
|
|
35
|
+
* uncommitted work is never discarded just to tidy up disk space.
|
|
36
|
+
* Deleting the now-redundant local branch (`git branch -d`, never `-D`) is a
|
|
37
|
+
* separate, best-effort tidiness step AFTER the worktree is already gone —
|
|
38
|
+
* it checks merge state against local HEAD rather than origin, so it can
|
|
39
|
+
* legitimately fail if the local integration branch hasn't caught up yet.
|
|
40
|
+
* That failure doesn't undo the (already-safe) worktree removal or keep it
|
|
41
|
+
* out of the returned list; it just leaves a harmless leftover branch ref.
|
|
42
|
+
*/
|
|
43
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
44
|
+
import { realpathSync } from "node:fs";
|
|
45
|
+
import { basename, dirname } from "node:path";
|
|
46
|
+
/**
|
|
47
|
+
* Is a Claude Code session's current working directory inside `dir` right
|
|
48
|
+
* now? `lsof`'s own exit code is NOT reliable for this — it returns
|
|
49
|
+
* non-zero both when nothing matches AND when it merely fails to inspect
|
|
50
|
+
* some unrelated process it lacks permission for (common, e.g. root-owned
|
|
51
|
+
* system daemons), even while correctly printing real matches. `-a` ANDs
|
|
52
|
+
* the two filters together (lsof ORs by default) so this only matches
|
|
53
|
+
* things actually inside `dir`, not every process on the system.
|
|
54
|
+
*
|
|
55
|
+
* Deliberately narrower than "any process at all": a lane keeps
|
|
56
|
+
* accumulating incidental subprocesses whose lifetime doesn't track the
|
|
57
|
+
* Claude Code session that spawned them — an MCP server is the confirmed
|
|
58
|
+
* live case (a lingering `@brightdata/mcp` process kept a fully-landed,
|
|
59
|
+
* already-abandoned lane stuck on disk indefinitely; caffeinate and stray
|
|
60
|
+
* build/watch processes are the same shape of problem). Counting any of
|
|
61
|
+
* those as "still in use" means a lane can never actually get swept once
|
|
62
|
+
* its real agent session exits, which defeats the entire point of pruning.
|
|
63
|
+
* Only a row whose COMMAND column is actually the Claude Code binary
|
|
64
|
+
* counts as "someone's still working here" — matched by prefix since
|
|
65
|
+
* `lsof` truncates COMMAND (macOS: `claude.ex`, so an exact match would be
|
|
66
|
+
* platform-fragile in the other direction).
|
|
67
|
+
*
|
|
68
|
+
* If `lsof` isn't available at all, this fails CLOSED — treats liveness as
|
|
69
|
+
* unknown/possible rather than confirmed-safe, so an unverifiable state
|
|
70
|
+
* never gets treated the same as a verified-idle one.
|
|
71
|
+
*/
|
|
72
|
+
/** Exported so the matching rule itself is unit-testable without spawning real processes. */
|
|
73
|
+
export function isClaudeProcessRow(lsofRow) {
|
|
74
|
+
return (lsofRow.trim().split(/\s+/)[0] ?? "").toLowerCase().startsWith("claude");
|
|
75
|
+
}
|
|
76
|
+
function hasLiveProcessInside(dir) {
|
|
77
|
+
const result = spawnSync("lsof", ["-a", "-d", "cwd", "+D", dir], { encoding: "utf8" });
|
|
78
|
+
if (result.error)
|
|
79
|
+
return true; // lsof missing/unspawnable — can't verify, assume in use
|
|
80
|
+
const rows = result.stdout.trim().split("\n").slice(1); // drop the header row
|
|
81
|
+
return rows.some(isClaudeProcessRow);
|
|
82
|
+
}
|
|
83
|
+
// Best-effort realpath — a "current worktree" that's already gone (or was
|
|
84
|
+
// never real to begin with) shouldn't crash the sweep over one path.
|
|
85
|
+
function existsRealpath(path) {
|
|
86
|
+
try {
|
|
87
|
+
return realpathSync(path);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return path;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function tryRemoveWorktree(mainTop, wt) {
|
|
94
|
+
try {
|
|
95
|
+
execFileSync("git", ["worktree", "remove", wt], { cwd: mainTop, stdio: "ignore" });
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function listWorktrees(mainTop) {
|
|
103
|
+
const out = execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: mainTop, encoding: "utf8" });
|
|
104
|
+
const entries = [];
|
|
105
|
+
let current = {};
|
|
106
|
+
const flush = () => {
|
|
107
|
+
if (current.path)
|
|
108
|
+
entries.push({ path: current.path, branch: current.branch ?? null });
|
|
109
|
+
current = {};
|
|
110
|
+
};
|
|
111
|
+
for (const line of out.split("\n")) {
|
|
112
|
+
if (line.startsWith("worktree ")) {
|
|
113
|
+
flush();
|
|
114
|
+
current.path = line.slice("worktree ".length);
|
|
115
|
+
}
|
|
116
|
+
else if (line.startsWith("branch ")) {
|
|
117
|
+
current.branch = line.slice("branch ".length).replace("refs/heads/", "");
|
|
118
|
+
}
|
|
119
|
+
else if (line === "") {
|
|
120
|
+
flush();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
flush();
|
|
124
|
+
return entries;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Removes already-landed sibling lane worktrees. Returns the paths it
|
|
128
|
+
* actually removed. Best-effort throughout — any failure for a given
|
|
129
|
+
* worktree (dirty, diverged, busy) just skips that one; this never blocks
|
|
130
|
+
* or fails the `land` it's running as part of.
|
|
131
|
+
*/
|
|
132
|
+
export function pruneLandedLanes(mainTop, cfg, currentWorktree) {
|
|
133
|
+
const pruned = [];
|
|
134
|
+
// `git worktree list` reports fully realpath-resolved paths (symlinks like
|
|
135
|
+
// macOS's /var -> /private/var followed) — resolve our own reference
|
|
136
|
+
// points the same way, or every comparison below silently never matches.
|
|
137
|
+
const mainTopReal = realpathSync(mainTop);
|
|
138
|
+
const currentWorktreeReal = existsRealpath(currentWorktree);
|
|
139
|
+
const laneDirPrefix = `${basename(mainTopReal)}${cfg.worktreeSuffix}`;
|
|
140
|
+
const parent = dirname(mainTopReal);
|
|
141
|
+
const upstream = `origin/${cfg.integrationBranch}`;
|
|
142
|
+
const regenerable = new Set(cfg.regenerableFiles);
|
|
143
|
+
let worktrees;
|
|
144
|
+
try {
|
|
145
|
+
worktrees = listWorktrees(mainTop);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return pruned;
|
|
149
|
+
}
|
|
150
|
+
for (const { path: wt, branch } of worktrees) {
|
|
151
|
+
if (wt === mainTopReal || wt === currentWorktreeReal)
|
|
152
|
+
continue;
|
|
153
|
+
if (dirname(wt) !== parent || !basename(wt).startsWith(laneDirPrefix))
|
|
154
|
+
continue; // not one of ours
|
|
155
|
+
if (!branch || !branch.startsWith(cfg.branchPrefix))
|
|
156
|
+
continue;
|
|
157
|
+
try {
|
|
158
|
+
execFileSync("git", ["merge-base", "--is-ancestor", branch, upstream], {
|
|
159
|
+
cwd: mainTop,
|
|
160
|
+
stdio: "ignore",
|
|
161
|
+
}); // throws (non-zero exit) if NOT an ancestor — caught below, left alone
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
continue; // not safe to touch — leave this lane exactly as it is
|
|
165
|
+
}
|
|
166
|
+
if (hasLiveProcessInside(wt))
|
|
167
|
+
continue; // someone's actively in here — never touch it
|
|
168
|
+
let removed = tryRemoveWorktree(mainTop, wt);
|
|
169
|
+
if (!removed) {
|
|
170
|
+
// Blocked by dirty files? Only retry if EVERY one of them is a
|
|
171
|
+
// configured regenerable file — anything else is real uncommitted
|
|
172
|
+
// work, and this lane is left alone exactly as before.
|
|
173
|
+
const dirty = execFileSync("git", ["status", "--porcelain"], { cwd: wt, encoding: "utf8" })
|
|
174
|
+
.split("\n")
|
|
175
|
+
.filter(Boolean)
|
|
176
|
+
.map((line) => line.slice(3).trim());
|
|
177
|
+
const blocking = dirty.filter((f) => !regenerable.has(f));
|
|
178
|
+
if (dirty.length > 0 && blocking.length === 0) {
|
|
179
|
+
execFileSync("git", ["checkout", "--", ...dirty], { cwd: wt, stdio: "ignore" });
|
|
180
|
+
removed = tryRemoveWorktree(mainTop, wt);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (!removed)
|
|
184
|
+
continue; // still dirty (real work) or otherwise busy — leave it alone
|
|
185
|
+
// The worktree is gone — this is now unconditionally a pruned lane,
|
|
186
|
+
// regardless of what happens to the branch ref below.
|
|
187
|
+
pruned.push(wt);
|
|
188
|
+
try {
|
|
189
|
+
execFileSync("git", ["branch", "-d", branch], { cwd: mainTop, stdio: "ignore" });
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
/* local integration branch may not be fast-forwarded yet — harmless, leaves the ref behind */
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return pruned;
|
|
196
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
interface LockHolder {
|
|
2
|
+
pid: number;
|
|
3
|
+
lane: string;
|
|
4
|
+
label?: string;
|
|
5
|
+
ts: number;
|
|
6
|
+
}
|
|
7
|
+
export interface AcquireOptions {
|
|
8
|
+
label?: string;
|
|
9
|
+
onWait?: (info: {
|
|
10
|
+
ahead: number;
|
|
11
|
+
holder: LockHolder | null;
|
|
12
|
+
}) => void;
|
|
13
|
+
}
|
|
14
|
+
export interface QueueLock {
|
|
15
|
+
acquire(options?: AcquireOptions): Promise<void>;
|
|
16
|
+
release(): void;
|
|
17
|
+
readonly lane: string;
|
|
18
|
+
readonly held: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Create a named FIFO lock. Each distinct `queueName` is an independent
|
|
22
|
+
* mutex — "build" and "land" never contend with each other even though
|
|
23
|
+
* they share this exact same code.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createQueueLock(queueName: string): QueueLock;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A generic, cross-worktree FIFO lock: the one primitive every other command
|
|
3
|
+
* in this repo is built on. `build-lock` and `land` are the same core idea —
|
|
4
|
+
* "serialize one action, machine-wide" — wearing two different hats.
|
|
5
|
+
*
|
|
6
|
+
* One queue name = one global mutex for this repo, shared by every worktree
|
|
7
|
+
* of it (keyed off git's common dir, so a different clone gets its own queue
|
|
8
|
+
* and two unrelated repos never contend with each other).
|
|
9
|
+
*
|
|
10
|
+
* Design:
|
|
11
|
+
* - FIFO: each waiter enrolls a timestamped ticket and only competes for
|
|
12
|
+
* the lock once it owns the oldest still-live ticket. No starvation, no
|
|
13
|
+
* "whoever polls fastest wins."
|
|
14
|
+
* - Crash-safe with NO timeouts, so there's no magic staleness threshold to
|
|
15
|
+
* tune: a lock or ticket whose holder PID is no longer alive is reclaimed
|
|
16
|
+
* the instant another waiter checks. Kill -9 the holder mid-lock and the
|
|
17
|
+
* queue heals itself on the next poll.
|
|
18
|
+
*/
|
|
19
|
+
import { mkdirSync, writeFileSync, readFileSync, linkSync, unlinkSync, readdirSync, } from "node:fs";
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join, basename } from "node:path";
|
|
23
|
+
import { execSync } from "node:child_process";
|
|
24
|
+
// How often a waiter re-checks whether it's its turn. Not a behavioral cap —
|
|
25
|
+
// just poll granularity.
|
|
26
|
+
const POLL_MS = 200;
|
|
27
|
+
function repoKey() {
|
|
28
|
+
try {
|
|
29
|
+
const commonDir = execSync("git rev-parse --git-common-dir", {
|
|
30
|
+
encoding: "utf8",
|
|
31
|
+
}).trim();
|
|
32
|
+
// Resolve to an absolute, worktree-independent path so every worktree of
|
|
33
|
+
// the same repo hashes to the same queue.
|
|
34
|
+
return execSync(`cd "${commonDir}" && pwd -P`, { encoding: "utf8" }).trim();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return process.cwd();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Create a named FIFO lock. Each distinct `queueName` is an independent
|
|
42
|
+
* mutex — "build" and "land" never contend with each other even though
|
|
43
|
+
* they share this exact same code.
|
|
44
|
+
*/
|
|
45
|
+
export function createQueueLock(queueName) {
|
|
46
|
+
const QUEUE_DIR = join(tmpdir(), `claude-code-local-merge-${queueName}-queue-${createHash("sha1").update(repoKey()).digest("hex").slice(0, 12)}`);
|
|
47
|
+
const TICKETS_DIR = join(QUEUE_DIR, "tickets");
|
|
48
|
+
const LOCK_FILE = join(QUEUE_DIR, "lock");
|
|
49
|
+
mkdirSync(TICKETS_DIR, { recursive: true });
|
|
50
|
+
const lane = basename(process.cwd());
|
|
51
|
+
const ME = process.pid;
|
|
52
|
+
const TICKET_TS = Date.now();
|
|
53
|
+
const TICKET_NAME = `${TICKET_TS}-${ME}`;
|
|
54
|
+
const TICKET_FILE = join(TICKETS_DIR, TICKET_NAME);
|
|
55
|
+
function alive(pid) {
|
|
56
|
+
if (!pid || pid === ME)
|
|
57
|
+
return pid === ME;
|
|
58
|
+
try {
|
|
59
|
+
process.kill(pid, 0);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
return e.code === "EPERM"; // exists but owned by someone else
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function pidOf(name) {
|
|
67
|
+
const dash = name.lastIndexOf("-");
|
|
68
|
+
return dash === -1 ? 0 : Number(name.slice(dash + 1));
|
|
69
|
+
}
|
|
70
|
+
function pruneDeadTickets() {
|
|
71
|
+
let names;
|
|
72
|
+
try {
|
|
73
|
+
names = readdirSync(TICKETS_DIR);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
const live = [];
|
|
79
|
+
for (const name of names) {
|
|
80
|
+
if (alive(pidOf(name))) {
|
|
81
|
+
live.push(name);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
try {
|
|
85
|
+
unlinkSync(join(TICKETS_DIR, name));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
/* someone else cleaned it */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
live.sort((a, b) => {
|
|
93
|
+
const [ta, pa] = a.split("-").map(Number);
|
|
94
|
+
const [tb, pb] = b.split("-").map(Number);
|
|
95
|
+
return ta - tb || pa - pb;
|
|
96
|
+
});
|
|
97
|
+
return live;
|
|
98
|
+
}
|
|
99
|
+
function readLockHolder() {
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(readFileSync(LOCK_FILE, "utf8"));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Atomically take the lock via link() (fails if it already exists). Reclaim
|
|
108
|
+
// a lock whose holder is dead. Returns true iff we now hold it.
|
|
109
|
+
function tryTakeLock(info) {
|
|
110
|
+
const tmp = `${LOCK_FILE}.${ME}.tmp`;
|
|
111
|
+
writeFileSync(tmp, JSON.stringify(info));
|
|
112
|
+
try {
|
|
113
|
+
linkSync(tmp, LOCK_FILE);
|
|
114
|
+
unlinkSync(tmp);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
try {
|
|
119
|
+
unlinkSync(tmp);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* noop */
|
|
123
|
+
}
|
|
124
|
+
if (e.code !== "EEXIST")
|
|
125
|
+
throw e;
|
|
126
|
+
const holder = readLockHolder();
|
|
127
|
+
if (!holder || !alive(holder.pid)) {
|
|
128
|
+
try {
|
|
129
|
+
unlinkSync(LOCK_FILE);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
/* another waiter beat us to it */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
let HOLD = false;
|
|
139
|
+
function release() {
|
|
140
|
+
if (HOLD) {
|
|
141
|
+
const holder = readLockHolder();
|
|
142
|
+
if (holder && holder.pid === ME) {
|
|
143
|
+
try {
|
|
144
|
+
unlinkSync(LOCK_FILE);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
/* already gone */
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
HOLD = false;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
unlinkSync(TICKET_FILE);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
/* already gone */
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
160
|
+
/**
|
|
161
|
+
* Wait for and take the lock. `onWait({ahead, holder})` fires whenever the
|
|
162
|
+
* queue position changes, so callers can print progress.
|
|
163
|
+
*/
|
|
164
|
+
async function acquire({ label, onWait } = {}) {
|
|
165
|
+
writeFileSync(TICKET_FILE, JSON.stringify({ pid: ME, lane, label, ts: TICKET_TS }));
|
|
166
|
+
let announced = -1;
|
|
167
|
+
for (;;) {
|
|
168
|
+
const queue = pruneDeadTickets();
|
|
169
|
+
const ahead = queue.indexOf(TICKET_NAME); // 0 = our turn
|
|
170
|
+
const holder = readLockHolder();
|
|
171
|
+
const lockFree = !holder || !alive(holder.pid);
|
|
172
|
+
if (ahead <= 0 && lockFree) {
|
|
173
|
+
if (tryTakeLock({ pid: ME, lane, label, ts: Date.now() })) {
|
|
174
|
+
HOLD = true;
|
|
175
|
+
try {
|
|
176
|
+
unlinkSync(TICKET_FILE);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
/* noop */
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (ahead > 0 && ahead !== announced) {
|
|
185
|
+
announced = ahead;
|
|
186
|
+
onWait?.({ ahead, holder: null });
|
|
187
|
+
}
|
|
188
|
+
else if (ahead <= 0 && holder && alive(holder.pid) && announced !== 0) {
|
|
189
|
+
announced = 0;
|
|
190
|
+
onWait?.({ ahead: 0, holder });
|
|
191
|
+
}
|
|
192
|
+
await sleep(POLL_MS);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Best-effort release on graceful exit. Deliberately NOT registering
|
|
196
|
+
// SIGINT/SIGTERM/SIGHUP handlers here: adding any listener for those
|
|
197
|
+
// signals cancels Node's default "terminate the process" behavior, and
|
|
198
|
+
// this module doesn't own whether/how a caller's process should exit
|
|
199
|
+
// (build-lock.ts needs its OWN signal handler to kill a child's process
|
|
200
|
+
// group first). Correctness doesn't depend on this firing anyway — a
|
|
201
|
+
// lock/ticket left behind by a killed process is reclaimed deterministically
|
|
202
|
+
// by the next acquire() via the PID-liveness check above, same as a SIGKILL.
|
|
203
|
+
process.on("exit", release);
|
|
204
|
+
return {
|
|
205
|
+
acquire,
|
|
206
|
+
release,
|
|
207
|
+
lane,
|
|
208
|
+
get held() {
|
|
209
|
+
return HOLD;
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function promptTtyConfirm(promptText: string): string | null;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A synchronous, type-the-exact-word confirmation prompt read straight from
|
|
3
|
+
* /dev/tty — the same trick interactive git hooks use, since stdin during a
|
|
4
|
+
* `git push` is git's own protocol, not free for a hook to read from.
|
|
5
|
+
*
|
|
6
|
+
* Used exactly once: the emergency bypass for a blocked push (see
|
|
7
|
+
* check-push.ts). Returns null if there's no interactive terminal to prompt
|
|
8
|
+
* on at all (CI, a piped/non-interactive push) — the caller treats that as
|
|
9
|
+
* "can't confirm," not "confirmed."
|
|
10
|
+
*/
|
|
11
|
+
import { openSync, closeSync, readSync, writeSync } from "node:fs";
|
|
12
|
+
export function promptTtyConfirm(promptText) {
|
|
13
|
+
let fd;
|
|
14
|
+
try {
|
|
15
|
+
fd = openSync("/dev/tty", "r+");
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
writeSync(fd, promptText);
|
|
22
|
+
const buf = Buffer.alloc(1);
|
|
23
|
+
let input = "";
|
|
24
|
+
for (;;) {
|
|
25
|
+
let bytesRead;
|
|
26
|
+
try {
|
|
27
|
+
bytesRead = readSync(fd, buf, 0, 1, null);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
if (bytesRead <= 0)
|
|
33
|
+
break;
|
|
34
|
+
const ch = buf.toString("utf8");
|
|
35
|
+
if (ch === "\n" || ch === "\r")
|
|
36
|
+
break;
|
|
37
|
+
input += ch;
|
|
38
|
+
}
|
|
39
|
+
return input.trim();
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
closeSync(fd);
|
|
43
|
+
}
|
|
44
|
+
}
|