lanekeeper 0.1.1 → 0.1.2

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": {
@@ -14,7 +14,17 @@
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
29
  * just because its branch happens to be merged.
20
30
  * Deleting the now-redundant local branch (`git branch -d`, never `-D`) is a
@@ -24,9 +34,31 @@
24
34
  * That failure doesn't undo the (already-safe) worktree removal or keep it
25
35
  * out of the returned list; it just leaves a harmless leftover branch ref.
26
36
  */
27
- import { execFileSync } from "node:child_process";
37
+ import { execFileSync, spawnSync } from "node:child_process";
28
38
  import { realpathSync } from "node:fs";
29
39
  import { basename, dirname } from "node:path";
40
+ /**
41
+ * Is any live process's current working directory inside `dir` right now?
42
+ * `lsof`'s own exit code is NOT reliable for this — it returns non-zero
43
+ * both when nothing matches AND when it merely fails to inspect some
44
+ * unrelated process it lacks permission for (common, e.g. root-owned system
45
+ * daemons), even while correctly printing real matches. The trustworthy
46
+ * signal is whether stdout has any content at all: genuinely idle
47
+ * directories produce completely empty output; a live process's cwd entry
48
+ * always prints a real row. `-a` ANDs the two filters together (lsof ORs by
49
+ * default) so this only matches things actually inside `dir`, not every
50
+ * process on the system.
51
+ *
52
+ * If `lsof` isn't available at all, this fails CLOSED — treats liveness as
53
+ * unknown/possible rather than confirmed-safe, so an unverifiable state
54
+ * never gets treated the same as a verified-idle one.
55
+ */
56
+ function hasLiveProcessInside(dir) {
57
+ const result = spawnSync("lsof", ["-a", "-d", "cwd", "+D", dir], { encoding: "utf8" });
58
+ if (result.error)
59
+ return true; // lsof missing/unspawnable — can't verify, assume in use
60
+ return result.stdout.trim().length > 0;
61
+ }
30
62
  // Best-effort realpath — a "current worktree" that's already gone (or was
31
63
  // never real to begin with) shouldn't crash the sweep over one path.
32
64
  function existsRealpath(path) {
@@ -100,6 +132,8 @@ export function pruneLandedLanes(mainTop, cfg, currentWorktree) {
100
132
  catch {
101
133
  continue; // not safe to touch — leave this lane exactly as it is
102
134
  }
135
+ if (hasLiveProcessInside(wt))
136
+ continue; // someone's actively in here — never touch it
103
137
  try {
104
138
  execFileSync("git", ["worktree", "remove", wt], { cwd: mainTop, stdio: "ignore" });
105
139
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lanekeeper",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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",