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
@@ -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
+ }