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
@@ -0,0 +1,137 @@
1
+ /**
2
+ * lanekeeper.config.ts (or .js — plain JS is fine too) is the only thing
3
+ * that changes between repos. Every command in here reads its knobs from
4
+ * here instead of hardcoding a branch name, a repo name, or a command —
5
+ * that's the difference between "a tool we wrote for one repo" and "a tool
6
+ * anyone can point at theirs."
7
+ *
8
+ * `lanekeeper init` writes a starter config into the repo you run it from.
9
+ * Worktree isolation itself is Claude Code's job now (native `--worktree` /
10
+ * `isolation: worktree`) — this config is read by the WorktreeCreate hook
11
+ * that plugs LaneKeeper's lane numbering into that, and by everything
12
+ * downstream of it (the build queue, the landing queue, preview).
13
+ */
14
+ import { existsSync } from "node:fs";
15
+ import { execFileSync } from "node:child_process";
16
+ import { join } from "node:path";
17
+ import { pathToFileURL } from "node:url";
18
+ export const DEFAULTS = {
19
+ branchPrefix: "lane/",
20
+ worktreeSuffix: "-lane-",
21
+ portBase: 3000,
22
+ integrationBranch: "main",
23
+ productionBranch: null,
24
+ protectedBranches: [],
25
+ regenerableFiles: [],
26
+ symlinks: [".env", ".env.local", "node_modules"],
27
+ buildOutputDirs: ["dist", "build", ".next"],
28
+ checkCommand: null,
29
+ checksRequired: true,
30
+ };
31
+ /**
32
+ * Fail loud on a malformed config instead of silently misbehaving three
33
+ * commands later. Returns a list of human-readable problems — empty means
34
+ * valid.
35
+ */
36
+ export function validateConfig(cfg) {
37
+ const problems = [];
38
+ const nonEmptyString = (v) => typeof v === "string" && v.trim().length > 0;
39
+ if (!nonEmptyString(cfg.branchPrefix))
40
+ problems.push("branchPrefix must be a non-empty string.");
41
+ if (!nonEmptyString(cfg.worktreeSuffix))
42
+ problems.push("worktreeSuffix must be a non-empty string.");
43
+ if (typeof cfg.portBase !== "number" || !Number.isInteger(cfg.portBase) || cfg.portBase <= 0) {
44
+ problems.push("portBase must be a positive integer.");
45
+ }
46
+ if (!nonEmptyString(cfg.integrationBranch))
47
+ problems.push("integrationBranch must be a non-empty string.");
48
+ if (cfg.productionBranch !== null && !nonEmptyString(cfg.productionBranch)) {
49
+ problems.push("productionBranch must be null or a non-empty string.");
50
+ }
51
+ if (cfg.productionBranch !== null && cfg.productionBranch === cfg.integrationBranch) {
52
+ problems.push("productionBranch and integrationBranch are the same branch — that's a no-op two-stage model. Set productionBranch to null instead.");
53
+ }
54
+ if (!Array.isArray(cfg.protectedBranches) || !cfg.protectedBranches.every(nonEmptyString)) {
55
+ problems.push("protectedBranches must be an array of non-empty strings.");
56
+ }
57
+ else if (cfg.protectedBranches.includes(cfg.integrationBranch)) {
58
+ problems.push("protectedBranches contains integrationBranch — that branch is where lanekeeper land pushes; it can't also be blocked.");
59
+ }
60
+ if (!Array.isArray(cfg.regenerableFiles) || !cfg.regenerableFiles.every((v) => typeof v === "string")) {
61
+ problems.push("regenerableFiles must be an array of strings.");
62
+ }
63
+ if (!Array.isArray(cfg.symlinks) || !cfg.symlinks.every((v) => typeof v === "string")) {
64
+ problems.push("symlinks must be an array of strings.");
65
+ }
66
+ if (!Array.isArray(cfg.buildOutputDirs) || !cfg.buildOutputDirs.every((v) => typeof v === "string")) {
67
+ problems.push("buildOutputDirs must be an array of strings.");
68
+ }
69
+ if (cfg.checkCommand !== null && !nonEmptyString(cfg.checkCommand)) {
70
+ problems.push("checkCommand must be null or a non-empty string.");
71
+ }
72
+ if (typeof cfg.checksRequired !== "boolean") {
73
+ problems.push("checksRequired must be a boolean.");
74
+ }
75
+ return problems;
76
+ }
77
+ /**
78
+ * The repo's actual current branch, so `init` doesn't blindly assume "main"
79
+ * — plenty of real repos still default to "master" (or something else
80
+ * entirely), and a generated config pointing at a branch that doesn't exist
81
+ * is exactly the kind of out-of-the-box friction this tool exists to avoid.
82
+ * Returns null (letting the caller fall back to DEFAULTS) if there's no
83
+ * commit yet or HEAD is detached.
84
+ */
85
+ export function detectCurrentBranch(cwd = process.cwd()) {
86
+ try {
87
+ const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
88
+ cwd,
89
+ encoding: "utf8",
90
+ stdio: ["ignore", "pipe", "pipe"],
91
+ }).trim();
92
+ return branch && branch !== "HEAD" ? branch : null;
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
98
+ export function findRepoRoot(cwd = process.cwd()) {
99
+ try {
100
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
101
+ cwd,
102
+ encoding: "utf8",
103
+ }).trim();
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }
109
+ function candidatePaths(root) {
110
+ return [join(root, "lanekeeper.config.mjs"), join(root, "lanekeeper.config.js")];
111
+ }
112
+ export function configPath(cwd = process.cwd()) {
113
+ const root = findRepoRoot(cwd);
114
+ if (!root)
115
+ return null;
116
+ return candidatePaths(root).find((p) => existsSync(p)) ?? null;
117
+ }
118
+ export function hasConfig(cwd = process.cwd()) {
119
+ return configPath(cwd) !== null;
120
+ }
121
+ /**
122
+ * Load lanekeeper.config.(m)js from the current repo, merged over DEFAULTS.
123
+ * Throws with every problem listed if the merged config is invalid — a
124
+ * config that's silently wrong is worse than a command that refuses to run.
125
+ */
126
+ export async function loadConfig(cwd = process.cwd()) {
127
+ const p = configPath(cwd);
128
+ if (!p)
129
+ return { ...DEFAULTS };
130
+ const mod = (await import(pathToFileURL(p).href));
131
+ const cfg = { ...DEFAULTS, ...(mod.default ?? {}) };
132
+ const problems = validateConfig(cfg);
133
+ if (problems.length > 0) {
134
+ throw new Error(`Invalid lanekeeper.config at ${p}:\n${problems.map((p2) => ` - ${p2}`).join("\n")}`);
135
+ }
136
+ return cfg;
137
+ }
@@ -0,0 +1,40 @@
1
+ export interface EphemeralResourceProvider<T> {
2
+ /** Create one resource for this run and return whatever callers need to use it. */
3
+ create(): Promise<T>;
4
+ /** Tear down a resource this same process created. */
5
+ destroy(handle: T): Promise<void>;
6
+ /**
7
+ * Tear down a resource some OTHER (now-dead) process created and never got
8
+ * to destroy — a crash, a SIGKILL, a CI runner that got cancelled. Given
9
+ * only what was recorded at claim time (see ClaimRegistry below).
10
+ */
11
+ destroyOrphan(claim: Claim): Promise<void>;
12
+ }
13
+ export interface Claim {
14
+ id: string;
15
+ pid: number;
16
+ createdAt: number;
17
+ }
18
+ /**
19
+ * A tiny on-disk registry of "what's claimed, by which PID." Not a database
20
+ * — just enough bookkeeping to answer "is this orphaned?" the same way
21
+ * queue-lock.ts answers it for locks: check whether the claiming PID is
22
+ * still alive, with no timeout to tune.
23
+ */
24
+ export declare class ClaimRegistry {
25
+ private readonly dir;
26
+ constructor(registryDir: string);
27
+ record(claim: Claim): void;
28
+ release(id: string): void;
29
+ /** Every claim whose owning PID is no longer alive — safe to destroy. */
30
+ orphans(): Claim[];
31
+ }
32
+ /**
33
+ * Run `fn` against a freshly created resource, prune any orphans left by a
34
+ * previous crashed run first, and always release the claim — including on
35
+ * a thrown error. If THIS process is killed before the `finally` runs, the
36
+ * resource isn't leaked forever: the next call to `withEphemeralResource`
37
+ * (in the next test run, from any lane) prunes it via `orphans()` before
38
+ * creating its own.
39
+ */
40
+ export declare function withEphemeralResource<T>(provider: EphemeralResourceProvider<T>, registry: ClaimRegistry, fn: (resource: T) => Promise<void>): Promise<void>;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The extension point for per-run ephemeral test resources — a throwaway
3
+ * database branch, a scratch bucket, a sandboxed queue, whatever your test
4
+ * suite needs that would otherwise race across concurrent lanes.
5
+ *
6
+ * This file is deliberately NOT wired to any specific provider. Everyone's
7
+ * test setup is different (Neon, a local Postgres with template databases, a
8
+ * Docker container per run, nothing at all) and shipping one company's
9
+ * choice as the default would make this less useful, not more. What's
10
+ * shipped instead is the *shape* — the same claim → use → release, and the
11
+ * same PID-liveness self-heal, that queue-lock.ts and launch.ts already use
12
+ * for the lock and the lane. One pattern, three places, so a crashed run
13
+ * never needs a human to notice and clean up after it.
14
+ *
15
+ * See examples/ephemeral-tmp-dir.example.ts for a complete, runnable
16
+ * implementation (a scratch directory per run, no external service) — copy
17
+ * its shape when you wire this to your own database or resource provider.
18
+ */
19
+ import { mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ function alive(pid) {
22
+ try {
23
+ process.kill(pid, 0);
24
+ return true;
25
+ }
26
+ catch (e) {
27
+ return e.code === "EPERM";
28
+ }
29
+ }
30
+ /**
31
+ * A tiny on-disk registry of "what's claimed, by which PID." Not a database
32
+ * — just enough bookkeeping to answer "is this orphaned?" the same way
33
+ * queue-lock.ts answers it for locks: check whether the claiming PID is
34
+ * still alive, with no timeout to tune.
35
+ */
36
+ export class ClaimRegistry {
37
+ dir;
38
+ constructor(registryDir) {
39
+ this.dir = registryDir;
40
+ mkdirSync(this.dir, { recursive: true });
41
+ }
42
+ record(claim) {
43
+ writeFileSync(join(this.dir, claim.id), JSON.stringify(claim));
44
+ }
45
+ release(id) {
46
+ try {
47
+ unlinkSync(join(this.dir, id));
48
+ }
49
+ catch {
50
+ /* already gone */
51
+ }
52
+ }
53
+ /** Every claim whose owning PID is no longer alive — safe to destroy. */
54
+ orphans() {
55
+ let names;
56
+ try {
57
+ names = readdirSync(this.dir);
58
+ }
59
+ catch {
60
+ return [];
61
+ }
62
+ const found = [];
63
+ for (const name of names) {
64
+ try {
65
+ const claim = JSON.parse(readFileSync(join(this.dir, name), "utf8"));
66
+ if (!alive(claim.pid))
67
+ found.push(claim);
68
+ }
69
+ catch {
70
+ /* unreadable claim file — leave it, don't guess */
71
+ }
72
+ }
73
+ return found;
74
+ }
75
+ }
76
+ /**
77
+ * Run `fn` against a freshly created resource, prune any orphans left by a
78
+ * previous crashed run first, and always release the claim — including on
79
+ * a thrown error. If THIS process is killed before the `finally` runs, the
80
+ * resource isn't leaked forever: the next call to `withEphemeralResource`
81
+ * (in the next test run, from any lane) prunes it via `orphans()` before
82
+ * creating its own.
83
+ */
84
+ export async function withEphemeralResource(provider, registry, fn) {
85
+ for (const orphan of registry.orphans()) {
86
+ await provider.destroyOrphan(orphan);
87
+ registry.release(orphan.id);
88
+ }
89
+ const id = `${Date.now()}-${process.pid}`;
90
+ const claim = { id, pid: process.pid, createdAt: Date.now() };
91
+ registry.record(claim);
92
+ const resource = await provider.create();
93
+ try {
94
+ await fn(resource);
95
+ }
96
+ finally {
97
+ await provider.destroy(resource);
98
+ registry.release(id);
99
+ }
100
+ }
@@ -0,0 +1,3 @@
1
+ import type { LaneKeeperConfig } from "./config.js";
2
+ export declare function laneNumberFromPath(path: string, cfg: Pick<LaneKeeperConfig, "worktreeSuffix">): number | null;
3
+ export declare function lanePort(path: string, cfg: Pick<LaneKeeperConfig, "worktreeSuffix" | "portBase">): number | null;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A lane's dev-server port, derived from where it's sitting on disk rather
3
+ * than an injected environment variable. Claude Code's WorktreeCreate hook
4
+ * has no mechanism to hand environment variables to the session that runs
5
+ * in the worktree it creates — only the directory path. So instead of
6
+ * betting on a second, less-certain hook (SessionStart + CLAUDE_ENV_FILE)
7
+ * to smuggle a port in, the worktree's own name already carries it: Lane
8
+ * Keeper names worktrees "<repo><worktreeSuffix><n>", so any script running
9
+ * inside one can read its own lane number straight off `process.cwd()`.
10
+ * Self-describing beats injected, when the information's already sitting
11
+ * right there in the path.
12
+ */
13
+ import { basename } from "node:path";
14
+ export function laneNumberFromPath(path, cfg) {
15
+ const name = basename(path);
16
+ const idx = name.lastIndexOf(cfg.worktreeSuffix);
17
+ if (idx === -1)
18
+ return null;
19
+ const n = Number(name.slice(idx + cfg.worktreeSuffix.length));
20
+ return Number.isInteger(n) && n > 0 ? n : null;
21
+ }
22
+ export function lanePort(path, cfg) {
23
+ const lane = laneNumberFromPath(path, cfg);
24
+ return lane === null ? null : cfg.portBase + lane;
25
+ }
@@ -0,0 +1 @@
1
+ export declare function resolveMainCheckout(cwd?: string): string;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Resolve the MAIN checkout — the one non-lane checkout your dev server (or
3
+ * CI, or whatever's watching the filesystem) actually runs from — starting
4
+ * from any lane worktree.
5
+ *
6
+ * git-common-dir is ".git" (relative) from the main checkout itself, or an
7
+ * absolute path to <main>/.git from a linked worktree — either way its
8
+ * parent is MAIN. Shared by sync and preview so they agree on the one true
9
+ * answer.
10
+ */
11
+ import { execFileSync } from "node:child_process";
12
+ import { dirname, resolve } from "node:path";
13
+ export function resolveMainCheckout(cwd = process.cwd()) {
14
+ const common = execFileSync("git", ["rev-parse", "--git-common-dir"], {
15
+ cwd,
16
+ encoding: "utf8",
17
+ }).trim();
18
+ return dirname(resolve(cwd, common));
19
+ }
@@ -0,0 +1,8 @@
1
+ import type { LaneKeeperConfig } from "./config.js";
2
+ /**
3
+ * Removes already-landed sibling lane worktrees. Returns the paths it
4
+ * actually removed. Best-effort throughout — any failure for a given
5
+ * worktree (dirty, diverged, busy) just skips that one; this never blocks
6
+ * or fails the `land` it's running as part of.
7
+ */
8
+ export declare function pruneLandedLanes(mainTop: string, cfg: Pick<LaneKeeperConfig, "worktreeSuffix" | "branchPrefix" | "integrationBranch">, currentWorktree: string): string[];
@@ -0,0 +1,120 @@
1
+ /**
2
+ * After a lane lands, sibling lanes that were ALSO already fully landed have
3
+ * no more reason to keep a worktree around — nothing created one on the way
4
+ * in tore it down on the way out (Claude Code's own worktree lifecycle
5
+ * doesn't either), so directories silently accumulate on disk forever
6
+ * unless something sweeps them. This runs that sweep as a side effect of
7
+ * every successful `land`, never touching the worktree currently running
8
+ * this process or the main checkout itself.
9
+ *
10
+ * Safety:
11
+ * 1. Only prune a worktree whose branch is a git ANCESTOR of
12
+ * origin/<integrationBranch> — the authoritative, already-pushed
13
+ * truth — not the local integration branch, which may not be
14
+ * fast-forwarded yet at the exact moment this runs. That's the literal
15
+ * definition of "nothing to lose": the work already made it upstream
16
+ * under its own name.
17
+ * 2. `git worktree remove` (no `--force`) refuses on its own if the
18
+ * worktree has uncommitted changes — dirty work is never discarded
19
+ * just because its branch happens to be merged.
20
+ * Deleting the now-redundant local branch (`git branch -d`, never `-D`) is a
21
+ * separate, best-effort tidiness step AFTER the worktree is already gone —
22
+ * it checks merge state against local HEAD rather than origin, so it can
23
+ * legitimately fail if the local integration branch hasn't caught up yet.
24
+ * That failure doesn't undo the (already-safe) worktree removal or keep it
25
+ * out of the returned list; it just leaves a harmless leftover branch ref.
26
+ */
27
+ import { execFileSync } from "node:child_process";
28
+ import { realpathSync } from "node:fs";
29
+ import { basename, dirname } from "node:path";
30
+ // Best-effort realpath — a "current worktree" that's already gone (or was
31
+ // never real to begin with) shouldn't crash the sweep over one path.
32
+ function existsRealpath(path) {
33
+ try {
34
+ return realpathSync(path);
35
+ }
36
+ catch {
37
+ return path;
38
+ }
39
+ }
40
+ function listWorktrees(mainTop) {
41
+ const out = execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: mainTop, encoding: "utf8" });
42
+ const entries = [];
43
+ let current = {};
44
+ const flush = () => {
45
+ if (current.path)
46
+ entries.push({ path: current.path, branch: current.branch ?? null });
47
+ current = {};
48
+ };
49
+ for (const line of out.split("\n")) {
50
+ if (line.startsWith("worktree ")) {
51
+ flush();
52
+ current.path = line.slice("worktree ".length);
53
+ }
54
+ else if (line.startsWith("branch ")) {
55
+ current.branch = line.slice("branch ".length).replace("refs/heads/", "");
56
+ }
57
+ else if (line === "") {
58
+ flush();
59
+ }
60
+ }
61
+ flush();
62
+ return entries;
63
+ }
64
+ /**
65
+ * Removes already-landed sibling lane worktrees. Returns the paths it
66
+ * actually removed. Best-effort throughout — any failure for a given
67
+ * worktree (dirty, diverged, busy) just skips that one; this never blocks
68
+ * or fails the `land` it's running as part of.
69
+ */
70
+ export function pruneLandedLanes(mainTop, cfg, currentWorktree) {
71
+ const pruned = [];
72
+ // `git worktree list` reports fully realpath-resolved paths (symlinks like
73
+ // macOS's /var -> /private/var followed) — resolve our own reference
74
+ // points the same way, or every comparison below silently never matches.
75
+ const mainTopReal = realpathSync(mainTop);
76
+ const currentWorktreeReal = existsRealpath(currentWorktree);
77
+ const laneDirPrefix = `${basename(mainTopReal)}${cfg.worktreeSuffix}`;
78
+ const parent = dirname(mainTopReal);
79
+ const upstream = `origin/${cfg.integrationBranch}`;
80
+ let worktrees;
81
+ try {
82
+ worktrees = listWorktrees(mainTop);
83
+ }
84
+ catch {
85
+ return pruned;
86
+ }
87
+ for (const { path: wt, branch } of worktrees) {
88
+ if (wt === mainTopReal || wt === currentWorktreeReal)
89
+ continue;
90
+ if (dirname(wt) !== parent || !basename(wt).startsWith(laneDirPrefix))
91
+ continue; // not one of ours
92
+ if (!branch || !branch.startsWith(cfg.branchPrefix))
93
+ continue;
94
+ try {
95
+ execFileSync("git", ["merge-base", "--is-ancestor", branch, upstream], {
96
+ cwd: mainTop,
97
+ stdio: "ignore",
98
+ }); // throws (non-zero exit) if NOT an ancestor — caught below, left alone
99
+ }
100
+ catch {
101
+ continue; // not safe to touch — leave this lane exactly as it is
102
+ }
103
+ try {
104
+ execFileSync("git", ["worktree", "remove", wt], { cwd: mainTop, stdio: "ignore" });
105
+ }
106
+ catch {
107
+ continue; // dirty or busy — worktree remove refused on its own, nothing removed
108
+ }
109
+ // The worktree is gone — this is now unconditionally a pruned lane,
110
+ // regardless of what happens to the branch ref below.
111
+ pruned.push(wt);
112
+ try {
113
+ execFileSync("git", ["branch", "-d", branch], { cwd: mainTop, stdio: "ignore" });
114
+ }
115
+ catch {
116
+ /* local integration branch may not be fast-forwarded yet — harmless, leaves the ref behind */
117
+ }
118
+ }
119
+ return pruned;
120
+ }
@@ -0,0 +1,26 @@
1
+ interface LockHolder {
2
+ pid: number;
3
+ lane: string;
4
+ label?: string;
5
+ ts: number;
6
+ }
7
+ export interface AcquireOptions {
8
+ label?: string;
9
+ onWait?: (info: {
10
+ ahead: number;
11
+ holder: LockHolder | null;
12
+ }) => void;
13
+ }
14
+ export interface QueueLock {
15
+ acquire(options?: AcquireOptions): Promise<void>;
16
+ release(): void;
17
+ readonly lane: string;
18
+ readonly held: boolean;
19
+ }
20
+ /**
21
+ * Create a named FIFO lock. Each distinct `queueName` is an independent
22
+ * mutex — "build" and "land" never contend with each other even though
23
+ * they share this exact same code.
24
+ */
25
+ export declare function createQueueLock(queueName: string): QueueLock;
26
+ export {};