lanekeeper 0.1.0

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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -0
  3. package/dist/bin/lanekeeper.d.ts +2 -0
  4. package/dist/bin/lanekeeper.js +269 -0
  5. package/dist/build-lock.d.ts +1 -0
  6. package/dist/build-lock.js +70 -0
  7. package/dist/hooks/worktree-create.d.ts +13 -0
  8. package/dist/hooks/worktree-create.js +150 -0
  9. package/dist/land.d.ts +1 -0
  10. package/dist/land.js +128 -0
  11. package/dist/lib/check-command.d.ts +29 -0
  12. package/dist/lib/check-command.js +83 -0
  13. package/dist/lib/check-push.d.ts +37 -0
  14. package/dist/lib/check-push.js +48 -0
  15. package/dist/lib/claude-md-snippet.d.ts +16 -0
  16. package/dist/lib/claude-md-snippet.js +18 -0
  17. package/dist/lib/config.d.ts +92 -0
  18. package/dist/lib/config.js +137 -0
  19. package/dist/lib/ephemeral.d.ts +40 -0
  20. package/dist/lib/ephemeral.js +100 -0
  21. package/dist/lib/lane-port.d.ts +3 -0
  22. package/dist/lib/lane-port.js +25 -0
  23. package/dist/lib/main-checkout.d.ts +1 -0
  24. package/dist/lib/main-checkout.js +19 -0
  25. package/dist/lib/prune-lanes.d.ts +8 -0
  26. package/dist/lib/prune-lanes.js +120 -0
  27. package/dist/lib/queue-lock.d.ts +26 -0
  28. package/dist/lib/queue-lock.js +212 -0
  29. package/dist/lib/tty-confirm.d.ts +1 -0
  30. package/dist/lib/tty-confirm.js +44 -0
  31. package/dist/lib/wire-hooks.d.ts +26 -0
  32. package/dist/lib/wire-hooks.js +123 -0
  33. package/dist/preview.d.ts +1 -0
  34. package/dist/preview.js +119 -0
  35. package/dist/promote.d.ts +1 -0
  36. package/dist/promote.js +77 -0
  37. package/dist/sync.d.ts +2 -0
  38. package/dist/sync.js +115 -0
  39. package/examples/ephemeral-tmp-dir.example.ts +66 -0
  40. package/examples/lanekeeper.config.mjs +67 -0
  41. package/hooks/claude-settings.example.json +14 -0
  42. package/hooks/pre-push +23 -0
  43. package/package.json +46 -0
