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
 
@@ -10,4 +10,5 @@ export declare function createLane(mainTop: string, cfg: LaneKeeperConfig): {
10
10
  branch: string;
11
11
  lane: number;
12
12
  };
13
+ export declare function isEphemeralNpxCopy(selfPath: string): boolean;
13
14
  export declare function runWorktreeCreateHook(): Promise<void>;
@@ -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[];
@@ -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
- try {
138
- execFileSync("git", ["worktree", "remove", wt], { cwd: mainTop, stdio: "ignore" });
139
- }
140
- catch {
141
- continue; // dirty or busy worktree remove refused on its own, nothing removed
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.2",
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",