lanekeeper 0.1.1 → 0.1.3

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
@@ -60,6 +60,7 @@ locally instead of in someone else's billed cloud. 💸
60
60
  | `lanekeeper promote` | Ships the integration branch to production. **Human-only** — never in an agent's instructions, never automated. |
61
61
  | `lanekeeper preview` | Instantly mirrors a lane's live working tree — uncommitted changes included — onto the main checkout, so you can look at it without a build. |
62
62
  | `lanekeeper port` | Prints a lane's dev-server port, derived from its own directory name. |
63
+ | `lanekeeper prune` | Removes already-landed sibling lane worktrees on demand — `land` already does this automatically, this is for "clean these up right now" instead of waiting for the next lane to land something. |
63
64
 
64
65
  Plus 🔒 a pre-push hook that makes `land` non-optional: a direct `git push`
65
66
  straight to the integration branch gets bounced, full stop. Not a lint
@@ -259,6 +260,12 @@ Things a sharp reader should already know before they ask:
259
260
  CLAUDE.md tells it to resolve the conflict itself and re-run `land`, the
260
261
  same way it'd fix any other bug — `checkCommand` still gates the result
261
262
  either way, so a bad resolution gets caught there.
263
+ - **Auto-pruning checks for a live process, via `lsof`.** A merged branch
264
+ alone isn't enough to prune a lane — a brand-new, zero-commit lane is
265
+ *trivially* "merged" too (nothing's diverged yet), so pruning also
266
+ refuses to touch any worktree with a live process's cwd inside it right
267
+ now. That check needs `lsof` on PATH; if it's missing, pruning fails
268
+ closed (treats liveness as unknown, never removes) rather than guessing.
262
269
 
263
270
  ## 🧬 Where this came from
264
271
 
@@ -17,6 +17,8 @@ import { lanePort } from "../lib/lane-port.js";
17
17
  import { claudeMdSnippet, MARKER } from "../lib/claude-md-snippet.js";
18
18
  import { detectCheckCommand, runCheckCommand } from "../lib/check-command.js";
19
19
  import { wireClaudeSettings, wireHuskyPrePush, ensureHooksPath } from "../lib/wire-hooks.js";
20
+ import { resolveMainCheckout } from "../lib/main-checkout.js";
21
+ import { pruneLandedLanes } from "../lib/prune-lanes.js";
20
22
  const [, , command, ...rest] = process.argv;
21
23
  async function readStdin() {
22
24
  const chunks = [];
@@ -180,6 +182,30 @@ async function main() {
180
182
  case "promote":
181
183
  process.exit(await promote());
182
184
  return;
185
+ case "prune": {
186
+ // `land` already does this automatically as a side effect of every
187
+ // successful landing — this is the on-demand escape hatch for
188
+ // "clean these up right now" instead of waiting for the next lane to
189
+ // land something. Same safety rules apply: only already-merged
190
+ // branches, never anything with a live process inside, never the
191
+ // worktree this command itself is running from.
192
+ const root = findRepoRoot();
193
+ if (!root || !hasConfig(root)) {
194
+ console.error("lanekeeper prune: no lanekeeper.config found — nothing to do.");
195
+ process.exit(0);
196
+ }
197
+ const cfg = await loadConfig(root);
198
+ const mainTop = resolveMainCheckout(process.cwd());
199
+ const pruned = pruneLandedLanes(mainTop, cfg, process.cwd());
200
+ if (pruned.length === 0) {
201
+ console.log("lanekeeper prune: nothing to clean up — no already-landed sibling lanes found.");
202
+ }
203
+ else {
204
+ const names = pruned.map((p) => p.split("/").pop()).join(", ");
205
+ console.log(`lanekeeper prune: removed ${pruned.length} already-landed lane${pruned.length === 1 ? "" : "s"}: ${names}`);
206
+ }
207
+ return;
208
+ }
183
209
  case "preview":
184
210
  return runPreview(rest);
185
211
  case "build-lock": {
@@ -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[];
@@ -14,9 +14,25 @@
14
14
  * fast-forwarded yet at the exact moment this runs. That's the literal
15
15
  * definition of "nothing to lose": the work already made it upstream
16
16
  * under its own name.
17
- * 2. `git worktree remove` (no `--force`) refuses on its own if the
17
+ * 2. Never touch a worktree with a LIVE process currently working in it
18
+ * (checked via `lsof -a -d cwd`, see below) — this is NOT redundant
19
+ * with the ancestor check. A brand-new lane that hasn't diverged yet
20
+ * is *trivially* an ancestor of upstream (its tip IS a commit already
21
+ * on the integration branch, just because nothing's been committed
22
+ * there yet) — structurally identical, in the git graph alone, to a
23
+ * lane whose own real work already landed. Only a liveness check can
24
+ * tell "someone's about to start working here" apart from "this is
25
+ * truly done." Confirmed live: a fresh, zero-commit lane got swept by
26
+ * another lane's land before its own first commit.
27
+ * 3. `git worktree remove` (no `--force`) refuses on its own if the
18
28
  * worktree has uncommitted changes — dirty work is never discarded
19
- * 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.
20
36
  * Deleting the now-redundant local branch (`git branch -d`, never `-D`) is a
21
37
  * separate, best-effort tidiness step AFTER the worktree is already gone —
22
38
  * it checks merge state against local HEAD rather than origin, so it can
@@ -24,9 +40,31 @@
24
40
  * That failure doesn't undo the (already-safe) worktree removal or keep it
25
41
  * out of the returned list; it just leaves a harmless leftover branch ref.
26
42
  */
27
- import { execFileSync } from "node:child_process";
43
+ import { execFileSync, spawnSync } from "node:child_process";
28
44
  import { realpathSync } from "node:fs";
29
45
  import { basename, dirname } from "node:path";
46
+ /**
47
+ * Is any live process's current working directory inside `dir` right now?
48
+ * `lsof`'s own exit code is NOT reliable for this — it returns non-zero
49
+ * both when nothing matches AND when it merely fails to inspect some
50
+ * unrelated process it lacks permission for (common, e.g. root-owned system
51
+ * daemons), even while correctly printing real matches. The trustworthy
52
+ * signal is whether stdout has any content at all: genuinely idle
53
+ * directories produce completely empty output; a live process's cwd entry
54
+ * always prints a real row. `-a` ANDs the two filters together (lsof ORs by
55
+ * default) so this only matches things actually inside `dir`, not every
56
+ * process on the system.
57
+ *
58
+ * If `lsof` isn't available at all, this fails CLOSED — treats liveness as
59
+ * unknown/possible rather than confirmed-safe, so an unverifiable state
60
+ * never gets treated the same as a verified-idle one.
61
+ */
62
+ function hasLiveProcessInside(dir) {
63
+ const result = spawnSync("lsof", ["-a", "-d", "cwd", "+D", dir], { encoding: "utf8" });
64
+ if (result.error)
65
+ return true; // lsof missing/unspawnable — can't verify, assume in use
66
+ return result.stdout.trim().length > 0;
67
+ }
30
68
  // Best-effort realpath — a "current worktree" that's already gone (or was
31
69
  // never real to begin with) shouldn't crash the sweep over one path.
32
70
  function existsRealpath(path) {
@@ -37,6 +75,15 @@ function existsRealpath(path) {
37
75
  return path;
38
76
  }
39
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
+ }
40
87
  function listWorktrees(mainTop) {
41
88
  const out = execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: mainTop, encoding: "utf8" });
42
89
  const entries = [];
@@ -77,6 +124,7 @@ export function pruneLandedLanes(mainTop, cfg, currentWorktree) {
77
124
  const laneDirPrefix = `${basename(mainTopReal)}${cfg.worktreeSuffix}`;
78
125
  const parent = dirname(mainTopReal);
79
126
  const upstream = `origin/${cfg.integrationBranch}`;
127
+ const regenerable = new Set(cfg.regenerableFiles);
80
128
  let worktrees;
81
129
  try {
82
130
  worktrees = listWorktrees(mainTop);
@@ -100,12 +148,25 @@ export function pruneLandedLanes(mainTop, cfg, currentWorktree) {
100
148
  catch {
101
149
  continue; // not safe to touch — leave this lane exactly as it is
102
150
  }
103
- try {
104
- execFileSync("git", ["worktree", "remove", wt], { cwd: mainTop, stdio: "ignore" });
105
- }
106
- catch {
107
- continue; // dirty or busy worktree remove refused on its own, nothing removed
151
+ if (hasLiveProcessInside(wt))
152
+ continue; // someone's actively in here never touch it
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
+ }
108
167
  }
168
+ if (!removed)
169
+ continue; // still dirty (real work) or otherwise busy — leave it alone
109
170
  // The worktree is gone — this is now unconditionally a pruned lane,
110
171
  // regardless of what happens to the branch ref below.
111
172
  pruned.push(wt);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lanekeeper",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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",