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,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(), `lanekeeper-${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
+ }
@@ -0,0 +1,26 @@
1
+ export type WireResult = "created" | "merged" | "already-wired" | "unparseable" | "no-husky";
2
+ export declare function wireClaudeSettings(root: string): WireResult;
3
+ export declare function wireHuskyPrePush(root: string): WireResult;
4
+ export type HooksPathResult = "set" | "already-set" | "custom-path";
5
+ /**
6
+ * A `.husky/pre-push` file on disk enforces nothing on its own — git only
7
+ * runs it if `core.hooksPath` points somewhere that resolves to it, which is
8
+ * normally set as a side effect of the package manager's install step
9
+ * (husky's own `prepare` script). On a freshly cloned repo where nobody's
10
+ * run that install yet — the exact state Quickstart leaves you in right
11
+ * after `init` — the file is silently inert and a direct push sails through
12
+ * uncontested. Since LaneKeeper is the one promising "pushes are gated now,"
13
+ * it sets this itself instead of depending on a step that may not have
14
+ * happened yet, mirroring exactly what `husky install` itself does.
15
+ *
16
+ * Husky v9 changed its own convention mid-flight: v6–v8 point
17
+ * core.hooksPath directly at `.husky` (hook files run as-is); v9 points it
18
+ * at `.husky/_`, a generated wrapper directory that then execs the real
19
+ * `.husky/<hookname>` file. Both are legitimate, already-correct setups —
20
+ * only treat something OTHER than either as a deliberate custom path worth
21
+ * warning about. `.husky/_` doesn't exist yet on the fresh-clone/no-install
22
+ * case this function exists for, so `.husky` remains the right thing to set
23
+ * when nothing's configured at all; if the project turns out to be v9,
24
+ * husky's own next real install corrects it to `.husky/_`.
25
+ */
26
+ export declare function ensureHooksPath(root: string): HooksPathResult;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The rest of `init`'s job: safely wire the WorktreeCreate hook into
3
+ * `.claude/settings.json` and the pre-push hook into `.husky/pre-push`,
4
+ * instead of leaving them as "copy this file yourself" — the exact kind of
5
+ * manual step that undercuts a tool whose whole point is fewer manual steps.
6
+ *
7
+ * Both merges are additive and idempotent: creating the file if it's
8
+ * missing, adding just our entry without touching anything else if the
9
+ * file already exists, and doing nothing (safely) if our entry's already
10
+ * there. Neither ever overwrites content that isn't ours.
11
+ */
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, chmodSync } from "node:fs";
13
+ import { execFileSync } from "node:child_process";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ const HOOK_COMMAND = "npx lanekeeper hook worktree-create";
17
+ const PRE_PUSH_MARKER = "lanekeeper check-push";
18
+ export function wireClaudeSettings(root) {
19
+ const dir = join(root, ".claude");
20
+ const path = join(dir, "settings.json");
21
+ if (!existsSync(path)) {
22
+ mkdirSync(dir, { recursive: true });
23
+ const settings = {
24
+ hooks: { WorktreeCreate: [{ hooks: [{ type: "command", command: HOOK_COMMAND }] }] },
25
+ };
26
+ writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
27
+ return "created";
28
+ }
29
+ let settings;
30
+ try {
31
+ settings = JSON.parse(readFileSync(path, "utf8"));
32
+ }
33
+ catch {
34
+ return "unparseable"; // leave it alone — don't guess at broken JSON
35
+ }
36
+ settings.hooks ??= {};
37
+ settings.hooks.WorktreeCreate ??= [];
38
+ const alreadyWired = settings.hooks.WorktreeCreate.some((group) => group.hooks?.some((h) => h.command?.includes(HOOK_COMMAND)));
39
+ if (alreadyWired)
40
+ return "already-wired";
41
+ settings.hooks.WorktreeCreate.push({ hooks: [{ type: "command", command: HOOK_COMMAND }] });
42
+ writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
43
+ return "merged";
44
+ }
45
+ function shippedPrePushTemplate() {
46
+ // dist/lib/wire-hooks.js -> ../../hooks/pre-push at the package root.
47
+ const here = dirname(fileURLToPath(import.meta.url));
48
+ return readFileSync(join(here, "..", "..", "hooks", "pre-push"), "utf8");
49
+ }
50
+ // The template file is written to stand alone (shebang + comments explaining
51
+ // itself to a human reading it fresh). Appending it whole into an *existing*
52
+ // hook file would duplicate the shebang mid-script and leave behind prose
53
+ // like "copy this file to .husky/pre-push" that's nonsensical once it's
54
+ // already there. So strip the shebang and the leading comment block, and
55
+ // append only the functional part — the same source of truth, no second
56
+ // copy to drift out of sync.
57
+ function functionalSnippet(template) {
58
+ const lines = template.split("\n");
59
+ let i = 0;
60
+ if (lines[0]?.startsWith("#!"))
61
+ i++;
62
+ for (; i < lines.length; i++) {
63
+ const trimmed = lines[i]?.trim() ?? "";
64
+ if (trimmed !== "" && !trimmed.startsWith("#"))
65
+ break;
66
+ }
67
+ return lines.slice(i).join("\n").trimEnd() + "\n";
68
+ }
69
+ export function wireHuskyPrePush(root) {
70
+ const huskyDir = join(root, ".husky");
71
+ if (!existsSync(huskyDir))
72
+ return "no-husky";
73
+ const path = join(huskyDir, "pre-push");
74
+ const template = shippedPrePushTemplate();
75
+ if (!existsSync(path)) {
76
+ writeFileSync(path, template);
77
+ chmodSync(path, 0o755);
78
+ return "created";
79
+ }
80
+ const existing = readFileSync(path, "utf8");
81
+ if (existing.includes(PRE_PUSH_MARKER))
82
+ return "already-wired";
83
+ const marker = "# --- LaneKeeper (appended by `lanekeeper init`) — see node_modules/lanekeeper/hooks/pre-push for the full comments ---";
84
+ appendFileSync(path, `\n${marker}\n${functionalSnippet(template)}`);
85
+ chmodSync(path, 0o755);
86
+ return "merged";
87
+ }
88
+ /**
89
+ * A `.husky/pre-push` file on disk enforces nothing on its own — git only
90
+ * runs it if `core.hooksPath` points somewhere that resolves to it, which is
91
+ * normally set as a side effect of the package manager's install step
92
+ * (husky's own `prepare` script). On a freshly cloned repo where nobody's
93
+ * run that install yet — the exact state Quickstart leaves you in right
94
+ * after `init` — the file is silently inert and a direct push sails through
95
+ * uncontested. Since LaneKeeper is the one promising "pushes are gated now,"
96
+ * it sets this itself instead of depending on a step that may not have
97
+ * happened yet, mirroring exactly what `husky install` itself does.
98
+ *
99
+ * Husky v9 changed its own convention mid-flight: v6–v8 point
100
+ * core.hooksPath directly at `.husky` (hook files run as-is); v9 points it
101
+ * at `.husky/_`, a generated wrapper directory that then execs the real
102
+ * `.husky/<hookname>` file. Both are legitimate, already-correct setups —
103
+ * only treat something OTHER than either as a deliberate custom path worth
104
+ * warning about. `.husky/_` doesn't exist yet on the fresh-clone/no-install
105
+ * case this function exists for, so `.husky` remains the right thing to set
106
+ * when nothing's configured at all; if the project turns out to be v9,
107
+ * husky's own next real install corrects it to `.husky/_`.
108
+ */
109
+ export function ensureHooksPath(root) {
110
+ let current;
111
+ try {
112
+ current = execFileSync("git", ["config", "core.hooksPath"], { cwd: root, encoding: "utf8" }).trim();
113
+ }
114
+ catch {
115
+ current = null; // unset
116
+ }
117
+ if (current === ".husky" || current === ".husky/_")
118
+ return "already-set";
119
+ if (current)
120
+ return "custom-path"; // respect an existing deliberate setup — don't override it
121
+ execFileSync("git", ["config", "core.hooksPath", ".husky"], { cwd: root });
122
+ return "set";
123
+ }
@@ -0,0 +1 @@
1
+ export declare function runPreview(args: string[]): Promise<void>;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * preview.ts — instantly preview a lane's working tree on the ONE shared dev
3
+ * server, no build, no deploy.
4
+ *
5
+ * A hosted preview deployment is too slow for "let me glance at this." A dev
6
+ * server is just files on disk being watched by your framework's bundler —
7
+ * so this copies a lane's working tree (including uncommitted changes,
8
+ * exactly what's being iterated on) straight onto the MAIN checkout. The
9
+ * bundler picks up the change and hot-reloads in seconds.
10
+ *
11
+ * This is framework-agnostic by construction: it's an rsync, not a build
12
+ * step, so it has no opinion about Next.js, Vite, or anything else — your
13
+ * own dev server does the actual watching and reloading. The one place a
14
+ * framework's fingerprints show up is `buildOutputDirs` in your config
15
+ * (never copy someone's stale ".next" or "dist" over a live checkout).
16
+ *
17
+ * lanekeeper preview from a lane worktree — swap the dev server
18
+ * to show THIS lane's current working tree.
19
+ * lanekeeper preview --restore from anywhere — put the dev server back on
20
+ * the integration branch's real HEAD.
21
+ *
22
+ * Safety:
23
+ * - Refuses to start a new preview if the MAIN checkout isn't clean (a
24
+ * previous preview wasn't restored, or it has real local changes) —
25
+ * never silently overwrites unknown state.
26
+ * - Additive only (no rsync --delete): a file the lane DELETED won't show
27
+ * up deleted in the preview. Deleting untracked files in a live checkout
28
+ * with no git record to recover them isn't a risk worth taking for a
29
+ * "quick look" tool — this only ever adds or modifies files.
30
+ * - Exact restore, not a guessed `git clean`: every newly-created
31
+ * untracked path introduced by the swap is recorded in a manifest up
32
+ * front, and restore removes precisely those paths, then
33
+ * `git checkout -- .` to revert every modified TRACKED file to HEAD.
34
+ * - Never touches .git, node_modules, build output, or env files in the
35
+ * target — only the source tree itself moves.
36
+ */
37
+ import { execSync, execFileSync, spawnSync } from "node:child_process";
38
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, rmSync } from "node:fs";
39
+ import { createHash } from "node:crypto";
40
+ import { tmpdir } from "node:os";
41
+ import { join } from "node:path";
42
+ import { resolveMainCheckout } from "./lib/main-checkout.js";
43
+ import { loadConfig } from "./lib/config.js";
44
+ const DIM = "\x1b[2m", RESET = "\x1b[0m", RED = "\x1b[31m", GREEN = "\x1b[32m";
45
+ // Always excluded, regardless of framework — never rsync git internals,
46
+ // dependencies, or secrets over a live checkout.
47
+ const BASE_EXCLUDES = [".git", "node_modules", ".env", ".env.local"];
48
+ function gitStatus(dir) {
49
+ return execFileSync("git", ["status", "--porcelain"], { cwd: dir, encoding: "utf8" });
50
+ }
51
+ function restore(target, manifestPath) {
52
+ if (!existsSync(manifestPath)) {
53
+ console.log(`${DIM}preview: no active preview to restore.${RESET}`);
54
+ return;
55
+ }
56
+ const { addedPaths } = JSON.parse(readFileSync(manifestPath, "utf8"));
57
+ console.log(`${DIM}reverting tracked-file changes on the dev checkout…${RESET}`);
58
+ execFileSync("git", ["checkout", "--", "."], { cwd: target, stdio: "inherit" });
59
+ for (const p of addedPaths) {
60
+ rmSync(join(target, p), { recursive: true, force: true });
61
+ }
62
+ unlinkSync(manifestPath);
63
+ const head = execFileSync("git", ["rev-parse", "--short", "HEAD"], { cwd: target, encoding: "utf8" }).trim();
64
+ console.log(`${GREEN}✓ dev server restored to HEAD @ ${head}.${RESET}`);
65
+ }
66
+ function preview(source, target, manifestPath, excludes) {
67
+ if (source === target) {
68
+ console.error("lanekeeper preview: refusing to run from the dev-server checkout itself — run this from a lane worktree.");
69
+ process.exit(1);
70
+ }
71
+ if (existsSync(manifestPath)) {
72
+ console.error(`${RED}preview: a preview is already active on the dev server.${RESET} Run 'lanekeeper preview --restore' first.`);
73
+ process.exit(1);
74
+ }
75
+ const before = gitStatus(target);
76
+ if (before.trim() !== "") {
77
+ console.error(`${RED}preview: the dev-server checkout isn't clean — refusing to swap over unknown local changes.${RESET}`);
78
+ console.error(before);
79
+ process.exit(1);
80
+ }
81
+ const branch = execFileSync("git", ["-C", source, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" }).trim();
82
+ console.log(`${DIM}copying ${branch}'s working tree onto the dev server…${RESET}`);
83
+ const rsyncArgs = ["-a", ...excludes.flatMap((e) => ["--exclude", e]), `${source}/`, `${target}/`];
84
+ const rsync = spawnSync("rsync", rsyncArgs, { stdio: "inherit" });
85
+ if (rsync.status !== 0) {
86
+ console.error(`${RED}preview: rsync failed.${RESET}`);
87
+ process.exit(1);
88
+ }
89
+ const after = gitStatus(target);
90
+ const addedPaths = after
91
+ .split("\n")
92
+ .filter((l) => l.startsWith("??"))
93
+ .map((l) => l.slice(3).trim());
94
+ writeFileSync(manifestPath, JSON.stringify({ branch, addedPaths }, null, 2));
95
+ console.log(`${GREEN}✓ dev server now showing ${branch}.${RESET} Refresh the browser.`);
96
+ console.log(`${DIM}Run 'lanekeeper preview --restore' when done.${RESET}`);
97
+ }
98
+ export async function runPreview(args) {
99
+ const source = process.cwd();
100
+ const target = resolveMainCheckout(source);
101
+ const manifestPath = join(tmpdir(), `lanekeeper-preview-manifest-${createHash("sha1").update(target).digest("hex").slice(0, 12)}.json`);
102
+ // Fail fast and legibly if rsync isn't available, rather than a cryptic
103
+ // spawn ENOENT partway through copying files.
104
+ try {
105
+ execSync("command -v rsync", { stdio: "ignore" });
106
+ }
107
+ catch {
108
+ console.error("lanekeeper preview: rsync is required and wasn't found on PATH.");
109
+ process.exit(1);
110
+ }
111
+ if (args.includes("--restore")) {
112
+ restore(target, manifestPath);
113
+ }
114
+ else {
115
+ const cfg = await loadConfig(source);
116
+ const excludes = [...BASE_EXCLUDES, ...cfg.buildOutputDirs];
117
+ preview(source, target, manifestPath, excludes);
118
+ }
119
+ }
@@ -0,0 +1 @@
1
+ export declare function promote(): Promise<number>;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * promote.ts — ship the integration branch to production by fast-forwarding
3
+ * origin/<productionBranch> to origin/<integrationBranch>.
4
+ *
5
+ * This is the one command in LaneKeeper that's deliberately NOT part of the
6
+ * automated workflow. Agents land on `integrationBranch` continuously and
7
+ * autonomously (see the CLAUDE.md workflow section `lanekeeper init` writes) —
8
+ * production only moves when a human decides to run this. If your
9
+ * lanekeeper.config has no `productionBranch` set, there's nothing to
10
+ * promote: `integrationBranch` already IS production, and this is a no-op.
11
+ *
12
+ * Usage: lanekeeper promote (run from anywhere in the repo)
13
+ *
14
+ * Safe by construction:
15
+ * - Fetches first, then verifies origin/productionBranch is an ANCESTOR of
16
+ * origin/integrationBranch — a pure fast-forward, linear history, no
17
+ * merge commit. If production has commits not on the integration branch
18
+ * (someone pushed it directly), it ABORTS rather than force anything.
19
+ * - No local checkout needed: pushes the remote ref straight across.
20
+ * - --no-verify on the push: every commit on the integration branch
21
+ * already passed the full pre-push check when it landed, so re-running
22
+ * that suite here is pure waste. Your own CI still gates whatever runs
23
+ * on the production branch on its side.
24
+ * - Nothing to promote (already equal) → reports and exits 0.
25
+ */
26
+ import { execFileSync } from "node:child_process";
27
+ import { hasConfig, loadConfig } from "./lib/config.js";
28
+ function git(args, { allowFail = false } = {}) {
29
+ try {
30
+ return { ok: true, out: execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim() };
31
+ }
32
+ catch (e) {
33
+ const err = e;
34
+ if (!allowFail)
35
+ throw e;
36
+ return { ok: false, out: `${err.stdout ?? ""}${err.stderr ?? ""}`.trim() };
37
+ }
38
+ }
39
+ export async function promote() {
40
+ if (!hasConfig()) {
41
+ console.error("lanekeeper promote: no lanekeeper.config found at the repo root.");
42
+ return 1;
43
+ }
44
+ const cfg = await loadConfig();
45
+ if (!cfg.productionBranch) {
46
+ console.log(`lanekeeper promote: no productionBranch configured — '${cfg.integrationBranch}' already IS production. Nothing to do.`);
47
+ return 0;
48
+ }
49
+ const { integrationBranch, productionBranch } = cfg;
50
+ git(["fetch", "origin", "--quiet"], { allowFail: true });
51
+ const prod = git(["rev-parse", `origin/${productionBranch}`], { allowFail: true });
52
+ const integ = git(["rev-parse", `origin/${integrationBranch}`], { allowFail: true });
53
+ if (!prod.ok || !integ.ok) {
54
+ console.error(`lanekeeper promote: could not resolve origin/${productionBranch} or origin/${integrationBranch} — are both branches created and fetched?`);
55
+ return 1;
56
+ }
57
+ if (prod.out === integ.out) {
58
+ console.log(`lanekeeper promote: ${productionBranch} already at ${integrationBranch} (${integ.out.slice(0, 7)}) — nothing to ship.`);
59
+ return 0;
60
+ }
61
+ // Pure fast-forward only: origin/productionBranch must be an ancestor of
62
+ // origin/integrationBranch.
63
+ const ff = git(["merge-base", "--is-ancestor", `origin/${productionBranch}`, `origin/${integrationBranch}`], { allowFail: true });
64
+ if (!ff.ok) {
65
+ console.error(`lanekeeper promote: origin/${productionBranch} has commits NOT on origin/${integrationBranch} — history has diverged.\n` +
66
+ `Someone pushed ${productionBranch} directly. Reconcile manually before promoting.\n` +
67
+ "Left untouched — refusing to force-push production.");
68
+ return 1;
69
+ }
70
+ const push = git(["push", "--no-verify", "origin", `origin/${integrationBranch}:${productionBranch}`], { allowFail: true });
71
+ if (!push.ok) {
72
+ console.error(`lanekeeper promote: push to ${productionBranch} FAILED — production NOT updated.\n${push.out}`);
73
+ return 1;
74
+ }
75
+ console.log(`lanekeeper promote: shipped ${integrationBranch} → ${productionBranch} ${prod.out.slice(0, 7)} → ${integ.out.slice(0, 7)}`);
76
+ return 0;
77
+ }
package/dist/sync.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** Fast-forwards the MAIN checkout. Returns a process exit code; never throws. */
2
+ export declare function sync(): Promise<number>;