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,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>;
|
package/dist/preview.js
ADDED
|
@@ -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>;
|
package/dist/promote.js
ADDED
|
@@ -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