lanekeeper 0.1.0 → 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": {
package/dist/land.js CHANGED
@@ -101,8 +101,13 @@ export async function land() {
101
101
  // Landing isn't "done" until the checkout that actually serves your
102
102
  // dev server can see it — call sync in-process rather than shelling
103
103
  // back out to the CLI, so this doesn't depend on `lanekeeper` being
104
- // resolvable on PATH.
105
- exitCode = await sync();
104
+ // resolvable on PATH. Pass this lane's own already-loaded cfg through
105
+ // rather than letting sync() reload from MAIN — MAIN hasn't been
106
+ // fast-forwarded yet at this exact moment (that's what sync is about
107
+ // to do), so if this push just introduced or changed
108
+ // lanekeeper.config.mjs itself, a fresh MAIN-side load would silently
109
+ // fall back to DEFAULTS instead of the real config.
110
+ exitCode = await sync(cfg);
106
111
  // Housekeeping, never a reason to fail this landing: sweep sibling
107
112
  // lanes whose OWN branch already made it upstream (nothing created
108
113
  // ever tears a worktree down on the way out) so they don't
@@ -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/dist/sync.d.ts CHANGED
@@ -1,2 +1,16 @@
1
- /** Fast-forwards the MAIN checkout. Returns a process exit code; never throws. */
2
- export declare function sync(): Promise<number>;
1
+ import { type LaneKeeperConfig } from "./lib/config.js";
2
+ /**
3
+ * Fast-forwards the MAIN checkout. Returns a process exit code; never throws.
4
+ *
5
+ * Accepts an already-loaded config, for `land` calling this immediately
6
+ * after a push that ITSELF introduced or changed lanekeeper.config.mjs: the
7
+ * MAIN checkout hasn't been fast-forwarded yet at that exact moment (that's
8
+ * this function's whole job), so loading fresh from MAIN would silently
9
+ * fall back to DEFAULTS and could reject a perfectly good sync — the same
10
+ * bootstrap gap createLane had to be fixed for. The lane's own config,
11
+ * which just successfully rebased onto and pushed to the real
12
+ * integrationBranch, is the more trustworthy answer at that moment. A bare
13
+ * `lanekeeper sync` (no caller-provided config) still loads fresh from
14
+ * MAIN, same as before.
15
+ */
16
+ export declare function sync(providedCfg?: LaneKeeperConfig): Promise<number>;
package/dist/sync.js CHANGED
@@ -40,8 +40,21 @@ function sleep(ms) {
40
40
  /* tiny synchronous backoff for index.lock */
41
41
  }
42
42
  }
43
- /** Fast-forwards the MAIN checkout. Returns a process exit code; never throws. */
44
- export async function sync() {
43
+ /**
44
+ * Fast-forwards the MAIN checkout. Returns a process exit code; never throws.
45
+ *
46
+ * Accepts an already-loaded config, for `land` calling this immediately
47
+ * after a push that ITSELF introduced or changed lanekeeper.config.mjs: the
48
+ * MAIN checkout hasn't been fast-forwarded yet at that exact moment (that's
49
+ * this function's whole job), so loading fresh from MAIN would silently
50
+ * fall back to DEFAULTS and could reject a perfectly good sync — the same
51
+ * bootstrap gap createLane had to be fixed for. The lane's own config,
52
+ * which just successfully rebased onto and pushed to the real
53
+ * integrationBranch, is the more trustworthy answer at that moment. A bare
54
+ * `lanekeeper sync` (no caller-provided config) still loads fresh from
55
+ * MAIN, same as before.
56
+ */
57
+ export async function sync(providedCfg) {
45
58
  let MAIN;
46
59
  try {
47
60
  MAIN = resolveMainCheckout(process.cwd());
@@ -50,7 +63,7 @@ export async function sync() {
50
63
  console.error("lanekeeper sync: not inside a git repo — nothing to do.");
51
64
  return 0;
52
65
  }
53
- const cfg = await loadConfig(MAIN);
66
+ const cfg = providedCfg ?? (await loadConfig(MAIN));
54
67
  const regenerable = new Set(cfg.regenerableFiles);
55
68
  const branchRes = git(MAIN, ["rev-parse", "--abbrev-ref", "HEAD"], { allowFail: true });
56
69
  const branch = branchRes.out.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lanekeeper",
3
- "version": "0.1.0",
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",
@@ -8,7 +8,7 @@
8
8
  "homepage": "https://github.com/funador/lanekeeper",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "https://github.com/funador/lanekeeper.git"
11
+ "url": "git+https://github.com/funador/lanekeeper.git"
12
12
  },
13
13
  "keywords": [
14
14
  "claude-code",
@@ -20,7 +20,7 @@
20
20
  "monorepo-tooling"
21
21
  ],
22
22
  "bin": {
23
- "lanekeeper": "./dist/bin/lanekeeper.js"
23
+ "lanekeeper": "dist/bin/lanekeeper.js"
24
24
  },
25
25
  "files": [
26
26
  "dist",