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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -0
  3. package/dist/bin/lanekeeper.d.ts +2 -0
  4. package/dist/bin/lanekeeper.js +269 -0
  5. package/dist/build-lock.d.ts +1 -0
  6. package/dist/build-lock.js +70 -0
  7. package/dist/hooks/worktree-create.d.ts +13 -0
  8. package/dist/hooks/worktree-create.js +150 -0
  9. package/dist/land.d.ts +1 -0
  10. package/dist/land.js +128 -0
  11. package/dist/lib/check-command.d.ts +29 -0
  12. package/dist/lib/check-command.js +83 -0
  13. package/dist/lib/check-push.d.ts +37 -0
  14. package/dist/lib/check-push.js +48 -0
  15. package/dist/lib/claude-md-snippet.d.ts +16 -0
  16. package/dist/lib/claude-md-snippet.js +18 -0
  17. package/dist/lib/config.d.ts +92 -0
  18. package/dist/lib/config.js +137 -0
  19. package/dist/lib/ephemeral.d.ts +40 -0
  20. package/dist/lib/ephemeral.js +100 -0
  21. package/dist/lib/lane-port.d.ts +3 -0
  22. package/dist/lib/lane-port.js +25 -0
  23. package/dist/lib/main-checkout.d.ts +1 -0
  24. package/dist/lib/main-checkout.js +19 -0
  25. package/dist/lib/prune-lanes.d.ts +8 -0
  26. package/dist/lib/prune-lanes.js +120 -0
  27. package/dist/lib/queue-lock.d.ts +26 -0
  28. package/dist/lib/queue-lock.js +212 -0
  29. package/dist/lib/tty-confirm.d.ts +1 -0
  30. package/dist/lib/tty-confirm.js +44 -0
  31. package/dist/lib/wire-hooks.d.ts +26 -0
  32. package/dist/lib/wire-hooks.js +123 -0
  33. package/dist/preview.d.ts +1 -0
  34. package/dist/preview.js +119 -0
  35. package/dist/promote.d.ts +1 -0
  36. package/dist/promote.js +77 -0
  37. package/dist/sync.d.ts +2 -0
  38. package/dist/sync.js +115 -0
  39. package/examples/ephemeral-tmp-dir.example.ts +66 -0
  40. package/examples/lanekeeper.config.mjs +67 -0
  41. package/hooks/claude-settings.example.json +14 -0
  42. package/hooks/pre-push +23 -0
  43. 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>;