lanekeeper 0.1.2 → 0.1.4
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/README.md
CHANGED
|
@@ -266,6 +266,19 @@ Things a sharp reader should already know before they ask:
|
|
|
266
266
|
refuses to touch any worktree with a live process's cwd inside it right
|
|
267
267
|
now. That check needs `lsof` on PATH; if it's missing, pruning fails
|
|
268
268
|
closed (treats liveness as unknown, never removes) rather than guessing.
|
|
269
|
+
- **The `WorktreeCreate` hook needs the host project's own real install.**
|
|
270
|
+
It runs via `npx lanekeeper hook worktree-create` (a raw hook command has
|
|
271
|
+
no `node_modules/.bin` on its PATH, unlike an `npm run` script) — but npx
|
|
272
|
+
silently falls back to fetching an ephemeral, unpinned copy when it can't
|
|
273
|
+
resolve the package locally, which is exactly what happens if the host
|
|
274
|
+
project's own `node_modules` install of lanekeeper is missing or
|
|
275
|
+
mid-upgrade. That's a real failure mode, not hypothetical: it happened in
|
|
276
|
+
production and the fallback ran silently long enough to mask a broken
|
|
277
|
+
install for two lane-landings. The hook now refuses to run at all if it
|
|
278
|
+
detects it's executing from npx's ephemeral cache rather than the
|
|
279
|
+
project's own installed copy, so a broken install fails loud immediately
|
|
280
|
+
(`npm install` and retry) instead of quietly limping along on a
|
|
281
|
+
mismatched stand-in version.
|
|
269
282
|
|
|
270
283
|
## 🧬 Where this came from
|
|
271
284
|
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import { execFileSync } from "node:child_process";
|
|
27
27
|
import { existsSync, mkdirSync, symlinkSync } from "node:fs";
|
|
28
|
-
import { dirname, join, basename } from "node:path";
|
|
28
|
+
import { dirname, join, basename, sep } from "node:path";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
29
30
|
import { loadConfig, hasConfig, DEFAULTS } from "../lib/config.js";
|
|
30
31
|
import { resolveMainCheckout } from "../lib/main-checkout.js";
|
|
31
32
|
// A lane-claim loop with no upper bound is exactly one path-resolution bug
|
|
@@ -121,6 +122,24 @@ export function createLane(mainTop, cfg) {
|
|
|
121
122
|
return { wt, branch, lane };
|
|
122
123
|
}
|
|
123
124
|
}
|
|
125
|
+
// `.claude/settings.json` invokes this hook via `npx lanekeeper hook
|
|
126
|
+
// worktree-create` rather than a project script, precisely because a raw
|
|
127
|
+
// hook command has no `node_modules/.bin` on its PATH the way `npm run`
|
|
128
|
+
// does — npx's own directory-walking local resolution is what makes that
|
|
129
|
+
// work at all. The problem: npx treats a package it can't resolve locally as
|
|
130
|
+
// license to silently fetch an ephemeral, unpinned copy from the registry
|
|
131
|
+
// and run *that* instead of failing — which is exactly what happens when the
|
|
132
|
+
// host project's own install of lanekeeper is missing or mid-upgrade (npm
|
|
133
|
+
// removes the old version's files before extracting the new one; anything
|
|
134
|
+
// that interrupts that leaves precisely this state). That fallback ran
|
|
135
|
+
// silently for long enough in production to block two lanes from landing
|
|
136
|
+
// before anyone noticed node_modules was broken. Refuse to proceed if this
|
|
137
|
+
// module is executing from npx's ephemeral cache instead of the project's
|
|
138
|
+
// own installed copy, so a broken install fails loud immediately instead of
|
|
139
|
+
// limping along on a stand-in version nobody asked for.
|
|
140
|
+
export function isEphemeralNpxCopy(selfPath) {
|
|
141
|
+
return selfPath.includes(`${sep}_npx${sep}`);
|
|
142
|
+
}
|
|
124
143
|
async function readStdin() {
|
|
125
144
|
const chunks = [];
|
|
126
145
|
for await (const chunk of process.stdin)
|
|
@@ -137,6 +156,10 @@ export async function runWorktreeCreateHook() {
|
|
|
137
156
|
}
|
|
138
157
|
const fromCwd = input.cwd ?? process.cwd();
|
|
139
158
|
try {
|
|
159
|
+
if (isEphemeralNpxCopy(fileURLToPath(import.meta.url))) {
|
|
160
|
+
throw new Error("running from npx's ephemeral cache, not this project's own installed dependency — " +
|
|
161
|
+
"node_modules is missing or broken. Run `npm install` in the main checkout and try again.");
|
|
162
|
+
}
|
|
140
163
|
const mainTop = resolveMainCheckout(fromCwd);
|
|
141
164
|
const cfg = hasConfig(mainTop) ? await loadConfig(mainTop) : { ...DEFAULTS };
|
|
142
165
|
const { wt } = createLane(mainTop, cfg);
|
|
@@ -5,4 +5,4 @@ import type { LaneKeeperConfig } from "./config.js";
|
|
|
5
5
|
* worktree (dirty, diverged, busy) just skips that one; this never blocks
|
|
6
6
|
* or fails the `land` it's running as part of.
|
|
7
7
|
*/
|
|
8
|
-
export declare function pruneLandedLanes(mainTop: string, cfg: Pick<LaneKeeperConfig, "worktreeSuffix" | "branchPrefix" | "integrationBranch">, currentWorktree: string): string[];
|
|
8
|
+
export declare function pruneLandedLanes(mainTop: string, cfg: Pick<LaneKeeperConfig, "worktreeSuffix" | "branchPrefix" | "integrationBranch" | "regenerableFiles">, currentWorktree: string): string[];
|
package/dist/lib/prune-lanes.js
CHANGED
|
@@ -26,7 +26,13 @@
|
|
|
26
26
|
* another lane's land before its own first commit.
|
|
27
27
|
* 3. `git worktree remove` (no `--force`) refuses on its own if the
|
|
28
28
|
* worktree has uncommitted changes — dirty work is never discarded
|
|
29
|
-
* just because its branch happens to be merged.
|
|
29
|
+
* just because its branch happens to be merged. The ONE exception,
|
|
30
|
+
* matching `sync`/`land`: files listed in `regenerableFiles`
|
|
31
|
+
* (next-env.d.ts and the like) are discarded first and the removal
|
|
32
|
+
* retried, since a build tool rewriting its own output shouldn't be
|
|
33
|
+
* the thing that leaves an otherwise fully-landed lane stuck forever.
|
|
34
|
+
* Any OTHER dirty file blocks pruning exactly as before — real
|
|
35
|
+
* uncommitted work is never discarded just to tidy up disk space.
|
|
30
36
|
* Deleting the now-redundant local branch (`git branch -d`, never `-D`) is a
|
|
31
37
|
* separate, best-effort tidiness step AFTER the worktree is already gone —
|
|
32
38
|
* it checks merge state against local HEAD rather than origin, so it can
|
|
@@ -69,6 +75,15 @@ function existsRealpath(path) {
|
|
|
69
75
|
return path;
|
|
70
76
|
}
|
|
71
77
|
}
|
|
78
|
+
function tryRemoveWorktree(mainTop, wt) {
|
|
79
|
+
try {
|
|
80
|
+
execFileSync("git", ["worktree", "remove", wt], { cwd: mainTop, stdio: "ignore" });
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
72
87
|
function listWorktrees(mainTop) {
|
|
73
88
|
const out = execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: mainTop, encoding: "utf8" });
|
|
74
89
|
const entries = [];
|
|
@@ -109,6 +124,7 @@ export function pruneLandedLanes(mainTop, cfg, currentWorktree) {
|
|
|
109
124
|
const laneDirPrefix = `${basename(mainTopReal)}${cfg.worktreeSuffix}`;
|
|
110
125
|
const parent = dirname(mainTopReal);
|
|
111
126
|
const upstream = `origin/${cfg.integrationBranch}`;
|
|
127
|
+
const regenerable = new Set(cfg.regenerableFiles);
|
|
112
128
|
let worktrees;
|
|
113
129
|
try {
|
|
114
130
|
worktrees = listWorktrees(mainTop);
|
|
@@ -134,12 +150,23 @@ export function pruneLandedLanes(mainTop, cfg, currentWorktree) {
|
|
|
134
150
|
}
|
|
135
151
|
if (hasLiveProcessInside(wt))
|
|
136
152
|
continue; // someone's actively in here — never touch it
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
153
|
+
let removed = tryRemoveWorktree(mainTop, wt);
|
|
154
|
+
if (!removed) {
|
|
155
|
+
// Blocked by dirty files? Only retry if EVERY one of them is a
|
|
156
|
+
// configured regenerable file — anything else is real uncommitted
|
|
157
|
+
// work, and this lane is left alone exactly as before.
|
|
158
|
+
const dirty = execFileSync("git", ["status", "--porcelain"], { cwd: wt, encoding: "utf8" })
|
|
159
|
+
.split("\n")
|
|
160
|
+
.filter(Boolean)
|
|
161
|
+
.map((line) => line.slice(3).trim());
|
|
162
|
+
const blocking = dirty.filter((f) => !regenerable.has(f));
|
|
163
|
+
if (dirty.length > 0 && blocking.length === 0) {
|
|
164
|
+
execFileSync("git", ["checkout", "--", ...dirty], { cwd: wt, stdio: "ignore" });
|
|
165
|
+
removed = tryRemoveWorktree(mainTop, wt);
|
|
166
|
+
}
|
|
142
167
|
}
|
|
168
|
+
if (!removed)
|
|
169
|
+
continue; // still dirty (real work) or otherwise busy — leave it alone
|
|
143
170
|
// The worktree is gone — this is now unconditionally a pruned lane,
|
|
144
171
|
// regardless of what happens to the branch ref below.
|
|
145
172
|
pruned.push(wt);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lanekeeper",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "The local, zero-cost merge queue for parallel Claude Code agents. Plugs into Claude Code's native worktree isolation; one build at a time, one landing at a time, zero races.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|