package/dist/sync.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * sync.ts — fast-forward the MAIN checkout to its upstream branch.
3
+ *
4
+ * Your dev server (or whatever's watching the filesystem) runs on the MAIN
5
+ * checkout, which tracks the integration branch. Lanes land onto that branch
6
+ * via a push, but the MAIN checkout's working tree only advances on a pull —
7
+ * so it serves stale files until something fast-forwards it. `land` runs
8
+ * this immediately after every successful push, so the dev server picks up
9
+ * landed work with zero manual `git pull`.
10
+ *
11
+ * Safe by construction:
12
+ * - Fast-forward ONLY. If the checkout has diverged from its upstream, it
13
+ * warns and leaves it untouched — never a force, never a merge commit.
14
+ * - Retries transient index.lock contention (two lanes landing near-simultaneously).
15
+ * - If a fast-forward is blocked only by a locally-modified *regenerable*
16
+ * file (configured in lanekeeper.config), it discards that file and
17
+ * retries. Any other dirty file → warn and skip.
18
+ */
19
+ import { execFileSync } from "node:child_process";
20
+ import { loadConfig } from "./lib/config.js";
21
+ import { resolveMainCheckout } from "./lib/main-checkout.js";
22
+ const LOCK_RETRIES = 3;
23
+ function git(cwd, args, { allowFail = false } = {}) {
24
+ try {
25
+ return {
26
+ ok: true,
27
+ out: execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }),
28
+ };
29
+ }
30
+ catch (e) {
31
+ if (!allowFail)
32
+ throw e;
33
+ const err = e;
34
+ return { ok: false, out: `${err.stdout ?? ""}${err.stderr ?? ""}` };
35
+ }
36
+ }
37
+ function sleep(ms) {
38
+ const end = Date.now() + ms;
39
+ while (Date.now() < end) {
40
+ /* tiny synchronous backoff for index.lock */
41
+ }
42
+ }
43
+ /** Fast-forwards the MAIN checkout. Returns a process exit code; never throws. */
44
+ export async function sync() {
45
+ let MAIN;
46
+ try {
47
+ MAIN = resolveMainCheckout(process.cwd());
48
+ }
49
+ catch {
50
+ console.error("lanekeeper sync: not inside a git repo — nothing to do.");
51
+ return 0;
52
+ }
53
+ const cfg = await loadConfig(MAIN);
54
+ const regenerable = new Set(cfg.regenerableFiles);
55
+ const branchRes = git(MAIN, ["rev-parse", "--abbrev-ref", "HEAD"], { allowFail: true });
56
+ const branch = branchRes.out.trim();
57
+ if (!branch || branch === "HEAD") {
58
+ console.error("lanekeeper sync: the checkout is detached or unresolved — left untouched.");
59
+ return 0;
60
+ }
61
+ // The main checkout is meant to stay parked on integrationBranch permanently
62
+ // (that's what makes "fast-forward it" a safe, unattended operation). If
63
+ // it's on something else — someone switched branches in it by hand, or ran
64
+ // `land` from a single non-worktree checkout instead of a lane worktree —
65
+ // fast-forwarding "whatever HEAD happens to be" silently does the wrong
66
+ // thing. Say so plainly instead of surfacing a raw git error later.
67
+ if (branch !== cfg.integrationBranch) {
68
+ console.error(`lanekeeper sync: this checkout is on '${branch}', not the configured integrationBranch ` +
69
+ `('${cfg.integrationBranch}'). sync only fast-forwards the main checkout — run it from ` +
70
+ `there, or check out '${cfg.integrationBranch}' here first. Left untouched.`);
71
+ return 0;
72
+ }
73
+ const upstream = `origin/${branch}`;
74
+ const before = git(MAIN, ["rev-parse", "--short", "HEAD"], { allowFail: true }).out.trim();
75
+ git(MAIN, ["fetch", "origin", "--quiet"], { allowFail: true });
76
+ const tryFastForward = () => git(MAIN, ["merge", "--ff-only", upstream], { allowFail: true });
77
+ let res = tryFastForward();
78
+ // Retry transient lock contention (another lane landing at the same instant).
79
+ for (let i = 0; i < LOCK_RETRIES && !res.ok && /index\.lock|Unable to create|another git process/i.test(res.out); i++) {
80
+ sleep(400);
81
+ res = tryFastForward();
82
+ }
83
+ // Blocked by a locally-modified regenerable file? Discard it and retry once.
84
+ if (!res.ok && /would be overwritten by merge/i.test(res.out)) {
85
+ const files = res.out
86
+ .split("\n")
87
+ .map((l) => l.trim())
88
+ .filter((l) => l && !/would be overwritten|please commit|aborting|^error:/i.test(l));
89
+ const blocking = files.filter((f) => !regenerable.has(f));
90
+ if (blocking.length === 0 && files.length > 0) {
91
+ git(MAIN, ["checkout", "--", ...files], { allowFail: true });
92
+ res = tryFastForward();
93
+ }
94
+ else {
95
+ console.error(`lanekeeper sync: ${branch} has local changes blocking fast-forward (${blocking.join(", ")}). Left untouched — resolve in the checkout.`);
96
+ return 0;
97
+ }
98
+ }
99
+ if (res.ok) {
100
+ const after = git(MAIN, ["rev-parse", "--short", "HEAD"], { allowFail: true }).out.trim();
101
+ if (before === after) {
102
+ console.log(`lanekeeper sync: ${branch} already current at ${after}.`);
103
+ }
104
+ else {
105
+ console.log(`lanekeeper sync: fast-forwarded ${branch} ${before} → ${after} — the dev server will pick it up.`);
106
+ }
107
+ return 0;
108
+ }
109
+ if (/Not possible to fast-forward|diverging|non-fast-forward/i.test(res.out)) {
110
+ console.error(`lanekeeper sync: local ${branch} has DIVERGED from ${upstream} (something was committed directly on the checkout). Left untouched — reconcile it manually.`);
111
+ return 0;
112
+ }
113
+ console.error(`lanekeeper sync: could not fast-forward ${branch} — left untouched.\n${res.out.trim()}`);
114
+ return 0;
115
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * A complete, runnable EphemeralResourceProvider — a scratch directory per
3
+ * test run instead of a database branch, so this example works with zero
4
+ * external services and zero cost. Swap `create`/`destroy`/`destroyOrphan`
5
+ * for calls to your actual provider (a Neon branch-create/delete API, a
6
+ * `CREATE DATABASE ... TEMPLATE ...`, a Docker container) and everything
7
+ * else — the claim registry, the orphan pruning, the finally-block release —
8
+ * carries over unchanged.
9
+ *
10
+ * Run it: node --import tsx examples/ephemeral-tmp-dir.example.ts
11
+ * Simulate a crash: node --import tsx examples/ephemeral-tmp-dir.example.ts --crash
12
+ * then run it again without --crash and watch it prune the orphan first.
13
+ */
14
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { ClaimRegistry, withEphemeralResource, type EphemeralResourceProvider } from "../src/lib/ephemeral.js";
18
+
19
+ const REGISTRY_DIR = join(tmpdir(), "lanekeeper-example-ephemeral-registry");
20
+ const RESOURCE_ROOT = join(tmpdir(), "lanekeeper-example-ephemeral-resources");
21
+ mkdirSync(RESOURCE_ROOT, { recursive: true });
22
+
23
+ const tmpDirProvider: EphemeralResourceProvider<string> = {
24
+ async create() {
25
+ const dir = mkdtempSync(join(RESOURCE_ROOT, "run-"));
26
+ console.log(` created scratch dir: ${dir}`);
27
+ return dir;
28
+ },
29
+ async destroy(dir) {
30
+ rmSync(dir, { recursive: true, force: true });
31
+ console.log(` destroyed scratch dir: ${dir}`);
32
+ },
33
+ async destroyOrphan(claim) {
34
+ // The claim only tells us WHO made it and WHEN, not which directory it
35
+ // owned — in a real provider you'd store that mapping yourself (e.g. the
36
+ // resource's ID inside the claim record). For this example, orphaned
37
+ // "run-*" directories are swept by age instead.
38
+ console.log(` pruning orphan left by dead pid ${claim.pid} (claimed at ${new Date(claim.createdAt).toISOString()})`);
39
+ for (const name of readdirSync(RESOURCE_ROOT)) {
40
+ const path = join(RESOURCE_ROOT, name);
41
+ if (existsSync(join(path, ".orphan-sweep-safe"))) {
42
+ rmSync(path, { recursive: true, force: true });
43
+ }
44
+ }
45
+ },
46
+ };
47
+
48
+ const registry = new ClaimRegistry(REGISTRY_DIR);
49
+
50
+ if (process.argv.includes("--crash")) {
51
+ // Simulate a run that claims a resource and then dies before its finally
52
+ // block runs — no rmSync, no registry.release(). The NEXT run should find
53
+ // and prune this.
54
+ const dir = mkdtempSync(join(RESOURCE_ROOT, "run-"));
55
+ writeFileSync(join(dir, ".orphan-sweep-safe"), "");
56
+ registry.record({ id: `${Date.now()}-${process.pid}`, pid: process.pid, createdAt: Date.now() });
57
+ console.log(`simulated crash: claimed ${dir} and exiting without cleanup (pid ${process.pid})`);
58
+ process.exit(1);
59
+ }
60
+
61
+ await withEphemeralResource(tmpDirProvider, registry, async (dir) => {
62
+ writeFileSync(join(dir, "example.txt"), "this file only exists for the run's lifetime\n");
63
+ console.log(` running your tests against ${dir}…`);
64
+ });
65
+
66
+ console.log("done — resource was created, used, and torn down; any prior orphan was pruned first.");
@@ -0,0 +1,67 @@
1
+ // lanekeeper.config.mjs — lives at your repo root. `lanekeeper init` writes a
2
+ // copy of this for you; edit the values below for your project.
3
+ //
4
+ // Worktree isolation is Claude Code's job (native `--worktree` /
5
+ // `isolation: "worktree"`) — this file is what the WorktreeCreate hook
6
+ // (see hooks/claude-settings.example.json) reads to name and shape the lane
7
+ // it creates, and what everything downstream (build queue, landing queue,
8
+ // preview) reads too.
9
+
10
+ /** @type {import("lanekeeper").LaneKeeperConfig} */
11
+ export default {
12
+ // Lane branches: lane/1, lane/2, ...
13
+ branchPrefix: "lane/",
14
+
15
+ // Sibling worktree dirs: ../<your-repo>-lane-1, -lane-2, ...
16
+ worktreeSuffix: "-lane-",
17
+
18
+ // Lane 1 gets this port, lane 2 gets portBase + 2, and so on — handy if
19
+ // each lane also runs its own throwaway dev server.
20
+ portBase: 3000,
21
+
22
+ // The branch `lanekeeper land` rebases onto and pushes to. Agents land
23
+ // here continuously and autonomously — see the CLAUDE.md workflow section
24
+ // `lanekeeper init` writes.
25
+ integrationBranch: "main",
26
+
27
+ // Set this if you run a two-stage model: agents land on integrationBranch,
28
+ // a human ships to productionBranch on their own schedule with
29
+ // `lanekeeper promote`. null (the default) means integrationBranch IS
30
+ // production — no separate promotion step. Example: integrationBranch
31
+ // "dev", productionBranch "main". Automatically protected by the pre-push
32
+ // hook when set — you don't need to also list it below.
33
+ productionBranch: null,
34
+
35
+ // Extra branches the pre-push hook refuses a *direct* push to, beyond
36
+ // integrationBranch and productionBranch. Most repos need nothing here.
37
+ protectedBranches: [],
38
+
39
+ // Files your build tool rewrites on its own that should never block a
40
+ // rebase or a fast-forward. Next.js projects typically want
41
+ // ["next-env.d.ts"] here at minimum — add to this list the first time a
42
+ // regenerated file blocks a landing, and never think about it again.
43
+ regenerableFiles: [],
44
+
45
+ // Git-ignored paths symlinked into every new lane so it needs no fresh
46
+ // install and no copy of your secrets.
47
+ symlinks: [".env", ".env.local", "node_modules"],
48
+
49
+ // Build-output dirs `lanekeeper preview` never copies onto your dev
50
+ // checkout. preview is framework-agnostic (it's an rsync, not a build
51
+ // step) — this is the one place your framework's name shows up. Add
52
+ // ".output" for Nuxt, ".svelte-kit" for SvelteKit, etc.
53
+ buildOutputDirs: ["dist", "build", ".next"],
54
+
55
+ // The command that actually gates a landing — your lint/typecheck/test/
56
+ // build. `lanekeeper init` tries to detect this from package.json
57
+ // (check:push, check, ci, or test, in that order) and fills it in for
58
+ // you. null means nothing runs, which is only allowed if checksRequired
59
+ // is also false — see below.
60
+ checkCommand: "npm run check",
61
+
62
+ // true (the default): a null checkCommand FAILS every push rather than
63
+ // landing unverified code. Set to false yourself to deliberately run
64
+ // with no checks — a real state for a repo with nothing to test yet, but
65
+ // one that should be a visible, committed choice, not a silent default.
66
+ checksRequired: true,
67
+ };
@@ -0,0 +1,14 @@
1
+ {
2
+ "hooks": {
3
+ "WorktreeCreate": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "npx lanekeeper hook worktree-create"
9
+ }
10
+ ]
11
+ }
12
+ ]
13
+ }
14
+ }
package/hooks/pre-push ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env sh
2
+ # LaneKeeper — landing-queue enforcement + your checks, gating every push.
3
+ #
4
+ # Copy this file to .husky/pre-push (recommended — Husky wires it in on
5
+ # your package manager's install step, for every clone and every lane
6
+ # worktree) or straight to
7
+ # .git/hooks/pre-push (works, but isn't versioned or shared with the team).
8
+ # If you already have a pre-push hook, append this block instead of
9
+ # replacing the file. `lanekeeper init` does all of this for you.
10
+ #
11
+ # `lanekeeper check-push` does two things, in order:
12
+ # 1. Blocks a direct push to your integration/protected/production
13
+ # branches that didn't go through `lanekeeper land` / `promote`.
14
+ # 2. Runs `checkCommand` from lanekeeper.config.mjs (your lint/typecheck/
15
+ # test/build) and fails the push if it fails — or if checkCommand
16
+ # isn't set and checksRequired wasn't deliberately turned off.
17
+ #
18
+ # See src/land.ts and src/lib/check-push.ts for why a convention alone
19
+ # isn't enough to guarantee either of those.
20
+
21
+ if ! npx lanekeeper check-push; then
22
+ exit 1
23
+ fi
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "lanekeeper",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Jesse Heaslip",
8
+ "homepage": "https://github.com/funador/lanekeeper",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/funador/lanekeeper.git"
12
+ },
13
+ "keywords": [
14
+ "claude-code",
15
+ "ai-agents",
16
+ "git-worktree",
17
+ "parallel-agents",
18
+ "coding-agent",
19
+ "merge-queue",
20
+ "monorepo-tooling"
21
+ ],
22
+ "bin": {
23
+ "lanekeeper": "./dist/bin/lanekeeper.js"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "hooks",
28
+ "examples",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "scripts": {
36
+ "build": "tsc -p tsconfig.json && chmod +x dist/bin/lanekeeper.js",
37
+ "prepare": "npm run build",
38
+ "pretest": "npm run build",
39
+ "test": "node --import tsx --test test/*.test.ts"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^22.10.0",
43
+ "tsx": "^4.19.0",
44
+ "typescript": "^5.7.0"
45
+ }
46
+ }