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 +7 -0
- package/dist/bin/lanekeeper.js +26 -0
- package/dist/land.js +7 -2
- package/dist/lib/prune-lanes.js +36 -2
- package/dist/sync.d.ts +16 -2
- package/dist/sync.js +16 -3
- package/package.json +3 -3
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": {
|
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
|
-
|
|
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
|
package/dist/lib/prune-lanes.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
2
|
-
|
|
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
|
-
/**
|
|
44
|
-
|
|
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.
|
|
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": "
|
|
23
|
+
"lanekeeper": "dist/bin/lanekeeper.js"
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
26
|
"dist",
|