lanekeeper 0.1.0
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 +274 -0
- package/dist/bin/lanekeeper.d.ts +2 -0
- package/dist/bin/lanekeeper.js +269 -0
- package/dist/build-lock.d.ts +1 -0
- package/dist/build-lock.js +70 -0
- package/dist/hooks/worktree-create.d.ts +13 -0
- package/dist/hooks/worktree-create.js +150 -0
- package/dist/land.d.ts +1 -0
- package/dist/land.js +128 -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 +37 -0
- package/dist/lib/check-push.js +48 -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 +8 -0
- package/dist/lib/prune-lanes.js +120 -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 +26 -0
- package/dist/lib/wire-hooks.js +123 -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 +2 -0
- package/dist/sync.js +115 -0
- package/examples/ephemeral-tmp-dir.example.ts +66 -0
- package/examples/lanekeeper.config.mjs +67 -0
- package/hooks/claude-settings.example.json +14 -0
- package/hooks/pre-push +23 -0
- package/package.json +46 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Claude Code WorktreeCreate hook. Claude Code already isolates agents
|
|
3
|
+
* in worktrees natively (`--worktree`, `isolation: "worktree"`) — this hook
|
|
4
|
+
* doesn't compete with that, it plugs into it. A WorktreeCreate hook
|
|
5
|
+
* "replaces default git behavior entirely" (Claude Code's own docs), so
|
|
6
|
+
* this script is responsible for actually creating the worktree; what it
|
|
7
|
+
* adds on top of the native flow is LaneKeeper's numbered-lane convention
|
|
8
|
+
* and a `node_modules` SYMLINK instead of a copy — Claude Code's own
|
|
9
|
+
* `.worktreeinclude` mechanism copies gitignored files in, which is fine
|
|
10
|
+
* for a `.env` file and genuinely expensive for `node_modules`.
|
|
11
|
+
*
|
|
12
|
+
* Per Claude Code's hook contract: print the new worktree's absolute path
|
|
13
|
+
* on stdout and exit 0, or print an error to stderr and exit non-zero to
|
|
14
|
+
* abort creation (WorktreeCreate is the one hook event that can block).
|
|
15
|
+
*
|
|
16
|
+
* There's no long-lived process behind this the way there was behind the
|
|
17
|
+
* old standalone launcher — the hook runs once and exits. So lane claiming
|
|
18
|
+
* can't be PID-liveness-based here; there's no PID to track. Instead the
|
|
19
|
+
* claim IS the worktree: a lane is free iff `<repo><worktreeSuffix><n>`
|
|
20
|
+
* doesn't exist on disk, and `git worktree add` failing on an
|
|
21
|
+
* already-claimed path is the same atomicity guarantee a `mkdir` race gave
|
|
22
|
+
* the old launcher, just delegated to git itself. When Claude Code (or you,
|
|
23
|
+
* or its cleanup sweep) removes a worktree, that lane number is free again
|
|
24
|
+
* automatically — nothing to release by hand.
|
|
25
|
+
*/
|
|
26
|
+
import { execFileSync } from "node:child_process";
|
|
27
|
+
import { existsSync, mkdirSync, symlinkSync } from "node:fs";
|
|
28
|
+
import { dirname, join, basename } from "node:path";
|
|
29
|
+
import { loadConfig, hasConfig, DEFAULTS } from "../lib/config.js";
|
|
30
|
+
import { resolveMainCheckout } from "../lib/main-checkout.js";
|
|
31
|
+
// A lane-claim loop with no upper bound is exactly one path-resolution bug
|
|
32
|
+
// away from spinning the CPU forever instead of failing loud — which is
|
|
33
|
+
// precisely what happened here: an earlier, separate implementation of what
|
|
34
|
+
// is now resolveMainCheckout used path.join instead of path.resolve, so
|
|
35
|
+
// invoking this hook from INSIDE an already-created linked worktree (where
|
|
36
|
+
// git reports an ABSOLUTE git-common-dir, not the relative ".git" a fresh
|
|
37
|
+
// checkout reports) produced a nonsense path that never matched an existing
|
|
38
|
+
// lane and never let `git worktree add` succeed either — an infinite loop,
|
|
39
|
+
// confirmed burning 80%+ CPU indefinitely in production use. Fixing the path
|
|
40
|
+
// bug alone doesn't rule out some other future bug in this class; capping
|
|
41
|
+
// the loop means any of them fails loud instead of hanging.
|
|
42
|
+
const MAX_LANE_ATTEMPTS = 1000;
|
|
43
|
+
// Trying a lane number that turns out to already be claimed (the expected,
|
|
44
|
+
// routine race with another concurrent hook invocation) is not an error
|
|
45
|
+
// worth showing anyone — git's own "fatal: cannot lock ref" on that attempt
|
|
46
|
+
// would otherwise leak to the terminal even though the hook recovers and
|
|
47
|
+
// succeeds. Force stdout/stderr into pipes explicitly rather than trusting
|
|
48
|
+
// the ambient default, so a probing attempt is always silent until we
|
|
49
|
+
// decide it's actually fatal.
|
|
50
|
+
function tryGit(args, cwd) {
|
|
51
|
+
try {
|
|
52
|
+
return {
|
|
53
|
+
ok: true,
|
|
54
|
+
out: execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
const err = e;
|
|
59
|
+
return { ok: false, out: `${err.stdout ?? ""}${err.stderr ?? ""}` };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Claim the lowest free lane, create its worktree, and symlink the
|
|
64
|
+
* configured git-ignored paths into it. Throws with a human-readable
|
|
65
|
+
* message on failure — the caller turns that into the hook's stderr +
|
|
66
|
+
* non-zero exit.
|
|
67
|
+
*/
|
|
68
|
+
export function createLane(mainTop, cfg) {
|
|
69
|
+
// Clean up administrative entries for worktrees whose directories are
|
|
70
|
+
// already gone (e.g. someone `rm -rf`'d one instead of `git worktree
|
|
71
|
+
// remove`) so reusing that lane number's branch name doesn't fail with
|
|
72
|
+
// "already checked out elsewhere."
|
|
73
|
+
tryGit(["worktree", "prune"], mainTop);
|
|
74
|
+
const repoName = basename(mainTop);
|
|
75
|
+
let lane = 0;
|
|
76
|
+
for (;;) {
|
|
77
|
+
lane += 1;
|
|
78
|
+
if (lane > MAX_LANE_ATTEMPTS) {
|
|
79
|
+
throw new Error(`could not claim a lane after ${MAX_LANE_ATTEMPTS} attempts — is mainTop ('${mainTop}') actually the repo root?`);
|
|
80
|
+
}
|
|
81
|
+
const wt = join(dirname(mainTop), `${repoName}${cfg.worktreeSuffix}${lane}`);
|
|
82
|
+
if (existsSync(wt))
|
|
83
|
+
continue; // still claimed — try the next lane
|
|
84
|
+
const branch = `${cfg.branchPrefix}${lane}`;
|
|
85
|
+
const branchExists = tryGit(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], mainTop).ok;
|
|
86
|
+
// Base every new lane on the main checkout's own current HEAD — never on
|
|
87
|
+
// origin/integrationBranch. The main checkout is kept fast-forwarded by
|
|
88
|
+
// `sync` precisely so its local HEAD IS the trusted, up-to-date view;
|
|
89
|
+
// reading origin directly instead can be *behind* local HEAD (a commit
|
|
90
|
+
// made here but not yet pushed) and silently drop it from every new
|
|
91
|
+
// lane. That's not a hypothetical: it breaks the literal Quickstart —
|
|
92
|
+
// `init` writes lanekeeper.config.mjs/CLAUDE.md/.claude locally, and the
|
|
93
|
+
// very first lane created before that commit is pushed anywhere would
|
|
94
|
+
// otherwise come up with none of it.
|
|
95
|
+
let add;
|
|
96
|
+
if (branchExists) {
|
|
97
|
+
add = tryGit(["worktree", "add", wt, branch], mainTop);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
add = tryGit(["worktree", "add", wt, "-b", branch], mainTop);
|
|
101
|
+
}
|
|
102
|
+
if (!add.ok) {
|
|
103
|
+
// Someone else claimed this exact lane between our existsSync check
|
|
104
|
+
// and `git worktree add` — the same race a `mkdir` guards against.
|
|
105
|
+
// Try the next one.
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
for (const rel of cfg.symlinks) {
|
|
109
|
+
const src = join(mainTop, rel);
|
|
110
|
+
const dest = join(wt, rel);
|
|
111
|
+
if (!existsSync(src) || existsSync(dest))
|
|
112
|
+
continue;
|
|
113
|
+
try {
|
|
114
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
115
|
+
symlinkSync(src, dest);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
/* best-effort — a missing symlink degrades to "run npm install," not a hard failure */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { wt, branch, lane };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async function readStdin() {
|
|
125
|
+
const chunks = [];
|
|
126
|
+
for await (const chunk of process.stdin)
|
|
127
|
+
chunks.push(chunk);
|
|
128
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
129
|
+
}
|
|
130
|
+
export async function runWorktreeCreateHook() {
|
|
131
|
+
let input = {};
|
|
132
|
+
try {
|
|
133
|
+
input = JSON.parse(await readStdin());
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
/* no/invalid stdin — fall back to process.cwd() below */
|
|
137
|
+
}
|
|
138
|
+
const fromCwd = input.cwd ?? process.cwd();
|
|
139
|
+
try {
|
|
140
|
+
const mainTop = resolveMainCheckout(fromCwd);
|
|
141
|
+
const cfg = hasConfig(mainTop) ? await loadConfig(mainTop) : { ...DEFAULTS };
|
|
142
|
+
const { wt } = createLane(mainTop, cfg);
|
|
143
|
+
process.stdout.write(wt + "\n");
|
|
144
|
+
process.exit(0);
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
process.stderr.write(`lanekeeper worktree-create hook failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
}
|
package/dist/land.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function land(): Promise<void>;
|
package/dist/land.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
* `lanekeeper 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 `lanekeeper 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 LANEKEEPER_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: lanekeeper 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("lanekeeper land: no lanekeeper.config found at the repo root. Run `lanekeeper 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(`lanekeeper 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
|
+
const regenerable = new Set(cfg.regenerableFiles);
|
|
54
|
+
const status = execSync("git status --porcelain", { encoding: "utf8" });
|
|
55
|
+
const dirty = status
|
|
56
|
+
.split("\n")
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.map((line) => line.slice(3).trim());
|
|
59
|
+
const blocking = dirty.filter((f) => !regenerable.has(f));
|
|
60
|
+
if (dirty.length > 0 && blocking.length === 0) {
|
|
61
|
+
execSync(`git checkout -- ${dirty.map((f) => `"${f}"`).join(" ")}`);
|
|
62
|
+
}
|
|
63
|
+
const lock = createQueueLock("land");
|
|
64
|
+
await lock.acquire({
|
|
65
|
+
label: branch,
|
|
66
|
+
onWait: ({ ahead, holder }) => {
|
|
67
|
+
if (ahead > 0) {
|
|
68
|
+
console.log(`${DIM}[land-queue] ${branch}: waiting — ${ahead} landing${ahead === 1 ? "" : "s"} ahead…${RESET}`);
|
|
69
|
+
}
|
|
70
|
+
else if (holder) {
|
|
71
|
+
console.log(`${DIM}[land-queue] ${branch}: next up — waiting for '${holder.label ?? holder.lane}' to finish landing…${RESET}`);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
let exitCode = 0;
|
|
76
|
+
try {
|
|
77
|
+
console.log(`${DIM}[land-queue] ${branch}: lock acquired — landing…${RESET}`);
|
|
78
|
+
console.log(`${DIM}fetching origin/${cfg.integrationBranch}…${RESET}`);
|
|
79
|
+
execSync(`git fetch origin ${cfg.integrationBranch} --quiet`, { stdio: "inherit" });
|
|
80
|
+
console.log(`${DIM}rebasing onto origin/${cfg.integrationBranch}…${RESET}`);
|
|
81
|
+
const rebase = spawnSync("git", ["rebase", `origin/${cfg.integrationBranch}`], { stdio: "inherit" });
|
|
82
|
+
if (rebase.status !== 0) {
|
|
83
|
+
spawnSync("git", ["rebase", "--abort"], { stdio: "ignore" });
|
|
84
|
+
console.error(`\n${RED}land: rebase onto origin/${cfg.integrationBranch} conflicted — aborted, working tree left clean.${RESET}`);
|
|
85
|
+
console.error(`Resolve it yourself (git fetch origin ${cfg.integrationBranch} && git rebase origin/${cfg.integrationBranch}), then re-run 'lanekeeper land'.`);
|
|
86
|
+
exitCode = 1;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
console.log(`${DIM}pushing to ${cfg.integrationBranch} (this is where your CI/checks hook runs)…${RESET}`);
|
|
90
|
+
const push = spawnSync("git", ["push", "origin", `HEAD:${cfg.integrationBranch}`], {
|
|
91
|
+
stdio: "inherit",
|
|
92
|
+
env: { ...process.env, LANEKEEPER_LANDING: "1" },
|
|
93
|
+
});
|
|
94
|
+
if (push.status !== 0) {
|
|
95
|
+
console.error(`\n${RED}land: push to ${cfg.integrationBranch} failed — see output above.${RESET}`);
|
|
96
|
+
console.error(`Fix the failure, then re-run 'lanekeeper land'.`);
|
|
97
|
+
exitCode = 1;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
console.log(`${GREEN}✓ ${branch} landed on ${cfg.integrationBranch}.${RESET}`);
|
|
101
|
+
// Landing isn't "done" until the checkout that actually serves your
|
|
102
|
+
// dev server can see it — call sync in-process rather than shelling
|
|
103
|
+
// back out to the CLI, so this doesn't depend on `lanekeeper` being
|
|
104
|
+
// resolvable on PATH.
|
|
105
|
+
exitCode = await sync();
|
|
106
|
+
// Housekeeping, never a reason to fail this landing: sweep sibling
|
|
107
|
+
// lanes whose OWN branch already made it upstream (nothing created
|
|
108
|
+
// ever tears a worktree down on the way out) so they don't
|
|
109
|
+
// accumulate on disk forever waiting for someone to remember.
|
|
110
|
+
try {
|
|
111
|
+
const mainTop = resolveMainCheckout(process.cwd());
|
|
112
|
+
const pruned = pruneLandedLanes(mainTop, cfg, process.cwd());
|
|
113
|
+
if (pruned.length > 0) {
|
|
114
|
+
const names = pruned.map((p) => p.split("/").pop()).join(", ");
|
|
115
|
+
console.log(`${DIM}pruned ${pruned.length} already-landed lane${pruned.length === 1 ? "" : "s"}: ${names}${RESET}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
/* best-effort — never block a successful landing over cleanup */
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
lock.release();
|
|
126
|
+
}
|
|
127
|
+
process.exit(exitCode);
|
|
128
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { LaneKeeperConfig } 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<LaneKeeperConfig, "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 lanekeeper.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("lanekeeper check-push: no checkCommand configured (checksRequired: false — running with no checks, on purpose).");
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
console.log(`lanekeeper 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,37 @@
|
|
|
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 LANEKEEPER_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: it needs BOTH
|
|
17
|
+
* LANEKEEPER_EMERGENCY_PUSH=1 (declares the intent) AND
|
|
18
|
+
* LANEKEEPER_EMERGENCY_PUSH_CONFIRM=<exact branch name> (names the specific
|
|
19
|
+
* target) set and matching. Two independent, specific pieces of intent, not
|
|
20
|
+
* one flag — the CLI's check-push handler fills the second one in via an
|
|
21
|
+
* interactive /dev/tty prompt if you only set the first, so a human doing
|
|
22
|
+
* this on purpose types the branch name once; nothing about it happens by
|
|
23
|
+
* flipping a single boolean.
|
|
24
|
+
*/
|
|
25
|
+
import type { LaneKeeperConfig } from "./config.js";
|
|
26
|
+
export interface RefUpdate {
|
|
27
|
+
localRef: string;
|
|
28
|
+
localSha: string;
|
|
29
|
+
remoteRef: string;
|
|
30
|
+
remoteSha: string;
|
|
31
|
+
}
|
|
32
|
+
export interface CheckResult {
|
|
33
|
+
ok: boolean;
|
|
34
|
+
message?: string;
|
|
35
|
+
}
|
|
36
|
+
export declare function parseRefUpdates(stdin: string): RefUpdate[];
|
|
37
|
+
export declare function checkPush(refUpdates: RefUpdate[], cfg: Pick<LaneKeeperConfig, "integrationBranch" | "productionBranch" | "protectedBranches">, env: NodeJS.ProcessEnv): CheckResult;
|
|
@@ -0,0 +1,48 @@
|
|
|
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(branch, env) {
|
|
12
|
+
return env.LANEKEEPER_EMERGENCY_PUSH === "1" && env.LANEKEEPER_EMERGENCY_PUSH_CONFIRM === branch;
|
|
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(branch, 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: LANEKEEPER_EMERGENCY_PUSH=1 git push … (you'll be asked to`,
|
|
28
|
+
` type "${branch}" to confirm — or set LANEKEEPER_EMERGENCY_PUSH_CONFIRM=${branch} yourself for a non-interactive push).`,
|
|
29
|
+
"",
|
|
30
|
+
].join("\n"),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (remoteRef === integrationRef && env.LANEKEEPER_LANDING !== "1" && !emergencyConfirmed(branch, env)) {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
message: [
|
|
37
|
+
"",
|
|
38
|
+
`✋ Direct pushes to '${cfg.integrationBranch}' are blocked — landing goes through the queue.`,
|
|
39
|
+
` Land your work: lanekeeper land`,
|
|
40
|
+
` Genuine emergency: LANEKEEPER_EMERGENCY_PUSH=1 git push … (you'll be asked to`,
|
|
41
|
+
` type "${cfg.integrationBranch}" to confirm).`,
|
|
42
|
+
"",
|
|
43
|
+
].join("\n"),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { ok: true };
|
|
48
|
+
}
|
|
@@ -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 `lanekeeper 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
|
+
* `lanekeeper 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 { LaneKeeperConfig } from "./config.js";
|
|
15
|
+
export declare const MARKER = "<!-- lanekeeper:workflow -->";
|
|
16
|
+
export declare function claudeMdSnippet(cfg: LaneKeeperConfig): string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const MARKER = "<!-- lanekeeper: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 \`lanekeeper 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
|
+
## LaneKeeper workflow
|
|
8
|
+
|
|
9
|
+
This repo uses [LaneKeeper](https://github.com/funador/lanekeeper) 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>\`, LaneKeeper'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 \`lanekeeper 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 \`lanekeeper land\`.
|
|
14
|
+
- **A failed \`lanekeeper 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 \`lanekeeper 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 \`lanekeeper 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 LaneKeeperConfig {
|
|
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 `lanekeeper 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 `lanekeeper promote`). `null` means
|
|
14
|
+
* `integrationBranch` IS production — no separate promotion step, and
|
|
15
|
+
* `lanekeeper 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 `lanekeeper 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: LaneKeeperConfig;
|
|
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: LaneKeeperConfig): 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 lanekeeper.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<LaneKeeperConfig>;
|