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 +7 -0
- package/dist/bin/lanekeeper.js +26 -0
- package/dist/lib/prune-lanes.d.ts +1 -1
- package/dist/lib/prune-lanes.js +69 -8
- package/package.json +1 -1
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
|
|
package/dist/bin/lanekeeper.js
CHANGED
|
@@ -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[];
|
package/dist/lib/prune-lanes.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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.
|
|
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",
|