From 7a68a9817fff173b8375ea8d5ada81d67bbb95a6 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 16:03:40 -0500 Subject: [PATCH 01/31] RT-34: writeJson writes tmp+rename so stores never tear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements atomic write via write-to-temp-then-rename pattern. All existing callers benefit silently — same signature, same throw-on-failure contract. Co-Authored-By: Claude Fable 5 --- lib/__tests__/json-store.test.ts | 11 +++++++++++ lib/json-store.ts | 8 ++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/__tests__/json-store.test.ts b/lib/__tests__/json-store.test.ts index f82e67d9..926bbdae 100644 --- a/lib/__tests__/json-store.test.ts +++ b/lib/__tests__/json-store.test.ts @@ -43,3 +43,14 @@ describe("json-store", () => { expect(readFileSync(p, "utf8")).toBe('{\n "a": 1\n}'); }); }); + +describe("writeJson atomicity", () => { + test("round-trips and leaves no tmp file behind", () => { + const dir = mkdtempSync(join(tmpdir(), "jsonstore-")); + const p = join(dir, "nested", "file.json"); + writeJson(p, { a: 1 }); + expect(readJson<{ a: number } | null>(p, null)).toEqual({ a: 1 }); + const { readdirSync } = require("fs"); + expect(readdirSync(join(dir, "nested"))).toEqual(["file.json"]); + }); +}); diff --git a/lib/json-store.ts b/lib/json-store.ts index 0d63f803..a403142c 100644 --- a/lib/json-store.ts +++ b/lib/json-store.ts @@ -3,13 +3,15 @@ * hand-rolled the same "read JSON or return a default" and "write JSON, creating * the parent dir" boilerplate. * + * Writes are write-temp-then-rename atomic — stores never tear (spec §4). + * * Scope note: adopted in the per-repo store modules touched by the ~/.rt/repos * move (repo-index, workspace-sync, parking-lot). Other hand-rolled callsites * are intentionally left alone — converting all of them is a separate, app-wide * sweep, not part of the path refactor. */ -import { mkdirSync, readFileSync, writeFileSync } from "fs"; +import { mkdirSync, readFileSync, writeFileSync, renameSync } from "fs"; import { dirname } from "path"; /** @@ -32,5 +34,7 @@ export function readJson(path: string, fallback: T): T { */ export function writeJson(path: string, value: unknown): void { mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, JSON.stringify(value, null, 2)); + const tmp = `${path}.tmp`; + writeFileSync(tmp, JSON.stringify(value, null, 2)); + renameSync(tmp, path); } From 43e0afdcd6d5d490b51520dad8d4a074e6225e18 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 16:07:03 -0500 Subject: [PATCH 02/31] RT-34: worktree registry store (kinds, states, per-repo json) Co-Authored-By: Claude Fable 5 --- lib/worktree/__tests__/registry.test.ts | 42 ++++++++++++++++++ lib/worktree/registry.ts | 58 +++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 lib/worktree/__tests__/registry.test.ts create mode 100644 lib/worktree/registry.ts diff --git a/lib/worktree/__tests__/registry.test.ts b/lib/worktree/__tests__/registry.test.ts new file mode 100644 index 00000000..7bd8e8b0 --- /dev/null +++ b/lib/worktree/__tests__/registry.test.ts @@ -0,0 +1,42 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { mkdtempSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { + loadRegistry, + saveRegistry, + findByBranch, + usedNames, + type TreeRecord, +} from "../registry.ts"; + +const rec = (over: Partial): TreeRecord => ({ + name: "bellatrix", + path: "/tmp/x", + kind: "ephemeral", + state: "on-deck", + branch: "on-deck/bellatrix", + createdAt: new Date(0).toISOString(), + ...over, +}); + +describe("worktree registry", () => { + beforeEach(() => { + process.env.HOME = mkdtempSync(join(tmpdir(), "rtreg-")); + }); + test("empty loads []", () => expect(loadRegistry("r")).toEqual([])); + test("round-trip", () => { + saveRegistry("r", [rec({})]); + expect(loadRegistry("r")[0]!.name).toBe("bellatrix"); + }); + test("findByBranch returns all matches", () => { + const trees = [ + rec({ path: "/a", branch: "x" }), + rec({ name: "dobby", path: "/b", branch: "x" }), + ]; + expect(findByBranch(trees, "x").length).toBe(2); + }); + test("usedNames includes creating", () => { + expect(usedNames([rec({ state: "creating" })]).has("bellatrix")).toBe(true); + }); +}); diff --git a/lib/worktree/registry.ts b/lib/worktree/registry.ts new file mode 100644 index 00000000..22d74657 --- /dev/null +++ b/lib/worktree/registry.ts @@ -0,0 +1,58 @@ +import { join } from "path"; +import { readJson, writeJson } from "../json-store.ts"; +import { repoDataDir } from "../rt-paths.ts"; + +export type TreeKind = "main" | "ephemeral" | "unmanaged"; +export type TreeState = "creating" | "on-deck" | "claimed" | "disposable"; +export type DisposalMode = "merge" | "job"; + +export interface TreeRecord { + name: string; + path: string; // absolute + kind: TreeKind; + state?: TreeState; // ephemeral only + branch: string | null; // git ground truth, reconciled every pass + owner?: string; + disposal?: DisposalMode; + createdAt: string; // ISO + claimedAt?: string; + readyAt?: string; // last successful full readiness (ISO) + readyStamp?: string; // commit sha the ready steps last ran against + disposableReason?: string; + retryFailures?: number; // shared backoff counter (create/freshen) + nextRetryAt?: string; // ISO; skip mutating work until then +} + +interface RegistryFile { + trees: TreeRecord[]; +} + +export function registryPath(repoName: string): string { + return join(repoDataDir(repoName), "worktrees.json"); +} + +export function loadRegistry(repoName: string): TreeRecord[] { + const path = registryPath(repoName); + const data = readJson(path, { trees: [] }); + return data.trees; +} + +export function saveRegistry(repoName: string, trees: TreeRecord[]): void { + const path = registryPath(repoName); + writeJson(path, { trees }); +} + +export function findByPath( + trees: TreeRecord[], + path: string +): TreeRecord | undefined { + return trees.find((t) => t.path === path); +} + +export function findByBranch(trees: TreeRecord[], branch: string): TreeRecord[] { + return trees.filter((t) => t.branch === branch); +} + +export function usedNames(trees: TreeRecord[]): Set { + return new Set(trees.map((t) => t.name)); +} From f8fe963bfa317fa12c9fdf344421c3550ec59fa7 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 16:11:42 -0500 Subject: [PATCH 03/31] RT-34: async git helpers for the worktree lifecycle (hooks-suppressed, daemon-safe) runGit/gitOk/currentBranchAsync/etc. in lib/worktree/git-async.ts port the execSync-based helpers in lib/git-ops.ts and lib/git-worktrees.ts to Bun.spawn so daemon-reachable code never blocks the event loop. Every mutating command runs with core.hooksPath=/dev/null (repo stealth: no target-repo hooks fire). remoteDefaultRef ports the getRemoteDefaultBranch rev-parse ladder rather than symbolic-ref refs/remotes/origin/HEAD, which only exists after clone/set-head and is absent from remote-add+fetch test fixtures. Extends runCapture (lib/subprocess.ts) with an opt-in opts.stderr: "pipe" so git failure detail survives past the default "ignore" - existing callers are unaffected since the new stderr result field defaults to empty. Co-Authored-By: Claude Fable 5 --- lib/subprocess.ts | 21 ++- lib/worktree/__tests__/git-async.test.ts | 120 ++++++++++++++ lib/worktree/git-async.ts | 199 +++++++++++++++++++++++ 3 files changed, 334 insertions(+), 6 deletions(-) create mode 100644 lib/worktree/__tests__/git-async.test.ts create mode 100644 lib/worktree/git-async.ts diff --git a/lib/subprocess.ts b/lib/subprocess.ts index 6a8c70f4..8142f1cf 100644 --- a/lib/subprocess.ts +++ b/lib/subprocess.ts @@ -8,27 +8,32 @@ export interface RunResult { stdout: string; + stderr: string; exitCode: number; } /** * Run argv and capture stdout. Never throws: spawn failures and timeouts * surface as a non-zero exitCode with whatever stdout was collected. + * + * stderr is discarded by default (`opts.stderr` defaults to `"ignore"`); pass + * `"pipe"` to capture it for callers that need failure detail (e.g. git). */ export async function runCapture( argv: [string, ...string[]], - opts: { cwd?: string; timeoutMs?: number } = {}, + opts: { cwd?: string; timeoutMs?: number; stderr?: "ignore" | "pipe" } = {}, ): Promise { + const captureStderr = opts.stderr === "pipe"; let proc: ReturnType; try { proc = Bun.spawn(argv, { cwd: opts.cwd, stdin: "ignore", stdout: "pipe", - stderr: "ignore", + stderr: captureStderr ? "pipe" : "ignore", }); } catch { - return { stdout: "", exitCode: -1 }; + return { stdout: "", stderr: "", exitCode: -1 }; } const timer = setTimeout(() => { @@ -36,11 +41,15 @@ export async function runCapture( }, opts.timeoutMs ?? 10_000); try { - const stdout = await new Response(proc.stdout as ReadableStream).text(); + const stdoutPromise = new Response(proc.stdout as ReadableStream).text(); + const stderrPromise = captureStderr + ? new Response(proc.stderr as ReadableStream).text() + : Promise.resolve(""); + const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]); const exitCode = await proc.exited; - return { stdout, exitCode }; + return { stdout, stderr, exitCode }; } catch { - return { stdout: "", exitCode: -1 }; + return { stdout: "", stderr: "", exitCode: -1 }; } finally { clearTimeout(timer); } diff --git a/lib/worktree/__tests__/git-async.test.ts b/lib/worktree/__tests__/git-async.test.ts new file mode 100644 index 00000000..9d3b2061 --- /dev/null +++ b/lib/worktree/__tests__/git-async.test.ts @@ -0,0 +1,120 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { execSync } from "child_process"; +import { mkdtempSync, readFileSync, realpathSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { + currentBranchAsync, + branchExistsLocalAsync, + isAncestorAsync, + remoteDefaultRef, + ensureInfoExclude, + listWorktreesAsync, + runGit, + gitOk, + statusPorcelainAsync, + remoteRefExists, + headSha, + stashChangesAsync, + popStashAsync, + findDesktopStashAsync, +} from "../git-async.ts"; + +function makeRepo(): string { + // realpathSync: git canonicalizes /var → /private/var on macOS (Global Constraints) + const dir = realpathSync(mkdtempSync(join(tmpdir(), "rtgit-"))); + execSync("git init -b main && git -c user.email=t@t -c user.name=t commit --allow-empty -m init", { cwd: dir, shell: "/bin/zsh" }); + return dir; +} + +describe("git-async", () => { + let repo: string; + beforeEach(() => { repo = makeRepo(); }); + + test("currentBranchAsync", async () => expect(await currentBranchAsync(repo)).toBe("main")); + + test("currentBranchAsync returns null when detached", async () => { + execSync("git checkout --detach HEAD", { cwd: repo, shell: "/bin/zsh" }); + expect(await currentBranchAsync(repo)).toBeNull(); + }); + + test("branchExistsLocalAsync", async () => { + expect(await branchExistsLocalAsync(repo, "main")).toBe(true); + expect(await branchExistsLocalAsync(repo, "nope")).toBe(false); + }); + + test("isAncestorAsync HEAD of itself", async () => expect(await isAncestorAsync(repo, "HEAD", "HEAD")).toBe(true)); + + test("remoteDefaultRef falls back with no remote", async () => expect(await remoteDefaultRef(repo)).toBe("origin/master")); + + test("remoteDefaultRef resolves origin/main on a main-default origin", async () => { + // bare-clone repo as origin, add + fetch (this fixture shape is reused by Tasks 8/11/12/13) + const bare = mkdtempSync(join(tmpdir(), "rtgit-bare-")); + execSync(`git clone --bare ${repo} ${bare}/o.git && git -C ${repo} remote add origin ${bare}/o.git && git -C ${repo} fetch origin`, { shell: "/bin/zsh" }); + expect(await remoteDefaultRef(repo)).toBe("origin/main"); + }); + + test("listWorktreesAsync lists main + added tree with branches", async () => { + execSync(`git -C ${repo} worktree add ${repo}-wt -b side`, { shell: "/bin/zsh" }); + const trees = await listWorktreesAsync(repo); + expect(trees.length).toBe(2); + expect(trees[1]).toEqual({ path: `${repo}-wt`, branch: "side" }); + }); + + test("runGit captures stderr", async () => { + const r = await runGit(repo, ["checkout", "definitely-not-a-ref"]); + expect(r.exitCode).not.toBe(0); + expect(r.stderr.length).toBeGreaterThan(0); + }); + + test("ensureInfoExclude appends once", async () => { + expect(await ensureInfoExclude(repo, ".worktrees/")).toBe(true); + expect(await ensureInfoExclude(repo, ".worktrees/")).toBe(false); + const content = readFileSync(join(repo, ".git", "info", "exclude"), "utf8"); + expect(content.match(/\.worktrees\//g)!.length).toBe(1); + }); + + test("gitOk true/false on exit code", async () => { + expect(await gitOk(repo, ["rev-parse", "HEAD"])).toBe(true); + expect(await gitOk(repo, ["rev-parse", "no-such-ref"])).toBe(false); + }); + + test("statusPorcelainAsync reflects an untracked file", async () => { + writeFileSync(join(repo, "untracked.txt"), "hi"); + const status = await statusPorcelainAsync(repo); + expect(status).toContain("untracked.txt"); + }); + + test("remoteRefExists true only after fetch", async () => { + expect(await remoteRefExists(repo, "main")).toBe(false); + const bare = mkdtempSync(join(tmpdir(), "rtgit-bare-")); + execSync(`git clone --bare ${repo} ${bare}/o.git && git -C ${repo} remote add origin ${bare}/o.git && git -C ${repo} fetch origin`, { shell: "/bin/zsh" }); + expect(await remoteRefExists(repo, "main")).toBe(true); + }); + + test("headSha returns the current commit sha", async () => { + const expected = execSync("git rev-parse HEAD", { cwd: repo, encoding: "utf8" }).trim(); + expect(await headSha(repo)).toBe(expected); + }); + + test("stashChangesAsync / findDesktopStashAsync / popStashAsync round-trip", async () => { + writeFileSync(join(repo, "tracked.txt"), "v1"); + execSync("git add tracked.txt && git commit -m tracked", { cwd: repo, shell: "/bin/zsh" }); + writeFileSync(join(repo, "tracked.txt"), "v2"); + + await stashChangesAsync(repo, "main"); + const status = await statusPorcelainAsync(repo); + expect(status).toBe(""); + + const found = await findDesktopStashAsync(repo, "main"); + expect(found).not.toBeNull(); + + await popStashAsync(repo, found!.name); + const content = readFileSync(join(repo, "tracked.txt"), "utf8"); + expect(content).toBe("v2"); + }); + + test("findDesktopStashAsync returns null when no stash exists", async () => { + expect(await findDesktopStashAsync(repo, "main")).toBeNull(); + }); +}); diff --git a/lib/worktree/git-async.ts b/lib/worktree/git-async.ts new file mode 100644 index 00000000..7473c951 --- /dev/null +++ b/lib/worktree/git-async.ts @@ -0,0 +1,199 @@ +/** + * Async git helpers for the worktree lifecycle. + * + * The daemon-safe replacement for the execSync-based git wrappers in + * lib/git-ops.ts and lib/git-worktrees.ts: execSync blocks Bun's event loop + * for the whole child lifetime, which on the daemon shows up as timed-out + * status polls (see lib/subprocess.ts). Everything reachable from a daemon + * timer or handler goes through here instead. + */ + +import { existsSync, readFileSync, appendFileSync, mkdirSync } from "fs"; +import { dirname, isAbsolute, join } from "path"; +import { runCapture } from "../subprocess.ts"; + +export interface GitResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export interface WorktreeEntry { + path: string; + branch: string | null; +} + +export interface DesktopStashEntry { + name: string; // e.g. "stash@{0}" +} + +// rt drives git inside target repos but must never fire their hooks (repo +// stealth). A broken husky post-checkout/post-merge hook otherwise makes the +// checkout exit non-zero *after* it already succeeded, surfacing as a spurious +// "checkout-failed" even though the branch switched fine. Disable hooks on every +// mutating command by pointing hooksPath at a nonexistent dir. +const NO_HOOKS = ["-c", "core.hooksPath=/dev/null"]; + +const DEFAULT_TIMEOUT_MS = 60_000; + +const DESKTOP_STASH_RE = /!!GitHub_Desktop<(.+)>$/; + +/** Run a git command with hooks suppressed, capturing stdout+stderr. Never throws. */ +export async function runGit( + cwd: string, + args: string[], + opts: { timeoutMs?: number } = {}, +): Promise { + const argv: [string, ...string[]] = ["git", ...NO_HOOKS, ...args]; + const result = await runCapture(argv, { + cwd, + timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, + stderr: "pipe", + }); + return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }; +} + +/** Run a git command and report only whether it succeeded (exitCode === 0). */ +export async function gitOk(cwd: string, args: string[]): Promise { + const r = await runGit(cwd, args); + return r.exitCode === 0; +} + +/** Current branch name, or null when HEAD is detached. */ +export async function currentBranchAsync(cwd: string): Promise { + const r = await runGit(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"]); + if (r.exitCode !== 0) return null; + const branch = r.stdout.trim(); + return branch.length > 0 ? branch : null; +} + +export async function statusPorcelainAsync(cwd: string): Promise { + const r = await runGit(cwd, ["status", "--porcelain"]); + return r.stdout; +} + +export async function branchExistsLocalAsync(cwd: string, branch: string): Promise { + return gitOk(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]); +} + +/** Whether refs/remotes/origin/ exists (i.e. was fetched). */ +export async function remoteRefExists(cwd: string, branch: string): Promise { + return gitOk(cwd, ["rev-parse", "--verify", `refs/remotes/origin/${branch}`]); +} + +export async function isAncestorAsync(cwd: string, ancestor: string, tip: string): Promise { + return gitOk(cwd, ["merge-base", "--is-ancestor", ancestor, tip]); +} + +/** + * Resolve the repo's default remote ref. Ported from getRemoteDefaultBranch + * (lib/git-ops.ts) rather than `symbolic-ref refs/remotes/origin/HEAD` — that + * ref only exists after `git clone`/`remote set-head`, and fixtures built with + * `remote add` + `fetch` never have it. Always returns; falls back to + * "origin/master" when neither candidate resolves. + */ +export async function remoteDefaultRef(cwd: string): Promise { + for (const candidate of ["origin/main", "origin/master"]) { + if (await gitOk(cwd, ["rev-parse", "--verify", candidate])) return candidate; + } + return "origin/master"; +} + +export async function headSha(cwd: string): Promise { + const r = await runGit(cwd, ["rev-parse", "HEAD"]); + if (r.exitCode !== 0) return null; + const sha = r.stdout.trim(); + return sha.length > 0 ? sha : null; +} + +/** + * Enumerate worktrees for a repo via `git worktree list --porcelain`. + * branch is null when a worktree is detached. Paths are filtered to those + * that exist on disk (a worktree removed via `rm -rf` still shows up in + * git's porcelain output, but it's not one we can operate on). + * + * NOTE: returns git's canonicalized paths (/private/var/... on macOS + * tmpdirs); callers comparing against stored paths rely on the + * canonical-fixtures rule in Global Constraints. + */ +export async function listWorktreesAsync(repoPath: string): Promise { + const r = await runGit(repoPath, ["worktree", "list", "--porcelain"]); + const results: WorktreeEntry[] = []; + let curPath: string | null = null; + let curBranch: string | null = null; + + const flush = () => { + if (curPath && existsSync(curPath)) { + results.push({ path: curPath, branch: curBranch }); + } + }; + + for (const line of r.stdout.split("\n")) { + if (line.startsWith("worktree ")) { + flush(); + curPath = line.slice("worktree ".length).trim(); + curBranch = null; + } else if (line.startsWith("branch ")) { + curBranch = line.slice("branch ".length).trim().replace(/^refs\/heads\//, ""); + } + } + flush(); + + return results; +} + +/** + * Idempotently append `pattern` to the common git dir's info/exclude, with a + * "# rt worktree" marker comment written on first use of this file. Returns + * true when it wrote (pattern was missing), false when already present. + */ +export async function ensureInfoExclude(repoPath: string, pattern: string): Promise { + const r = await runGit(repoPath, ["rev-parse", "--git-common-dir"]); + if (r.exitCode !== 0) return false; + const commonDirRaw = r.stdout.trim(); + const commonDir = isAbsolute(commonDirRaw) ? commonDirRaw : join(repoPath, commonDirRaw); + const excludePath = join(commonDir, "info", "exclude"); + + mkdirSync(dirname(excludePath), { recursive: true }); + + const content = existsSync(excludePath) ? readFileSync(excludePath, "utf8") : ""; + if (content.split("\n").some((line) => line.trim() === pattern.trim())) { + return false; + } + + const prefix = content.length > 0 ? "\n" : ""; + const header = content.includes("# rt worktree") ? "" : "# rt worktree\n"; + appendFileSync(excludePath, `${prefix}${header}${pattern}\n`); + return true; +} + +/** + * Stash uncommitted changes with a GitHub Desktop-compatible marker. + * Async port of git-ops.ts stashChanges — interoperable with GitHub Desktop + * and worktree-context. + */ +export async function stashChangesAsync(cwd: string, label: string): Promise { + const message = `!!GitHub_Desktop<${label}>`; + await runGit(cwd, ["stash", "push", "-u", "-m", message]); +} + +export async function popStashAsync(cwd: string, stashName: string): Promise { + await runGit(cwd, ["stash", "pop", stashName]); +} + +/** + * Find the most recent GitHub Desktop-tagged stash entry for a branch. + * Async port of git-ops.ts findDesktopStash. + */ +export async function findDesktopStashAsync(cwd: string, branch: string): Promise<{ name: string } | null> { + const r = await runGit(cwd, ["stash", "list"]); + if (r.exitCode !== 0) return null; + for (const line of r.stdout.split("\n")) { + const match = DESKTOP_STASH_RE.exec(line); + if (match && match[1] === branch) { + const nameMatch = /^(stash@\{\d+\})/.exec(line); + if (nameMatch) return { name: nameMatch[1]! }; + } + } + return null; +} From 119a6e22cdb7bfcde5e4ae30b3c8760443f99740 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 16:12:07 -0500 Subject: [PATCH 04/31] RT-34: drop unused DesktopStashEntry interface from git-async Self-review cleanup: findDesktopStashAsync uses the brief's exact inline return type, so the named interface was dead code. Co-Authored-By: Claude Fable 5 --- lib/worktree/git-async.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/worktree/git-async.ts b/lib/worktree/git-async.ts index 7473c951..b22d91c7 100644 --- a/lib/worktree/git-async.ts +++ b/lib/worktree/git-async.ts @@ -23,10 +23,6 @@ export interface WorktreeEntry { branch: string | null; } -export interface DesktopStashEntry { - name: string; // e.g. "stash@{0}" -} - // rt drives git inside target repos but must never fire their hooks (repo // stealth). A broken husky post-checkout/post-merge hook otherwise makes the // checkout exit non-zero *after* it already succeeded, surfacing as a spurious From b61db4be0a346b0ac17380144610e23b2ed3d3cf Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 16:15:52 -0500 Subject: [PATCH 05/31] RT-34: name pools + ticket branch derivation (random pick, neutral fallback) Implements pickName() for random pool selection with neutral generator fallback, and slugifyTicketTitle()/disambiguate() for branch name derivation with collision handling. All 13 new tests pass; full test suite (32 tests) GREEN. Co-Authored-By: Claude Fable 5 --- lib/worktree/__tests__/names.test.ts | 123 +++++++++++++++++++++++++++ lib/worktree/branch-name.ts | 56 ++++++++++++ lib/worktree/names.ts | 82 ++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 lib/worktree/__tests__/names.test.ts create mode 100644 lib/worktree/branch-name.ts create mode 100644 lib/worktree/names.ts diff --git a/lib/worktree/__tests__/names.test.ts b/lib/worktree/__tests__/names.test.ts new file mode 100644 index 00000000..5457ae00 --- /dev/null +++ b/lib/worktree/__tests__/names.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, spyOn } from "bun:test"; +import { pickName } from "../names"; +import { slugifyTicketTitle, disambiguate } from "../branch-name"; + +describe("pickName", () => { + it("picks a random unused name from pool", () => { + const pool = ["alpha", "bravo", "charlie"]; + const used = new Set(["bravo"]); + + // Monkeypatch Math.random to return 0.5 (middle of pool after filter) + const originalRandom = Math.random; + const spy = spyOn(Math, "random").mockReturnValue(0.5); + + const result = pickName(pool, used); + + // With random 0.5 and pool ["alpha", "charlie"], should pick "charlie" (index 1) + expect(result).toBe("charlie"); + + spy.mockRestore(); + }); + + it("falls back to neutral generator when pool is exhausted", () => { + const pool = ["alpha", "bravo"]; + const used = new Set(["alpha", "bravo"]); + + const result = pickName(pool, used); + + // Should be in format "-" + expect(result).toMatch(/^[a-z]+-[a-z]+$/); + expect(result).not.toEqual("alpha"); + expect(result).not.toEqual("bravo"); + }); + + it("falls back to neutral generator when pool is undefined", () => { + const used = new Set(); + + const result = pickName(undefined, used); + + // Should be in format "-" + expect(result).toMatch(/^[a-z]+-[a-z]+$/); + }); + + it("retries generator with numeric suffix on collision", () => { + const used = new Set(); + // Pre-populate used set with all possible combinations (force collision) + // We'll just add a few key ones and monkeypatch to force collisions + + const originalRandom = Math.random; + let callCount = 0; + + // First call returns 0.0 (first adj, first noun), second returns 0.0 again, third different + const spy = spyOn(Math, "random").mockImplementation(() => { + const sequence = [0.0, 0.0, 0.001]; + const value = sequence[callCount % sequence.length]; + callCount++; + return value; + }); + + // Add the first generated name to used set manually to force collision + const result = pickName(undefined, used); + + // The result should either have a numeric suffix or be different + // For this test, we'll verify it's a valid format + expect(result).toMatch(/^[a-z]+-[a-z]+(-\d+)?$/); + + spy.mockRestore(); + }); +}); + +describe("slugifyTicketTitle", () => { + it("converts ticket ID and title to slug format", () => { + const result = slugifyTicketTitle("RT-34", "Ephemeral Worktrees: rule!", "-"); + expect(result).toBe("rt-34-ephemeral-worktrees-rule"); + }); + + it("handles non-alphanumeric characters", () => { + const result = slugifyTicketTitle("ABC-123", "Hello & World!!!!", "-"); + expect(result).toBe("abc-123-hello-world"); + }); + + it("collapses multiple dashes", () => { + const result = slugifyTicketTitle("TEST-1", "Multiple -- Dashes", "-"); + expect(result).toBe("test-1-multiple-dashes"); + }); + + it("caps slug at 40 characters", () => { + const longTitle = "This is a very long title that exceeds forty characters when slugified"; + const result = slugifyTicketTitle("ID-1", longTitle, "-"); + + const parts = result.split("-"); + // The slug part should not exceed 40 chars total from the format + expect(result.length).toBeLessThanOrEqual(7 + 40); // "id-1-" is 5 chars + hyphen buffer + }); + + it("trims whitespace from slug", () => { + const result = slugifyTicketTitle("X-1", " Title With Spaces ", "-"); + expect(result).toMatch(/^x-1-/); + expect(result).not.toMatch(/^x-1-\s/); + expect(result).not.toMatch(/\s$/); + }); +}); + +describe("disambiguate", () => { + it("returns base when exists returns false", () => { + const result = disambiguate("x", () => false); + expect(result).toBe("x"); + }); + + it("returns base-2 when base exists", () => { + const result = disambiguate("x", (c) => c === "x"); + expect(result).toBe("x-2"); + }); + + it("returns base-3 when base and base-2 exist", () => { + const result = disambiguate("x", (c) => c === "x" || c === "x-2"); + expect(result).toBe("x-3"); + }); + + it("finds first non-existing candidate", () => { + const result = disambiguate("myname", (c) => c === "myname" || c === "myname-2"); + expect(result).toBe("myname-3"); + }); +}); diff --git a/lib/worktree/branch-name.ts b/lib/worktree/branch-name.ts new file mode 100644 index 00000000..7a353a00 --- /dev/null +++ b/lib/worktree/branch-name.ts @@ -0,0 +1,56 @@ +/** + * Convert ticket ID and title to slug format per the given format string. + * Format: lowercase ticketId + "-" + title lowercased, with non-alphanumerics → "-", + * collapsed, trimmed, and slug capped at 40 chars. + * + * Example: slugifyTicketTitle("RT-34", "Ephemeral Worktrees: rule!", "-") + * → "rt-34-ephemeral-worktrees-rule" + */ +export function slugifyTicketTitle( + ticketId: string, + title: string, + format: string +): string { + const ticketLower = ticketId.toLowerCase(); + const titleLower = title.toLowerCase(); + + // Replace non-alphanumeric characters with dashes + const titleSlug = titleLower + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); // Trim dashes from start/end + + // Build the result based on format + let result = format + .replace("", ticketLower) + .replace("", titleSlug); + + // Cap the overall slug portion at 40 characters + // Find where the slug starts in the result + const slashIndex = result.indexOf("-"); + if (slashIndex !== -1) { + const prefix = result.substring(0, slashIndex + 1); // include the dash + const slug = result.substring(slashIndex + 1); + const cappedSlug = slug.substring(0, 40); + result = prefix + cappedSlug; + } + + return result; +} + +/** + * Find the first candidate (base, base-2, base-3...) where exists() returns false. + */ +export function disambiguate(base: string, exists: (candidate: string) => boolean): string { + if (!exists(base)) { + return base; + } + + let counter = 2; + while (true) { + const candidate = `${base}-${counter}`; + if (!exists(candidate)) { + return candidate; + } + counter++; + } +} diff --git a/lib/worktree/names.ts b/lib/worktree/names.ts new file mode 100644 index 00000000..6b66125c --- /dev/null +++ b/lib/worktree/names.ts @@ -0,0 +1,82 @@ +// Adjectives for neutral name generator +const ADJECTIVES = [ + "amber", + "brisk", + "calm", + "deft", + "eager", + "frank", + "grand", + "happy", + "ideal", + "joyful", + "kind", + "lively", + "merry", + "noble", + "olive", + "proud", +]; + +// Nouns for neutral name generator +const NOUNS = [ + "anvil", + "beacon", + "cedar", + "daisy", + "eagle", + "feather", + "garden", + "harbor", + "inlet", + "jungle", + "kettle", + "lantern", + "marble", + "nectar", + "oyster", + "paddle", +]; + +/** + * Pick a random unused name from pool, or generate neutral name if pool exhausted/absent. + * Neutral names are generated in format "-" with numeric suffixes on collision. + */ +export function pickName(pool: string[] | undefined, used: Set): string { + // Filter pool to unused names + if (pool && pool.length > 0) { + const available = pool.filter((name) => !used.has(name)); + if (available.length > 0) { + const randomIndex = Math.floor(Math.random() * available.length); + return available[randomIndex]; + } + } + + // Fall back to neutral generator + return generateNeutralName(used); +} + +/** + * Generate a neutral name in format "-" with retry on collision. + */ +function generateNeutralName(used: Set): string { + let suffix = ""; + let attempt = 1; + + while (true) { + const adjIndex = Math.floor(Math.random() * ADJECTIVES.length); + const nounIndex = Math.floor(Math.random() * NOUNS.length); + + const adj = ADJECTIVES[adjIndex]; + const noun = NOUNS[nounIndex]; + + const candidate = `${adj}-${noun}${suffix}`; + + if (!used.has(candidate)) { + return candidate; + } + + attempt++; + suffix = `-${attempt}`; + } +} From 1b3f45e67b6ef3ecf637e98207b0a657085476b4 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 16:17:53 -0500 Subject: [PATCH 06/31] RT-34: fix TypeScript errors (noUncheckedIndexedAccess + unused vars) Add non-null assertions to array indexing (available[randomIndex]!, ADJECTIVES[adjIndex]!, NOUNS[nounIndex]!). Fix mock implementation return type to strictly number. Use .ts extensions in imports. Remove unused originalRandom and parts variables. Co-Authored-By: Claude Fable 5 --- lib/worktree/__tests__/names.test.ts | 13 +++++-------- lib/worktree/names.ts | 6 +++--- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/lib/worktree/__tests__/names.test.ts b/lib/worktree/__tests__/names.test.ts index 5457ae00..48588c5a 100644 --- a/lib/worktree/__tests__/names.test.ts +++ b/lib/worktree/__tests__/names.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, spyOn } from "bun:test"; -import { pickName } from "../names"; -import { slugifyTicketTitle, disambiguate } from "../branch-name"; +import { pickName } from "../names.ts"; +import { slugifyTicketTitle, disambiguate } from "../branch-name.ts"; describe("pickName", () => { it("picks a random unused name from pool", () => { @@ -8,7 +8,6 @@ describe("pickName", () => { const used = new Set(["bravo"]); // Monkeypatch Math.random to return 0.5 (middle of pool after filter) - const originalRandom = Math.random; const spy = spyOn(Math, "random").mockReturnValue(0.5); const result = pickName(pool, used); @@ -45,13 +44,12 @@ describe("pickName", () => { // Pre-populate used set with all possible combinations (force collision) // We'll just add a few key ones and monkeypatch to force collisions - const originalRandom = Math.random; let callCount = 0; // First call returns 0.0 (first adj, first noun), second returns 0.0 again, third different - const spy = spyOn(Math, "random").mockImplementation(() => { - const sequence = [0.0, 0.0, 0.001]; - const value = sequence[callCount % sequence.length]; + const spy = spyOn(Math, "random").mockImplementation((): number => { + const sequence: number[] = [0.0, 0.0, 0.001]; + const value = sequence[callCount % sequence.length]!; callCount++; return value; }); @@ -87,7 +85,6 @@ describe("slugifyTicketTitle", () => { const longTitle = "This is a very long title that exceeds forty characters when slugified"; const result = slugifyTicketTitle("ID-1", longTitle, "-"); - const parts = result.split("-"); // The slug part should not exceed 40 chars total from the format expect(result.length).toBeLessThanOrEqual(7 + 40); // "id-1-" is 5 chars + hyphen buffer }); diff --git a/lib/worktree/names.ts b/lib/worktree/names.ts index 6b66125c..8b6f5b77 100644 --- a/lib/worktree/names.ts +++ b/lib/worktree/names.ts @@ -48,7 +48,7 @@ export function pickName(pool: string[] | undefined, used: Set): string const available = pool.filter((name) => !used.has(name)); if (available.length > 0) { const randomIndex = Math.floor(Math.random() * available.length); - return available[randomIndex]; + return available[randomIndex]!; } } @@ -67,8 +67,8 @@ function generateNeutralName(used: Set): string { const adjIndex = Math.floor(Math.random() * ADJECTIVES.length); const nounIndex = Math.floor(Math.random() * NOUNS.length); - const adj = ADJECTIVES[adjIndex]; - const noun = NOUNS[nounIndex]; + const adj = ADJECTIVES[adjIndex]!; + const noun = NOUNS[nounIndex]!; const candidate = `${adj}-${noun}${suffix}`; From d81b8fddf98c1f013b3b1b7c4c634dcf70a1f99d Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 16:23:47 -0500 Subject: [PATCH 07/31] RT-34: fix slug capping, collision retry, and strengthen tests 1. Cap slug BEFORE assembling (not after) to avoid ticket ID dashes eating the budget. Trim trailing dashes from hard cap. 2. Collision retry now uses same base pair with incrementing suffixes (amber-anvil, amber-anvil-2...) not fresh random pairs. 3. Test: collision-retry now forces a collision by pre-seeding used set with "amber-anvil", verifies result is "amber-anvil-2". 4. Test: 40-char cap is now exact assertion, not fuzzy length check. All 32 tests pass; TypeScript clean. Co-Authored-By: Claude Fable 5 --- lib/worktree/__tests__/names.test.ts | 29 ++++++++++++++-------------- lib/worktree/branch-name.ts | 17 +++++----------- lib/worktree/names.ts | 28 +++++++++++++++------------ 3 files changed, 35 insertions(+), 39 deletions(-) diff --git a/lib/worktree/__tests__/names.test.ts b/lib/worktree/__tests__/names.test.ts index 48588c5a..f026beeb 100644 --- a/lib/worktree/__tests__/names.test.ts +++ b/lib/worktree/__tests__/names.test.ts @@ -40,26 +40,24 @@ describe("pickName", () => { }); it("retries generator with numeric suffix on collision", () => { - const used = new Set(); - // Pre-populate used set with all possible combinations (force collision) - // We'll just add a few key ones and monkeypatch to force collisions - let callCount = 0; - // First call returns 0.0 (first adj, first noun), second returns 0.0 again, third different + // Mock Math.random to return predictable sequence + // First two calls return 0.0 (first adj, first noun) const spy = spyOn(Math, "random").mockImplementation((): number => { - const sequence: number[] = [0.0, 0.0, 0.001]; + const sequence: number[] = [0.0, 0.0]; const value = sequence[callCount % sequence.length]!; callCount++; return value; }); - // Add the first generated name to used set manually to force collision + // Based on the mock, first generation would be "amber-anvil" (indices 0, 0) + const used = new Set(["amber-anvil"]); + const result = pickName(undefined, used); - // The result should either have a numeric suffix or be different - // For this test, we'll verify it's a valid format - expect(result).toMatch(/^[a-z]+-[a-z]+(-\d+)?$/); + // Should have tried "amber-anvil", found collision, and returned "amber-anvil-2" + expect(result).toBe("amber-anvil-2"); spy.mockRestore(); }); @@ -82,11 +80,12 @@ describe("slugifyTicketTitle", () => { }); it("caps slug at 40 characters", () => { - const longTitle = "This is a very long title that exceeds forty characters when slugified"; - const result = slugifyTicketTitle("ID-1", longTitle, "-"); - - // The slug part should not exceed 40 chars total from the format - expect(result.length).toBeLessThanOrEqual(7 + 40); // "id-1-" is 5 chars + hyphen buffer + // Title that produces a slug longer than 40 chars + // "aaaaa bbbbb ccccc ddddd eeeee fffff ggggg" → slug "aaaaa-bbbbb-ccccc-ddddd-eeeee-fffff-ggggg" (41 chars) + // Capped at 40: "aaaaa-bbbbb-ccccc-ddddd-eeeee-fffff-gggg" + const result = slugifyTicketTitle("ID-1", "aaaaa bbbbb ccccc ddddd eeeee fffff ggggg", "-"); + // Exact expected: "id-1-" (5 chars) + slug capped at 40 chars + expect(result).toBe("id-1-aaaaa-bbbbb-ccccc-ddddd-eeeee-fffff-gggg"); }); it("trims whitespace from slug", () => { diff --git a/lib/worktree/branch-name.ts b/lib/worktree/branch-name.ts index 7a353a00..4b43fdfa 100644 --- a/lib/worktree/branch-name.ts +++ b/lib/worktree/branch-name.ts @@ -15,25 +15,18 @@ export function slugifyTicketTitle( const titleLower = title.toLowerCase(); // Replace non-alphanumeric characters with dashes - const titleSlug = titleLower + let titleSlug = titleLower .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); // Trim dashes from start/end + // Cap the slug portion at 40 characters BEFORE assembling + titleSlug = titleSlug.substring(0, 40).replace(/-+$/, ""); // Trim trailing dashes after cap + // Build the result based on format - let result = format + const result = format .replace("", ticketLower) .replace("", titleSlug); - // Cap the overall slug portion at 40 characters - // Find where the slug starts in the result - const slashIndex = result.indexOf("-"); - if (slashIndex !== -1) { - const prefix = result.substring(0, slashIndex + 1); // include the dash - const slug = result.substring(slashIndex + 1); - const cappedSlug = slug.substring(0, 40); - result = prefix + cappedSlug; - } - return result; } diff --git a/lib/worktree/names.ts b/lib/worktree/names.ts index 8b6f5b77..bb8ad625 100644 --- a/lib/worktree/names.ts +++ b/lib/worktree/names.ts @@ -58,25 +58,29 @@ export function pickName(pool: string[] | undefined, used: Set): string /** * Generate a neutral name in format "-" with retry on collision. + * Generates base pair once, then retries with numeric suffixes. */ function generateNeutralName(used: Set): string { - let suffix = ""; - let attempt = 1; + // Generate the base pair + const adjIndex = Math.floor(Math.random() * ADJECTIVES.length); + const nounIndex = Math.floor(Math.random() * NOUNS.length); - while (true) { - const adjIndex = Math.floor(Math.random() * ADJECTIVES.length); - const nounIndex = Math.floor(Math.random() * NOUNS.length); - - const adj = ADJECTIVES[adjIndex]!; - const noun = NOUNS[nounIndex]!; + const adj = ADJECTIVES[adjIndex]!; + const noun = NOUNS[nounIndex]!; + const baseName = `${adj}-${noun}`; - const candidate = `${adj}-${noun}${suffix}`; + // Return base if unused + if (!used.has(baseName)) { + return baseName; + } + // Retry with numeric suffixes until unused + let counter = 2; + while (true) { + const candidate = `${baseName}-${counter}`; if (!used.has(candidate)) { return candidate; } - - attempt++; - suffix = `-${attempt}`; + counter++; } } From 4111aa3b01a2621c167433febeba72a276b92a65 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 16:29:10 -0500 Subject: [PATCH 08/31] RT-34: worktree config (repo overlay defaults, install ladder, app-level compat seed) Adds lib/worktree/config.ts: - loadWorktreeRepoConfig reads only the optional "worktrees" key of ~/.rt/repos//config.json (repo-config.ts owns the rest of that file and is never written here), applying defaults for onDeck (0), root (/.worktrees), branchFormat (-), ready ([]). - resolveImplicitInstall/resolveReadySteps implement the install ladder (packageManager field, then lockfile sniff, else npm) and prepend the implicit install unless a declared step's run already starts with the detected manager's name. - loadWorktreeAppConfig owns ~/.rt/worktrees.json {enabled, killProcesses}, seeding it once from the legacy ~/.rt/parking-lot.json (same raw?.field !== false defaulting as parking-lot-config.ts) when the new file is absent and the old one exists. Co-Authored-By: Claude Fable 5 --- lib/worktree/__tests__/config.test.ts | 207 ++++++++++++++++++++++++++ lib/worktree/config.ts | 147 ++++++++++++++++++ 2 files changed, 354 insertions(+) create mode 100644 lib/worktree/__tests__/config.test.ts create mode 100644 lib/worktree/config.ts diff --git a/lib/worktree/__tests__/config.test.ts b/lib/worktree/__tests__/config.test.ts new file mode 100644 index 00000000..13fe7069 --- /dev/null +++ b/lib/worktree/__tests__/config.test.ts @@ -0,0 +1,207 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { mkdtempSync, realpathSync, writeFileSync, mkdirSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { writeJson } from "../../json-store.ts"; +import { repoDataDir, rtDir } from "../../rt-paths.ts"; +import { + loadWorktreeRepoConfig, + resolveImplicitInstall, + resolveReadySteps, + loadWorktreeAppConfig, + type WorktreeRepoConfig, +} from "../config.ts"; + +function tmpRepoPath(prefix: string): string { + return realpathSync(mkdtempSync(join(tmpdir(), prefix))); +} + +describe("worktree config", () => { + beforeEach(() => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtcfg-home-"))); + }); + + describe("loadWorktreeRepoConfig", () => { + test("defaults when config.json is missing", () => { + const repoPath = tmpRepoPath("rtcfg-repo-"); + const cfg = loadWorktreeRepoConfig("myrepo", repoPath); + expect(cfg).toEqual({ + onDeck: 0, + root: join(repoPath, ".worktrees"), + branchFormat: "-", + ready: [], + }); + }); + + test("defaults when config.json exists but has no 'worktrees' key", () => { + const repoPath = tmpRepoPath("rtcfg-repo-"); + writeJson(join(repoDataDir("myrepo"), "config.json"), { + setup: [], + clean: [], + startScript: "start", + open: { base: "" }, + }); + const cfg = loadWorktreeRepoConfig("myrepo", repoPath); + expect(cfg).toEqual({ + onDeck: 0, + root: join(repoPath, ".worktrees"), + branchFormat: "-", + ready: [], + }); + }); + + test("declared block round-trips", () => { + const repoPath = tmpRepoPath("rtcfg-repo-"); + const declared = { + onDeck: 2, + namePool: ["hogwarts", "bellatrix"], + root: "~/Documents/GitHub/assured", + branchFormat: "", + ready: [ + { run: "pnpm genTypes", when: "changed:db/schema/**" }, + ], + }; + writeJson(join(repoDataDir("myrepo"), "config.json"), { + setup: [{ label: "x", command: "y" }], + worktrees: declared, + }); + const cfg = loadWorktreeRepoConfig("myrepo", repoPath); + expect(cfg).toEqual(declared); + }); + }); + + describe("resolveImplicitInstall", () => { + test("no package.json -> null", () => { + const repoPath = tmpRepoPath("rtcfg-noinstall-"); + expect(resolveImplicitInstall(repoPath)).toBeNull(); + }); + + test("packageManager field prefix -> matching manager step", () => { + const repoPath = tmpRepoPath("rtcfg-pm-"); + writeFileSync( + join(repoPath, "package.json"), + JSON.stringify({ name: "x", packageManager: "pnpm@9.1.0" }) + ); + expect(resolveImplicitInstall(repoPath)).toEqual({ + run: "pnpm install --side-effects-cache", + when: "changed:pnpm-lock.yaml", + }); + }); + + test("lockfile sniff: only bun.lockb present -> bun step", () => { + const repoPath = tmpRepoPath("rtcfg-bunlock-"); + writeFileSync(join(repoPath, "package.json"), JSON.stringify({ name: "x" })); + writeFileSync(join(repoPath, "bun.lockb"), ""); + expect(resolveImplicitInstall(repoPath)).toEqual({ + run: "bun install", + when: "changed:bun.lock*", + }); + }); + + test("lockfile sniff: bun.lock (text lockfile) -> bun step", () => { + const repoPath = tmpRepoPath("rtcfg-bunlock2-"); + writeFileSync(join(repoPath, "package.json"), JSON.stringify({ name: "x" })); + writeFileSync(join(repoPath, "bun.lock"), ""); + expect(resolveImplicitInstall(repoPath)).toEqual({ + run: "bun install", + when: "changed:bun.lock*", + }); + }); + + test("lockfile sniff: pnpm-lock.yaml -> pnpm step", () => { + const repoPath = tmpRepoPath("rtcfg-pnpmlock-"); + writeFileSync(join(repoPath, "package.json"), JSON.stringify({ name: "x" })); + writeFileSync(join(repoPath, "pnpm-lock.yaml"), ""); + expect(resolveImplicitInstall(repoPath)).toEqual({ + run: "pnpm install --side-effects-cache", + when: "changed:pnpm-lock.yaml", + }); + }); + + test("lockfile sniff: yarn.lock -> yarn step", () => { + const repoPath = tmpRepoPath("rtcfg-yarnlock-"); + writeFileSync(join(repoPath, "package.json"), JSON.stringify({ name: "x" })); + writeFileSync(join(repoPath, "yarn.lock"), ""); + expect(resolveImplicitInstall(repoPath)).toEqual({ + run: "yarn install", + when: "changed:yarn.lock", + }); + }); + + test("package.json alone (no packageManager, no lockfile) -> npm step", () => { + const repoPath = tmpRepoPath("rtcfg-npm-"); + writeFileSync(join(repoPath, "package.json"), JSON.stringify({ name: "x" })); + expect(resolveImplicitInstall(repoPath)).toEqual({ + run: "npm install", + when: "changed:package-lock.json", + }); + }); + }); + + describe("resolveReadySteps", () => { + test("prepends implicit install when not declared", () => { + const repoPath = tmpRepoPath("rtcfg-resolve1-"); + writeFileSync(join(repoPath, "package.json"), JSON.stringify({ name: "x" })); + writeFileSync(join(repoPath, "pnpm-lock.yaml"), ""); + const cfg: WorktreeRepoConfig = { + onDeck: 0, + root: join(repoPath, ".worktrees"), + branchFormat: "-", + ready: [{ run: "node scripts/gen-types.js", when: "changed:db/schema/**" }], + }; + expect(resolveReadySteps(cfg, repoPath)).toEqual([ + { run: "pnpm install --side-effects-cache", when: "changed:pnpm-lock.yaml" }, + { run: "node scripts/gen-types.js", when: "changed:db/schema/**" }, + ]); + }); + + test("does not double the implicit install when config declares its own pnpm line", () => { + const repoPath = tmpRepoPath("rtcfg-resolve2-"); + writeFileSync(join(repoPath, "package.json"), JSON.stringify({ name: "x" })); + writeFileSync(join(repoPath, "pnpm-lock.yaml"), ""); + const cfg: WorktreeRepoConfig = { + onDeck: 0, + root: join(repoPath, ".worktrees"), + branchFormat: "-", + ready: [ + { run: "pnpm install --side-effects-cache", when: "changed:pnpm-lock.yaml" }, + { run: "pnpm genTypes", when: "changed:db/schema/**" }, + ], + }; + expect(resolveReadySteps(cfg, repoPath)).toEqual(cfg.ready); + }); + + test("no implicit install (no package.json) -> just declared steps", () => { + const repoPath = tmpRepoPath("rtcfg-resolve3-"); + const cfg: WorktreeRepoConfig = { + onDeck: 0, + root: join(repoPath, ".worktrees"), + branchFormat: "-", + ready: [{ run: "echo hi" }], + }; + expect(resolveReadySteps(cfg, repoPath)).toEqual(cfg.ready); + }); + }); + + describe("loadWorktreeAppConfig", () => { + test("defaults when neither worktrees.json nor parking-lot.json exists", () => { + expect(loadWorktreeAppConfig()).toEqual({ enabled: true, killProcesses: true }); + }); + + test("seeds from parking-lot.json once, then reads the new file thereafter", () => { + mkdirSync(rtDir(), { recursive: true }); + writeFileSync( + join(rtDir(), "parking-lot.json"), + JSON.stringify({ enabled: false }) + ); + + const first = loadWorktreeAppConfig(); + expect(first).toEqual({ enabled: false, killProcesses: true }); + + // Prove it's now reading the new file, not re-seeding from the legacy one. + writeJson(join(rtDir(), "worktrees.json"), { enabled: true, killProcesses: false }); + const second = loadWorktreeAppConfig(); + expect(second).toEqual({ enabled: true, killProcesses: false }); + }); + }); +}); diff --git a/lib/worktree/config.ts b/lib/worktree/config.ts new file mode 100644 index 00000000..a7954cd0 --- /dev/null +++ b/lib/worktree/config.ts @@ -0,0 +1,147 @@ +/** + * Worktree config: repo overlay + app-level, with a one-time compat seed. + * + * Two files, two owners: + * - `~/.rt/repos//config.json` — repo-config.ts owns most of this file + * (setup/clean/startScript/open). This module reads the SAME file but only + * ever looks at its optional "worktrees" key, and never writes it. + * - `~/.rt/worktrees.json` — owned entirely by this module. `{enabled, + * killProcesses}`, seeded once from the legacy `~/.rt/parking-lot.json` + * (section 11.1 retires the old file after the seed) when the new file is + * absent and the old one exists. Both default to true, matching + * parking-lot-config.ts's legacy `raw?.enabled !== false` semantics. + */ + +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; +import { readJson, writeJson } from "../json-store.ts"; +import { repoDataDir, rtDir } from "../rt-paths.ts"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface ReadyStep { + run: string; + when?: string; +} + +export interface WorktreeRepoConfig { + onDeck: number; // default 0 + namePool?: string[]; + root: string; // default join(repoPath, ".worktrees") + branchFormat: string; // default "-" + ready: ReadyStep[]; // declared domain steps ONLY (implicit install prepended at resolve time) +} + +export interface WorktreeAppConfig { + enabled: boolean; + killProcesses: boolean; +} + +// ─── Repo overlay ──────────────────────────────────────────────────────────── + +interface RawRepoConfigFile { + worktrees?: Partial; +} + +/** + * Reads the "worktrees" key of ~/.rt/repos//config.json. repo-config.ts + * owns every other key in that file; this never writes it. + */ +export function loadWorktreeRepoConfig(repoName: string, repoPath: string): WorktreeRepoConfig { + const path = join(repoDataDir(repoName), "config.json"); + const raw = readJson(path, {}); + const declared = raw.worktrees ?? {}; + + const cfg: WorktreeRepoConfig = { + onDeck: declared.onDeck ?? 0, + root: declared.root ?? join(repoPath, ".worktrees"), + branchFormat: declared.branchFormat ?? "-", + ready: declared.ready ?? [], + }; + if (declared.namePool) cfg.namePool = declared.namePool; + return cfg; +} + +// ─── Implicit install ladder ───────────────────────────────────────────────── + +type Manager = "pnpm" | "bun" | "yarn" | "npm"; + +const MANAGER_STEP: Record = { + pnpm: { run: "pnpm install --side-effects-cache", when: "changed:pnpm-lock.yaml" }, + bun: { run: "bun install", when: "changed:bun.lock*" }, + yarn: { run: "yarn install", when: "changed:yarn.lock" }, + npm: { run: "npm install", when: "changed:package-lock.json" }, +}; + +function detectManager(repoPath: string): Manager | null { + const packageJsonPath = join(repoPath, "package.json"); + if (!existsSync(packageJsonPath)) return null; + + try { + const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8")); + const declared = typeof pkg?.packageManager === "string" ? pkg.packageManager : undefined; + if (declared) { + const prefix = declared.split("@")[0]; + if (prefix === "pnpm" || prefix === "bun" || prefix === "yarn" || prefix === "npm") { + return prefix; + } + } + } catch { + // malformed package.json falls through to lockfile sniffing + } + + if (existsSync(join(repoPath, "pnpm-lock.yaml"))) return "pnpm"; + if (existsSync(join(repoPath, "bun.lock")) || existsSync(join(repoPath, "bun.lockb"))) return "bun"; + if (existsSync(join(repoPath, "yarn.lock"))) return "yarn"; + if (existsSync(join(repoPath, "package-lock.json"))) return "npm"; + return "npm"; +} + +/** + * ladder (spec §5): no package.json → null; packageManager field prefix + * pnpm|bun|yarn|npm → step; else lockfile sniff pnpm-lock.yaml→pnpm, + * bun.lock|bun.lockb→bun, yarn.lock→yarn, package-lock.json→npm; else npm. + */ +export function resolveImplicitInstall(repoPath: string): ReadyStep | null { + const manager = detectManager(repoPath); + return manager ? MANAGER_STEP[manager] : null; +} + +/** + * Implicit install first UNLESS cfg.ready already contains a step whose run + * starts with the detected manager name; then cfg.ready in order. + */ +export function resolveReadySteps(cfg: WorktreeRepoConfig, repoPath: string): ReadyStep[] { + const manager = detectManager(repoPath); + if (!manager) return cfg.ready; + + const alreadyDeclared = cfg.ready.some((step) => step.run.startsWith(manager)); + if (alreadyDeclared) return cfg.ready; + + return [MANAGER_STEP[manager], ...cfg.ready]; +} + +// ─── App-level config ──────────────────────────────────────────────────────── + +const APP_CONFIG_DEFAULTS: WorktreeAppConfig = { enabled: true, killProcesses: true }; + +/** + * ~/.rt/worktrees.json; if absent AND ~/.rt/parking-lot.json exists, seed from + * it once (write the new file), then read the new file. Defaults + * { enabled: true, killProcesses: true }. + */ +export function loadWorktreeAppConfig(): WorktreeAppConfig { + const path = join(rtDir(), "worktrees.json"); + const legacyPath = join(rtDir(), "parking-lot.json"); + + if (!existsSync(path) && existsSync(legacyPath)) { + const legacy = readJson<{ enabled?: boolean; killProcesses?: boolean }>(legacyPath, {}); + const seeded: WorktreeAppConfig = { + enabled: legacy.enabled !== false, + killProcesses: legacy.killProcesses !== false, + }; + writeJson(path, seeded); + } + + return readJson(path, APP_CONFIG_DEFAULTS); +} From c94deee12d14c4a73c74b92c1bd6f8512739094d Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 16:30:26 -0500 Subject: [PATCH 09/31] RT-34: tighten resolveReadySteps dedup to install-step match only Ruling: the spec is binding over the plan's literal "starts with manager name" wording. Only a declared INSTALL step (run starts with " install", e.g. "pnpm install --side-effects-cache") suppresses the implicit install prepend; any other declared command for that manager (e.g. "pnpm lint") no longer suppresses it. Adds a regression test for that case. Co-Authored-By: Claude Fable 5 --- lib/worktree/__tests__/config.test.ts | 16 ++++++++++++++++ lib/worktree/config.ts | 10 +++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/lib/worktree/__tests__/config.test.ts b/lib/worktree/__tests__/config.test.ts index 13fe7069..76c4ae96 100644 --- a/lib/worktree/__tests__/config.test.ts +++ b/lib/worktree/__tests__/config.test.ts @@ -171,6 +171,22 @@ describe("worktree config", () => { expect(resolveReadySteps(cfg, repoPath)).toEqual(cfg.ready); }); + test("a declared non-install step for the manager does not suppress the implicit install", () => { + const repoPath = tmpRepoPath("rtcfg-resolve4-"); + writeFileSync(join(repoPath, "package.json"), JSON.stringify({ name: "x" })); + writeFileSync(join(repoPath, "pnpm-lock.yaml"), ""); + const cfg: WorktreeRepoConfig = { + onDeck: 0, + root: join(repoPath, ".worktrees"), + branchFormat: "-", + ready: [{ run: "pnpm lint" }], + }; + expect(resolveReadySteps(cfg, repoPath)).toEqual([ + { run: "pnpm install --side-effects-cache", when: "changed:pnpm-lock.yaml" }, + { run: "pnpm lint" }, + ]); + }); + test("no implicit install (no package.json) -> just declared steps", () => { const repoPath = tmpRepoPath("rtcfg-resolve3-"); const cfg: WorktreeRepoConfig = { diff --git a/lib/worktree/config.ts b/lib/worktree/config.ts index a7954cd0..dc0b939e 100644 --- a/lib/worktree/config.ts +++ b/lib/worktree/config.ts @@ -108,14 +108,18 @@ export function resolveImplicitInstall(repoPath: string): ReadyStep | null { } /** - * Implicit install first UNLESS cfg.ready already contains a step whose run - * starts with the detected manager name; then cfg.ready in order. + * Implicit install first UNLESS cfg.ready already declares its own install + * step for the detected manager (run starts with " install", e.g. + * "pnpm install --side-effects-cache") — only an install step replaces the + * implicit one; any other declared command for that manager (e.g. "pnpm + * lint") does not suppress it. Otherwise cfg.ready in order. */ export function resolveReadySteps(cfg: WorktreeRepoConfig, repoPath: string): ReadyStep[] { const manager = detectManager(repoPath); if (!manager) return cfg.ready; - const alreadyDeclared = cfg.ready.some((step) => step.run.startsWith(manager)); + const installPrefix = `${manager} install`; + const alreadyDeclared = cfg.ready.some((step) => step.run.startsWith(installPrefix)); if (alreadyDeclared) return cfg.ready; return [MANAGER_STEP[manager], ...cfg.ready]; From ddfbd3578f29983cff409eab7c19c87d0bedda75 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 16:34:43 -0500 Subject: [PATCH 10/31] RT-34: ready-step engine (changed-glob triggers, long-timeout runner) changedSince diffs a ready stamp against HEAD, returning null when the stamp is unknown to git so callers treat it as "everything changed". stepsToRun filters ReadyStep[] by changed: triggers via Bun.Glob, skipping no-when steps unless changed is null. runReadySteps executes steps in order through zsh with a 15-minute timeout, stopping at the first failure and reporting stdout+stderr combined (including stderr-only failures). Co-Authored-By: Claude Fable 5 --- lib/worktree/__tests__/ready.test.ts | 118 +++++++++++++++++++++++++++ lib/worktree/ready.ts | 62 ++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 lib/worktree/__tests__/ready.test.ts create mode 100644 lib/worktree/ready.ts diff --git a/lib/worktree/__tests__/ready.test.ts b/lib/worktree/__tests__/ready.test.ts new file mode 100644 index 00000000..f3642eb7 --- /dev/null +++ b/lib/worktree/__tests__/ready.test.ts @@ -0,0 +1,118 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { execSync } from "child_process"; +import { mkdtempSync, realpathSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import type { ReadyStep } from "../config.ts"; +import { changedSince, stepsToRun, runReadySteps } from "../ready.ts"; + +function makeRepo(): string { + // realpathSync: git canonicalizes /var → /private/var on macOS (Global Constraints) + const dir = realpathSync(mkdtempSync(join(tmpdir(), "rtready-"))); + execSync("git init -b main && git -c user.email=t@t -c user.name=t commit --allow-empty -m init", { + cwd: dir, + shell: "/bin/zsh", + }); + return dir; +} + +describe("changedSince", () => { + let repo: string; + beforeEach(() => { + repo = makeRepo(); + }); + + test("returns touched files between two commits", async () => { + const stamp = execSync("git rev-parse HEAD", { cwd: repo, shell: "/bin/zsh" }).toString().trim(); + execSync("mkdir -p db/schema", { cwd: repo, shell: "/bin/zsh" }); + writeFileSync(join(repo, "db", "schema", "x.sql"), "select 1;\n"); + execSync("git add -A && git -c user.email=t@t -c user.name=t commit -m add-schema", { + cwd: repo, + shell: "/bin/zsh", + }); + + const changed = await changedSince(repo, stamp); + expect(changed).not.toBeNull(); + expect(changed).toContain("db/schema/x.sql"); + }); + + test("returns null when the stamp is unknown to git", async () => { + const changed = await changedSince(repo, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"); + expect(changed).toBeNull(); + }); +}); + +describe("stepsToRun", () => { + const installStep: ReadyStep = { run: "bun install", when: "changed:bun.lock*" }; + const schemaStep: ReadyStep = { run: "db push", when: "changed:db/schema/**" }; + const noWhenStep: ReadyStep = { run: "echo hi" }; + const steps: ReadyStep[] = [installStep, schemaStep, noWhenStep]; + + test("changed === null runs every step, including no-when steps", () => { + expect(stepsToRun(steps, null)).toEqual(steps); + }); + + test("glob matching fires on a matching changed path", () => { + const result = stepsToRun(steps, ["db/schema/x.sql"]); + expect(result).toEqual([schemaStep]); + }); + + test("glob matching does not fire on a non-matching changed path", () => { + const result = stepsToRun(steps, ["README.md"]); + expect(result).toEqual([]); + }); + + test("no-when step is skipped when changed is a concrete list", () => { + const result = stepsToRun([noWhenStep], ["README.md"]); + expect(result).toEqual([]); + }); + + test("no-when step is included when changed === null", () => { + const result = stepsToRun([noWhenStep], null); + expect(result).toEqual([noWhenStep]); + }); +}); + +describe("runReadySteps", () => { + let repo: string; + beforeEach(() => { + repo = makeRepo(); + }); + + test("all steps succeed", async () => { + const result = await runReadySteps(repo, [{ run: "true" }, { run: "true" }]); + expect(result).toEqual({ ok: true }); + }); + + test("executes in order and stops at the first failure", async () => { + const marker = join(repo, "marker"); + const result = await runReadySteps(repo, [ + { run: `echo one > ${marker}` }, + { run: "false" }, + { run: `echo two >> ${marker}` }, + ]); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failedStep).toBe("false"); + } + const content = execSync(`cat ${marker}`, { shell: "/bin/zsh" }).toString(); + expect(content).toBe("one\n"); + }); + + test("failing step surfaces stdout+stderr in output", async () => { + const result = await runReadySteps(repo, [{ run: "echo out-line; echo err-line >&2; exit 1" }]); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.output).toContain("out-line"); + expect(result.output).toContain("err-line"); + } + }); + + test("a failing step that writes only to stderr still surfaces its output", async () => { + const result = await runReadySteps(repo, [{ run: "echo boom >&2; exit 1" }]); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.output).toContain("boom"); + } + }); +}); diff --git a/lib/worktree/ready.ts b/lib/worktree/ready.ts new file mode 100644 index 00000000..fde3811e --- /dev/null +++ b/lib/worktree/ready.ts @@ -0,0 +1,62 @@ +/** + * Ready-step engine: which steps a worktree freshen needs to run, and + * running them. + * + * "changed" steps only fire when their glob matches a path that actually + * changed since the worktree's last ready stamp; a null changed set (stamp + * unknown to git, e.g. cold create) means run everything. + */ + +import { runGit } from "./git-async.ts"; +import { runCapture } from "../subprocess.ts"; +import type { ReadyStep } from "./config.ts"; + +/** + * Paths changed between readyStamp and HEAD, or null when readyStamp is + * unknown to git (treat as "everything changed"). + */ +export async function changedSince(worktreePath: string, readyStamp: string): Promise { + const r = await runGit(worktreePath, ["diff", "--name-only", `${readyStamp}..HEAD`]); + if (r.exitCode !== 0) return null; + return r.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +const CHANGED_WHEN_RE = /^changed:(.+)$/; + +/** + * changed === null → every step (cold create / unknown stamp). Otherwise: + * steps whose `when` is "changed:" and the glob matches some changed + * path; steps with no `when` are skipped. + */ +export function stepsToRun(steps: ReadyStep[], changed: string[] | null): ReadyStep[] { + if (changed === null) return steps; + + return steps.filter((step) => { + if (!step.when) return false; + const match = CHANGED_WHEN_RE.exec(step.when); + if (!match) return false; + const glob = new Bun.Glob(match[1]!); + return changed.some((path) => glob.match(path)); + }); +} + +/** Run ready steps in order via zsh, stopping at the first failure. */ +export async function runReadySteps( + worktreePath: string, + steps: ReadyStep[], +): Promise<{ ok: true } | { ok: false; failedStep: string; output: string }> { + for (const step of steps) { + const r = await runCapture(["/bin/zsh", "-lc", step.run], { + cwd: worktreePath, + timeoutMs: 15 * 60_000, + stderr: "pipe", + }); + if (r.exitCode !== 0) { + return { ok: false, failedStep: step.run, output: r.stdout + r.stderr }; + } + } + return { ok: true }; +} From c0d77184f878a50a81be8d4afe906c5bbc52ec9d Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 16:37:29 -0500 Subject: [PATCH 11/31] RT-34: in-memory per-tree operation locks Implement tryLockTree, isTreeLocked, and withTreeLock functions for managing per-tree operation locks. Single daemon process uses in-memory Map keyed by tree path. Includes comprehensive tests for lock acquisition, release, concurrent access, and error handling. All tests pass; TypeScript clean. Co-Authored-By: Claude Fable 5 --- lib/worktree/__tests__/locks.test.ts | 118 +++++++++++++++++++++++++++ lib/worktree/locks.ts | 32 ++++++++ 2 files changed, 150 insertions(+) create mode 100644 lib/worktree/__tests__/locks.test.ts create mode 100644 lib/worktree/locks.ts diff --git a/lib/worktree/__tests__/locks.test.ts b/lib/worktree/__tests__/locks.test.ts new file mode 100644 index 00000000..e22eb5d3 --- /dev/null +++ b/lib/worktree/__tests__/locks.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from "bun:test"; +import { tryLockTree, isTreeLocked, withTreeLock } from "../locks"; + +describe("tryLockTree", () => { + it("acquires lock and returns release function on first call", () => { + const path = "/test/tree/1"; + const release = tryLockTree(path); + expect(release).not.toBeNull(); + expect(typeof release).toBe("function"); + release?.(); + }); + + it("returns null when trying to acquire already-held lock", () => { + const path = "/test/tree/2"; + const release1 = tryLockTree(path); + expect(release1).not.toBeNull(); + + const release2 = tryLockTree(path); + expect(release2).toBeNull(); + + release1?.(); + }); + + it("allows re-acquiring lock after release", () => { + const path = "/test/tree/3"; + const release1 = tryLockTree(path); + expect(release1).not.toBeNull(); + release1?.(); + + const release2 = tryLockTree(path); + expect(release2).not.toBeNull(); + release2?.(); + }); +}); + +describe("isTreeLocked", () => { + it("returns false when tree is not locked", () => { + const path = "/test/tree/4"; + expect(isTreeLocked(path)).toBe(false); + }); + + it("returns true when tree is locked", () => { + const path = "/test/tree/5"; + const release = tryLockTree(path); + expect(isTreeLocked(path)).toBe(true); + release?.(); + }); + + it("returns false after lock is released", () => { + const path = "/test/tree/6"; + const release = tryLockTree(path); + release?.(); + expect(isTreeLocked(path)).toBe(false); + }); +}); + +describe("withTreeLock", () => { + it("executes function when lock is acquired", async () => { + const path = "/test/tree/7"; + let executed = false; + const result = await withTreeLock(path, async () => { + executed = true; + return "success"; + }); + expect(executed).toBe(true); + expect(result).toBe("success"); + }); + + it("returns 'busy' when lock is already held", async () => { + const path = "/test/tree/8"; + const release = tryLockTree(path); + expect(release).not.toBeNull(); + + const result = await withTreeLock(path, async () => { + return "should not execute"; + }); + expect(result).toBe("busy"); + + release?.(); + }); + + it("releases lock after function resolves", async () => { + const path = "/test/tree/9"; + await withTreeLock(path, async () => { + expect(isTreeLocked(path)).toBe(true); + return "done"; + }); + expect(isTreeLocked(path)).toBe(false); + }); + + it("releases lock when function throws", async () => { + const path = "/test/tree/10"; + try { + await withTreeLock(path, async () => { + expect(isTreeLocked(path)).toBe(true); + throw new Error("test error"); + }); + } catch { + // Expected to throw + } + expect(isTreeLocked(path)).toBe(false); + }); + + it("does not acquire lock when returning 'busy'", async () => { + const path = "/test/tree/11"; + const release1 = tryLockTree(path); + expect(release1).not.toBeNull(); + + const result = await withTreeLock(path, async () => { + return "should not execute"; + }); + expect(result).toBe("busy"); + expect(isTreeLocked(path)).toBe(true); + + release1?.(); + expect(isTreeLocked(path)).toBe(false); + }); +}); diff --git a/lib/worktree/locks.ts b/lib/worktree/locks.ts new file mode 100644 index 00000000..c40839ea --- /dev/null +++ b/lib/worktree/locks.ts @@ -0,0 +1,32 @@ +const locks = new Map(); + +export function tryLockTree(path: string): (() => void) | null { + if (locks.has(path)) { + return null; + } + locks.set(path, true); + return () => { + locks.delete(path); + }; +} + +export function isTreeLocked(path: string): boolean { + return locks.has(path); +} + +export async function withTreeLock( + path: string, + fn: () => Promise +): Promise { + const release = tryLockTree(path); + if (release === null) { + return "busy"; + } + + try { + const result = await fn(); + return result; + } finally { + release(); + } +} From 46d3fb6af0ad4819095884e0f6bb8a9e84ec11fa Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 16:40:08 -0500 Subject: [PATCH 12/31] RT-34: fix stale-release vulnerability with symbol-based ownership tokens Replace Map with Map to prevent stale releases from stealing a new holder's lock. Each acquisition gets a unique symbol token; the release closure only deletes when the token still matches. Prevents race condition where A acquires, releases, B acquires, then A's stale release (from catch+finally or retry logic) could silently delete B's live lock. Add regression test: A acquires/releases, B acquires, A stale-releases again; assert B's lock survives and B's release still works. All 12 tests pass; TypeScript clean. Co-Authored-By: Claude Fable 5 --- lib/worktree/__tests__/locks.test.ts | 26 ++++++++++++++++++++++++++ lib/worktree/locks.ts | 10 +++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/lib/worktree/__tests__/locks.test.ts b/lib/worktree/__tests__/locks.test.ts index e22eb5d3..d2a9ba5d 100644 --- a/lib/worktree/__tests__/locks.test.ts +++ b/lib/worktree/__tests__/locks.test.ts @@ -115,4 +115,30 @@ describe("withTreeLock", () => { release1?.(); expect(isTreeLocked(path)).toBe(false); }); + + it("regression: stale release does not steal new holder's lock", () => { + // A acquires lock + const path = "/test/tree/12"; + const releaseA = tryLockTree(path); + expect(releaseA).not.toBeNull(); + expect(isTreeLocked(path)).toBe(true); + + // A releases + releaseA?.(); + expect(isTreeLocked(path)).toBe(false); + + // B acquires the same lock + const releaseB = tryLockTree(path); + expect(releaseB).not.toBeNull(); + expect(isTreeLocked(path)).toBe(true); + + // A's stale release fires again (e.g., from catch+finally or retry logic) + releaseA?.(); + // B's lock should still be held (not stolen by A's stale release) + expect(isTreeLocked(path)).toBe(true); + + // B's release should still work + releaseB?.(); + expect(isTreeLocked(path)).toBe(false); + }); }); diff --git a/lib/worktree/locks.ts b/lib/worktree/locks.ts index c40839ea..b0ef09c7 100644 --- a/lib/worktree/locks.ts +++ b/lib/worktree/locks.ts @@ -1,12 +1,16 @@ -const locks = new Map(); +const locks = new Map(); export function tryLockTree(path: string): (() => void) | null { if (locks.has(path)) { return null; } - locks.set(path, true); + const token = Symbol("lock"); + locks.set(path, token); return () => { - locks.delete(path); + // Only delete if the token still matches (ownership check prevents stale releases) + if (locks.get(path) === token) { + locks.delete(path); + } }; } From 3f0e91db53225f15e89054dc5836ab703cb9f4b1 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 16:46:14 -0500 Subject: [PATCH 13/31] RT-34: cold create (registry-first creating state, scrap-on-failure) createTree picks a name, writes a "creating" registry row before any git mutation, fetches + adds the on-deck/ worktree off the remote default ref, runs ready steps, reconciles doppler in-process, then flips the row to on-deck with a readyStamp. Any failure after the registry write scraps the worktree, its branch, and the registry row, returning a typed create-failed result with failedStep/output. scrapTree is tolerant of partial existence. Co-Authored-By: Claude Fable 5 --- lib/worktree/__tests__/create.test.ts | 117 ++++++++++++++++++++ lib/worktree/create.ts | 152 ++++++++++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 lib/worktree/__tests__/create.test.ts create mode 100644 lib/worktree/create.ts diff --git a/lib/worktree/__tests__/create.test.ts b/lib/worktree/__tests__/create.test.ts new file mode 100644 index 00000000..f70d27db --- /dev/null +++ b/lib/worktree/__tests__/create.test.ts @@ -0,0 +1,117 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { execSync } from "child_process"; +import { existsSync, mkdtempSync, readFileSync, realpathSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { writeJson } from "../../json-store.ts"; +import { repoDataDir } from "../../rt-paths.ts"; +import { loadRegistry } from "../registry.ts"; +import { branchExistsLocalAsync, listWorktreesAsync } from "../git-async.ts"; +import { createTree, type CreateDeps } from "../create.ts"; + +function makeRepo(): string { + // realpathSync: git canonicalizes /var -> /private/var on macOS (Global Constraints) + const dir = realpathSync(mkdtempSync(join(tmpdir(), "rtcreate-"))); + execSync( + "git init -b main && git -c user.email=t@t -c user.name=t commit --allow-empty -m init", + { cwd: dir, shell: "/bin/zsh" } + ); + return dir; +} + +/** Bare-clone `repo` as its own "origin" and fetch, so remoteDefaultRef resolves origin/main. */ +function addBareOrigin(repo: string): void { + const bare = mkdtempSync(join(tmpdir(), "rtcreate-bare-")); + execSync( + `git clone --bare ${repo} ${bare}/o.git && git -C ${repo} remote add origin ${bare}/o.git && git -C ${repo} fetch origin`, + { shell: "/bin/zsh" } + ); +} + +function makeDeps(repoName: string, repoPath: string, events: Array<{ type: string; data: unknown }>): CreateDeps { + return { + repoName, + repoPath, + emit: (type, data) => events.push({ type, data }), + log: { info: () => {}, warn: () => {} }, + }; +} + +describe("createTree", () => { + let repo: string; + let repoName: string; + let events: Array<{ type: string; data: unknown }>; + + beforeEach(() => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtcreate-home-"))); + repo = makeRepo(); + addBareOrigin(repo); + repoName = "acme"; + events = []; + }); + + test("happy path: registry ends with one on-deck record on origin default sha", async () => { + const expectedSha = execSync("git rev-parse HEAD", { cwd: repo, encoding: "utf8" }).trim(); + + const deps = makeDeps(repoName, repo, events); + const result = await createTree(deps); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.tree.state).toBe("on-deck"); + expect(result.tree.branch).toBe(`on-deck/${result.tree.name}`); + expect(result.tree.readyStamp).toBe(expectedSha); + + const registry = loadRegistry(repoName); + expect(registry.length).toBe(1); + expect(registry[0]!.state).toBe("on-deck"); + expect(registry[0]!.name).toBe(result.tree.name); + + const worktrees = await listWorktreesAsync(repo); + const entry = worktrees.find((w) => w.path === result.tree.path); + expect(entry).toBeDefined(); + expect(entry!.branch).toBe(`on-deck/${result.tree.name}`); + + expect(events.some((e) => e.type === "worktree:created")).toBe(true); + }); + + test("in-repo default root writes .git/info/exclude", async () => { + const deps = makeDeps(repoName, repo, events); + const result = await createTree(deps); + + expect(result.ok).toBe(true); + const excludePath = join(repo, ".git", "info", "exclude"); + expect(existsSync(excludePath)).toBe(true); + const content = readFileSync(excludePath, "utf8"); + expect(content).toContain(".worktrees/"); + }); + + test("failing ready step scraps the worktree, branch, and registry entry", async () => { + writeJson(join(repoDataDir(repoName), "config.json"), { + worktrees: { + namePool: ["failtree"], + ready: [{ run: "exit 1" }], + }, + }); + + const deps = makeDeps(repoName, repo, events); + const result = await createTree(deps); + + expect(result.ok).toBe(false); + if (result.ok || result.error !== "create-failed") throw new Error("expected create-failed"); + expect(result.error).toBe("create-failed"); + expect(result.failedStep).toBe("exit 1"); + + const path = join(repo, ".worktrees", "failtree"); + expect(existsSync(path)).toBe(false); + + const worktrees = await listWorktreesAsync(repo); + expect(worktrees.some((w) => w.path === path)).toBe(false); + + expect(await branchExistsLocalAsync(repo, "on-deck/failtree")).toBe(false); + + const registry = loadRegistry(repoName); + expect(registry.length).toBe(0); + }); +}); diff --git a/lib/worktree/create.ts b/lib/worktree/create.ts new file mode 100644 index 00000000..b893de03 --- /dev/null +++ b/lib/worktree/create.ts @@ -0,0 +1,152 @@ +/** + * Cold create: allocate a fresh ephemeral worktree from scratch. + * + * Registry-first ordering is load-bearing: the "creating" entry is written + * BEFORE `git worktree add` runs, and the tree lock is taken before that + * write. That way a crash mid-create always leaves a registry row a sweep + * can find and reconcile, never an orphaned worktree the registry doesn't + * know about. Any failure after the registry write scraps everything + * (worktree + on-deck branch + registry row) and reports typed detail. + */ + +import { join } from "path"; +import { + loadRegistry, + saveRegistry, + usedNames, + type TreeRecord, +} from "./registry.ts"; +import { + runGit, + remoteDefaultRef, + headSha, + ensureInfoExclude, + listWorktreesAsync, +} from "./git-async.ts"; +import { pickName } from "./names.ts"; +import { loadWorktreeRepoConfig, resolveReadySteps, type WorktreeRepoConfig } from "./config.ts"; +import { runReadySteps } from "./ready.ts"; +import { withTreeLock } from "./locks.ts"; +import { reconcileForRepo } from "../daemon/doppler-sync.ts"; + +const CREATE_TIMEOUT_MS = 5 * 60_000; + +export interface CreateDeps { + repoName: string; + repoPath: string; + emit: (type: string, data: unknown) => void; + log: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void }; +} + +export type CreateResult = + | { ok: true; tree: TreeRecord } + | { ok: false; error: "create-failed"; failedStep?: string; output?: string } + | { ok: false; error: "busy" }; + +export async function createTree(deps: CreateDeps): Promise { + const { repoName, repoPath } = deps; + const cfg = loadWorktreeRepoConfig(repoName, repoPath); + const existing = loadRegistry(repoName); + const name = pickName(cfg.namePool, usedNames(existing)); + const path = join(cfg.root, name); + + const defaultRoot = join(repoPath, ".worktrees"); + if (cfg.root === defaultRoot) { + await ensureInfoExclude(repoPath, ".worktrees/"); + } + + const outcome = await withTreeLock(path, () => runCreate(deps, cfg, name, path)); + if (outcome === "busy") { + return { ok: false, error: "busy" }; + } + return outcome; +} + +async function runCreate( + deps: CreateDeps, + cfg: WorktreeRepoConfig, + name: string, + path: string, +): Promise { + const { repoName, repoPath, emit, log } = deps; + const branch = `on-deck/${name}`; + + const rec: TreeRecord = { + name, + path, + kind: "ephemeral", + state: "creating", + branch, + createdAt: new Date().toISOString(), + }; + + // Registry-first: this write must land before any git mutation below. + const trees = loadRegistry(repoName); + trees.push(rec); + saveRegistry(repoName, trees); + + const fail = async (failedStep: string, output: string): Promise => { + log.warn("worktree create failed", { repo: repoName, tree: name, failedStep }); + await scrapTree(deps, rec); + return { ok: false, error: "create-failed", failedStep, output }; + }; + + const defaultRef = await remoteDefaultRef(repoPath); + const defaultBranchName = defaultRef.replace(/^origin\//, ""); + + const fetchResult = await runGit(repoPath, ["fetch", "origin", defaultBranchName], { + timeoutMs: CREATE_TIMEOUT_MS, + }); + if (fetchResult.exitCode !== 0) { + return fail(`git fetch origin ${defaultBranchName}`, fetchResult.stdout + fetchResult.stderr); + } + + const addResult = await runGit( + repoPath, + ["worktree", "add", "-b", branch, path, defaultRef], + { timeoutMs: CREATE_TIMEOUT_MS }, + ); + if (addResult.exitCode !== 0) { + return fail(`git worktree add -b ${branch} ${path} ${defaultRef}`, addResult.stdout + addResult.stderr); + } + + const readySteps = resolveReadySteps(cfg, repoPath); + const readyResult = await runReadySteps(path, readySteps); + if (!readyResult.ok) { + return fail(readyResult.failedStep, readyResult.output); + } + + const worktreeRoots = (await listWorktreesAsync(repoPath)).map((w) => w.path); + await reconcileForRepo({ repoName, worktreeRoots }); + + const readyStamp = await headSha(path); + const updated: TreeRecord = { + ...rec, + state: "on-deck", + readyAt: new Date().toISOString(), + ...(readyStamp ? { readyStamp } : {}), + }; + + const finalTrees = loadRegistry(repoName).map((t) => (t.path === path ? updated : t)); + saveRegistry(repoName, finalTrees); + + emit("worktree:created", { repo: repoName, tree: name, path }); + log.info("worktree created", { repo: repoName, tree: name, path }); + + return { ok: true, tree: updated }; +} + +/** + * Force-remove the worktree, delete its on-deck/ branch, and prune the + * registry entry. Tolerant of partial existence: the worktree may not exist + * yet (git worktree add never ran or failed before creating it), and the + * branch may not exist either. + */ +export async function scrapTree(deps: CreateDeps, rec: TreeRecord): Promise { + await runGit(deps.repoPath, ["worktree", "remove", "--force", rec.path]); + if (rec.branch) { + await runGit(deps.repoPath, ["branch", "-D", rec.branch]); + } + const trees = loadRegistry(deps.repoName).filter((t) => t.path !== rec.path); + saveRegistry(deps.repoName, trees); +} From dc0266fcce72a9fdc2701df03294766a627a6b0b Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 16:58:05 -0500 Subject: [PATCH 14/31] RT-34: dispose guard (generated-drift tolerant, MR-sha anchor, lease-aware) + lease probe Co-Authored-By: Claude Fable 5 --- lib/worktree/__tests__/dispose.test.ts | 507 +++++++++++++++++++++++++ lib/worktree/dispose.ts | 242 ++++++++++++ lib/worktree/lease.ts | 94 +++++ 3 files changed, 843 insertions(+) create mode 100644 lib/worktree/__tests__/dispose.test.ts create mode 100644 lib/worktree/dispose.ts create mode 100644 lib/worktree/lease.ts diff --git a/lib/worktree/__tests__/dispose.test.ts b/lib/worktree/__tests__/dispose.test.ts new file mode 100644 index 00000000..73d90bde --- /dev/null +++ b/lib/worktree/__tests__/dispose.test.ts @@ -0,0 +1,507 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { execSync } from "child_process"; +import { existsSync, mkdirSync, mkdtempSync, writeFileSync, realpathSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { repoDataDir } from "../../rt-paths.ts"; +import { saveSyncConfig } from "../../sync-config.ts"; +import { loadRegistry, saveRegistry, type TreeRecord } from "../registry.ts"; +import { branchExistsLocalAsync, listWorktreesAsync } from "../git-async.ts"; +import { hasFreshAttendantLease } from "../lease.ts"; +import { classifyDirtyAsync, disposeTree, type DisposeDeps } from "../dispose.ts"; + +const GIT_ID = "-c user.email=t@t -c user.name=t"; + +/** A repo whose initial commit carries a tracked `gen.txt` (the "generated" file). */ +function makeRepo(): string { + // realpathSync: git canonicalizes /var -> /private/var on macOS (Global Constraints) + const dir = realpathSync(mkdtempSync(join(tmpdir(), "rtdispose-"))); + writeFileSync(join(dir, "gen.txt"), "alpha\nbeta\n"); + execSync(`git init -b main && git add gen.txt && git ${GIT_ID} commit -m init`, { + cwd: dir, + shell: "/bin/zsh", + stdio: "pipe", + }); + return dir; +} + +/** Bare-clone `repo` as its own "origin" and fetch. Returns the bare repo path. */ +function addBareOrigin(repo: string): string { + const bare = join(realpathSync(mkdtempSync(join(tmpdir(), "rtdispose-bare-"))), "o.git"); + execSync( + `git clone --bare ${repo} ${bare} && git -C ${repo} remote add origin ${bare} && git -C ${repo} fetch origin`, + { shell: "/bin/zsh", stdio: "pipe" }, + ); + return bare; +} + +/** Add a worktree on a fresh branch cut from `base`, and return its (canonical) path. */ +function addTree(repo: string, name: string, branch: string, base = "origin/main"): string { + const path = join(repo, ".worktrees", name); + execSync(`git -C ${repo} worktree add -b ${branch} ${path} ${base}`, { + shell: "/bin/zsh", + stdio: "pipe", + }); + return path; +} + +function commitIn(worktree: string, file: string, content: string): void { + writeFileSync(join(worktree, file), content); + execSync(`git add ${file} && git ${GIT_ID} commit -m change`, { + cwd: worktree, + shell: "/bin/zsh", + stdio: "pipe", + }); +} + +function register(repoName: string, rec: TreeRecord): TreeRecord { + saveRegistry(repoName, [...loadRegistry(repoName), rec]); + return rec; +} + +function ephemeral(name: string, path: string, branch: string, extra: Partial = {}): TreeRecord { + return { + name, + path, + kind: "ephemeral", + state: "claimed", + branch, + createdAt: new Date(Date.now() - 60 * 60_000).toISOString(), + ...extra, + }; +} + +function writeLease(filename: string, body: unknown): void { + const dir = join(process.env.HOME!, ".mattstack", "ci-attendants"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, filename), typeof body === "string" ? body : JSON.stringify(body)); +} + +describe("hasFreshAttendantLease", () => { + beforeEach(() => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtlease-home-"))); + }); + + test("no lease directory at all → false", () => { + expect(hasFreshAttendantLease(42)).toBe(false); + }); + + test("fresh lease (real BOARD-10 shape) matches by filename suffix", () => { + writeLease("assured-dev-42.json", { + mr: "https://gitlab.com/acme/assured-dev/-/merge_requests/42", + heartbeatAt: Date.now(), + ttlSeconds: 300, + }); + expect(hasFreshAttendantLease(42)).toBe(true); + expect(hasFreshAttendantLease(43)).toBe(false); + }); + + test("lease whose filename carries no iid still matches by the mr URL's trailing iid", () => { + writeLease("attendant.json", { + mr: "https://gitlab.com/acme/assured-dev/-/merge_requests/77", + heartbeatAt: Date.now(), + ttlSeconds: 300, + }); + expect(hasFreshAttendantLease(77)).toBe(true); + expect(hasFreshAttendantLease(7)).toBe(false); + }); + + test("heartbeat older than ttlSeconds → stale", () => { + writeLease("assured-dev-42.json", { + mr: "https://gitlab.com/acme/assured-dev/-/merge_requests/42", + heartbeatAt: Date.now() - 400_000, + ttlSeconds: 300, + }); + expect(hasFreshAttendantLease(42)).toBe(false); + }); + + test("missing ttlSeconds falls back to 300s", () => { + writeLease("assured-dev-9.json", { + mr: "https://gitlab.com/acme/assured-dev/-/merge_requests/9", + heartbeatAt: Date.now() - 100_000, + }); + expect(hasFreshAttendantLease(9)).toBe(true); + + writeLease("assured-dev-9.json", { + mr: "https://gitlab.com/acme/assured-dev/-/merge_requests/9", + heartbeatAt: Date.now() - 400_000, + }); + expect(hasFreshAttendantLease(9)).toBe(false); + }); + + test("ISO-string heartbeatAt is tolerated", () => { + writeLease("assured-dev-5.json", { + mr: "https://gitlab.com/acme/assured-dev/-/merge_requests/5", + heartbeatAt: new Date().toISOString(), + ttlSeconds: 300, + }); + expect(hasFreshAttendantLease(5)).toBe(true); + }); + + test("garbage files never block disposal", () => { + writeLease("assured-dev-42.json", "{ not json at all"); + writeLease("assured-dev-43.json", { mr: 12345, heartbeatAt: "nonsense" }); + expect(hasFreshAttendantLease(42)).toBe(false); + expect(hasFreshAttendantLease(43)).toBe(false); + }); + + test("`now` is injectable", () => { + const heartbeatAt = 1_786_998_298_000; + writeLease("assured-dev-42.json", { + mr: "https://gitlab.com/acme/assured-dev/-/merge_requests/42", + heartbeatAt, + ttlSeconds: 300, + }); + expect(hasFreshAttendantLease(42, heartbeatAt + 1_000)).toBe(true); + expect(hasFreshAttendantLease(42, heartbeatAt + 300_001)).toBe(false); + }); +}); + +describe("classifyDirtyAsync", () => { + let repo: string; + let tree: string; + const repoName = "acme"; + + beforeEach(() => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtdispose-home-"))); + repo = makeRepo(); + addBareOrigin(repo); + tree = addTree(repo, "tree-a", "feature-a"); + }); + + test("clean tree classifies as nothing at all", async () => { + const result = await classifyDirtyAsync(tree, repoName); + expect(result.discard).toEqual([]); + expect(result.blockers).toEqual([]); + }); + + test("untracked file is a blocker", async () => { + writeFileSync(join(tree, "scratch.txt"), "hi\n"); + const result = await classifyDirtyAsync(tree, repoName); + expect(result.blockers).toEqual(["scratch.txt"]); + expect(result.discard).toEqual([]); + }); + + test("declared generated file with whitespace-only drift is discardable", async () => { + saveSyncConfig(repoDataDir(repoName), { + autoResolve: [{ glob: "gen.txt", strategy: "theirs" }], + }); + writeFileSync(join(tree, "gen.txt"), "alpha \nbeta\n"); + + const result = await classifyDirtyAsync(tree, repoName); + expect(result.discard).toEqual(["gen.txt"]); + expect(result.blockers).toEqual([]); + }); + + test("declared generated file with a substantive edit is a blocker", async () => { + saveSyncConfig(repoDataDir(repoName), { + autoResolve: [{ glob: "gen.txt", strategy: "theirs" }], + }); + writeFileSync(join(tree, "gen.txt"), "alpha\nbeta\ngamma\n"); + + const result = await classifyDirtyAsync(tree, repoName); + expect(result.discard).toEqual([]); + expect(result.blockers).toEqual(["gen.txt"]); + }); + + test("undeclared modified file is a blocker even when whitespace-only", async () => { + writeFileSync(join(tree, "gen.txt"), "alpha \nbeta\n"); + const result = await classifyDirtyAsync(tree, repoName); + expect(result.blockers).toEqual(["gen.txt"]); + }); +}); + +describe("disposeTree", () => { + const repoName = "acme"; + let repo: string; + let events: Array<{ type: string; data: unknown }>; + + beforeEach(() => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtdispose-home-"))); + repo = makeRepo(); + addBareOrigin(repo); + events = []; + }); + + function makeDeps(overrides: Partial = {}): DisposeDeps { + return { + repoName, + repoPath: repo, + cacheEntries: {}, + emit: (type, data) => events.push({ type, data }), + log: { info: () => {}, warn: () => {} }, + killProcesses: false, + ...overrides, + }; + } + + test("kind=main refuses even under force", async () => { + const rec = register(repoName, { + name: "main", + path: repo, + kind: "main", + branch: "main", + createdAt: new Date().toISOString(), + }); + + const result = await disposeTree(makeDeps(), rec, { force: true }); + expect(result).toEqual({ disposed: false, refusal: "kind-main" }); + expect(existsSync(repo)).toBe(true); + expect(loadRegistry(repoName).length).toBe(1); + }); + + test("kind=unmanaged refuses even under force", async () => { + const path = addTree(repo, "theirs", "someone-else"); + const rec = register(repoName, { + name: "theirs", + path, + kind: "unmanaged", + branch: "someone-else", + createdAt: new Date().toISOString(), + }); + + const result = await disposeTree(makeDeps(), rec, { force: true }); + expect(result).toEqual({ disposed: false, refusal: "kind-unmanaged" }); + expect(existsSync(path)).toBe(true); + }); + + test("dirty tracked file refuses with \"dirty\"", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + writeFileSync(join(path, "gen.txt"), "alpha\nbeta\ngamma\n"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + + const result = await disposeTree(makeDeps(), rec, {}); + expect(result).toEqual({ disposed: false, refusal: "dirty" }); + expect(existsSync(path)).toBe(true); + }); + + test("--force disposes a dirty tree", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + writeFileSync(join(path, "gen.txt"), "alpha\nbeta\ngamma\n"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + + const result = await disposeTree(makeDeps(), rec, { force: true }); + expect(result).toEqual({ disposed: true }); + expect(existsSync(path)).toBe(false); + }); + + test("whitespace-only drift in a declared generated file does not refuse", async () => { + saveSyncConfig(repoDataDir(repoName), { + autoResolve: [{ glob: "gen.txt", strategy: "theirs" }], + }); + const path = addTree(repo, "tree-a", "feature-a"); + writeFileSync(join(path, "gen.txt"), "alpha \nbeta\n"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + + const result = await disposeTree(makeDeps(), rec, {}); + expect(result).toEqual({ disposed: true }); + expect(existsSync(path)).toBe(false); + const disposed = events.find((e) => e.type === "worktree:disposed"); + expect(disposed).toBeDefined(); + expect((disposed!.data as { discarded: string[] }).discarded).toEqual(["gen.txt"]); + }); + + test("unpushed commit refuses with \"unpushed\"", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + commitIn(path, "new.txt", "local only\n"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + + const result = await disposeTree(makeDeps(), rec, {}); + expect(result).toEqual({ disposed: false, refusal: "unpushed" }); + expect(existsSync(path)).toBe(true); + + const forced = await disposeTree(makeDeps(), rec, { force: true }); + expect(forced).toEqual({ disposed: true }); + }); + + test("pushed branch with no MR disposes via the origin/ anchor", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + commitIn(path, "new.txt", "pushed\n"); + execSync(`git -C ${path} push origin feature-a && git -C ${repo} fetch origin`, { + shell: "/bin/zsh", + stdio: "pipe", + }); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + + const result = await disposeTree(makeDeps(), rec, {}); + expect(result).toEqual({ disposed: true }); + + // worktree, branch, and registry entry all gone + expect(existsSync(path)).toBe(false); + expect((await listWorktreesAsync(repo)).some((w) => w.path === path)).toBe(false); + expect(await branchExistsLocalAsync(repo, "feature-a")).toBe(false); + expect(loadRegistry(repoName).length).toBe(0); + + const disposed = events.find((e) => e.type === "worktree:disposed"); + expect(disposed).toBeDefined(); + expect((disposed!.data as { tree: string }).tree).toBe("tree-a"); + }); + + test("auto disposal anchors on the MR head sha, not the default branch", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + commitIn(path, "new.txt", "squash-merged upstream\n"); + const sha = execSync(`git -C ${path} rev-parse HEAD`, { encoding: "utf8" }).trim(); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a", { + claimedAt: new Date(Date.now() - 60 * 60_000).toISOString(), + })); + + const deps = makeDeps({ + cacheEntries: { "feature-a": { mr: { iid: 42, sha }, repoName } }, + }); + const result = await disposeTree(deps, rec, { auto: true }); + expect(result).toEqual({ disposed: true }); + expect(existsSync(path)).toBe(false); + }); + + test("auto disposal with an unknown MR sha refuses with \"mr-sha-unresolvable\"", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a", { + claimedAt: new Date(Date.now() - 60 * 60_000).toISOString(), + })); + + const deps = makeDeps({ + cacheEntries: { + "feature-a": { mr: { iid: 42, sha: "0".repeat(40) }, repoName }, + }, + }); + const result = await disposeTree(deps, rec, { auto: true }); + expect(result).toEqual({ disposed: false, refusal: "mr-sha-unresolvable" }); + expect(existsSync(path)).toBe(true); + }); + + test("auto disposal refuses \"unpushed\" when HEAD is ahead of the MR head sha", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + commitIn(path, "new.txt", "in the MR\n"); + const sha = execSync(`git -C ${path} rev-parse HEAD`, { encoding: "utf8" }).trim(); + commitIn(path, "later.txt", "never pushed\n"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a", { + claimedAt: new Date(Date.now() - 60 * 60_000).toISOString(), + })); + + const deps = makeDeps({ + cacheEntries: { "feature-a": { mr: { iid: 42, sha }, repoName } }, + }); + const result = await disposeTree(deps, rec, { auto: true }); + expect(result).toEqual({ disposed: false, refusal: "unpushed" }); + }); + + test("a fresh attendant lease on the joined MR refuses with \"attended\"", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + writeLease("acme-42.json", { + mr: "https://gitlab.com/acme/acme/-/merge_requests/42", + heartbeatAt: Date.now(), + ttlSeconds: 300, + }); + + const deps = makeDeps({ + cacheEntries: { "feature-a": { mr: { iid: 42, sha: null }, repoName } }, + }); + const result = await disposeTree(deps, rec, {}); + expect(result).toEqual({ disposed: false, refusal: "attended" }); + expect(existsSync(path)).toBe(true); + + const forced = await disposeTree(deps, rec, { force: true }); + expect(forced).toEqual({ disposed: true }); + }); + + test("a stale attendant lease does not refuse", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + writeLease("acme-42.json", { + mr: "https://gitlab.com/acme/acme/-/merge_requests/42", + heartbeatAt: Date.now() - 400_000, + ttlSeconds: 300, + }); + + const deps = makeDeps({ + cacheEntries: { "feature-a": { mr: { iid: 42, sha: null }, repoName } }, + }); + const result = await disposeTree(deps, rec, {}); + expect(result).toEqual({ disposed: true }); + }); + + test("auto disposal of a just-claimed tree refuses with \"grace\"; explicit disposal proceeds", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a", { + claimedAt: new Date().toISOString(), + })); + + const auto = await disposeTree(makeDeps(), rec, { auto: true }); + expect(auto).toEqual({ disposed: false, refusal: "grace" }); + expect(existsSync(path)).toBe(true); + + const explicit = await disposeTree(makeDeps(), rec, { auto: false }); + expect(explicit).toEqual({ disposed: true }); + expect(existsSync(path)).toBe(false); + }); + + test("a claim older than the 10-minute grace window auto-disposes", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a", { + claimedAt: new Date(Date.now() - 11 * 60_000).toISOString(), + })); + + const result = await disposeTree(makeDeps(), rec, { auto: true }); + expect(result).toEqual({ disposed: true }); + }); + + test("guard order: dirty is reported before unpushed", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + commitIn(path, "new.txt", "local only\n"); + writeFileSync(join(path, "gen.txt"), "alpha\nbeta\ngamma\n"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + + const result = await disposeTree(makeDeps(), rec, {}); + expect(result).toEqual({ disposed: false, refusal: "dirty" }); + }); + + test("guard order: attended is reported before grace", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a", { + claimedAt: new Date().toISOString(), + })); + writeLease("acme-42.json", { + mr: "https://gitlab.com/acme/acme/-/merge_requests/42", + heartbeatAt: Date.now(), + ttlSeconds: 300, + }); + + const sha = execSync(`git -C ${path} rev-parse HEAD`, { encoding: "utf8" }).trim(); + const deps = makeDeps({ cacheEntries: { "feature-a": { mr: { iid: 42, sha }, repoName } } }); + const result = await disposeTree(deps, rec, { auto: true }); + expect(result).toEqual({ disposed: false, refusal: "attended" }); + }); + + test("killProcesses wiring runs the killer without disturbing disposal", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + + const result = await disposeTree(makeDeps({ killProcesses: true }), rec, {}); + expect(result).toEqual({ disposed: true }); + expect(existsSync(path)).toBe(false); + }); + + test("a branchless record disposes and prunes the registry", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, { ...ephemeral("tree-a", path, "feature-a"), branch: null }); + + const result = await disposeTree(makeDeps(), rec, {}); + expect(result).toEqual({ disposed: true }); + expect(existsSync(path)).toBe(false); + expect(loadRegistry(repoName).length).toBe(0); + }); + + test("dispose tolerates a branch git already deleted", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + execSync(`git -C ${repo} worktree remove --force ${path} && git -C ${repo} branch -D feature-a`, { + shell: "/bin/zsh", + stdio: "pipe", + }); + + const result = await disposeTree(makeDeps(), rec, { force: true }); + expect(result).toEqual({ disposed: true }); + expect(loadRegistry(repoName).length).toBe(0); + }); +}); diff --git a/lib/worktree/dispose.ts b/lib/worktree/dispose.ts new file mode 100644 index 00000000..bf8a092a --- /dev/null +++ b/lib/worktree/dispose.ts @@ -0,0 +1,242 @@ +/** + * Dispose guard + removal (spec §8). + * + * Disposal is the only destructive verb in the lifecycle, so the guard is the + * feature: five checks, in a fixed order, each returning a stable refusal + * string the reactor records as `disposableReason` and the CLI prints. The + * order matters — cheap, local, categorical checks first, then the ones that + * can hit the network (fetch) or someone else's coordination state (leases). + * + * `force` overrides guards 2-5 and never guard 1: "main" and "unmanaged" trees + * are categorically not rt's to delete, no matter what the caller asks for. + */ + +import { statusPorcelainAsync, gitOk, isAncestorAsync, remoteDefaultRef, remoteRefExists, runGit } from "./git-async.ts"; +import { loadRegistry, saveRegistry, type TreeRecord } from "./registry.ts"; +import { hasFreshAttendantLease } from "./lease.ts"; +import { loadSyncConfig, matchRule } from "../sync-config.ts"; +import { repoDataDir } from "../rt-paths.ts"; +import { killWorktreeProcesses } from "../daemon/worktree-process-kill.ts"; + +/** Merge-reactor disposals ignore claims younger than this (stale-event protection). */ +const GRACE_MS = 10 * 60_000; + +const FETCH_TIMEOUT_MS = 2 * 60_000; + +// ─── Dirty classification (harvested from parking-lot.ts, execSync → runGit) ── + +/** One entry from `git status --porcelain`. */ +interface DirtyEntry { status: string; path: string; } + +function parseDirtyEntries(porcelain: string): DirtyEntry[] { + const entries: DirtyEntry[] = []; + for (const line of porcelain.split("\n")) { + if (line.length < 4) continue; + const status = line.slice(0, 2); + let path = line.slice(3); + // Rename/copy entries read "old -> new"; the destination is what's dirty. + const arrow = path.indexOf(" -> "); + if (arrow !== -1) path = path.slice(arrow + 4); + // git quotes paths containing specials (C-style escapes). + if (path.startsWith('"') && path.endsWith('"')) { + try { path = JSON.parse(path) as string; } catch { path = path.slice(1, -1); } + } + entries.push({ status, path }); + } + return entries; +} + +/** + * Whether a tracked file's local change is pure whitespace relative to HEAD. + * Compared against HEAD (not the index) so a staged whitespace-only edit counts. + */ +async function isWhitespaceOnlyChange(cwd: string, path: string): Promise { + // exit 0 → nothing left once whitespace is ignored + return gitOk(cwd, ["diff", "HEAD", "--ignore-all-space", "--exit-code", "--", path]); +} + +/** + * Split a worktree's dirt into what may be thrown away and what must not be. + * + * `discard` covers tracked modifications to files the repo's sync.json declares + * auto-resolvable with `strategy: "theirs"`, whose local diff is pure + * whitespace. These are generated artifacts that drift by a trailing newline + * and are rebuilt by the next build; the declaration says upstream wins. + * + * Everything else is a blocker — disposal refuses and freshen stashes. A + * background sweep must not destroy content it didn't generate. That + * deliberately includes substantive changes to declared files: the declaration + * is about regenerable drift, not licence to delete real edits. + * + * One intelligence, two callers (dispose guard 2 and the freshen sweep). + */ +export async function classifyDirtyAsync( + worktreePath: string, + repoName: string, +): Promise<{ discard: string[]; blockers: string[] }> { + const rules = loadSyncConfig(repoDataDir(repoName)).autoResolve; + const entries = parseDirtyEntries(await statusPorcelainAsync(worktreePath)); + + const discard: string[] = []; + const blockers: string[] = []; + + for (const e of entries) { + const untracked = e.status === "??"; + const modified = !untracked && e.status.includes("M"); + if ( + modified && + matchRule(e.path, rules)?.strategy === "theirs" && + await isWhitespaceOnlyChange(worktreePath, e.path) + ) { + discard.push(e.path); + } else { + blockers.push(e.path); + } + } + + return { discard, blockers }; +} + +// ─── Dispose ───────────────────────────────────────────────────────────────── + +export interface DisposeDeps { + repoName: string; + repoPath: string; + /** Branch-keyed MR cache (daemon `ctx.cache.entries`). */ + cacheEntries: Record; + emit: (type: string, data: unknown) => void; + log: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void }; + killProcesses: boolean; +} + +export type DisposeOutcome = { disposed: true } | { disposed: false; refusal: string }; + +/** The MR joined to this tree, if the branch cache knows one for this repo. */ +function joinedMr(deps: DisposeDeps, rec: TreeRecord): { iid?: number; sha?: string | null } | null { + if (!rec.branch) return null; + const entry = deps.cacheEntries[rec.branch]; + if (!entry || !entry.mr) return null; + // Entries carry repoName once freshness has attributed them; an unattributed + // entry is accepted (single-repo caches predate the field). + if (entry.repoName && entry.repoName !== deps.repoName) return null; + return entry.mr as { iid?: number; sha?: string | null }; +} + +/** + * Guard 3, MR-anchored: under squash/rebase merge the branch tip is never an + * ancestor of the default branch, and delete-source-branch removes + * origin/ before the tick sees the merge — so the MR head sha from the + * branch cache is the only anchor that survives both. Unknown sha → fetch once, + * then refuse rather than guess. + */ +async function mrAnchorRefusal(deps: DisposeDeps, rec: TreeRecord, sha: string): Promise { + if (!(await gitOk(rec.path, ["cat-file", "-e", sha]))) { + await runGit(deps.repoPath, ["fetch", "origin"], { timeoutMs: FETCH_TIMEOUT_MS }); + if (!(await gitOk(rec.path, ["cat-file", "-e", sha]))) return "mr-sha-unresolvable"; + } + return (await isAncestorAsync(rec.path, "HEAD", sha)) ? null : "unpushed"; +} + +/** Guard 3, no-MR: pushed-but-MR-less branches anchor on their own remote ref. */ +async function remoteAnchorRefusal(rec: TreeRecord): Promise { + const anchor = + rec.branch && (await remoteRefExists(rec.path, rec.branch)) + ? `refs/remotes/origin/${rec.branch}` + : await remoteDefaultRef(rec.path); + return (await isAncestorAsync(rec.path, "HEAD", anchor)) ? null : "unpushed"; +} + +/** + * Guard the tree, then remove it: worktree, branch, registry entry, event. + * + * Returns the refusal reason instead of throwing; callers decide what to do + * with it (the reactor flips the tree to `disposable` with the reason, the CLI + * prints it). + */ +export async function disposeTree( + deps: DisposeDeps, + rec: TreeRecord, + opts: { force?: boolean; auto?: boolean }, +): Promise { + const { repoName, repoPath, emit, log } = deps; + const force = opts.force === true; + const auto = opts.auto === true; + + const refuse = (refusal: string): DisposeOutcome => { + log.info("worktree dispose refused", { repo: repoName, tree: rec.name, refusal }); + return { disposed: false, refusal }; + }; + + // 1. Categorical: only rt-built ephemeral trees are rt's to delete. No force. + if (rec.kind !== "ephemeral") return refuse(`kind-${rec.kind}`); + + let discarded: string[] = []; + + if (!force) { + // 2. Clean modulo declared generated drift. + const { discard, blockers } = await classifyDirtyAsync(rec.path, repoName); + if (blockers.length > 0) return refuse("dirty"); + discarded = discard; + + // 3. Nothing local-only, checked against the right anchor. + const mr = joinedMr(deps, rec); + const anchorRefusal = + auto && mr + ? typeof mr.sha === "string" && mr.sha.length > 0 + ? await mrAnchorRefusal(deps, rec, mr.sha) + : "mr-sha-unresolvable" + : await remoteAnchorRefusal(rec); + if (anchorRefusal) return refuse(anchorRefusal); + + // 4. Nobody is attending the MR right now. + if (mr && typeof mr.iid === "number" && hasFreshAttendantLease(mr.iid)) { + return refuse("attended"); + } + + // 5. Auto only: a just-claimed tree can't be reaped by a stale merge event. + if (auto && rec.claimedAt) { + const claimedMs = Date.parse(rec.claimedAt); + if (!Number.isNaN(claimedMs) && Date.now() - claimedMs < GRACE_MS) return refuse("grace"); + } + } + + if (deps.killProcesses) { + const { terminated } = killWorktreeProcesses(rec.path); + if (terminated.length > 0) { + log.info("worktree processes terminated", { + repo: repoName, tree: rec.name, count: terminated.length, + }); + } + } + + // --force here is plumbing, not a safety statement: `git worktree remove` + // refuses on any untracked file, and the guard above (or the caller's + // explicit force) already made the decision. + const removal = await runGit(repoPath, ["worktree", "remove", "--force", rec.path]); + if (removal.exitCode !== 0) { + // Tolerated (the dir may already be gone by hand), but never silent. + log.warn("git worktree remove failed during dispose", { + repo: repoName, tree: rec.name, path: rec.path, + output: (removal.stdout + removal.stderr).trim(), + }); + } + + if (rec.branch) { + // Already-gone branches are expected (the reactor disposes after a merge + // that may have deleted it), so a failure here is not worth a warning. + await runGit(repoPath, ["branch", "-D", rec.branch]); + } + + saveRegistry(repoName, loadRegistry(repoName).filter((t) => t.path !== rec.path)); + + emit("worktree:disposed", { + repo: repoName, + tree: rec.name, + path: rec.path, + branch: rec.branch, + discarded, + }); + log.info("worktree disposed", { repo: repoName, tree: rec.name, path: rec.path }); + + return { disposed: true }; +} diff --git a/lib/worktree/lease.ts b/lib/worktree/lease.ts new file mode 100644 index 00000000..46626f3d --- /dev/null +++ b/lib/worktree/lease.ts @@ -0,0 +1,94 @@ +/** + * CI-attendant lease probe (BOARD-10). + * + * An attendant (a CI-babysitting agent) writes a heartbeat file per MR it is + * attending into ~/.mattstack/ci-attendants/. Disposal defers to the next tick + * while such a lease is fresh: reaping a tree out from under an agent that is + * still pushing fixes to its MR is the one un-undoable mistake here. + * + * The on-disk shape is BOARD-10's, verified against real files — `mr` is the MR + * *URL string* (not an iid) and `heartbeatAt` is epoch millis (not ISO). This + * module reads that shape and never writes it: rt is a reader of someone else's + * coordination state (~/.mattstack is cross-actor, no single owner). + * + * Everything here is deliberately forgiving. An unreadable, half-written, or + * schema-drifted lease file yields "not attended" rather than an exception: a + * garbage file must never wedge the disposal path forever. + */ + +import { existsSync, readdirSync, readFileSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; + +/** Attendant heartbeats older than this (when the file declares no ttl) are stale. */ +const DEFAULT_TTL_SECONDS = 300; + +/** + * ~/.mattstack/ci-attendants — HOME resolved at CALL time (same convention as + * lib/rt-paths.ts) so a test pointing process.env.HOME at a temp dir isolates + * the real one. + */ +export function ciAttendantsDir(): string { + return join(process.env.HOME ?? homedir(), ".mattstack", "ci-attendants"); +} + +/** Epoch millis from either the real (number) or a defensively-tolerated (ISO) heartbeat. */ +function heartbeatMillis(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; + } + return null; +} + +/** Whether a lease's `mr` URL points at this iid (trailing /merge_requests/). */ +function urlMatchesIid(mr: unknown, mrIid: number): boolean { + if (typeof mr !== "string") return false; + return mr.replace(/\/+$/, "").endsWith(`/merge_requests/${mrIid}`); +} + +/** + * Whether some attendant currently holds a fresh lease on this MR. + * + * Matching is belt-and-braces: the filename convention is + * `-.json`, but the authoritative field is the `mr` URL, so + * either identifies the MR. `now` is injectable for tests. + */ +export function hasFreshAttendantLease(mrIid: number, now: number = Date.now()): boolean { + const dir = ciAttendantsDir(); + if (!existsSync(dir)) return false; + + let files: string[]; + try { + files = readdirSync(dir).filter((f) => f.endsWith(".json")); + } catch { + return false; + } + + for (const file of files) { + let lease: Record; + try { + const raw = JSON.parse(readFileSync(join(dir, file), "utf8")); + if (!raw || typeof raw !== "object") continue; + lease = raw as Record; + } catch { + continue; // unreadable or half-written — never block on garbage + } + + const matches = urlMatchesIid(lease.mr, mrIid) || file.endsWith(`-${mrIid}.json`); + if (!matches) continue; + + const heartbeat = heartbeatMillis(lease.heartbeatAt); + if (heartbeat === null) continue; + + const ttlSeconds = + typeof lease.ttlSeconds === "number" && Number.isFinite(lease.ttlSeconds) + ? lease.ttlSeconds + : DEFAULT_TTL_SECONDS; + + if (now - heartbeat < ttlSeconds * 1000) return true; + } + + return false; +} From a998795dc376492468e31290e03e835206646d05 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 17:17:33 -0500 Subject: [PATCH 15/31] RT-34: dispose fixes (fail-closed status, sha-absent anchor fallback, remove-failed refusal) Guard 2 read `git status` through a helper that discarded exitCode, so a failed status looked clean and a tree holding uncommitted work could be deleted; it now fails closed with a `` blocker. An MR with no cached sha (a quarter of merged entries) falls back to the remote anchor instead of refusing, reserving "mr-sha-unresolvable" for a sha that is present and unresolvable after the fetch. A worktree git refuses to remove now returns the new "remove-failed" refusal rather than pruning the registry and orphaning the tree. Logger calls in dispose.ts and create.ts flipped to pino order, with auto-path refusals at debug. Co-Authored-By: Claude Fable 5 --- lib/worktree/__tests__/dispose.test.ts | 102 ++++++++++++++++++++++++- lib/worktree/create.ts | 4 +- lib/worktree/dispose.ts | 74 +++++++++++++----- 3 files changed, 157 insertions(+), 23 deletions(-) diff --git a/lib/worktree/__tests__/dispose.test.ts b/lib/worktree/__tests__/dispose.test.ts index 73d90bde..6079fa49 100644 --- a/lib/worktree/__tests__/dispose.test.ts +++ b/lib/worktree/__tests__/dispose.test.ts @@ -8,7 +8,12 @@ import { saveSyncConfig } from "../../sync-config.ts"; import { loadRegistry, saveRegistry, type TreeRecord } from "../registry.ts"; import { branchExistsLocalAsync, listWorktreesAsync } from "../git-async.ts"; import { hasFreshAttendantLease } from "../lease.ts"; -import { classifyDirtyAsync, disposeTree, type DisposeDeps } from "../dispose.ts"; +import { + classifyDirtyAsync, + disposeTree, + STATUS_FAILED_BLOCKER, + type DisposeDeps, +} from "../dispose.ts"; const GIT_ID = "-c user.email=t@t -c user.name=t"; @@ -209,6 +214,18 @@ describe("classifyDirtyAsync", () => { const result = await classifyDirtyAsync(tree, repoName); expect(result.blockers).toEqual(["gen.txt"]); }); + + test("a failing git status fails CLOSED, never clean", async () => { + const gone = join(tree, "nope", "not-a-worktree"); + const result = await classifyDirtyAsync(gone, repoName); + expect(result.blockers).toEqual([STATUS_FAILED_BLOCKER]); + expect(result.discard).toEqual([]); + + // Same for a directory git refuses to read as a repo. + const notARepo = realpathSync(mkdtempSync(join(tmpdir(), "rtdispose-bare-dir-"))); + const outside = await classifyDirtyAsync(notARepo, repoName); + expect(outside.blockers).toEqual([STATUS_FAILED_BLOCKER]); + }); }); describe("disposeTree", () => { @@ -353,7 +370,7 @@ describe("disposeTree", () => { expect(existsSync(path)).toBe(false); }); - test("auto disposal with an unknown MR sha refuses with \"mr-sha-unresolvable\"", async () => { + test("a present-but-unresolvable MR sha refuses with \"mr-sha-unresolvable\"", async () => { const path = addTree(repo, "tree-a", "feature-a"); const rec = register(repoName, ephemeral("tree-a", path, "feature-a", { claimedAt: new Date(Date.now() - 60 * 60_000).toISOString(), @@ -369,6 +386,33 @@ describe("disposeTree", () => { expect(existsSync(path)).toBe(true); }); + test("an MR with NO sha falls back to the remote anchor instead of refusing", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a", { + claimedAt: new Date(Date.now() - 60 * 60_000).toISOString(), + })); + + // sha absent entirely (the projection drops it for many merged MRs) + const deps = makeDeps({ cacheEntries: { "feature-a": { mr: { iid: 42 }, repoName } } }); + const result = await disposeTree(deps, rec, { auto: true }); + expect(result).toEqual({ disposed: true }); + expect(existsSync(path)).toBe(false); + }); + + test("an MR with a null sha still gets a real containment check", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + commitIn(path, "new.txt", "local only\n"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a", { + claimedAt: new Date(Date.now() - 60 * 60_000).toISOString(), + })); + + const deps = makeDeps({ + cacheEntries: { "feature-a": { mr: { iid: 42, sha: null }, repoName } }, + }); + const result = await disposeTree(deps, rec, { auto: true }); + expect(result).toEqual({ disposed: false, refusal: "unpushed" }); + }); + test("auto disposal refuses \"unpushed\" when HEAD is ahead of the MR head sha", async () => { const path = addTree(repo, "tree-a", "feature-a"); commitIn(path, "new.txt", "in the MR\n"); @@ -446,6 +490,60 @@ describe("disposeTree", () => { expect(result).toEqual({ disposed: true }); }); + test("a cache entry with no repoName still joins its MR", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + writeLease("acme-42.json", { + mr: "https://gitlab.com/acme/acme/-/merge_requests/42", + heartbeatAt: Date.now(), + ttlSeconds: 300, + }); + + const deps = makeDeps({ cacheEntries: { "feature-a": { mr: { iid: 42, sha: null } } } }); + expect(await disposeTree(deps, rec, {})).toEqual({ disposed: false, refusal: "attended" }); + }); + + test("a cache entry attributed to another repo does not join", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + writeLease("other-42.json", { + mr: "https://gitlab.com/other/other/-/merge_requests/42", + heartbeatAt: Date.now(), + ttlSeconds: 300, + }); + + const deps = makeDeps({ + cacheEntries: { "feature-a": { mr: { iid: 42, sha: null }, repoName: "other-repo" } }, + }); + expect(await disposeTree(deps, rec, {})).toEqual({ disposed: true }); + }); + + test("a failing git status refuses \"dirty\" rather than disposing", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, { + ...ephemeral("tree-a", path, "feature-a"), + path: join(path, "gone", "missing"), + }); + + const result = await disposeTree(makeDeps(), rec, {}); + expect(result).toEqual({ disposed: false, refusal: "dirty" }); + expect(existsSync(path)).toBe(true); + expect(loadRegistry(repoName).length).toBe(1); + }); + + test("a worktree git refuses to remove refuses \"remove-failed\" and keeps the registry row", async () => { + const path = addTree(repo, "tree-a", "feature-a"); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + // A locked worktree needs --force twice; one --force fails and leaves the tree. + execSync(`git -C ${repo} worktree lock ${path}`, { shell: "/bin/zsh", stdio: "pipe" }); + + const result = await disposeTree(makeDeps(), rec, { force: true }); + expect(result).toEqual({ disposed: false, refusal: "remove-failed" }); + expect(existsSync(path)).toBe(true); + expect(loadRegistry(repoName).length).toBe(1); + expect(await branchExistsLocalAsync(repo, "feature-a")).toBe(true); + }); + test("guard order: dirty is reported before unpushed", async () => { const path = addTree(repo, "tree-a", "feature-a"); commitIn(path, "new.txt", "local only\n"); diff --git a/lib/worktree/create.ts b/lib/worktree/create.ts index b893de03..98c31d77 100644 --- a/lib/worktree/create.ts +++ b/lib/worktree/create.ts @@ -86,7 +86,7 @@ async function runCreate( saveRegistry(repoName, trees); const fail = async (failedStep: string, output: string): Promise => { - log.warn("worktree create failed", { repo: repoName, tree: name, failedStep }); + log.warn({ repo: repoName, tree: name, failedStep }, "worktree create failed"); await scrapTree(deps, rec); return { ok: false, error: "create-failed", failedStep, output }; }; @@ -131,7 +131,7 @@ async function runCreate( saveRegistry(repoName, finalTrees); emit("worktree:created", { repo: repoName, tree: name, path }); - log.info("worktree created", { repo: repoName, tree: name, path }); + log.info({ repo: repoName, tree: name, path }, "worktree created"); return { ok: true, tree: updated }; } diff --git a/lib/worktree/dispose.ts b/lib/worktree/dispose.ts index bf8a092a..719c3a58 100644 --- a/lib/worktree/dispose.ts +++ b/lib/worktree/dispose.ts @@ -11,7 +11,8 @@ * are categorically not rt's to delete, no matter what the caller asks for. */ -import { statusPorcelainAsync, gitOk, isAncestorAsync, remoteDefaultRef, remoteRefExists, runGit } from "./git-async.ts"; +import { existsSync } from "fs"; +import { gitOk, isAncestorAsync, remoteDefaultRef, remoteRefExists, runGit } from "./git-async.ts"; import { loadRegistry, saveRegistry, type TreeRecord } from "./registry.ts"; import { hasFreshAttendantLease } from "./lease.ts"; import { loadSyncConfig, matchRule } from "../sync-config.ts"; @@ -69,13 +70,24 @@ async function isWhitespaceOnlyChange(cwd: string, path: string): Promise` blocker rather than + * an empty-and-therefore-clean answer. Unknown dirt is dirt — the alternative + * is deleting a tree holding uncommitted work because git couldn't be asked. */ +export const STATUS_FAILED_BLOCKER = ""; + export async function classifyDirtyAsync( worktreePath: string, repoName: string, ): Promise<{ discard: string[]; blockers: string[] }> { const rules = loadSyncConfig(repoDataDir(repoName)).autoResolve; - const entries = parseDirtyEntries(await statusPorcelainAsync(worktreePath)); + const status = await runGit(worktreePath, ["status", "--porcelain"]); + if (status.exitCode !== 0) { + return { discard: [], blockers: [STATUS_FAILED_BLOCKER] }; + } + const entries = parseDirtyEntries(status.stdout); const discard: string[] = []; const blockers: string[] = []; @@ -105,7 +117,12 @@ export interface DisposeDeps { /** Branch-keyed MR cache (daemon `ctx.cache.entries`). */ cacheEntries: Record; emit: (type: string, data: unknown) => void; - log: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void }; + log: { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + /** Optional: auto-path refusals log here (they re-fire every reactor pass). */ + debug?: (...args: unknown[]) => void; + }; killProcesses: boolean; } @@ -126,8 +143,13 @@ function joinedMr(deps: DisposeDeps, rec: TreeRecord): { iid?: number; sha?: str * Guard 3, MR-anchored: under squash/rebase merge the branch tip is never an * ancestor of the default branch, and delete-source-branch removes * origin/ before the tick sees the merge — so the MR head sha from the - * branch cache is the only anchor that survives both. Unknown sha → fetch once, - * then refuse rather than guess. + * branch cache is the only anchor that survives both. A sha that is present but + * unknown locally → fetch once, then refuse rather than guess. + * + * Only reached when the MR actually carries a sha: roughly a quarter of merged + * entries in the live cache have no `sha` field at all, and refusing those + * would strand every one of them as disposable. Those fall back to the remote + * anchor (the caller decides), which is a real containment check, not a guess. */ async function mrAnchorRefusal(deps: DisposeDeps, rec: TreeRecord, sha: string): Promise { if (!(await gitOk(rec.path, ["cat-file", "-e", sha]))) { @@ -163,7 +185,11 @@ export async function disposeTree( const auto = opts.auto === true; const refuse = (refusal: string): DisposeOutcome => { - log.info("worktree dispose refused", { repo: repoName, tree: rec.name, refusal }); + const fields = { repo: repoName, tree: rec.name, refusal }; + // Auto refusals repeat every reactor pass for as long as the tree sits + // disposable, so they belong at debug; a human-driven refusal is a one-off. + if (auto && log.debug) log.debug(fields, "worktree dispose refused"); + else log.info(fields, "worktree dispose refused"); return { disposed: false, refusal }; }; @@ -180,11 +206,14 @@ export async function disposeTree( // 3. Nothing local-only, checked against the right anchor. const mr = joinedMr(deps, rec); + const mrSha = typeof mr?.sha === "string" && mr.sha.length > 0 ? mr.sha : null; + // An MR with no cached sha is common (the projection drops it for a + // sizeable slice of merged MRs) and is NOT an unresolvable sha — it falls + // back to the remote anchor. "mr-sha-unresolvable" is reserved for a sha + // that is present and still unknown after a fetch. const anchorRefusal = - auto && mr - ? typeof mr.sha === "string" && mr.sha.length > 0 - ? await mrAnchorRefusal(deps, rec, mr.sha) - : "mr-sha-unresolvable" + auto && mrSha + ? await mrAnchorRefusal(deps, rec, mrSha) : await remoteAnchorRefusal(rec); if (anchorRefusal) return refuse(anchorRefusal); @@ -203,9 +232,10 @@ export async function disposeTree( if (deps.killProcesses) { const { terminated } = killWorktreeProcesses(rec.path); if (terminated.length > 0) { - log.info("worktree processes terminated", { - repo: repoName, tree: rec.name, count: terminated.length, - }); + log.info( + { repo: repoName, tree: rec.name, count: terminated.length }, + "worktree processes terminated", + ); } } @@ -214,11 +244,17 @@ export async function disposeTree( // explicit force) already made the decision. const removal = await runGit(repoPath, ["worktree", "remove", "--force", rec.path]); if (removal.exitCode !== 0) { - // Tolerated (the dir may already be gone by hand), but never silent. - log.warn("git worktree remove failed during dispose", { - repo: repoName, tree: rec.name, path: rec.path, - output: (removal.stdout + removal.stderr).trim(), - }); + const output = (removal.stdout + removal.stderr).trim(); + log.warn( + { repo: repoName, tree: rec.name, path: rec.path, output }, + "git worktree remove failed during dispose", + ); + // A directory that is already gone is the expected failure and disposal + // continues (the registry row is the thing left to clean up). A tree still + // on disk means removal genuinely failed — locked file, permissions — and + // pruning the registry there would orphan a real worktree with its + // metadata lost. Refuse instead; the caller retries or forces. + if (existsSync(rec.path)) return refuse("remove-failed"); } if (rec.branch) { @@ -236,7 +272,7 @@ export async function disposeTree( branch: rec.branch, discarded, }); - log.info("worktree disposed", { repo: repoName, tree: rec.name, path: rec.path }); + log.info({ repo: repoName, tree: rec.name, path: rec.path }, "worktree disposed"); return { disposed: true }; } From 318c5b9452f62f0acd3abcfdbea7b76a9010edf0 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 17:27:51 -0500 Subject: [PATCH 16/31] RT-34: registry reconcile pass (ground-truth branches, unmanaged adoption, creating scrap) First slice of the daemon worktree reconciler. reconcileRepoRegistry prunes stale git worktree registrations first (so a create can reuse a name after an external rm -rf), then reconciles the on-disk registry against git ground truth: drops entries with no matching worktree, adopts unknown git worktrees (first as "main", rest as "unmanaged"), refreshes each registered tree's branch from git, and scraps orphaned "creating" entries that hold no lock. createWorktreeReconciler wires this into a runOnce/kick pair that Tasks 11-12 extend in place with the merge reactor, freshen, and replenish/shrink passes. Co-Authored-By: Claude Fable 5 --- .../__tests__/worktree-reconciler.test.ts | 204 ++++++++++++++++++ lib/daemon/worktree-reconciler.ts | 200 +++++++++++++++++ 2 files changed, 404 insertions(+) create mode 100644 lib/daemon/__tests__/worktree-reconciler.test.ts create mode 100644 lib/daemon/worktree-reconciler.ts diff --git a/lib/daemon/__tests__/worktree-reconciler.test.ts b/lib/daemon/__tests__/worktree-reconciler.test.ts new file mode 100644 index 00000000..8364aebd --- /dev/null +++ b/lib/daemon/__tests__/worktree-reconciler.test.ts @@ -0,0 +1,204 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { execSync } from "child_process"; +import { existsSync, mkdtempSync, realpathSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import type { Logger } from "pino"; +import { writeJson } from "../../json-store.ts"; +import { repoDataDir } from "../../rt-paths.ts"; +import { findByPath, loadRegistry, saveRegistry, type TreeRecord } from "../../worktree/registry.ts"; +import { branchExistsLocalAsync, listWorktreesAsync } from "../../worktree/git-async.ts"; +import { createTree } from "../../worktree/create.ts"; +import { reconcileRepoRegistry, createWorktreeReconciler } from "../worktree-reconciler.ts"; + +function makeRepo(): string { + // realpathSync: git canonicalizes /var -> /private/var on macOS (Global Constraints) + const dir = realpathSync(mkdtempSync(join(tmpdir(), "rtrecon-"))); + execSync( + "git init -b main && git -c user.email=t@t -c user.name=t commit --allow-empty -m init", + { cwd: dir, shell: "/bin/zsh" } + ); + return dir; +} + +/** Bare-clone `repo` as its own "origin" and fetch, so remoteDefaultRef resolves origin/main. */ +function addBareOrigin(repo: string): void { + const bare = mkdtempSync(join(tmpdir(), "rtrecon-bare-")); + execSync( + `git clone --bare ${repo} ${bare}/o.git && git -C ${repo} remote add origin ${bare}/o.git && git -C ${repo} fetch origin`, + { shell: "/bin/zsh" } + ); +} + +function fakeLog(): Logger { + return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as unknown as Logger; +} + +function makeDeps(repoName: string, repoPath: string, events: Array<{ type: string; data: unknown }>) { + return { + repoName, + repoPath, + emit: (type: string, data: unknown) => events.push({ type, data }), + log: fakeLog(), + }; +} + +describe("reconcileRepoRegistry", () => { + let repo: string; + let repoName: string; + let events: Array<{ type: string; data: unknown }>; + + beforeEach(() => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtrecon-home-"))); + repo = makeRepo(); + repoName = "acme"; + events = []; + }); + + test("adopts main and a manually-added worktree as unmanaged", async () => { + const manualPath = join(repo, ".worktrees", "manual"); + execSync(`git worktree add -b manual-branch ${manualPath}`, { cwd: repo, shell: "/bin/zsh" }); + + const trees = await reconcileRepoRegistry(makeDeps(repoName, repo, events)); + + const main = findByPath(trees, repo); + expect(main).toBeDefined(); + expect(main!.kind).toBe("main"); + + const manual = findByPath(trees, manualPath); + expect(manual).toBeDefined(); + expect(manual!.kind).toBe("unmanaged"); + expect(manual!.branch).toBe("manual-branch"); + + const registry = loadRegistry(repoName); + expect(registry.length).toBe(2); + }); + + test("rm -rf'd manual tree is pruned, and prune lets the name be reused by createTree", async () => { + addBareOrigin(repo); + const name = "reuseme"; + const manualPath = join(repo, ".worktrees", name); + execSync(`git worktree add -b manual-branch ${manualPath}`, { cwd: repo, shell: "/bin/zsh" }); + + await reconcileRepoRegistry(makeDeps(repoName, repo, events)); + expect(findByPath(loadRegistry(repoName), manualPath)).toBeDefined(); + + // Simulate an external `rm -rf` of the worktree dir, leaving git's own + // registration (and the registry entry) stale. + rmSync(manualPath, { recursive: true, force: true }); + + const trees = await reconcileRepoRegistry(makeDeps(repoName, repo, events)); + expect(findByPath(trees, manualPath)).toBeUndefined(); + expect(findByPath(loadRegistry(repoName), manualPath)).toBeUndefined(); + + // Reusing the same name must succeed now that `git worktree prune` ran; + // without it git still holds the stale worktree registration at manualPath. + writeJson(join(repoDataDir(repoName), "config.json"), { + worktrees: { namePool: [name] }, + }); + + const result = await createTree({ + repoName, + repoPath: repo, + emit: (type, data) => events.push({ type, data }), + log: { info: () => {}, warn: () => {} }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.tree.name).toBe(name); + expect(result.tree.path).toBe(manualPath); + }); + + test("branch rename updates the registry's ground-truth branch field", async () => { + const manualPath = join(repo, ".worktrees", "renametree"); + execSync(`git worktree add -b old-name ${manualPath}`, { cwd: repo, shell: "/bin/zsh" }); + + await reconcileRepoRegistry(makeDeps(repoName, repo, events)); + expect(findByPath(loadRegistry(repoName), manualPath)!.branch).toBe("old-name"); + + execSync("git branch -m old-name new-name", { cwd: manualPath, shell: "/bin/zsh" }); + + const trees = await reconcileRepoRegistry(makeDeps(repoName, repo, events)); + const rec = findByPath(trees, manualPath); + expect(rec).toBeDefined(); + expect(rec!.branch).toBe("new-name"); + expect(rec!.kind).toBe("unmanaged"); // kind/state/owner untouched by ground-truth sync + }); + + test("orphaned creating entry with no held lock is scrapped", async () => { + const ghostPath = join(repo, ".worktrees", "ghost"); + execSync(`git worktree add -b on-deck/ghost ${ghostPath}`, { cwd: repo, shell: "/bin/zsh" }); + + const ghost: TreeRecord = { + name: "ghost", + path: ghostPath, + kind: "ephemeral", + state: "creating", + branch: "on-deck/ghost", + createdAt: new Date().toISOString(), + }; + saveRegistry(repoName, [ghost]); + + const trees = await reconcileRepoRegistry(makeDeps(repoName, repo, events)); + + expect(findByPath(trees, ghostPath)).toBeUndefined(); + expect(existsSync(ghostPath)).toBe(false); + expect(await branchExistsLocalAsync(repo, "on-deck/ghost")).toBe(false); + + const worktrees = await listWorktreesAsync(repo); + expect(worktrees.some((w) => w.path === ghostPath)).toBe(false); + }); +}); + +describe("createWorktreeReconciler", () => { + let repo: string; + let repoName: string; + + beforeEach(() => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtrecon-home-"))); + repo = makeRepo(); + repoName = "acme"; + }); + + test("runOnce reconciles only repos with registry entries or a worktrees config", async () => { + const manualPath = join(repo, ".worktrees", "manual"); + execSync(`git worktree add -b manual-branch ${manualPath}`, { cwd: repo, shell: "/bin/zsh" }); + // Opt this repo into worktree management so runOnce picks it up even + // though its registry starts empty. + writeJson(join(repoDataDir(repoName), "config.json"), { worktrees: {} }); + + const untouchedRepo = makeRepo(); + + const reconciler = createWorktreeReconciler({ + cache: { entries: {} }, + repoIndex: () => ({ [repoName]: repo, untouched: untouchedRepo }), + emit: () => {}, + log: fakeLog(), + }); + + await reconciler.runOnce(); + + expect(loadRegistry(repoName).length).toBe(2); // main + manual adopted + expect(loadRegistry("untouched").length).toBe(0); // never touched: no config, no entries + }); + + test("kick fires runOnce without awaiting and coalesces overlapping calls", async () => { + writeJson(join(repoDataDir(repoName), "config.json"), { worktrees: {} }); + + const reconciler = createWorktreeReconciler({ + cache: { entries: {} }, + repoIndex: () => ({ [repoName]: repo }), + emit: () => {}, + log: fakeLog(), + }); + + reconciler.kick(); + reconciler.kick(); // should be a no-op overlap guard, not a second pass + + // kick is fire-and-forget; give the microtask queue a turn to let it land. + await new Promise((r) => setTimeout(r, 50)); + + expect(loadRegistry(repoName).length).toBe(1); // just main, adopted once + }); +}); diff --git a/lib/daemon/worktree-reconciler.ts b/lib/daemon/worktree-reconciler.ts new file mode 100644 index 00000000..5029ff31 --- /dev/null +++ b/lib/daemon/worktree-reconciler.ts @@ -0,0 +1,200 @@ +/** + * Worktree reconciler — brings the on-disk registry back in line with git + * ground truth. First slice (Task 10): registry reconcile only. Tasks 11-12 + * extend `runOnce` in place with the merge reactor, freshen, and + * replenish/shrink passes, so structure here is deliberately left open for + * that: `reconcileRepoRegistry` is a standalone step `runOnce` calls per + * repo, and `createWorktreeReconciler`'s returned object is the single + * surface later tasks add to (e.g. `creationInFlight`). + */ + +import { basename, join } from "path"; +import { realpathSync } from "fs"; +import type { Logger } from "pino"; +import { readJson } from "../json-store.ts"; +import { repoDataDir } from "../rt-paths.ts"; +import { + loadRegistry, + saveRegistry, + type TreeKind, + type TreeRecord, +} from "../worktree/registry.ts"; +import { runGit, listWorktreesAsync, type WorktreeEntry } from "../worktree/git-async.ts"; +import { isTreeLocked } from "../worktree/locks.ts"; +import { scrapTree, type CreateDeps } from "../worktree/create.ts"; + +export interface ReconcilerDeps { + cache: { entries: Record }; + repoIndex: () => Record; + emit: (type: string, data: unknown) => void; + log: Logger; +} + +/** realpathSync defensively; a path that doesn't (yet) exist compares as-is. */ +function canon(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +/** + * Reconcile one repo's worktree registry against git ground truth (spec §4). + * + * Order matters: + * 1. `git worktree prune` FIRST — an `rm -rf`'d tree otherwise leaves git's + * stale worktree registration holding the path/branch, which blocks a + * later create from reusing the same name. + * 2. (d) orphaned `creating` entries (no held lock) are scrapped before (a) + * evaluates existence, since an in-flight (locked) `creating` entry has + * no git worktree yet and must not be pruned out from under the create. + * 3. (a) registry entries with no matching git/disk worktree are pruned. + * 4. (b) git worktrees unknown to the registry are adopted (main/unmanaged). + * 5. (c) every remaining registered tree's `branch` is set to git ground + * truth; kind/state/owner are left untouched. + * 6. (e) duplicate branches across registered trees are left as-is — + * surfaced elsewhere (findByBranch / T13's list handler). + */ +export async function reconcileRepoRegistry(deps: { + repoName: string; + repoPath: string; + emit: (type: string, data: unknown) => void; + log: Logger; +}): Promise { + const { repoName, repoPath, emit, log } = deps; + + await runGit(repoPath, ["worktree", "prune"]); + + let trees = loadRegistry(repoName); + let changed = false; + const createDeps: CreateDeps = { repoName, repoPath, emit, log }; + + // (d) creating entries with no held lock -> scrap, no recreate. Entries + // still locked (genuinely in-flight) pass through untouched. Scrapping + // mutates git state (worktree remove + branch -D), so the git listing used + // by (a)-(c) below is captured AFTER this loop, not before. + const afterScrap: TreeRecord[] = []; + for (const rec of trees) { + if (rec.state === "creating" && !isTreeLocked(rec.path)) { + log.info({ repo: repoName, tree: rec.name, path: rec.path }, "reconcile: scrapping orphaned creating tree"); + await scrapTree(createDeps, rec); + changed = true; + continue; + } + afterScrap.push(rec); + } + trees = afterScrap; + + const gitEntries = await listWorktreesAsync(repoPath); + const gitByCanon = new Map(); + for (const entry of gitEntries) { + gitByCanon.set(canon(entry.path), entry); + } + + // (a) registry paths missing from git/disk -> prune entry. `creating` + // entries are exempt: they legitimately have no git worktree yet. + const afterPrune: TreeRecord[] = []; + for (const rec of trees) { + if (rec.state === "creating") { + afterPrune.push(rec); + continue; + } + if (gitByCanon.has(canon(rec.path))) { + afterPrune.push(rec); + } else { + log.info({ repo: repoName, tree: rec.name, path: rec.path }, "reconcile: pruning registry entry with no matching worktree"); + changed = true; + } + } + trees = afterPrune; + + // (b) git paths unknown to registry -> adopt. The first porcelain entry is + // always the main clone. + const known = new Set(trees.map((t) => canon(t.path))); + let mainRegistered = trees.some((t) => t.kind === "main"); + for (const entry of gitEntries) { + const c = canon(entry.path); + if (known.has(c)) continue; + + const isMain = entry === gitEntries[0] && !mainRegistered; + const kind: TreeKind = isMain ? "main" : "unmanaged"; + if (isMain) mainRegistered = true; + + const rec: TreeRecord = { + name: basename(entry.path), + path: entry.path, + kind, + branch: entry.branch, + createdAt: new Date().toISOString(), + }; + trees.push(rec); + known.add(c); + changed = true; + log.info({ repo: repoName, tree: rec.name, kind, path: rec.path }, "reconcile: adopted worktree into registry"); + } + + // (c) ground-truth branch sync for every registered tree git still knows + // about. kind/state/owner are never touched here. + for (const rec of trees) { + const entry = gitByCanon.get(canon(rec.path)); + if (entry && rec.branch !== entry.branch) { + rec.branch = entry.branch; + changed = true; + } + } + + // (e) duplicate branches across registered trees: leave records as-is. + + if (changed) { + saveRegistry(repoName, trees); + } + + return trees; +} + +/** Whether a repo has any worktree state worth reconciling: registry entries or a declared "worktrees" config. */ +function repoHasWorktreeActivity(repoName: string): boolean { + if (loadRegistry(repoName).length > 0) return true; + const configPath = join(repoDataDir(repoName), "config.json"); + const raw = readJson<{ worktrees?: unknown }>(configPath, {}); + return raw.worktrees !== undefined; +} + +/** + * Assembles the worktree reconciler. This slice's `runOnce` only runs the + * registry reconcile pass per qualifying repo; Tasks 11-12 extend `runOnce` + * in place to add the merge reactor, freshen, and replenish/shrink passes. + */ +export function createWorktreeReconciler(deps: ReconcilerDeps): { + kick: () => void; + runOnce: () => Promise; +} { + let inFlight: Promise | null = null; + + async function runOnce(): Promise { + const repos = deps.repoIndex(); + for (const [repoName, repoPath] of Object.entries(repos)) { + if (!repoHasWorktreeActivity(repoName)) continue; + try { + await reconcileRepoRegistry({ repoName, repoPath, emit: deps.emit, log: deps.log }); + } catch (err) { + deps.log.warn({ err, repo: repoName }, "worktree reconciler: repo pass failed"); + } + } + } + + function kick(): void { + if (inFlight) return; + const p = runOnce() + .catch((err) => { + deps.log.warn({ err }, "worktree reconciler: kick failed"); + }) + .finally(() => { + if (inFlight === p) inFlight = null; + }); + inFlight = p; + } + + return { kick, runOnce }; +} From f7ba64be5a323d31e5512076f6e1f18f0e2d5bd2 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 17:43:35 -0500 Subject: [PATCH 17/31] RT-34: merge reactor (MR-keyed fired store, real retry, auto-return + guarded auto-dispose) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports parking-lot.ts's open→terminal detector into the worktree reconciler with three deliberate changes: - Real retry. The harvest source overwrote its MR-state snapshot on every tick regardless of outcome, so a failed action was never re-detected and the not-marking-fired retry was silently dead. Here the snapshot advances past "opened" only when the reaction completed or deliberately terminated; a mechanical failure (stash/checkout/ff, a busy tree lock, a refused worktree remove) holds the edge armed for the next pass. - Fired keys are MR-keyed (`disposed:::`) and pruned when the MR returns to "opened", so a recut MR on the same derived branch name still acts. - Merged, closed and reopened diverge by tree kind and disposal mode: ephemeral disposal:"merge" auto-disposes behind the guard (any refusal but the transient "remove-failed" flips the tree disposable with its reason and notifies once); closed-without-merge flips disposable, never deletes; reopened un-disposables the tree; disposal:"job" is untouched; and main holding the merged branch auto-returns to the default branch, stashing its dirt under the branch that left and deliberately not popping it. Co-Authored-By: Claude Fable 5 --- .../__tests__/worktree-reconciler.test.ts | 263 +++++++++++- lib/daemon/worktree-reconciler.ts | 380 +++++++++++++++++- 2 files changed, 623 insertions(+), 20 deletions(-) diff --git a/lib/daemon/__tests__/worktree-reconciler.test.ts b/lib/daemon/__tests__/worktree-reconciler.test.ts index 8364aebd..07ed7af3 100644 --- a/lib/daemon/__tests__/worktree-reconciler.test.ts +++ b/lib/daemon/__tests__/worktree-reconciler.test.ts @@ -1,15 +1,20 @@ import { describe, test, expect, beforeEach } from "bun:test"; import { execSync } from "child_process"; -import { existsSync, mkdtempSync, realpathSync, rmSync } from "fs"; +import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; -import { join } from "path"; +import { basename, join } from "path"; import type { Logger } from "pino"; -import { writeJson } from "../../json-store.ts"; -import { repoDataDir } from "../../rt-paths.ts"; +import { readJson, writeJson } from "../../json-store.ts"; +import { repoDataDir, rtDir } from "../../rt-paths.ts"; import { findByPath, loadRegistry, saveRegistry, type TreeRecord } from "../../worktree/registry.ts"; -import { branchExistsLocalAsync, listWorktreesAsync } from "../../worktree/git-async.ts"; +import { + branchExistsLocalAsync, + currentBranchAsync, + findDesktopStashAsync, + listWorktreesAsync, +} from "../../worktree/git-async.ts"; import { createTree } from "../../worktree/create.ts"; -import { reconcileRepoRegistry, createWorktreeReconciler } from "../worktree-reconciler.ts"; +import { reconcileRepoRegistry, createWorktreeReconciler, __test__ } from "../worktree-reconciler.ts"; function makeRepo(): string { // realpathSync: git canonicalizes /var -> /private/var on macOS (Global Constraints) @@ -202,3 +207,249 @@ describe("createWorktreeReconciler", () => { expect(loadRegistry(repoName).length).toBe(1); // just main, adopted once }); }); + +// ─── Merge reactor ─────────────────────────────────────────────────────────── + +const GIT_ID = "-c user.email=t@t -c user.name=t"; + +function sh(cmd: string, cwd?: string): void { + execSync(cmd, { cwd, shell: "/bin/zsh", stdio: "pipe" }); +} + +describe("merge reactor (detectTransitions)", () => { + const repoName = "acme"; + let repo: string; + let events: Array<{ type: string; data: any }>; + + beforeEach(() => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtreact-home-"))); + repo = makeRepo(); + addBareOrigin(repo); + // killProcesses off: the reactor must not go scanning this machine's + // process table during a unit test. + writeJson(join(rtDir(), "worktrees.json"), { enabled: true, killProcesses: false }); + events = []; + }); + + function detect(entries: Record): Promise { + return __test__.detectTransitions({ + repoName, + repoPath: repo, + cacheEntries: entries as any, + emit: (type: string, data: unknown) => events.push({ type, data }), + log: fakeLog(), + }); + } + + function mrCache(branch: string, state: string, iid = 42): Record { + return { [branch]: { repoName, mr: { iid, state }, fetchedAt: Date.now() } }; + } + + function reactorState(): { mrState: Record; fired: string[] } { + return readJson(__test__.reactorStatePath(), { mrState: {}, fired: [] }); + } + + function tracked(path: string): TreeRecord | undefined { + return loadRegistry(repoName).find((t) => t.path === path); + } + + /** Ephemeral worktree on a pushed feature branch, registered as claimed. */ + function ephemeralTree(name: string, branch: string, extra: Partial = {}): TreeRecord { + const path = join(repo, ".worktrees", name); + sh(`git -C ${repo} worktree add -b ${branch} ${path} origin/main`); + writeFileSync(join(path, `${name}.txt`), "work\n"); + sh(`git add -A && git ${GIT_ID} commit -m work`, path); + sh(`git push -q origin ${branch}`, path); + + const old = new Date(Date.now() - 3600_000).toISOString(); + const rec: TreeRecord = { + name, + path, + kind: "ephemeral", + state: "claimed", + branch, + disposal: "merge", + createdAt: old, + claimedAt: old, // outside dispose's 10-minute stale-event grace + ...extra, + }; + saveRegistry(repoName, [...loadRegistry(repoName), rec]); + return rec; + } + + /** Put the main clone itself on a pushed feature branch, registered as main. */ + function mainOnBranch(branch: string): void { + sh(`git ${GIT_ID} checkout -q -b ${branch} origin/main`, repo); + writeFileSync(join(repo, "feature.txt"), "main work\n"); + sh(`git add -A && git ${GIT_ID} commit -m mainwork`, repo); + sh(`git push -q origin ${branch}`, repo); + saveRegistry(repoName, [ + { name: basename(repo), path: repo, kind: "main", branch, createdAt: new Date().toISOString() }, + ]); + } + + test("merged MR on a clean claimed tree disposes it, emits, and records a fired key", async () => { + const rec = ephemeralTree("alpha", "feat-alpha"); + + await detect(mrCache("feat-alpha", "opened")); + expect(reactorState().mrState[`${repoName}:feat-alpha`]).toBe("opened"); + + await detect(mrCache("feat-alpha", "merged")); + + expect(existsSync(rec.path)).toBe(false); + expect(tracked(rec.path)).toBeUndefined(); + expect(events.filter((e) => e.type === "worktree:disposed").length).toBe(1); + expect(reactorState().fired).toContain(`disposed:${repoName}:42:merged`); + }); + + test("a dirty tree flips to disposable once and never re-notifies", async () => { + const rec = ephemeralTree("bravo", "feat-bravo"); + writeFileSync(join(rec.path, "scratch.txt"), "uncommitted\n"); + + await detect(mrCache("feat-bravo", "opened")); + await detect(mrCache("feat-bravo", "merged")); + + expect(existsSync(rec.path)).toBe(true); + expect(tracked(rec.path)!.state).toBe("disposable"); + expect(tracked(rec.path)!.disposableReason).toBe("dirty"); + expect(events.filter((e) => e.type === "worktree:disposable").length).toBe(1); + + await detect(mrCache("feat-bravo", "merged")); + expect(events.filter((e) => e.type === "worktree:disposable").length).toBe(1); + }); + + test("a closed MR flips the tree disposable and leaves the branch intact", async () => { + const rec = ephemeralTree("charlie", "feat-charlie"); + + await detect(mrCache("feat-charlie", "opened")); + await detect(mrCache("feat-charlie", "closed")); + + expect(existsSync(rec.path)).toBe(true); + expect(tracked(rec.path)!.state).toBe("disposable"); + expect(tracked(rec.path)!.disposableReason).toBe("MR closed without merge"); + expect(await branchExistsLocalAsync(repo, "feat-charlie")).toBe(true); + expect(reactorState().fired).toContain(`disposed:${repoName}:42:closed`); + }); + + test("reopening claims the tree back and prunes its fired keys; a later merge disposes", async () => { + const rec = ephemeralTree("delta", "feat-delta"); + const dirt = join(rec.path, "scratch.txt"); + writeFileSync(dirt, "uncommitted\n"); + + await detect(mrCache("feat-delta", "opened")); + await detect(mrCache("feat-delta", "merged")); + expect(tracked(rec.path)!.state).toBe("disposable"); + expect(reactorState().fired).toContain(`disposed:${repoName}:42:merged`); + + await detect(mrCache("feat-delta", "opened")); + expect(tracked(rec.path)!.state).toBe("claimed"); + expect(tracked(rec.path)!.disposableReason).toBeUndefined(); + expect(reactorState().fired).not.toContain(`disposed:${repoName}:42:merged`); + + rmSync(dirt); + await detect(mrCache("feat-delta", "merged")); + expect(existsSync(rec.path)).toBe(false); + expect(tracked(rec.path)).toBeUndefined(); + }); + + test("main holding the merged branch auto-returns to default, stashing and leaving the dirt", async () => { + mainOnBranch("feat-main"); + writeFileSync(join(repo, "dirty.txt"), "uncommitted\n"); + + await detect(mrCache("feat-main", "opened")); + await detect(mrCache("feat-main", "merged")); + + expect(await currentBranchAsync(repo)).toBe("main"); + const stash = await findDesktopStashAsync(repo, "feat-main"); + expect(stash).not.toBeNull(); + // stash-and-LEAVE: the dirt belonged to the branch that left, so it is + // never popped back onto the default branch. + expect(existsSync(join(repo, "dirty.txt"))).toBe(false); + }); + + test("a failed auto-return holds the edge armed and fires again once the repo is repaired", async () => { + mainOnBranch("feat-lock"); + const lock = join(repo, ".git", "index.lock"); + + await detect(mrCache("feat-lock", "opened")); + writeFileSync(lock, ""); + await detect(mrCache("feat-lock", "merged")); + + expect(await currentBranchAsync(repo)).toBe("feat-lock"); + // The snapshot must NOT advance to "merged", or the edge never re-arms. + expect(reactorState().mrState[`${repoName}:feat-lock`]).toBe("opened"); + expect(reactorState().fired).not.toContain(`disposed:${repoName}:42:merged`); + + rmSync(lock); + await detect(mrCache("feat-lock", "merged")); + + expect(await currentBranchAsync(repo)).toBe("main"); + expect(reactorState().mrState[`${repoName}:feat-lock`]).toBe("merged"); + }); + + test("a failed worktree removal is retried, never advertised as disposable", async () => { + const rec = ephemeralTree("hotel", "feat-hotel"); + // A locked worktree makes `git worktree remove --force` refuse (it wants + // --force twice) without touching the directory: a mechanical, transient + // removal failure, which must NOT be reported to the user as "disposable". + sh(`git -C ${repo} worktree lock ${rec.path}`); + + await detect(mrCache("feat-hotel", "opened")); + await detect(mrCache("feat-hotel", "merged")); + + expect(existsSync(rec.path)).toBe(true); + expect(tracked(rec.path)!.state).toBe("claimed"); + expect(tracked(rec.path)!.disposableReason).toBeUndefined(); + expect(events.some((e) => e.type === "worktree:disposable")).toBe(false); + expect(reactorState().mrState[`${repoName}:feat-hotel`]).toBe("opened"); + + sh(`git -C ${repo} worktree unlock ${rec.path}`); + await detect(mrCache("feat-hotel", "merged")); + + expect(existsSync(rec.path)).toBe(false); + expect(tracked(rec.path)).toBeUndefined(); + }); + + test('a disposal:"job" tree with a merged MR is untouched', async () => { + const rec = ephemeralTree("echo", "feat-echo", { disposal: "job" }); + + await detect(mrCache("feat-echo", "opened")); + await detect(mrCache("feat-echo", "merged")); + + expect(existsSync(rec.path)).toBe(true); + expect(tracked(rec.path)!.state).toBe("claimed"); + expect(tracked(rec.path)!.disposableReason).toBeUndefined(); + expect(events.length).toBe(0); + }); + + test("cache entries belonging to another repo never join this repo's trees", async () => { + const rec = ephemeralTree("foxtrot", "feat-foxtrot"); + const foreign = { "feat-foxtrot": { repoName: "other", mr: { iid: 42, state: "opened" } } }; + + await detect(foreign); + await detect({ "feat-foxtrot": { repoName: "other", mr: { iid: 42, state: "merged" } } }); + + expect(existsSync(rec.path)).toBe(true); + expect(tracked(rec.path)!.state).toBe("claimed"); + }); + + test("runOnce runs the reactor after the reconcile pass", async () => { + const rec = ephemeralTree("golf", "feat-golf"); + writeJson(join(repoDataDir(repoName), "config.json"), { worktrees: {} }); + + const cache = { entries: mrCache("feat-golf", "opened") as Record }; + const reconciler = createWorktreeReconciler({ + cache, + repoIndex: () => ({ [repoName]: repo }), + emit: (type: string, data: unknown) => events.push({ type, data }), + log: fakeLog(), + }); + + await reconciler.runOnce(); + cache.entries = mrCache("feat-golf", "merged") as Record; + await reconciler.runOnce(); + + expect(existsSync(rec.path)).toBe(false); + expect(events.some((e) => e.type === "worktree:disposed")).toBe(true); + }); +}); diff --git a/lib/daemon/worktree-reconciler.ts b/lib/daemon/worktree-reconciler.ts index 5029ff31..abfd2ecf 100644 --- a/lib/daemon/worktree-reconciler.ts +++ b/lib/daemon/worktree-reconciler.ts @@ -1,27 +1,38 @@ /** * Worktree reconciler — brings the on-disk registry back in line with git - * ground truth. First slice (Task 10): registry reconcile only. Tasks 11-12 - * extend `runOnce` in place with the merge reactor, freshen, and - * replenish/shrink passes, so structure here is deliberately left open for - * that: `reconcileRepoRegistry` is a standalone step `runOnce` calls per - * repo, and `createWorktreeReconciler`'s returned object is the single - * surface later tasks add to (e.g. `creationInFlight`). + * ground truth, then reacts to the MR transitions that end a tree's life. + * Task 12 extends `runOnce` in place with the freshen and replenish/shrink + * passes, so structure here is deliberately left open for that: each duty is + * a standalone step `runOnce` calls per repo, and `createWorktreeReconciler`'s + * returned object is the single surface later tasks add to (e.g. + * `creationInFlight`). */ import { basename, join } from "path"; import { realpathSync } from "fs"; import type { Logger } from "pino"; -import { readJson } from "../json-store.ts"; -import { repoDataDir } from "../rt-paths.ts"; +import { readJson, writeJson } from "../json-store.ts"; +import { repoDataDir, rtDir } from "../rt-paths.ts"; import { + findByBranch, loadRegistry, saveRegistry, type TreeKind, type TreeRecord, } from "../worktree/registry.ts"; -import { runGit, listWorktreesAsync, type WorktreeEntry } from "../worktree/git-async.ts"; -import { isTreeLocked } from "../worktree/locks.ts"; +import { + currentBranchAsync, + listWorktreesAsync, + remoteDefaultRef, + runGit, + stashChangesAsync, + type WorktreeEntry, +} from "../worktree/git-async.ts"; +import { isTreeLocked, withTreeLock } from "../worktree/locks.ts"; import { scrapTree, type CreateDeps } from "../worktree/create.ts"; +import { disposeTree } from "../worktree/dispose.ts"; +import { loadWorktreeAppConfig, type WorktreeAppConfig } from "../worktree/config.ts"; +import { killWorktreeProcesses } from "./worktree-process-kill.ts"; export interface ReconcilerDeps { cache: { entries: Record }; @@ -153,6 +164,332 @@ export async function reconcileRepoRegistry(deps: { return trees; } +// ─── Merge reactor (spec §6.2) ─────────────────────────────────────────────── + +/** + * The reactor's own memory, at `~/.rt/worktree-reactor-state.json`. + * + * `mrState` is the last-seen MR state per `:`, compared against + * the live cache to find `opened → merged|closed` edges. A branch the file has + * never seen fails the `prev === "opened"` gate, which is what makes a cold + * boot on an already-merged cache entry a no-op rather than a mass disposal. + * + * `fired` is keyed by MR, not branch: `disposed:::`. + * Branch keys are wrong here because this design derives branch names from + * tickets, so a recut MR reuses the branch and a branch-keyed fire would + * silently never act a second time. The MR's keys are pruned when it returns + * to `opened`. + */ +interface ReactorState { + mrState: Record; + fired: string[]; +} + +export function reactorStatePath(): string { + return join(rtDir(), "worktree-reactor-state.json"); +} + +function loadReactorState(): ReactorState { + const raw = readJson>(reactorStatePath(), {}); + return { + mrState: raw?.mrState ?? {}, + fired: Array.isArray(raw?.fired) ? raw.fired : [], + }; +} + +function saveReactorState(state: ReactorState, log: Logger): void { + try { + writeJson(reactorStatePath(), state); + } catch (err) { + log.warn({ err }, "worktree reactor: could not persist state"); + } +} + +/** Branch-keyed MR cache entry, as the daemon holds it (`ctx.cache.entries`). */ +interface ReactorCacheEntry { + mr?: { iid?: number; state?: string | null } | null; + repoName?: string; +} + +export interface ReactorDeps { + repoName: string; + repoPath: string; + /** Branch-keyed MR cache (daemon `ctx.cache.entries`). */ + cacheEntries: Record; + emit: (type: string, data: unknown) => void; + log: Logger; +} + +const TERMINAL_STATES = new Set(["merged", "closed"]); + +/** + * What one tree's reaction did, which is also what the snapshot is allowed to + * do afterwards: + * - `done` — nothing to react to (wrong kind, job disposal, main already + * moved on). The edge is spent; advance the snapshot. + * - `fired` — the reaction happened (disposed / flipped disposable / + * auto-returned). Advance the snapshot AND record the fired key + * so a cache churn can't re-notify. + * - `retry` — the reaction failed for a mechanical, transient reason. The + * snapshot must stay at "opened" or the edge never re-arms; this + * is the correctness fix over the harvested parking-lot version, + * which advanced the snapshot unconditionally and so silently + * defeated its own retry. + */ +type Reaction = "done" | "fired" | "retry"; + +/** Load-mutate-save one registry row without clobbering concurrent edits. */ +function patchTree(repoName: string, path: string, patch: (rec: TreeRecord) => void): void { + const trees = loadRegistry(repoName); + const rec = trees.find((t) => t.path === path); + if (!rec) return; + patch(rec); + saveRegistry(repoName, trees); +} + +function markDisposable(deps: ReactorDeps, rec: TreeRecord, reason: string): void { + patchTree(deps.repoName, rec.path, (r) => { + r.state = "disposable"; + r.disposableReason = reason; + }); + deps.emit("worktree:disposable", { + repo: deps.repoName, + tree: rec.name, + path: rec.path, + branch: rec.branch, + reason, + }); + deps.log.info( + { repo: deps.repoName, tree: rec.name, reason }, + `worktree ${rec.name} is disposable: ${reason}`, + ); +} + +/** + * Return the main clone to its default branch after its branch merged. + * + * Harvested from `park()` minus the parking-slot branch: verify main is still + * on the merged branch, stop its workload, stash-and-LEAVE any dirt under the + * GitHub Desktop-compatible marker keyed to the branch that left, check out the + * default branch, fast-forward it. The stash is deliberately never popped — it + * belongs to the merged branch, not to the default branch main now sits on. + * + * Any mechanical failure returns "retry" so the snapshot holds and the next + * pass tries again; main has no disposable-equivalent state to park a failure + * in, so without the retry a transient failure would strand main on a dead + * branch forever. + */ +async function autoReturnMain( + deps: ReactorDeps, + rec: TreeRecord, + mergedBranch: string, + appConfig: WorktreeAppConfig, +): Promise { + const { repoName, log } = deps; + const fields = { repo: repoName, tree: rec.name, path: rec.path, branch: mergedBranch }; + + const current = await currentBranchAsync(rec.path); + if (current !== mergedBranch) { + log.debug?.({ ...fields, current }, "auto-return skipped: main is no longer on the merged branch"); + return "done"; + } + + if (appConfig.killProcesses) { + // The ruled execSync exception (the process killer is sync by design); + // a failure here never blocks the return. + try { + const { terminated } = killWorktreeProcesses(rec.path); + if (terminated.length > 0) log.info({ ...fields, count: terminated.length }, "worktree processes terminated"); + } catch (err) { + log.warn({ err, ...fields }, "auto-return: process kill failed; returning anyway"); + } + } + + const status = await runGit(rec.path, ["status", "--porcelain"]); + if (status.exitCode !== 0) { + log.warn({ ...fields, output: status.stderr.trim() }, "auto-return: git status failed"); + return "retry"; + } + if (status.stdout.trim().length > 0) { + await stashChangesAsync(rec.path, mergedBranch); + const after = await runGit(rec.path, ["status", "--porcelain"]); + if (after.exitCode !== 0 || after.stdout.trim().length > 0) { + log.warn({ ...fields }, "auto-return: stash did not clear the worktree"); + return "retry"; + } + log.info({ ...fields }, `stashed uncommitted changes on "${mergedBranch}"`); + } + + const defaultRef = await remoteDefaultRef(rec.path); + const defaultBranch = defaultRef.replace(/^origin\//, ""); + + const checkout = await runGit(rec.path, ["checkout", defaultBranch]); + if (checkout.exitCode !== 0) { + log.warn({ ...fields, defaultBranch, output: checkout.stderr.trim() }, "auto-return: checkout failed"); + return "retry"; + } + + const ff = await runGit(rec.path, ["merge", "--ff-only", defaultRef]); + if (ff.exitCode !== 0) { + log.warn({ ...fields, defaultRef, output: ff.stderr.trim() }, "auto-return: fast-forward failed"); + return "retry"; + } + + log.info({ ...fields, defaultRef }, `returned ${rec.name} to ${defaultBranch} after ${mergedBranch} merged`); + return "fired"; +} + +/** React to one terminal MR state on one registered tree. Caller holds the tree lock. */ +async function actOnTree( + deps: ReactorDeps, + rec: TreeRecord, + branch: string, + mrState: string, + appConfig: WorktreeAppConfig, +): Promise { + if (rec.kind === "main") { + // Closed-without-merge leaves main alone: the branch's commits are still + // only on that branch, and a closed MR often means recut. + return mrState === "merged" ? autoReturnMain(deps, rec, branch, appConfig) : "done"; + } + if (rec.kind !== "ephemeral") return "done"; + // Job trees are the caller's to end, MR or no MR. + if (rec.disposal === "job") return "done"; + // claimed AND disposable both react, so a reopened-then-merged MR still + // disposes; on-deck/creating trees never carry MR branches. + if (rec.state !== "claimed" && rec.state !== "disposable") return "done"; + + if (mrState === "closed") { + markDisposable(deps, rec, "MR closed without merge"); + return "fired"; + } + + const outcome = await disposeTree( + { + repoName: deps.repoName, + repoPath: deps.repoPath, + cacheEntries: deps.cacheEntries as Record, + emit: deps.emit, + log: deps.log, + killProcesses: appConfig.killProcesses, + }, + rec, + { auto: true }, + ); + if (outcome.disposed) return "fired"; + + // "remove-failed" is mechanical and transient (a locked file, a busy + // directory) — the tree is still perfectly claimable, so it must NOT be + // advertised as disposable. Hold the edge and try again next pass. + if (outcome.refusal === "remove-failed") { + deps.log.warn( + { repo: deps.repoName, tree: rec.name, path: rec.path }, + "auto-dispose: worktree removal failed; retrying next pass", + ); + return "retry"; + } + + markDisposable(deps, rec, outcome.refusal); + return "fired"; +} + +/** Worst outcome wins: any retry re-arms the edge, otherwise any fire records it. */ +function worse(a: Reaction, b: Reaction): Reaction { + if (a === "retry" || b === "retry") return "retry"; + if (a === "fired" || b === "fired") return "fired"; + return "done"; +} + +/** An MR back to `opened` un-disposables the trees on its branch: work resumed. */ +async function resumeTrees(deps: ReactorDeps, branch: string): Promise { + for (const rec of findByBranch(loadRegistry(deps.repoName), branch)) { + if (rec.kind !== "ephemeral" || rec.state !== "disposable") continue; + await withTreeLock(rec.path, async () => { + patchTree(deps.repoName, rec.path, (r) => { + r.state = "claimed"; + delete r.disposableReason; + }); + deps.log.info( + { repo: deps.repoName, tree: rec.name, branch }, + `MR reopened — ${rec.name} is claimed again`, + ); + }); + } +} + +/** + * Detect `opened → merged|closed` MR transitions for one repo and react. + * + * Port of `parking-lot.ts` checkAndPark's detector with three deliberate + * changes: the retry fix (see `Reaction`), MR-keyed fired keys with reopen + * pruning, and a merged/closed/reopened dispatch that branches on tree kind + * and disposal mode instead of parking everything onto a slot branch. + */ +export async function detectTransitions(deps: ReactorDeps): Promise { + const { repoName, cacheEntries, log } = deps; + const appConfig = loadWorktreeAppConfig(); + + const state = loadReactorState(); + const fired = new Set(state.fired); + + // Snapshots are per repo; other repos' keys ride through untouched so a + // single-repo pass can't erase their memory, while this repo's stale + // branches drop out by being rebuilt from the live cache. + const prefix = `${repoName}:`; + const nextMrState: Record = {}; + for (const [key, value] of Object.entries(state.mrState)) { + if (!key.startsWith(prefix)) nextMrState[key] = value; + } + + for (const [branch, entry] of Object.entries(cacheEntries)) { + // Unattributed entries (older caches predate repoName) may join any repo; + // an entry attributed elsewhere never does. + if (entry.repoName && entry.repoName !== repoName) continue; + if (!entry.mr) continue; + + const cur = entry.mr.state ?? null; + const key = prefix + branch; + const prev = state.mrState[key] ?? null; + const iid = typeof entry.mr.iid === "number" ? String(entry.mr.iid) : branch; + + if (cur === "opened") { + nextMrState[key] = "opened"; + // Reopen: forget this MR's fires so a later merge acts again, and hand + // any disposable tree back to its owner. + for (const fireKey of [...fired]) { + if (fireKey.startsWith(`disposed:${repoName}:${iid}:`)) fired.delete(fireKey); + } + await resumeTrees(deps, branch); + continue; + } + + nextMrState[key] = cur; + if (prev !== "opened") continue; // cold-boot safety: unknown prev never fires + if (!cur || !TERMINAL_STATES.has(cur)) continue; + + const fireKey = `disposed:${repoName}:${iid}:${cur}`; + if (fired.has(fireKey)) continue; + + const trees = findByBranch(loadRegistry(repoName), branch); + if (trees.length === 0) { + log.debug?.({ repo: repoName, branch, mrState: cur }, "reactor: no registered tree on the branch"); + continue; + } + + let reaction: Reaction = "done"; + for (const rec of trees) { + const result = await withTreeLock(rec.path, () => actOnTree(deps, rec, branch, cur, appConfig)); + // A locked tree is someone else's in-flight work; come back next pass. + reaction = worse(reaction, result === "busy" ? "retry" : result); + } + + if (reaction === "retry") nextMrState[key] = "opened"; + else if (reaction === "fired") fired.add(fireKey); + } + + saveReactorState({ mrState: nextMrState, fired: [...fired] }, log); +} + /** Whether a repo has any worktree state worth reconciling: registry entries or a declared "worktrees" config. */ function repoHasWorktreeActivity(repoName: string): boolean { if (loadRegistry(repoName).length > 0) return true; @@ -162,9 +499,9 @@ function repoHasWorktreeActivity(repoName: string): boolean { } /** - * Assembles the worktree reconciler. This slice's `runOnce` only runs the - * registry reconcile pass per qualifying repo; Tasks 11-12 extend `runOnce` - * in place to add the merge reactor, freshen, and replenish/shrink passes. + * Assembles the worktree reconciler. `runOnce` runs reconcile then the merge + * reactor per qualifying repo; Task 12 extends it in place with the freshen + * and replenish/shrink passes. */ export function createWorktreeReconciler(deps: ReconcilerDeps): { kick: () => void; @@ -179,7 +516,20 @@ export function createWorktreeReconciler(deps: ReconcilerDeps): { try { await reconcileRepoRegistry({ repoName, repoPath, emit: deps.emit, log: deps.log }); } catch (err) { - deps.log.warn({ err, repo: repoName }, "worktree reconciler: repo pass failed"); + deps.log.warn({ err, repo: repoName }, "worktree reconciler: reconcile pass failed"); + } + // Separate catch: a reactor that throws must not cost the next repo its + // reconcile, and vice versa. + try { + await detectTransitions({ + repoName, + repoPath, + cacheEntries: deps.cache.entries, + emit: deps.emit, + log: deps.log, + }); + } catch (err) { + deps.log.warn({ err, repo: repoName }, "worktree reconciler: merge reactor pass failed"); } } } @@ -198,3 +548,5 @@ export function createWorktreeReconciler(deps: ReconcilerDeps): { return { kick, runOnce }; } + +export const __test__ = { detectTransitions, reactorStatePath }; From 60a361bf16a1a00f1a637a63747fed68e65aa2ca Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 17:54:24 -0500 Subject: [PATCH 18/31] =?UTF-8?q?RT-34:=20reactor=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20vet=20the=20auto-return=20destination=20before=20st?= =?UTF-8?q?ashing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round on the merge reactor: - autoReturnMain resolved the default branch AFTER killing processes and stashing, so two configurations the harvest source guarded against would stash the user's work and then retry forever: a repo whose default is develop/trunk (remoteDefaultRef hard-falls-back to an unverified "origin/master"), and a default branch another worktree already holds (park() refused up front for exactly this). Both checks now run before any destructive step and return "done" with a single warn — an unfixable configuration must not spin. - A successful auto-return patches main's registry branch to the default, so `rt worktree list` never shows main on a branch it already left. - Tests: the missing cold-boot regression (empty state + already-merged entry deletes nothing, no fired keys), both give-up paths (dirt left unstashed, edge spent), the registry-branch patch, another repo's snapshot and fired keys surviving a single-repo pass, and an unattributed cache entry joining. Co-Authored-By: Claude Fable 5 --- .../__tests__/worktree-reconciler.test.ts | 122 +++++++++++++++++- lib/daemon/worktree-reconciler.ts | 46 ++++++- 2 files changed, 163 insertions(+), 5 deletions(-) diff --git a/lib/daemon/__tests__/worktree-reconciler.test.ts b/lib/daemon/__tests__/worktree-reconciler.test.ts index 07ed7af3..c79bc9b3 100644 --- a/lib/daemon/__tests__/worktree-reconciler.test.ts +++ b/lib/daemon/__tests__/worktree-reconciler.test.ts @@ -231,16 +231,30 @@ describe("merge reactor (detectTransitions)", () => { events = []; }); - function detect(entries: Record): Promise { + function detect(entries: Record, log: Logger = fakeLog()): Promise { return __test__.detectTransitions({ repoName, repoPath: repo, cacheEntries: entries as any, emit: (type: string, data: unknown) => events.push({ type, data }), - log: fakeLog(), + log, }); } + /** A logger that keeps its warnings, for the "give up, don't spin" paths. */ + function capturingLog(): { log: Logger; warns: string[] } { + const warns: string[] = []; + return { + warns, + log: { + info: () => {}, + error: () => {}, + debug: () => {}, + warn: (_fields: unknown, msg?: string) => warns.push(msg ?? ""), + } as unknown as Logger, + }; + } + function mrCache(branch: string, state: string, iid = 42): Record { return { [branch]: { repoName, mr: { iid, state }, fetchedAt: Date.now() } }; } @@ -288,6 +302,22 @@ describe("merge reactor (detectTransitions)", () => { ]); } + test("cold boot on an already-merged cache entry deletes nothing", async () => { + const rec = ephemeralTree("cold", "feat-cold"); + + // FIRST call ever against an empty state file: the daemon has no "opened" + // snapshot to compare against, so there is no edge and nothing may happen. + await detect(mrCache("feat-cold", "merged")); + + expect(existsSync(rec.path)).toBe(true); + expect(tracked(rec.path)!.state).toBe("claimed"); + expect(tracked(rec.path)!.disposableReason).toBeUndefined(); + expect(events.length).toBe(0); + expect(reactorState().fired).toEqual([]); + // The snapshot still records what it saw, so a later reopen→merge fires. + expect(reactorState().mrState[`${repoName}:feat-cold`]).toBe("merged"); + }); + test("merged MR on a clean claimed tree disposes it, emits, and records a fired key", async () => { const rec = ephemeralTree("alpha", "feat-alpha"); @@ -410,6 +440,94 @@ describe("merge reactor (detectTransitions)", () => { expect(tracked(rec.path)).toBeUndefined(); }); + test("auto-return gives up (does not spin) when another worktree holds the default branch", async () => { + mainOnBranch("feat-elsewhere"); + // A second worktree parks on main, so `git checkout main` in the main + // clone can never succeed — a configuration, not a transient. + sh(`git -C ${repo} worktree add ${join(repo, ".worktrees", "holder")} main`); + writeFileSync(join(repo, "dirty.txt"), "uncommitted\n"); + + await detect(mrCache("feat-elsewhere", "opened")); + const { log, warns } = capturingLog(); + await detect(mrCache("feat-elsewhere", "merged"), log); + + expect(warns.length).toBe(1); + expect(warns[0]).toContain("main is checked out at"); + expect(await currentBranchAsync(repo)).toBe("feat-elsewhere"); + // The dirt is untouched: nothing may be stashed for a return that cannot happen. + expect(existsSync(join(repo, "dirty.txt"))).toBe(true); + expect(await findDesktopStashAsync(repo, "feat-elsewhere")).toBeNull(); + // Edge is spent, not re-armed — an unfixable config must not retry forever. + expect(reactorState().mrState[`${repoName}:feat-elsewhere`]).toBe("merged"); + }); + + test("auto-return gives up (does not spin) when the default branch cannot be resolved", async () => { + // A repo whose default is "develop": remoteDefaultRef falls back to an + // unverified "origin/master", so the checkout could never succeed. + const odd = realpathSync(mkdtempSync(join(tmpdir(), "rtreact-odd-"))); + sh(`git init -q -b develop ${odd}`); + sh(`git ${GIT_ID} commit -q --allow-empty -m init`, odd); + const bare = join(realpathSync(mkdtempSync(join(tmpdir(), "rtreact-oddbare-"))), "o.git"); + sh(`git clone -q --bare ${odd} ${bare} && git -C ${odd} remote add origin ${bare} && git -C ${odd} fetch -q origin`); + sh(`git ${GIT_ID} checkout -q -b feat-odd origin/develop`, odd); + writeFileSync(join(odd, "dirty.txt"), "uncommitted\n"); + saveRegistry(repoName, [ + { name: "odd", path: odd, kind: "main", branch: "feat-odd", createdAt: new Date().toISOString() }, + ]); + + const pass = (state: string, log: Logger) => + __test__.detectTransitions({ + repoName, + repoPath: odd, + cacheEntries: { "feat-odd": { repoName, mr: { iid: 42, state } } } as any, + emit: (type: string, data: unknown) => events.push({ type, data }), + log, + }); + + await pass("opened", fakeLog()); + const { log, warns } = capturingLog(); + await pass("merged", log); + + expect(warns.length).toBe(1); + expect(warns[0]).toContain("neither master nor origin/master exists"); + expect(await currentBranchAsync(odd)).toBe("feat-odd"); + expect(existsSync(join(odd, "dirty.txt"))).toBe(true); + expect(reactorState().mrState[`${repoName}:feat-odd`]).toBe("merged"); + }); + + test("a successful auto-return leaves main's registry branch on the default", async () => { + mainOnBranch("feat-ground"); + + await detect(mrCache("feat-ground", "opened")); + await detect(mrCache("feat-ground", "merged")); + + expect(tracked(repo)!.branch).toBe("main"); + }); + + test("another repo's snapshot and fired keys survive a single-repo pass", async () => { + writeJson(__test__.reactorStatePath(), { + mrState: { "otherrepo:feat-theirs": "opened" }, + fired: ["disposed:otherrepo:9:merged"], + }); + ephemeralTree("india", "feat-india"); + + await detect(mrCache("feat-india", "opened")); + + expect(reactorState().mrState["otherrepo:feat-theirs"]).toBe("opened"); + expect(reactorState().fired).toContain("disposed:otherrepo:9:merged"); + }); + + test("a cache entry with no repoName joins this repo and acts", async () => { + const rec = ephemeralTree("juliet", "feat-juliet"); + const unattributed = (state: string) => ({ "feat-juliet": { mr: { iid: 42, state } } }); + + await detect(unattributed("opened")); + await detect(unattributed("merged")); + + expect(existsSync(rec.path)).toBe(false); + expect(tracked(rec.path)).toBeUndefined(); + }); + test('a disposal:"job" tree with a merged MR is untouched', async () => { const rec = ephemeralTree("echo", "feat-echo", { disposal: "job" }); diff --git a/lib/daemon/worktree-reconciler.ts b/lib/daemon/worktree-reconciler.ts index abfd2ecf..c7242c7b 100644 --- a/lib/daemon/worktree-reconciler.ts +++ b/lib/daemon/worktree-reconciler.ts @@ -21,7 +21,9 @@ import { type TreeRecord, } from "../worktree/registry.ts"; import { + branchExistsLocalAsync, currentBranchAsync, + gitOk, listWorktreesAsync, remoteDefaultRef, runGit, @@ -294,6 +296,41 @@ async function autoReturnMain( return "done"; } + // Resolve and vet the destination BEFORE touching anything. Every step below + // is destructive-ish (kill, stash) and every failure after them re-arms the + // edge, so a destination that can never work would stash the user's dirt and + // then spin on it forever. Both of these are configurations, not transients: + // they return "done" (edge spent) with one warn, not "retry". + const defaultRef = await remoteDefaultRef(rec.path); + const defaultBranch = defaultRef.replace(/^origin\//, ""); + + // (a) remoteDefaultRef falls back to "origin/master" unverified, so a repo + // whose default is develop/trunk yields a ref that resolves nowhere and a + // checkout that can never succeed. + const haveLocal = await branchExistsLocalAsync(rec.path, defaultBranch); + const haveRemote = await gitOk(rec.path, ["rev-parse", "--verify", defaultRef]); + if (!haveLocal && !haveRemote) { + log.warn( + { ...fields, defaultRef }, + `auto-return skipped: neither ${defaultBranch} nor ${defaultRef} exists — set the repo's default branch`, + ); + return "done"; + } + + // (b) git refuses to check out a branch another worktree holds. park() + // refused up front for exactly this; without the check the checkout + // fails after the stash and retries every pass. + const holder = (await listWorktreesAsync(deps.repoPath)).find( + (w) => w.branch === defaultBranch && canon(w.path) !== canon(rec.path), + ); + if (holder) { + log.warn( + { ...fields, defaultBranch, holder: holder.path }, + `auto-return skipped: ${defaultBranch} is checked out at ${holder.path}`, + ); + return "done"; + } + if (appConfig.killProcesses) { // The ruled execSync exception (the process killer is sync by design); // a failure here never blocks the return. @@ -320,9 +357,6 @@ async function autoReturnMain( log.info({ ...fields }, `stashed uncommitted changes on "${mergedBranch}"`); } - const defaultRef = await remoteDefaultRef(rec.path); - const defaultBranch = defaultRef.replace(/^origin\//, ""); - const checkout = await runGit(rec.path, ["checkout", defaultBranch]); if (checkout.exitCode !== 0) { log.warn({ ...fields, defaultBranch, output: checkout.stderr.trim() }, "auto-return: checkout failed"); @@ -335,6 +369,12 @@ async function autoReturnMain( return "retry"; } + // Ground truth now, not next reconcile: `rt worktree list` must not show + // main sitting on a branch it already left. + patchTree(repoName, rec.path, (r) => { + r.branch = defaultBranch; + }); + log.info({ ...fields, defaultRef }, `returned ${rec.name} to ${defaultBranch} after ${mergedBranch} merged`); return "fired"; } From f0767349fd8259fe04e6a1ebb25e7ab2fcb58fd1 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 18:14:54 -0500 Subject: [PATCH 19/31] RT-34: freshen + replenish/shrink; reconciler kicks detached from cache refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the worktree reconciler with the freshen pass (fetch → ff-only merge default into on-deck trees / idle main → run triggered ready steps, with exponential retry backoff on failure) and the replenish/shrink pass (grow the on-deck pool serially up to a bounded per-pass attempt budget, shrink by disposing the stalest ready entry). Both are gated on loadWorktreeAppConfig().enabled, same as the merge reactor and the registry's orphaned-"creating" scrap step, which now share the same gate. createWorktreeReconciler() gains creationInFlight(repoName), exposing the live createTree promise replenish kicked off so a future provision handler can await it instead of racing its own create. Wires the reconciler into the daemon: cache-refresh.ts drops the old checkAndPark call/import (superseded by the reactor) and fires worktreeKick() detached right after the status broadcast; branch discovery now excludes on-deck/* branches from MR/Linear enrichment. daemon.ts constructs the reconciler and threads its kick into the cache refresher. Co-Authored-By: Claude Fable 5 --- lib/daemon.ts | 13 + .../__tests__/worktree-reconciler.test.ts | 307 ++++++++++++++++++ lib/daemon/cache-refresh.ts | 24 +- lib/daemon/worktree-reconciler.ts | 290 ++++++++++++++++- 4 files changed, 615 insertions(+), 19 deletions(-) diff --git a/lib/daemon.ts b/lib/daemon.ts index e7bce11e..6776fbe2 100644 --- a/lib/daemon.ts +++ b/lib/daemon.ts @@ -31,6 +31,7 @@ import { evictStaleDaemon } from "./daemon/boot-reconcile.ts"; import { resolveUserPath } from "./daemon/user-path.ts"; import { createBranchCache } from "./daemon/branch-cache.ts"; import { createCacheRefresher } from "./daemon/cache-refresh.ts"; +import { createWorktreeReconciler } from "./daemon/worktree-reconciler.ts"; import { loadRepoIndex, REPOS_JSON_PATH } from "./daemon/repo-index.ts"; import { createHooksGuard } from "./daemon/hooks-guard.ts"; import { buildRoutedHandlers } from "./daemon/command-router.ts"; @@ -90,12 +91,24 @@ const emit: typeof broadcast = (type, data) => { cron.onBroadcast(type, data); }; +// Worktree lifecycle reconciler: reconcile → merge reactor → freshen → +// replenish/shrink. Kicked detached off the tail of every cache refresh +// (see `worktreeKick` below), plus its own periodic pass isn't needed since +// the refresh timer already provides one. +const worktreeReconciler = createWorktreeReconciler({ + cache, + repoIndex: loadRepoIndex, + emit, + log, +}); + const refreshCache = createCacheRefresher({ log, cache, loadCache, refreshStatusRef, portCacheRef, repoIndex: loadRepoIndex, broadcast: emit, statusSnapshot: () => handleCommand("tray:status", {}), reconcileSubscriptions: () => reconcileFreshness(freshnessEnv), + worktreeKick: worktreeReconciler.kick, }); // ─── Handler context + command routing ─────────────────────────────────────── diff --git a/lib/daemon/__tests__/worktree-reconciler.test.ts b/lib/daemon/__tests__/worktree-reconciler.test.ts index c79bc9b3..c7084857 100644 --- a/lib/daemon/__tests__/worktree-reconciler.test.ts +++ b/lib/daemon/__tests__/worktree-reconciler.test.ts @@ -11,9 +11,11 @@ import { branchExistsLocalAsync, currentBranchAsync, findDesktopStashAsync, + headSha, listWorktreesAsync, } from "../../worktree/git-async.ts"; import { createTree } from "../../worktree/create.ts"; +import type { WorktreeAppConfig } from "../../worktree/config.ts"; import { reconcileRepoRegistry, createWorktreeReconciler, __test__ } from "../worktree-reconciler.ts"; function makeRepo(): string { @@ -571,3 +573,308 @@ describe("merge reactor (detectTransitions)", () => { expect(events.some((e) => e.type === "worktree:disposed")).toBe(true); }); }); + +// ─── Freshen ───────────────────────────────────────────────────────────────── + +/** Clone `repo`'s own bare origin to a scratch dir, for pushing "upstream" advances. */ +function cloneOrigin(repo: string): string { + const originUrl = execSync(`git -C ${repo} remote get-url origin`, { encoding: "utf8" }).trim(); + const clone = realpathSync(mkdtempSync(join(tmpdir(), "rtrecon-clone-"))); + sh(`git clone -q ${originUrl} ${clone}`); + return clone; +} + +function pushFile(clone: string, relPath: string, contents: string): string { + writeFileSync(join(clone, relPath), contents); + sh(`git add -A && git ${GIT_ID} commit -m ${relPath}`, clone); + sh(`git push -q origin main`, clone); + return execSync("git rev-parse HEAD", { cwd: clone, encoding: "utf8" }).trim(); +} + +describe("freshen", () => { + const repoName = "acme"; + let repo: string; + + beforeEach(() => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtfreshen-home-"))); + repo = makeRepo(); + addBareOrigin(repo); + writeJson(join(rtDir(), "worktrees.json"), { enabled: true, killProcesses: false }); + }); + + test("idle main behind origin gets ff'd; readyStamp advances only when a triggered step ran; worktree:freshened emitted", async () => { + writeJson(join(repoDataDir(repoName), "config.json"), { + worktrees: { ready: [{ run: "touch triggered.marker", when: "changed:*.txt" }] }, + }); + // Tracked and pushed so the ready step's own marker file never shows up as + // untracked dirt on a later pass (which would otherwise flip "idle main" + // non-idle and stall freshen on itself). + writeFileSync(join(repo, ".gitignore"), "*.marker\n"); + sh(`git add -A && git ${GIT_ID} commit -m gitignore`, repo); + sh(`git push -q origin main`, repo); + + saveRegistry(repoName, [ + { name: basename(repo), path: repo, kind: "main", branch: "main", createdAt: new Date().toISOString() }, + ]); + + const clone = cloneOrigin(repo); + const sha1 = pushFile(clone, "feature.txt", "hi\n"); + + const events: Array<{ type: string; data: any }> = []; + await __test__.freshenRepo({ + repoName, + repoPath: repo, + emit: (type: string, data: unknown) => events.push({ type, data }), + log: fakeLog(), + }); + + expect(await headSha(repo)).toBe(sha1); + expect(existsSync(join(repo, "triggered.marker"))).toBe(true); + const rec1 = loadRegistry(repoName).find((t) => t.path === repo)!; + expect(rec1.readyStamp).toBe(sha1); + expect(events.some((e) => e.type === "worktree:freshened")).toBe(true); + + // Second pass: origin advances again, but with a change the glob does not + // match. The ff still moves HEAD; readyStamp must NOT follow it, since no + // step actually validated content as of the new commit. + const sha2 = pushFile(clone, "notes.md", "hi\n"); + await __test__.freshenRepo({ repoName, repoPath: repo, emit: () => {}, log: fakeLog() }); + + expect(await headSha(repo)).toBe(sha2); + const rec2 = loadRegistry(repoName).find((t) => t.path === repo)!; + expect(rec2.readyStamp).toBe(sha1); + }); + + test("on-deck tree ff's its on-deck branch", async () => { + writeJson(join(repoDataDir(repoName), "config.json"), { + worktrees: { onDeck: 1, root: join(repo, ".worktrees") }, + }); + + const created = await createTree({ + repoName, + repoPath: repo, + emit: () => {}, + log: { info: () => {}, warn: () => {} }, + }); + expect(created.ok).toBe(true); + if (!created.ok) return; + const treePath = created.tree.path; + const beforeSha = await headSha(treePath); + + const clone = cloneOrigin(repo); + const sha1 = pushFile(clone, "feature.txt", "hi\n"); + + await __test__.freshenRepo({ repoName, repoPath: repo, emit: () => {}, log: fakeLog() }); + + const afterSha = await headSha(treePath); + expect(afterSha).not.toBe(beforeSha); + expect(afterSha).toBe(sha1); + }); + + test("a failing ready step sets nextRetryAt; the next immediate pass skips the tree", async () => { + const cfgPath = join(repoDataDir(repoName), "config.json"); + writeJson(cfgPath, { worktrees: { onDeck: 1, root: join(repo, ".worktrees") } }); + + const created = await createTree({ + repoName, + repoPath: repo, + emit: () => {}, + log: { info: () => {}, warn: () => {} }, + }); + expect(created.ok).toBe(true); + if (!created.ok) return; + const treePath = created.tree.path; + + // Reconfigure with a step that always fails once triggered. + writeJson(cfgPath, { + worktrees: { onDeck: 1, root: join(repo, ".worktrees"), ready: [{ run: "exit 1", when: "changed:*.txt" }] }, + }); + + const clone = cloneOrigin(repo); + pushFile(clone, "feature.txt", "hi\n"); + + await __test__.freshenRepo({ repoName, repoPath: repo, emit: () => {}, log: fakeLog() }); + + const rec1 = loadRegistry(repoName).find((t) => t.path === treePath)!; + expect(rec1.retryFailures).toBe(1); + expect(rec1.nextRetryAt).toBeDefined(); + expect(Date.parse(rec1.nextRetryAt!)).toBeGreaterThan(Date.now()); + + // Immediate second pass: nextRetryAt is in the future, so the tree must + // be skipped entirely, not retried (and re-failed) again. + await __test__.freshenRepo({ repoName, repoPath: repo, emit: () => {}, log: fakeLog() }); + + const rec2 = loadRegistry(repoName).find((t) => t.path === treePath)!; + expect(rec2.retryFailures).toBe(1); + expect(rec2.nextRetryAt).toBe(rec1.nextRetryAt); + }); + + test("dirty non-idle main is left untouched", async () => { + writeFileSync(join(repo, "dirty.txt"), "uncommitted\n"); + saveRegistry(repoName, [ + { name: basename(repo), path: repo, kind: "main", branch: "main", createdAt: new Date().toISOString() }, + ]); + + const beforeSha = await headSha(repo); + const events: Array<{ type: string; data: any }> = []; + await __test__.freshenRepo({ + repoName, + repoPath: repo, + emit: (type: string, data: unknown) => events.push({ type, data }), + log: fakeLog(), + }); + + expect(await headSha(repo)).toBe(beforeSha); + expect(existsSync(join(repo, "dirty.txt"))).toBe(true); + expect(events.length).toBe(0); + const rec = loadRegistry(repoName).find((t) => t.path === repo)!; + expect(rec.readyAt).toBeUndefined(); + }); +}); + +// ─── Replenish / shrink ─────────────────────────────────────────────────────── + +function fakeAppConfig(overrides: Partial = {}): WorktreeAppConfig { + return { enabled: true, killProcesses: false, ...overrides }; +} + +describe("replenish / shrink", () => { + const repoName = "acme"; + let repo: string; + + beforeEach(() => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtpool-home-"))); + repo = makeRepo(); + addBareOrigin(repo); + }); + + test("onDeck=2 with an empty registry creates 2, serially", async () => { + writeJson(join(repoDataDir(repoName), "config.json"), { + worktrees: { onDeck: 2, root: join(repo, ".worktrees") }, + }); + + await __test__.replenishAndShrink( + { repoName, repoPath: repo, emit: () => {}, log: fakeLog() }, + new Map(), + fakeAppConfig(), + ); + + const trees = loadRegistry(repoName).filter((t) => t.kind === "ephemeral" && t.state === "on-deck"); + expect(trees.length).toBe(2); + expect(new Set(trees.map((t) => t.name)).size).toBe(2); + }); + + test("an all-failing pool does not overshoot the onDeck cap", async () => { + writeJson(join(repoDataDir(repoName), "config.json"), { + worktrees: { onDeck: 2, root: join(repo, ".worktrees"), ready: [{ run: "exit 1" }] }, + }); + + const warns: string[] = []; + const log = { + info: () => {}, + error: () => {}, + debug: () => {}, + warn: (_fields: unknown, msg?: string) => warns.push(msg ?? ""), + } as unknown as Logger; + + await __test__.replenishAndShrink( + { repoName, repoPath: repo, emit: () => {}, log }, + new Map(), + fakeAppConfig(), + ); + + const trees = loadRegistry(repoName).filter((t) => t.kind === "ephemeral"); + expect(trees.length).toBe(0); // every attempt failed and self-scrapped + + // Bounded to the cap: exactly onDeck attempts, never more (no runaway loop). + expect(warns.filter((w) => w.includes("replenish create failed")).length).toBe(2); + }); + + test("lowering onDeck disposes the stalest ready entry", async () => { + const cfgPath = join(repoDataDir(repoName), "config.json"); + writeJson(cfgPath, { worktrees: { onDeck: 2, root: join(repo, ".worktrees") } }); + + await __test__.replenishAndShrink( + { repoName, repoPath: repo, emit: () => {}, log: fakeLog() }, + new Map(), + fakeAppConfig(), + ); + + let trees = loadRegistry(repoName).filter((t) => t.kind === "ephemeral" && t.state === "on-deck"); + expect(trees.length).toBe(2); + + // Force a deterministic staleness ordering rather than relying on the + // sub-millisecond gap between two serial creates. + const [older, newer] = trees; + saveRegistry( + repoName, + loadRegistry(repoName).map((t) => { + if (t.path === older!.path) return { ...t, readyAt: new Date(Date.now() - 60_000).toISOString() }; + if (t.path === newer!.path) return { ...t, readyAt: new Date().toISOString() }; + return t; + }), + ); + + writeJson(cfgPath, { worktrees: { onDeck: 1, root: join(repo, ".worktrees") } }); + await __test__.replenishAndShrink( + { repoName, repoPath: repo, emit: () => {}, log: fakeLog() }, + new Map(), + fakeAppConfig(), + ); + + trees = loadRegistry(repoName).filter((t) => t.kind === "ephemeral" && t.state === "on-deck"); + expect(trees.length).toBe(1); + expect(trees[0]!.path).toBe(newer!.path); + expect(existsSync(older!.path)).toBe(false); + }); +}); + +// ─── Detached trigger / latency ─────────────────────────────────────────────── + +async function waitFor(predicate: () => boolean, timeoutMs: number): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor: timed out"); + await new Promise((r) => setTimeout(r, 25)); + } +} + +describe("detached trigger / latency", () => { + test("kick() returns synchronously, coalesces a second kick during the pass, and creationInFlight tracks it", async () => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtkick-home-"))); + const repoName = "acme"; + const repo = makeRepo(); + addBareOrigin(repo); + // Non-idle main (real, unrelated dirt): keeps freshen from also picking + // up main and running its own "sleep 3" pass, which would confound the + // timing assertions below without changing what's under test here. + writeFileSync(join(repo, "wip.txt"), "not idle\n"); + + writeJson(join(repoDataDir(repoName), "config.json"), { + worktrees: { onDeck: 1, root: join(repo, ".worktrees"), ready: [{ run: "sleep 3" }] }, + }); + + const events: Array<{ type: string; data: any }> = []; + const reconciler = createWorktreeReconciler({ + cache: { entries: {} }, + repoIndex: () => ({ [repoName]: repo }), + emit: (type: string, data: unknown) => events.push({ type, data }), + log: fakeLog(), + }); + + const t0 = Date.now(); + reconciler.kick(); + const elapsed = Date.now() - t0; + expect(elapsed).toBeLessThan(500); // kick() itself never awaits the pass + + await waitFor(() => reconciler.creationInFlight(repoName) !== null, 2000); + expect(reconciler.creationInFlight(repoName)).not.toBeNull(); + + reconciler.kick(); // overlap guard: must not start a second concurrent pass + + await waitFor(() => reconciler.creationInFlight(repoName) === null, 6000); + expect(reconciler.creationInFlight(repoName)).toBeNull(); + + expect(events.filter((e) => e.type === "worktree:created").length).toBe(1); + }, 10_000); +}); diff --git a/lib/daemon/cache-refresh.ts b/lib/daemon/cache-refresh.ts index 50e01734..d91d4b7d 100644 --- a/lib/daemon/cache-refresh.ts +++ b/lib/daemon/cache-refresh.ts @@ -22,7 +22,6 @@ import { loadRepoTracking, grants } from "../repo-tracking.ts"; import { syncProjectMRs } from "./project-sync.ts"; import { getProjectMRs } from "./project-mrs-store.ts"; import { pruneDiscussionsStore } from "./discussions-file-store.ts"; -import { checkAndPark } from "./parking-lot.ts"; import { reconcileForRepo } from "./doppler-sync.ts"; import { listWorktreeRoots, listWorktrees } from "../git-worktrees.ts"; @@ -38,6 +37,8 @@ export interface CacheRefresherDeps { statusSnapshot: () => Promise; /** Reconcile freshness watchers against the freshly-loaded repo index. */ reconcileSubscriptions: () => Promise; + /** Fire-and-forget kick of the worktree reconciler after each refresh. */ + worktreeKick?: () => void; } export function createCacheRefresher(deps: CacheRefresherDeps): () => Promise { @@ -82,9 +83,12 @@ export function createCacheRefresher(deps: CacheRefresherDeps): () => Promise = - listWorktrees(repoPath).filter((w) => w.branch); + // 1. Discover worktree branches (detached worktrees have no branch). + // on-deck/* branches are pool plumbing, not feature work — never + // worth MR/Linear enrichment. + const branches: Array<{ path: string; branch: string }> = listWorktrees(repoPath).filter( + (w) => w.branch && !w.branch.startsWith("on-deck/"), + ); // 2. Discover local branches (not just worktrees) const worktreeBranchSet = new Set(branches.map(b => b.branch)); @@ -146,13 +150,6 @@ export function createCacheRefresher(deps: CacheRefresherDeps): () => Promise/doppler-template.yaml. Cheap (file I/O // only) and additive — never overwrites existing entries. @@ -179,6 +176,11 @@ export function createCacheRefresher(deps: CacheRefresherDeps): () => Promise { diff --git a/lib/daemon/worktree-reconciler.ts b/lib/daemon/worktree-reconciler.ts index c7242c7b..743e125f 100644 --- a/lib/daemon/worktree-reconciler.ts +++ b/lib/daemon/worktree-reconciler.ts @@ -23,7 +23,9 @@ import { import { branchExistsLocalAsync, currentBranchAsync, + findDesktopStashAsync, gitOk, + headSha, listWorktreesAsync, remoteDefaultRef, runGit, @@ -31,9 +33,15 @@ import { type WorktreeEntry, } from "../worktree/git-async.ts"; import { isTreeLocked, withTreeLock } from "../worktree/locks.ts"; -import { scrapTree, type CreateDeps } from "../worktree/create.ts"; -import { disposeTree } from "../worktree/dispose.ts"; -import { loadWorktreeAppConfig, type WorktreeAppConfig } from "../worktree/config.ts"; +import { createTree, scrapTree, type CreateDeps } from "../worktree/create.ts"; +import { classifyDirtyAsync, disposeTree } from "../worktree/dispose.ts"; +import { changedSince, stepsToRun, runReadySteps } from "../worktree/ready.ts"; +import { + loadWorktreeAppConfig, + loadWorktreeRepoConfig, + resolveReadySteps, + type WorktreeAppConfig, +} from "../worktree/config.ts"; import { killWorktreeProcesses } from "./worktree-process-kill.ts"; export interface ReconcilerDeps { @@ -87,9 +95,14 @@ export async function reconcileRepoRegistry(deps: { // still locked (genuinely in-flight) pass through untouched. Scrapping // mutates git state (worktree remove + branch -D), so the git listing used // by (a)-(c) below is captured AFTER this loop, not before. + // + // This is the one mutating step in an otherwise read-only reconcile, so + // (unlike (a)-(c)/(e), which only ever sync the registry file to ground + // truth) it is gated on the app-level enabled flag same as freshen/replenish. + const appConfig = loadWorktreeAppConfig(); const afterScrap: TreeRecord[] = []; for (const rec of trees) { - if (rec.state === "creating" && !isTreeLocked(rec.path)) { + if (appConfig.enabled && rec.state === "creating" && !isTreeLocked(rec.path)) { log.info({ repo: repoName, tree: rec.name, path: rec.path }, "reconcile: scrapping orphaned creating tree"); await scrapTree(createDeps, rec); changed = true; @@ -468,6 +481,7 @@ async function resumeTrees(deps: ReactorDeps, branch: string): Promise { export async function detectTransitions(deps: ReactorDeps): Promise { const { repoName, cacheEntries, log } = deps; const appConfig = loadWorktreeAppConfig(); + if (!appConfig.enabled) return; const state = loadReactorState(); const fired = new Set(state.fired); @@ -530,6 +544,237 @@ export async function detectTransitions(deps: ReactorDeps): Promise { saveReactorState({ mrState: nextMrState, fired: [...fired] }, log); } +// ─── Freshen (spec §6.3) ───────────────────────────────────────────────────── + +const FRESHEN_FETCH_TIMEOUT_MS = 5 * 60_000; +/** The backoff "pass" unit: failure N waits pass * 2^(N-1), capped below. */ +const FRESHEN_PASS_MS = 5 * 60_000; +const FRESHEN_MAX_BACKOFF_MS = 30 * 60_000; + +export interface FreshenDeps { + repoName: string; + repoPath: string; + emit: (type: string, data: unknown) => void; + log: Logger; +} + +/** + * Whether a registered tree is a freshen candidate: any on-deck ephemeral + * tree, or "idle main" — sitting on the default branch with no blocking dirt. + * A main clone on a feature branch is the merge reactor's concern (auto-return + * on merge); a main clone with real uncommitted work, even on the default + * branch, is the user's and must be left alone. + * + * `rec.branch` is trusted as ground truth here rather than re-reading git: + * `reconcileRepoRegistry` (T10) already ran earlier in the same `runOnce` pass + * and synced it. + */ +async function freshenCandidate(deps: FreshenDeps, rec: TreeRecord): Promise { + if (rec.kind === "ephemeral") return rec.state === "on-deck"; + if (rec.kind !== "main") return false; + + const defaultRef = await remoteDefaultRef(rec.path); + const defaultBranchName = defaultRef.replace(/^origin\//, ""); + if (rec.branch !== defaultBranchName) return false; + + const { blockers } = await classifyDirtyAsync(rec.path, deps.repoName); + return blockers.length === 0; +} + +/** + * Freshen one tree: fetch the default branch, ff-only merge it in, then run + * whatever ready steps that advance triggers. Caller holds the tree lock and + * has already verified `freshenCandidate` and that any `nextRetryAt` has + * passed. + * + * `readyStamp` (and therefore future `changedSince` diffs) only advances when + * a ready step actually ran and succeeded — a ff that triggers nothing hasn't + * validated anything new, so claiming otherwise would let a later real change + * hide behind a stamp nothing ever checked. + */ +async function freshenOne(deps: FreshenDeps, rec: TreeRecord): Promise { + const { repoName, log, emit } = deps; + const fields = { repo: repoName, tree: rec.name, path: rec.path }; + + const fail = (): void => { + const failures = (rec.retryFailures ?? 0) + 1; + const backoffMs = Math.min(FRESHEN_PASS_MS * 2 ** (failures - 1), FRESHEN_MAX_BACKOFF_MS); + patchTree(repoName, rec.path, (r) => { + r.retryFailures = failures; + r.nextRetryAt = new Date(Date.now() + backoffMs).toISOString(); + }); + }; + + const defaultRef = await remoteDefaultRef(rec.path); + const defaultBranchName = defaultRef.replace(/^origin\//, ""); + + const fetchResult = await runGit(rec.path, ["fetch", "origin", defaultBranchName], { + timeoutMs: FRESHEN_FETCH_TIMEOUT_MS, + }); + if (fetchResult.exitCode !== 0) { + log.warn({ ...fields, output: fetchResult.stderr.trim() }, "freshen: fetch failed"); + fail(); + return; + } + + const classify = await classifyDirtyAsync(rec.path, repoName); + if (classify.discard.length > 0) { + await runGit(rec.path, ["checkout", "--", ...classify.discard]); + } + + // Blockers stashed under the tree's own branch name (Desktop-compatible + // marker), harvested from parking-lot.ts's ff-sweep. On-deck trees are + // expected to be clean by construction; the idle-main case can legitimately + // have generated-only dirt left after the discard reset above. + let stashName: string | null = null; + const label = rec.branch ?? rec.name; + if (classify.blockers.length > 0) { + await stashChangesAsync(rec.path, label); + stashName = (await findDesktopStashAsync(rec.path, label))?.name ?? null; + } + + const popStash = async (): Promise => { + if (!stashName) return; + const pop = await runGit(rec.path, ["stash", "pop", stashName]); + if (pop.exitCode !== 0) { + log.warn( + { ...fields, stashName }, + `freshen: stash ${stashName} did not reapply cleanly in ${rec.path}... it is preserved, restore it with: git stash pop ${stashName}`, + ); + } + }; + + const ff = await runGit(rec.path, ["merge", "--ff-only", defaultRef]); + if (ff.exitCode !== 0) { + log.warn({ ...fields, defaultRef, output: ff.stderr.trim() }, "freshen: fast-forward failed"); + await popStash(); + fail(); + return; + } + await popStash(); + + const cfg = loadWorktreeRepoConfig(repoName, deps.repoPath); + const readySteps = resolveReadySteps(cfg, deps.repoPath); + const changed = rec.readyStamp ? await changedSince(rec.path, rec.readyStamp) : null; + const toRun = stepsToRun(readySteps, changed); + + const readyResult = await runReadySteps(rec.path, toRun); + if (!readyResult.ok) { + log.warn({ ...fields, failedStep: readyResult.failedStep }, "freshen: ready step failed"); + fail(); + return; + } + + const newStamp = toRun.length > 0 ? await headSha(rec.path) : null; + patchTree(repoName, rec.path, (r) => { + r.readyAt = new Date().toISOString(); + r.retryFailures = 0; + delete r.nextRetryAt; + if (newStamp) r.readyStamp = newStamp; + }); + + emit("worktree:freshened", { repo: repoName, tree: rec.name, path: rec.path }); + log.debug?.(fields, `worktree ${rec.name} freshened`); +} + +/** Freshen every eligible tree in one repo, each under its own tree lock. */ +async function freshenRepo(deps: FreshenDeps): Promise { + const { repoName } = deps; + const now = Date.now(); + const trees = loadRegistry(repoName); + for (const rec of trees) { + if (rec.nextRetryAt && Date.parse(rec.nextRetryAt) > now) continue; + if (!(await freshenCandidate(deps, rec))) continue; + await withTreeLock(rec.path, () => freshenOne(deps, rec)); + } +} + +// ─── Replenish / shrink (spec §6.4) ────────────────────────────────────────── + +/** On-deck / creating counts used to decide whether to grow or shrink the pool. */ +function poolCounts(repoName: string): { + ready: number; + totalUnclaimed: number; + onDeckEntries: TreeRecord[]; +} { + const trees = loadRegistry(repoName); + const now = Date.now(); + const onDeckEntries = trees.filter((t) => t.kind === "ephemeral" && t.state === "on-deck"); + const creatingEntries = trees.filter((t) => t.kind === "ephemeral" && t.state === "creating"); + const ready = onDeckEntries.filter((t) => !t.nextRetryAt || Date.parse(t.nextRetryAt) <= now).length; + return { ready, totalUnclaimed: onDeckEntries.length + creatingEntries.length, onDeckEntries }; +} + +/** + * Grow the on-deck pool toward `onDeck` (serially — one `createTree` in + * flight at a time, which `runOnce` awaiting each pass makes natural), then + * shrink it back down by disposing the stalest ready entries when it's over. + * + * Replenish is bounded to the deficit measured once at the start of the pass, + * not re-derived from live state on every iteration: `createTree` scraps its + * own registry row on failure (no trace, no retry bookkeeping), so an + * always-failing config would otherwise re-read "still short" forever and spin + * this pass indefinitely. Bounding to the initial deficit caps attempts at + * `onDeck` per pass either way — every attempt succeeds and fills a slot, or + * fails and wastes one of the budgeted attempts — and lets the next pass pick + * up any remaining shortfall. + */ +async function replenishAndShrink( + deps: FreshenDeps, + creationPromises: Map>, + appConfig: WorktreeAppConfig, +): Promise { + const { repoName, repoPath, emit, log } = deps; + const cfg = loadWorktreeRepoConfig(repoName, repoPath); + if (cfg.onDeck <= 0) return; + + let { ready, totalUnclaimed } = poolCounts(repoName); + let budget = Math.max(0, cfg.onDeck - totalUnclaimed); + while (budget > 0 && ready < cfg.onDeck && totalUnclaimed < cfg.onDeck) { + budget--; + const p: Promise = createTree({ repoName, repoPath, emit, log }) + .then((result) => { + if (!result.ok) { + log.warn({ repo: repoName, error: result.error }, "worktree reconciler: replenish create failed"); + } + }) + .catch((err) => { + log.warn({ err, repo: repoName }, "worktree reconciler: replenish create threw"); + }) + .finally(() => { + if (creationPromises.get(repoName) === p) creationPromises.delete(repoName); + }); + creationPromises.set(repoName, p); + await p; + ({ ready, totalUnclaimed } = poolCounts(repoName)); + } + + // `attempted` guards against spinning forever on an entry disposeTree keeps + // refusing (e.g. a guard failure) — each path gets one shrink attempt per + // pass; a refusal just leaves it for the next pass rather than looping here. + let counts = poolCounts(repoName); + const attempted = new Set(); + while (counts.ready > cfg.onDeck) { + const now = Date.now(); + const eligible = counts.onDeckEntries.filter( + (t) => !attempted.has(t.path) && (!t.nextRetryAt || Date.parse(t.nextRetryAt) <= now), + ); + if (eligible.length === 0) break; + const stalest = eligible.reduce((a, b) => + Date.parse(a.readyAt ?? a.createdAt) <= Date.parse(b.readyAt ?? b.createdAt) ? a : b, + ); + attempted.add(stalest.path); + await withTreeLock(stalest.path, () => + disposeTree( + { repoName, repoPath, cacheEntries: {}, emit, log, killProcesses: appConfig.killProcesses }, + stalest, + { auto: false }, + ), + ); + counts = poolCounts(repoName); + } +} + /** Whether a repo has any worktree state worth reconciling: registry entries or a declared "worktrees" config. */ function repoHasWorktreeActivity(repoName: string): boolean { if (loadRegistry(repoName).length > 0) return true; @@ -546,11 +791,19 @@ function repoHasWorktreeActivity(repoName: string): boolean { export function createWorktreeReconciler(deps: ReconcilerDeps): { kick: () => void; runOnce: () => Promise; + /** The live `createTree` promise replenish kicked off for `repoName`, or + * null when nothing is in flight. Task 13's provision handler awaits this + * instead of racing its own create against replenish's. */ + creationInFlight: (repoName: string) => Promise | null; } { let inFlight: Promise | null = null; + const creationPromises = new Map>(); async function runOnce(): Promise { const repos = deps.repoIndex(); + // One read for the whole pass: every repo shares the same app-level file. + const appConfig = loadWorktreeAppConfig(); + for (const [repoName, repoPath] of Object.entries(repos)) { if (!repoHasWorktreeActivity(repoName)) continue; try { @@ -558,8 +811,8 @@ export function createWorktreeReconciler(deps: ReconcilerDeps): { } catch (err) { deps.log.warn({ err, repo: repoName }, "worktree reconciler: reconcile pass failed"); } - // Separate catch: a reactor that throws must not cost the next repo its - // reconcile, and vice versa. + // Separate catches throughout: any one duty throwing must not cost the + // next repo (or the next duty) its own pass. try { await detectTransitions({ repoName, @@ -571,6 +824,23 @@ export function createWorktreeReconciler(deps: ReconcilerDeps): { } catch (err) { deps.log.warn({ err, repo: repoName }, "worktree reconciler: merge reactor pass failed"); } + + if (!appConfig.enabled) continue; + + try { + await freshenRepo({ repoName, repoPath, emit: deps.emit, log: deps.log }); + } catch (err) { + deps.log.warn({ err, repo: repoName }, "worktree reconciler: freshen pass failed"); + } + try { + await replenishAndShrink( + { repoName, repoPath, emit: deps.emit, log: deps.log }, + creationPromises, + appConfig, + ); + } catch (err) { + deps.log.warn({ err, repo: repoName }, "worktree reconciler: replenish/shrink pass failed"); + } } } @@ -586,7 +856,11 @@ export function createWorktreeReconciler(deps: ReconcilerDeps): { inFlight = p; } - return { kick, runOnce }; + function creationInFlight(repoName: string): Promise | null { + return creationPromises.get(repoName) ?? null; + } + + return { kick, runOnce, creationInFlight }; } -export const __test__ = { detectTransitions, reactorStatePath }; +export const __test__ = { detectTransitions, reactorStatePath, freshenRepo, replenishAndShrink, poolCounts }; From 4c1bc69d1dd948069e13817a0375e4c20802f1f6 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 18:44:44 -0500 Subject: [PATCH 20/31] RT-34: revalidate under lock, restore stash fallback, close review round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Important fixes from review: - freshenRepo and replenishAndShrink's shrink loop both re-read the registry as the first thing inside withTreeLock and bail if state/branch drifted from what candidacy was decided on. freshenRepo's per-tree snapshot is loaded once for the whole pass, so a later tree's candidacy can be minutes stale by the time its lock is acquired (a provision claim landing in between would otherwise run a ff + ready steps, or get deleted by shrink, inside a tree a human just claimed). Added a test that claims a second on-deck tree mid-pass (during the first tree's ~1s triggered ready step) and asserts freshen skipped it. - A pushed stash whose name findDesktopStashAsync then fails to resolve now falls back to "stash@{0}" (restored from parking-lot.ts's ff-sweep) instead of silently abandoning the restore. Plus the three cheap minors: an enabled:false runOnce test (registry still synced, reactor/freshen/replenish all skipped); on-deck/* branches excluded from cache-refresh's local-branch Linear-id sweep, not just worktree discovery; and a comment on daemon.ts's emit wiring into the reconciler. Also: createWorktreeReconciler() gains passInFlight() (test-only) and the "kick fires runOnce" test now polls it instead of a blind sleep — the new enabled:false test surfaced that the old fixed-sleep pattern could leave a kick()'d pass still running past its own test, and since every internal path resolves HOME dynamically at call time, that stale pass could read/write into whatever HOME a later test's beforeEach had since pointed at. Co-Authored-By: Claude Fable 5 --- lib/daemon.ts | 3 + .../__tests__/worktree-reconciler.test.ts | 109 +++++++++++++++++- lib/daemon/cache-refresh.ts | 2 +- lib/daemon/worktree-reconciler.ts | 68 +++++++++-- 4 files changed, 169 insertions(+), 13 deletions(-) diff --git a/lib/daemon.ts b/lib/daemon.ts index 6776fbe2..ebbfbe9d 100644 --- a/lib/daemon.ts +++ b/lib/daemon.ts @@ -98,6 +98,9 @@ const emit: typeof broadcast = (type, data) => { const worktreeReconciler = createWorktreeReconciler({ cache, repoIndex: loadRepoIndex, + // `emit` (not bare `broadcast`), deliberately: reconciler events (e.g. + // "worktree:freshened") should also reach the cron trigger layer, same as + // every other broadcast frame. emit, log, }); diff --git a/lib/daemon/__tests__/worktree-reconciler.test.ts b/lib/daemon/__tests__/worktree-reconciler.test.ts index c7084857..e074334d 100644 --- a/lib/daemon/__tests__/worktree-reconciler.test.ts +++ b/lib/daemon/__tests__/worktree-reconciler.test.ts @@ -203,11 +203,65 @@ describe("createWorktreeReconciler", () => { reconciler.kick(); reconciler.kick(); // should be a no-op overlap guard, not a second pass - // kick is fire-and-forget; give the microtask queue a turn to let it land. - await new Promise((r) => setTimeout(r, 50)); + // kick is fire-and-forget; poll `passInFlight()` for TRUE completion + // rather than a blind sleep or a registry-state proxy — a pass that's + // still running (even past its registry write) but hasn't returned yet + // would otherwise dangle past this test, and since every internal path + // (repoDataDir, rtDir, ...) resolves HOME dynamically at call time, that + // stale pass can read/write into a LATER test's HOME once that test's + // beforeEach repoints the (shared, global) env var. + await waitFor(() => !reconciler.passInFlight(), 5000); expect(loadRegistry(repoName).length).toBe(1); // just main, adopted once }); + + test("runOnce with the app disabled still syncs the registry, but skips reactor/freshen/replenish", async () => { + // A dedicated repoName (not the shared "acme" the other tests in this + // describe use): the prior test's `kick()` is deliberately unawaited by + // design, and since every internal path resolves HOME dynamically at call + // time, a still-running background pass from that test reading a fresh + // `worktrees.json`/onDeck config off "acme" could otherwise land its own + // (stale, enabled=true-baked-in) replenish attempt into this test's + // registry. A distinct repoName makes that collision structurally + // impossible regardless of any other test's timing. + const disabledRepoName = "acme-disabled"; + addBareOrigin(repo); + writeJson(join(repoDataDir(disabledRepoName), "config.json"), { + worktrees: { onDeck: 1, root: join(repo, ".worktrees") }, + }); + writeJson(join(rtDir(), "worktrees.json"), { enabled: false, killProcesses: false }); + + // Advance origin so a freshen (if it ran) would have something to do. + const originUrl = execSync(`git -C ${repo} remote get-url origin`, { encoding: "utf8" }).trim(); + const clone = realpathSync(mkdtempSync(join(tmpdir(), "rtrecon-clone-"))); + sh(`git clone -q ${originUrl} ${clone}`); + writeFileSync(join(clone, "feature.txt"), "hi\n"); + sh(`git add -A && git ${GIT_ID} commit -m feat`, clone); + sh(`git push -q origin main`, clone); + + const beforeSha = await headSha(repo); + const events: Array<{ type: string; data: any }> = []; + const reconciler = createWorktreeReconciler({ + cache: { entries: {} }, + repoIndex: () => ({ [disabledRepoName]: repo }), + emit: (type: string, data: unknown) => events.push({ type, data }), + log: fakeLog(), + }); + + await reconciler.runOnce(); + + // Read-only reconcile still ran: main got adopted into the registry. + const trees = loadRegistry(disabledRepoName); + expect(trees.some((t) => t.path === repo && t.kind === "main")).toBe(true); + + // Freshen skipped: main never fetched/ff'd despite being idle and behind. + expect(await headSha(repo)).toBe(beforeSha); + // Replenish skipped: no on-deck tree created despite onDeck:1. + expect(trees.some((t) => t.kind === "ephemeral")).toBe(false); + // Reactor skipped: it never even opened/wrote its state file. + expect(existsSync(__test__.reactorStatePath())).toBe(false); + expect(events.length).toBe(0); + }); }); // ─── Merge reactor ─────────────────────────────────────────────────────────── @@ -730,6 +784,57 @@ describe("freshen", () => { const rec = loadRegistry(repoName).find((t) => t.path === repo)!; expect(rec.readyAt).toBeUndefined(); }); + + test("a candidate claimed mid-pass is revalidated under the lock and skipped", async () => { + writeJson(join(repoDataDir(repoName), "config.json"), { + worktrees: { onDeck: 2, root: join(repo, ".worktrees"), ready: [{ run: "sleep 1", when: "changed:*.txt" }] }, + }); + + // Two on-deck trees. `freshenRepo` snapshots the whole registry once and + // processes them in order — A first (slow: its ready step triggers below), + // B only after A finishes. That gap is the window under test. + const a = await createTree({ repoName, repoPath: repo, emit: () => {}, log: { info: () => {}, warn: () => {} } }); + const b = await createTree({ repoName, repoPath: repo, emit: () => {}, log: { info: () => {}, warn: () => {} } }); + expect(a.ok).toBe(true); + expect(b.ok).toBe(true); + if (!a.ok || !b.ok) return; + const pathA = a.tree.path; + const pathB = b.tree.path; + const bBeforeSha = await headSha(pathB); + + const clone = cloneOrigin(repo); + const pushedSha = pushFile(clone, "feature.txt", "hi\n"); + + const events: Array<{ type: string; data: any }> = []; + const freshenPromise = __test__.freshenRepo({ + repoName, + repoPath: repo, + emit: (type: string, data: unknown) => events.push({ type, data }), + log: fakeLog(), + }); + + // Land well inside A's ~1s triggered ready step, long before the loop's + // (already-stale, in-memory) snapshot for B is ever acted on. This proves + // the fix, not the unmodified outer candidacy check: B's candidacy was + // already decided (true, "on-deck") against the pre-claim snapshot before + // this write lands on disk. + await new Promise((r) => setTimeout(r, 300)); + saveRegistry( + repoName, + loadRegistry(repoName).map((t) => (t.path === pathB ? { ...t, state: "claimed" as const } : t)), + ); + + await freshenPromise; + + // A freshened normally — the fix doesn't cost the happy path. + expect(await headSha(pathA)).toBe(pushedSha); + expect(events.some((e) => e.type === "worktree:freshened" && e.data.path === pathA)).toBe(true); + + // B was claimed mid-pass: freshen must not have touched it. + expect(await headSha(pathB)).toBe(bBeforeSha); + expect(events.some((e) => e.data?.path === pathB)).toBe(false); + expect(loadRegistry(repoName).find((t) => t.path === pathB)!.state).toBe("claimed"); + }, 10_000); }); // ─── Replenish / shrink ─────────────────────────────────────────────────────── diff --git a/lib/daemon/cache-refresh.ts b/lib/daemon/cache-refresh.ts index d91d4b7d..7fd1c7a2 100644 --- a/lib/daemon/cache-refresh.ts +++ b/lib/daemon/cache-refresh.ts @@ -100,7 +100,7 @@ export function createCacheRefresher(deps: CacheRefresherDeps): () => Promise { const label = rec.branch ?? rec.name; if (classify.blockers.length > 0) { await stashChangesAsync(rec.path, label); - stashName = (await findDesktopStashAsync(rec.path, label))?.name ?? null; + // A pushed stash whose name we then fail to resolve must still get a + // restore attempt, not be silently abandoned — "stash@{0}" is the entry + // we just pushed absent a race with something else stashing concurrently + // (harvested fallback from parking-lot.ts's ff-sweep). + stashName = (await findDesktopStashAsync(rec.path, label))?.name ?? "stash@{0}"; } const popStash = async (): Promise => { @@ -677,15 +682,36 @@ async function freshenOne(deps: FreshenDeps, rec: TreeRecord): Promise { log.debug?.(fields, `worktree ${rec.name} freshened`); } -/** Freshen every eligible tree in one repo, each under its own tree lock. */ +/** + * Freshen every eligible tree in one repo, each under its own tree lock. + * + * `trees` is one snapshot for the whole pass, but candidacy for tree N+1 + * isn't evaluated until tree N's (potentially slow — real fetches, ready + * steps) freshen finishes, so by the time a later tree's lock is acquired its + * snapshot `rec` can be minutes stale: a provision claim (T13, same event + * loop) could have landed in between. Re-reading the registry as the first + * thing inside the lock and bailing on any state/branch drift closes that + * window — the alternative is running a ff + ready steps inside a tree a + * human just claimed. + */ async function freshenRepo(deps: FreshenDeps): Promise { - const { repoName } = deps; + const { repoName, log } = deps; const now = Date.now(); const trees = loadRegistry(repoName); for (const rec of trees) { if (rec.nextRetryAt && Date.parse(rec.nextRetryAt) > now) continue; if (!(await freshenCandidate(deps, rec))) continue; - await withTreeLock(rec.path, () => freshenOne(deps, rec)); + await withTreeLock(rec.path, async () => { + const fresh = findByPath(loadRegistry(repoName), rec.path); + if (!fresh || fresh.state !== rec.state || fresh.branch !== rec.branch) { + log.debug?.( + { repo: repoName, tree: rec.name, path: rec.path }, + "freshen: skipping — tree changed since candidacy was decided", + ); + return; + } + await freshenOne(deps, fresh); + }); } } @@ -764,13 +790,25 @@ async function replenishAndShrink( Date.parse(a.readyAt ?? a.createdAt) <= Date.parse(b.readyAt ?? b.createdAt) ? a : b, ); attempted.add(stalest.path); - await withTreeLock(stalest.path, () => - disposeTree( + await withTreeLock(stalest.path, async () => { + // Same revalidation as freshen: `stalest` is a snapshot from this + // iteration's `poolCounts()` read; re-check it under the lock before + // disposing so a claim that landed since (no grace guard applies here — + // auto is false) can't get its tree deleted out from under it. + const fresh = findByPath(loadRegistry(repoName), stalest.path); + if (!fresh || fresh.kind !== "ephemeral" || fresh.state !== "on-deck") { + log.debug?.( + { repo: repoName, tree: stalest.name, path: stalest.path }, + "shrink: skipping — tree changed since candidacy was decided", + ); + return; + } + await disposeTree( { repoName, repoPath, cacheEntries: {}, emit, log, killProcesses: appConfig.killProcesses }, - stalest, + fresh, { auto: false }, - ), - ); + ); + }); counts = poolCounts(repoName); } } @@ -795,6 +833,12 @@ export function createWorktreeReconciler(deps: ReconcilerDeps): { * null when nothing is in flight. Task 13's provision handler awaits this * instead of racing its own create against replenish's. */ creationInFlight: (repoName: string) => Promise | null; + /** Whether a `kick()`-triggered pass is currently running. Test-only: lets a + * test that calls `kick()` (deliberately not awaited — that's the point of + * `kick`) poll for true completion instead of guessing at a sleep, so no + * background pass survives into a later test's HOME once its own + * `beforeEach` repoints that (shared, global) env var. */ + passInFlight: () => boolean; } { let inFlight: Promise | null = null; const creationPromises = new Map>(); @@ -860,7 +904,11 @@ export function createWorktreeReconciler(deps: ReconcilerDeps): { return creationPromises.get(repoName) ?? null; } - return { kick, runOnce, creationInFlight }; + function passInFlight(): boolean { + return inFlight !== null; + } + + return { kick, runOnce, creationInFlight, passInFlight }; } export const __test__ = { detectTransitions, reactorStatePath, freshenRepo, replenishAndShrink, poolCounts }; From 4919df71ad9bd5a954d2a1ac642531add5192b9a Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 19:00:02 -0500 Subject: [PATCH 21/31] RT-34: daemon worktree verbs (provision matrix, owner sweeps, adopt) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New lib/daemon/handlers/worktree.ts serves the six lifecycle verbs: provision, create, dispose, list, freshen, adopt. Provision runs spec §7 in order: intent resolution plus every registry-decidable refusal (branch-attached / branch-duplicated) before any tree moves; on-deck selection by readyAt desc skipping locked and backing-off trees; join an in-flight replenish create or cold-create inline; claim under the tree lock with a worktree:claimed emission; then the branch matrix against a fresh targeted fetch. The fetch's two non-zero outcomes are classified apart -- git's ref-not-found signature means "no such branch upstream", anything else (unreachable, auth, timeout) rolls the claim back, since treating it as absence would shadow a teammate's branch. Any failure after the claim reverts the tree to on-deck when it never left its on-deck/ branch, else flips it disposable carrying the failure. Dispose sweeps --owner globally across repos (repoName narrows) and refuses a bare tree name two repos both answer to. List joins MRs on (repoName, branch) and flags duplicateBranch. Adopt reconciles first, then disposes clean parking-lot/N trees through the guard and claims the rest. freshenRepo is promoted to a real export with an optional single-tree filter and now reports the trees it freshened. Router swaps the parking-lot handlers for these; daemon.ts threads the reconciler's emit/kick/creationInFlight through the new worktree opts. Co-Authored-By: Claude Fable 5 --- lib/daemon.ts | 11 +- .../__tests__/rt-client-commands.test.ts | 1 + .../__tests__/worktree-handlers.test.ts | 406 +++++++++++++ lib/daemon/command-router.ts | 6 +- lib/daemon/handlers/worktree.ts | 569 ++++++++++++++++++ lib/daemon/worktree-reconciler.ts | 28 +- 6 files changed, 1009 insertions(+), 12 deletions(-) create mode 100644 lib/daemon/__tests__/worktree-handlers.test.ts create mode 100644 lib/daemon/handlers/worktree.ts diff --git a/lib/daemon.ts b/lib/daemon.ts index ebbfbe9d..bf3cb7b7 100644 --- a/lib/daemon.ts +++ b/lib/daemon.ts @@ -131,7 +131,16 @@ const handlerCtx: HandlerContext = { /** Env bundle for the live-freshness subsystem. */ const freshnessEnv: FreshnessEnv = { ctx: handlerCtx, broadcast: emit }; -const routedHandlers = buildRoutedHandlers({ ctx: handlerCtx, broadcast: emit, systemProcessScanner }); +const routedHandlers = buildRoutedHandlers({ + ctx: handlerCtx, + broadcast: emit, + systemProcessScanner, + worktree: { + emit, + kick: worktreeReconciler.kick, + creationInFlight: worktreeReconciler.creationInFlight, + }, +}); async function handleCommand(cmd: string, payload: any): Promise { const t0 = Date.now(); diff --git a/lib/daemon/__tests__/rt-client-commands.test.ts b/lib/daemon/__tests__/rt-client-commands.test.ts index 97ec4ceb..55a98147 100644 --- a/lib/daemon/__tests__/rt-client-commands.test.ts +++ b/lib/daemon/__tests__/rt-client-commands.test.ts @@ -35,6 +35,7 @@ describe("rt-client command coverage", () => { ctx: stubCtx, broadcast: () => {}, systemProcessScanner: {} as any, + worktree: { emit: () => {}, kick: () => {}, creationInFlight: () => null }, }); for (const name of COMMAND_NAMES) { expect(handlers[name]).toBeDefined(); diff --git a/lib/daemon/__tests__/worktree-handlers.test.ts b/lib/daemon/__tests__/worktree-handlers.test.ts new file mode 100644 index 00000000..92af5ada --- /dev/null +++ b/lib/daemon/__tests__/worktree-handlers.test.ts @@ -0,0 +1,406 @@ +/** + * Handler-level tests for the worktree IPC verbs (spec §3/§7/§11.2). + * + * Real git repos (bare-clone origins, realpath'd tmpdirs per the fixture + * rule) plus a stub HandlerContext: the handlers are exercised exactly as the + * daemon calls them, so payload/data contracts and the provision matrix are + * asserted against ground truth rather than mocks. + */ + +import { describe, test, expect, beforeEach } from "bun:test"; +import { execSync } from "child_process"; +import { existsSync, mkdtempSync, realpathSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { basename, join } from "path"; +import type { Logger } from "pino"; +import { writeJson } from "../../json-store.ts"; +import { rtDir } from "../../rt-paths.ts"; +import { loadRegistry, saveRegistry, type TreeRecord } from "../../worktree/registry.ts"; +import { tryLockTree } from "../../worktree/locks.ts"; +import { branchExistsLocalAsync, currentBranchAsync, headSha } from "../../worktree/git-async.ts"; +import { createWorktreeHandlers } from "../handlers/worktree.ts"; +import type { HandlerContext, HandlerMap } from "../handlers/types.ts"; + +function sh(cmd: string, cwd?: string): string { + return execSync(cmd, { cwd, shell: "/bin/zsh", encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); +} + +/** A repo on `main` with one commit and a bare clone wired up as origin. */ +function makeRepo(prefix = "rtwh-"): string { + // realpathSync: git canonicalizes /var -> /private/var on macOS (fixture rule) + const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix))); + sh("git init -b main && git -c user.email=t@t -c user.name=t commit --allow-empty -m init", dir); + const bare = realpathSync(mkdtempSync(join(tmpdir(), `${prefix}bare-`))); + sh(`git clone --bare ${dir} ${bare}/o.git && git -C ${dir} remote add origin ${bare}/o.git && git -C ${dir} fetch origin`); + return dir; +} + +function fakeLog(): Logger { + return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as unknown as Logger; +} + +interface Harness { + h: HandlerMap; + events: Array<{ type: string; data: any }>; + kicks: number; +} + +function makeHandlers( + repos: Record, + entries: Record = {}, +): Harness { + const events: Array<{ type: string; data: any }> = []; + const state = { kicks: 0 }; + const ctx = { + cache: { entries }, + repoIndex: () => repos, + log: fakeLog(), + loadCache: () => {}, + flushCache: () => {}, + refreshCache: async () => {}, + } as unknown as HandlerContext; + const h = createWorktreeHandlers(ctx, { + emit: (type: string, data: unknown) => events.push({ type, data: data as any }), + kick: () => { state.kicks++; }, + creationInFlight: () => null, + }); + return { + h, + events, + get kicks() { return state.kicks; }, + } as Harness; +} + +/** Register a real on-deck worktree (git + registry) without paying createTree. */ +function seedOnDeck(repo: string, repoName: string, name: string, readyAt: string): TreeRecord { + const path = join(repo, ".worktrees", name); + sh(`git worktree add -b on-deck/${name} ${path} origin/main`, repo); + const rec: TreeRecord = { + name, path, + kind: "ephemeral", + state: "on-deck", + branch: `on-deck/${name}`, + createdAt: readyAt, + readyAt, + }; + const trees = loadRegistry(repoName); + trees.push(rec); + saveRegistry(repoName, trees); + return rec; +} + +/** Register a real claimed worktree on its own branch. */ +function seedClaimed(repo: string, repoName: string, name: string, branch: string, owner?: string): TreeRecord { + const path = join(repo, ".worktrees", name); + sh(`git worktree add -b ${branch} ${path} origin/main`, repo); + const rec: TreeRecord = { + name, path, + kind: "ephemeral", + state: "claimed", + branch, + createdAt: new Date().toISOString(), + claimedAt: new Date().toISOString(), + disposal: "merge", + ...(owner ? { owner } : {}), + }; + const trees = loadRegistry(repoName); + trees.push(rec); + saveRegistry(repoName, trees); + return rec; +} + +const repoName = "acme"; + +beforeEach(() => { + process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtwh-home-"))); + // killProcesses off: the process killer shells out to ps/lsof and has + // nothing to find in a fixture. + writeJson(join(rtDir(), "worktrees.json"), { enabled: true, killProcesses: false }); +}); + +describe("worktree:provision", () => { + test("claims an on-deck tree onto the derived branch and emits worktree:claimed", async () => { + const repo = makeRepo(); + const rec = seedOnDeck(repo, repoName, "alpha", new Date().toISOString()); + const { h, events } = makeHandlers({ [repoName]: repo }); + + const res: any = await h["worktree:provision"]!({ + repoName, ticket: "RT-99", ticketTitle: "Do the thing", owner: "pane-1", + }); + + expect(res.ok).toBe(true); + expect(res.data.tree).toBe("alpha"); + expect(res.data.wasOnDeck).toBe(true); + expect(res.data.branch).toBe("rt-99-do-the-thing"); + expect(res.data.branchState).toBe("new"); + expect(res.data.readyAt).toBe(rec.readyAt!); + + expect(await currentBranchAsync(rec.path)).toBe("rt-99-do-the-thing"); + expect(await branchExistsLocalAsync(repo, "on-deck/alpha")).toBe(false); + + const stored = loadRegistry(repoName).find((t) => t.name === "alpha")!; + expect(stored.state).toBe("claimed"); + expect(stored.owner).toBe("pane-1"); + expect(stored.disposal).toBe("merge"); + expect(stored.branch).toBe("rt-99-do-the-thing"); + expect(stored.claimedAt).toBeTruthy(); + + const claimed = events.find((e) => e.type === "worktree:claimed"); + expect(claimed).toBeDefined(); + expect(claimed!.data).toMatchObject({ repo: repoName, tree: "alpha", branch: "rt-99-do-the-thing", owner: "pane-1" }); + }); + + test("refuses a branch attached to another tree before touching the registry", async () => { + const repo = makeRepo(); + seedOnDeck(repo, repoName, "alpha", new Date().toISOString()); + seedClaimed(repo, repoName, "beta", "rt-1-taken"); + const before = JSON.stringify(loadRegistry(repoName)); + const { h, events } = makeHandlers({ [repoName]: repo }); + + const res: any = await h["worktree:provision"]!({ repoName, branch: "rt-1-taken" }); + + expect(res.ok).toBe(false); + expect(res.error).toBe("branch-attached:beta"); + expect(JSON.stringify(loadRegistry(repoName))).toBe(before); + expect(events.length).toBe(0); + }); + + test("refuses a branch duplicated across trees", async () => { + const repo = makeRepo(); + const trees: TreeRecord[] = [ + { name: "a", path: join(repo, ".worktrees", "a"), kind: "ephemeral", state: "claimed", branch: "dup", createdAt: new Date().toISOString() }, + { name: "b", path: join(repo, ".worktrees", "b"), kind: "ephemeral", state: "claimed", branch: "dup", createdAt: new Date().toISOString() }, + ]; + saveRegistry(repoName, trees); + const { h } = makeHandlers({ [repoName]: repo }); + + const res: any = await h["worktree:provision"]!({ repoName, branch: "dup" }); + expect(res.ok).toBe(false); + expect(res.error).toBe("branch-duplicated"); + }); + + test("unknown repo refuses", async () => { + const { h } = makeHandlers({}); + const res: any = await h["worktree:provision"]!({ repoName: "nope", branch: "x" }); + expect(res.ok).toBe(false); + expect(res.error).toBe("repo-unknown"); + }); + + test("cold-creates when the pool is empty (wasOnDeck false)", async () => { + const repo = makeRepo(); + const { h } = makeHandlers({ [repoName]: repo }); + + const res: any = await h["worktree:provision"]!({ repoName, ticket: "RT-7", ticketTitle: "Cold" }); + + expect(res.ok).toBe(true); + expect(res.data.wasOnDeck).toBe(false); + expect(res.data.branch).toBe("rt-7-cold"); + expect(existsSync(res.data.path)).toBe(true); + expect(await currentBranchAsync(res.data.path)).toBe("rt-7-cold"); + + const stored = loadRegistry(repoName).find((t) => t.name === res.data.tree)!; + expect(stored.state).toBe("claimed"); + expect(stored.kind).toBe("ephemeral"); + }); + + test("a branch that exists only on the remote is checked out tracking it", async () => { + const repo = makeRepo(); + // A teammate's branch: pushed to origin, never present locally. + sh("git -c user.email=t@t -c user.name=t checkout -q -b tmp-mate && git -c user.email=t@t -c user.name=t commit --allow-empty -m mate", repo); + const mateSha = sh("git rev-parse HEAD", repo).trim(); + sh("git push -q origin tmp-mate:refs/heads/mate-branch && git checkout -q main && git branch -qD tmp-mate", repo); + + const rec = seedOnDeck(repo, repoName, "alpha", new Date().toISOString()); + const { h } = makeHandlers({ [repoName]: repo }); + + const res: any = await h["worktree:provision"]!({ repoName, branch: "mate-branch" }); + + expect(res.ok).toBe(true); + expect(res.data.branchState).toBe("tracking-remote"); + expect(await headSha(rec.path)).toBe(mateSha); + expect(await currentBranchAsync(rec.path)).toBe("mate-branch"); + }); + + test("a failure after the claim rolls the tree back to on-deck", async () => { + const repo = makeRepo(); + const rec = seedOnDeck(repo, repoName, "alpha", new Date().toISOString()); + // Unreachable origin: the targeted fetch fails with something that is NOT + // git's ref-not-found signature, which must roll back rather than proceed. + sh(`git remote set-url origin ${join(tmpdir(), "rtwh-gone-nowhere.git")}`, repo); + const { h } = makeHandlers({ [repoName]: repo }); + + const res: any = await h["worktree:provision"]!({ repoName, branch: "rt-5-boom", owner: "pane-1" }); + + expect(res.ok).toBe(false); + expect(String(res.error).startsWith("checkout-failed:")).toBe(true); + + const stored = loadRegistry(repoName).find((t) => t.name === "alpha")!; + expect(stored.state).toBe("on-deck"); + expect(stored.owner).toBeUndefined(); + expect(stored.claimedAt).toBeUndefined(); + expect(stored.branch).toBe("on-deck/alpha"); + expect(await currentBranchAsync(rec.path)).toBe("on-deck/alpha"); + }); + + test("skips a locked on-deck tree and picks the next best", async () => { + const repo = makeRepo(); + const older = new Date(Date.now() - 60_000).toISOString(); + seedOnDeck(repo, repoName, "alpha", older); + const beta = seedOnDeck(repo, repoName, "beta", new Date().toISOString()); + + const release = tryLockTree(beta.path); + expect(release).not.toBeNull(); + try { + const { h } = makeHandlers({ [repoName]: repo }); + const res: any = await h["worktree:provision"]!({ repoName, branch: "rt-2-pick" }); + expect(res.ok).toBe(true); + // beta is the freshest but locked, so the claim falls to alpha. + expect(res.data.tree).toBe("alpha"); + } finally { + release!(); + } + }); +}); + +describe("worktree:create", () => { + test("--on-deck leaves the tree in the pool; without it the tree is claimed", async () => { + const repo = makeRepo(); + const { h } = makeHandlers({ [repoName]: repo }); + + const pooled: any = await h["worktree:create"]!({ repoName, onDeck: true }); + expect(pooled.ok).toBe(true); + expect(loadRegistry(repoName).find((t) => t.name === pooled.data.tree)!.state).toBe("on-deck"); + + const claimed: any = await h["worktree:create"]!({ repoName }); + expect(claimed.ok).toBe(true); + expect(existsSync(claimed.data.path)).toBe(true); + expect(loadRegistry(repoName).find((t) => t.name === claimed.data.tree)!.state).toBe("claimed"); + }); +}); + +describe("worktree:dispose", () => { + test("a locked tree gets the typed busy refusal", async () => { + const repo = makeRepo(); + const rec = seedClaimed(repo, repoName, "alpha", "rt-3-work"); + const release = tryLockTree(rec.path); + try { + const { h } = makeHandlers({ [repoName]: repo }); + const res: any = await h["worktree:dispose"]!({ repoName, tree: "alpha" }); + expect(res.ok).toBe(true); + expect(res.data.disposed).toEqual([]); + expect(res.data.refused).toEqual([{ tree: "alpha", reason: "busy" }]); + } finally { + release!(); + } + }); + + test("--owner sweeps every repo and reports the dirty tree's refusal", async () => { + const repoA = makeRepo(); + const repoB = makeRepo(); + const clean = seedClaimed(repoA, "acme", "alpha", "rt-4-clean", "job-1"); + const dirty = seedClaimed(repoB, "beta-repo", "bravo", "rt-4-dirty", "job-1"); + writeFileSync(join(dirty.path, "scratch.txt"), "uncommitted\n"); + + const { h } = makeHandlers({ acme: repoA, "beta-repo": repoB }); + const res: any = await h["worktree:dispose"]!({ owner: "job-1" }); + + expect(res.ok).toBe(true); + expect(res.data.disposed).toEqual(["alpha"]); + expect(res.data.refused).toEqual([{ tree: "bravo", reason: "dirty" }]); + expect(existsSync(clean.path)).toBe(false); + expect(existsSync(dirty.path)).toBe(true); + expect(loadRegistry("acme").find((t) => t.name === "alpha")).toBeUndefined(); + expect(loadRegistry("beta-repo").find((t) => t.name === "bravo")).toBeDefined(); + }); + + test("a bare tree name matching two repos refuses rather than guessing", async () => { + const repoA = makeRepo(); + const repoB = makeRepo(); + seedClaimed(repoA, "acme", "alpha", "rt-6-a"); + seedClaimed(repoB, "beta-repo", "alpha", "rt-6-b"); + + const { h } = makeHandlers({ acme: repoA, "beta-repo": repoB }); + const res: any = await h["worktree:dispose"]!({ tree: "alpha" }); + + expect(res.ok).toBe(false); + expect(res.error).toBe("tree-ambiguous"); + expect(loadRegistry("acme").length).toBe(1); + expect(loadRegistry("beta-repo").length).toBe(1); + }); +}); + +describe("worktree:list", () => { + test("flags duplicate branches and joins MRs on (repoName, branch)", async () => { + const repo = makeRepo(); + const now = new Date().toISOString(); + saveRegistry(repoName, [ + { name: "a", path: join(repo, ".worktrees", "a"), kind: "ephemeral", state: "claimed", branch: "dup", createdAt: now }, + { name: "b", path: join(repo, ".worktrees", "b"), kind: "ephemeral", state: "claimed", branch: "dup", createdAt: now }, + { name: "c", path: join(repo, ".worktrees", "c"), kind: "ephemeral", state: "claimed", branch: "solo", createdAt: now }, + { name: "d", path: join(repo, ".worktrees", "d"), kind: "ephemeral", state: "claimed", branch: "other-repo", createdAt: now }, + ]); + const entries = { + solo: { mr: { iid: 7, state: "opened", title: "Solo work" }, repoName }, + "other-repo": { mr: { iid: 9, state: "opened", title: "Not ours" }, repoName: "elsewhere" }, + }; + const { h } = makeHandlers({ [repoName]: repo }, entries); + + const res: any = await h["worktree:list"]!({ repoName }); + expect(res.ok).toBe(true); + const byName: Record = Object.fromEntries(res.data.trees.map((t: any) => [t.name, t])); + + expect(byName.a.duplicateBranch).toBe(true); + expect(byName.b.duplicateBranch).toBe(true); + expect(byName.c.duplicateBranch).toBeUndefined(); + expect(byName.c.mr).toMatchObject({ iid: 7, state: "opened", title: "Solo work" }); + // repoName mismatch means "no MR", never another repo's MR. + expect(byName.d.mr).toBeNull(); + }); +}); + +describe("worktree:freshen", () => { + test("runs the named tree and fast-forwards it", async () => { + const repo = makeRepo(); + const rec = seedOnDeck(repo, repoName, "alpha", new Date().toISOString()); + const before = await headSha(rec.path); + sh("git -c user.email=t@t -c user.name=t commit --allow-empty -m advance && git push -q origin main", repo); + + const { h } = makeHandlers({ [repoName]: repo }); + const res: any = await h["worktree:freshen"]!({ repoName, tree: "alpha" }); + + expect(res.ok).toBe(true); + expect(res.data.ran).toEqual(["alpha"]); + expect(await headSha(rec.path)).not.toBe(before); + }); +}); + +describe("worktree:adopt", () => { + test("registers main, disposes a clean parking-lot tree, claims the feature tree", async () => { + const repo = makeRepo(); + const parked = join(repo, ".worktrees", "parked"); + const feature = join(repo, ".worktrees", "feature"); + sh(`git worktree add -b parking-lot/1 ${parked} origin/main`, repo); + sh(`git worktree add -b cv-1-feature ${feature} origin/main`, repo); + + const { h } = makeHandlers({ [repoName]: repo }); + const res: any = await h["worktree:adopt"]!({ repoName }); + + expect(res.ok).toBe(true); + expect(res.data.main).toBe(basename(repo)); + expect(res.data.disposed).toEqual(["parked"]); + expect(res.data.claimed).toEqual(["feature"]); + expect(res.data.refused).toEqual([]); + + expect(existsSync(parked)).toBe(false); + expect(await branchExistsLocalAsync(repo, "parking-lot/1")).toBe(false); + + const trees = loadRegistry(repoName); + expect(trees.find((t) => t.path === repo)!.kind).toBe("main"); + expect(trees.find((t) => t.name === "parked")).toBeUndefined(); + const feat = trees.find((t) => t.name === "feature")!; + expect(feat.kind).toBe("ephemeral"); + expect(feat.state).toBe("claimed"); + expect(feat.disposal).toBe("merge"); + expect(feat.branch).toBe("cv-1-feature"); + }); +}); diff --git a/lib/daemon/command-router.ts b/lib/daemon/command-router.ts index 400fab63..9b9c0811 100644 --- a/lib/daemon/command-router.ts +++ b/lib/daemon/command-router.ts @@ -12,7 +12,7 @@ import { createHooksHandlers } from "./handlers/hooks.ts"; import { createStatusHandlers } from "./handlers/status.ts"; import { createWorkspaceHandlers } from "./handlers/workspace.ts"; import { createMRHandlers } from "./handlers/mr.ts"; -import { createParkingLotHandlers } from "./handlers/parking-lot.ts"; +import { createWorktreeHandlers, type WorktreeHandlerOpts } from "./handlers/worktree.ts"; import { createDiscussionHandlers } from "./handlers/discussions.ts"; import { createSystemProcessHandlers } from "./handlers/system-processes.ts"; import { createSdmHandlers } from "./handlers/sdm.ts"; @@ -30,6 +30,8 @@ export function buildRoutedHandlers(opts: { ctx: HandlerContext; broadcast: (type: string, data: any) => void; systemProcessScanner: SystemProcessScanner; + /** Reconciler seams the worktree verbs drive (spec §7): claim events, replenish kicks, in-flight creates. */ + worktree: WorktreeHandlerOpts; }): TypedHandlers & HandlerMap { const { ctx, broadcast, systemProcessScanner } = opts; return { @@ -38,7 +40,7 @@ export function buildRoutedHandlers(opts: { ...createStatusHandlers(ctx), ...createWorkspaceHandlers(ctx), ...createMRHandlers(ctx, broadcast), - ...createParkingLotHandlers(ctx), + ...createWorktreeHandlers(ctx, opts.worktree), ...createDiscussionHandlers(ctx, broadcast), ...createSystemProcessHandlers(systemProcessScanner, ctx), ...createSdmHandlers(ctx), diff --git a/lib/daemon/handlers/worktree.ts b/lib/daemon/handlers/worktree.ts new file mode 100644 index 00000000..8ed91e05 --- /dev/null +++ b/lib/daemon/handlers/worktree.ts @@ -0,0 +1,569 @@ +/** + * Worktree lifecycle IPC verbs (spec §3): provision, create, dispose, list, + * freshen, adopt. Every mutation the CLI and the skills perform goes through + * here, which is what makes the daemon the single writer of the registry. + * + * The verbs are thin: the intelligence lives in lib/worktree/* (create, + * dispose guard, locks, branch naming) and lib/daemon/worktree-reconciler.ts + * (reconcile, freshen). What this module owns is the ORDER those pieces run + * in, which is load-bearing for provision (§7): + * + * 1. resolve intent and fire every registry-decidable refusal BEFORE any + * tree is touched — an expected "resume there?" refusal must never + * strand a claimed tree; + * 2. select / create the tree; + * 3. claim it under its lock; + * 4. check the work branch out per the resolution matrix; + * 5. roll back on any failure after the claim — reverted to on-deck when the + * tree never left its `on-deck/` branch, disposable (with the + * failure as the reason) when it did. No limbo states. + * + * Handler outcomes are `{ok:true, data}` / `{ok:false, error}` with typed + * refusal strings; daemon.ts's handleCommand does the outcome logging, so + * nothing here logs request/response. + */ + +import { realpathSync } from "fs"; + +import type { HandlerContext, HandlerMap } from "./types.ts"; +import { + findByBranch, + loadRegistry, + saveRegistry, + type DisposalMode, + type TreeRecord, +} from "../../worktree/registry.ts"; +import { disambiguate, slugifyTicketTitle } from "../../worktree/branch-name.ts"; +import { createTree } from "../../worktree/create.ts"; +import { classifyDirtyAsync, disposeTree, type DisposeDeps } from "../../worktree/dispose.ts"; +import { isTreeLocked, withTreeLock } from "../../worktree/locks.ts"; +import { + branchExistsLocalAsync, + currentBranchAsync, + remoteDefaultRef, + remoteRefExists, + runGit, +} from "../../worktree/git-async.ts"; +import { + loadWorktreeAppConfig, + loadWorktreeRepoConfig, + resolveReadySteps, +} from "../../worktree/config.ts"; +import { changedSince, runReadySteps, stepsToRun } from "../../worktree/ready.ts"; +import { freshenRepo, reconcileRepoRegistry } from "../worktree-reconciler.ts"; + +const PROVISION_FETCH_TIMEOUT_MS = 5 * 60_000; + +/** git's ref-not-found signature — the ONE fetch failure that means "no such remote branch". */ +const NO_REMOTE_REF_RE = /couldn't find remote ref/i; + +const PARKING_LOT_BRANCH_RE = /^parking-lot\/\d+$/; + +export type BranchState = "new" | "tracking-remote" | "existing-clean" | "diverged" | "behind"; + +export interface WorktreeHandlerOpts { + /** Broadcast bus (daemon's broadcast+cron composite). */ + emit: (type: string, data: unknown) => void; + /** Ask the reconciler for a pass (replenish after a claim / disposal). */ + kick: () => void; + /** The live replenish create for a repo, or null; provision joins it rather than racing it. */ + creationInFlight: (repoName: string) => Promise | null; +} + +// ─── Small shared helpers ──────────────────────────────────────────────────── + +/** realpathSync defensively; a path that doesn't exist compares as-is. */ +function canon(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +function patchTree(repoName: string, path: string, patch: (rec: TreeRecord) => void): void { + const trees = loadRegistry(repoName); + const rec = trees.find((t) => t.path === path); + if (!rec) return; + patch(rec); + saveRegistry(repoName, trees); +} + +/** Every local branch name in the repo, for the sync `exists()` disambiguation predicate. */ +async function localBranchNames(repoPath: string): Promise> { + const r = await runGit(repoPath, ["for-each-ref", "--format=%(refname:short)", "refs/heads"]); + return new Set( + r.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0), + ); +} + +/** Repos this payload targets: the named one, or every repo in the index. */ +function targetRepos(ctx: HandlerContext, repoName?: string): Array<[string, string]> { + const index = ctx.repoIndex(); + if (repoName) { + const path = index[repoName]; + return path ? [[repoName, path]] : []; + } + return Object.entries(index); +} + +function disposeDeps( + ctx: HandlerContext, + opts: WorktreeHandlerOpts, + repoName: string, + repoPath: string, +): DisposeDeps { + return { + repoName, + repoPath, + cacheEntries: ctx.cache.entries as DisposeDeps["cacheEntries"], + emit: opts.emit, + log: ctx.log, + killProcesses: loadWorktreeAppConfig().killProcesses, + }; +} + +// ─── Provision (spec §7) ───────────────────────────────────────────────────── + +/** + * Best on-deck tree: freshest first (`readyAt` desc, `createdAt` desc as the + * tie-break), skipping trees another operation holds the lock on and trees + * whose last freshen failed (they are inside their retry backoff and are not + * "ready" in the sense provision needs). + */ +function selectOnDeck(repoName: string): TreeRecord | undefined { + const now = Date.now(); + const stamp = (t: TreeRecord): number => Date.parse(t.readyAt ?? "") || 0; + return loadRegistry(repoName) + .filter( + (t) => + t.kind === "ephemeral" && + t.state === "on-deck" && + !isTreeLocked(t.path) && + (!t.nextRetryAt || Date.parse(t.nextRetryAt) <= now), + ) + .sort((a, b) => stamp(b) - stamp(a) || Date.parse(b.createdAt) - Date.parse(a.createdAt))[0]; +} + +/** Ahead/behind counts of `branch` vs its `origin/` counterpart. */ +async function divergence( + treePath: string, + branch: string, +): Promise<{ ahead: number; behind: number } | null> { + const r = await runGit(treePath, [ + "rev-list", "--left-right", "--count", `${branch}...origin/${branch}`, + ]); + if (r.exitCode !== 0) return null; + const [ahead, behind] = r.stdout.trim().split(/\s+/).map((n) => Number.parseInt(n, 10)); + if (!Number.isFinite(ahead!) || !Number.isFinite(behind!)) return null; + return { ahead: ahead!, behind: behind! }; +} + +export function createWorktreeHandlers( + ctx: HandlerContext, + opts: WorktreeHandlerOpts, +): HandlerMap { + /** + * Undo a claim that could not be completed. Still on its `on-deck/` + * branch → the tree is untouched and goes back in the pool; already moved + * off it → it is no longer a pool tree, so it becomes disposable carrying + * the failure as its reason (never left claimed-but-unusable). + */ + async function rollbackClaim( + repoName: string, + rec: TreeRecord, + onDeckBranch: string | null, + reason: string, + ): Promise { + const current = await currentBranchAsync(rec.path); + if (onDeckBranch && current === onDeckBranch) { + patchTree(repoName, rec.path, (r) => { + r.state = "on-deck"; + r.branch = onDeckBranch; + delete r.owner; + delete r.disposal; + delete r.claimedAt; + }); + return; + } + patchTree(repoName, rec.path, (r) => { + r.state = "disposable"; + r.branch = current; + r.disposableReason = reason; + }); + opts.emit("worktree:disposable", { + repo: repoName, tree: rec.name, path: rec.path, reason, + }); + } + + return { + "worktree:provision": async (payload: any) => { + const repoName: string | undefined = payload?.repoName; + const repoPath = repoName ? ctx.repoIndex()[repoName] : undefined; + if (!repoName || !repoPath) return { ok: false, error: "repo-unknown" }; + + const cfg = loadWorktreeRepoConfig(repoName, repoPath); + const trees = loadRegistry(repoName); + + // ── 1. Intent + every registry-decidable refusal, before any tree moves. + let branch: string; + if (typeof payload.branch === "string" && payload.branch.length > 0) { + branch = payload.branch; + } else if (typeof payload.ticket === "string" && payload.ticket.length > 0) { + const base = slugifyTicketTitle( + payload.ticket, + typeof payload.ticketTitle === "string" ? payload.ticketTitle : "", + cfg.branchFormat, + ).replace(/-+$/, ""); + const local = await localBranchNames(repoPath); + const registered = new Set( + trees.map((t) => t.branch).filter((b): b is string => typeof b === "string"), + ); + branch = disambiguate(base, (candidate) => local.has(candidate) || registered.has(candidate)); + } else { + return { ok: false, error: "branch-unresolved" }; + } + + const attached = findByBranch(trees, branch); + if (attached.length > 1) return { ok: false, error: "branch-duplicated" }; + if (attached.length === 1) return { ok: false, error: `branch-attached:${attached[0]!.name}` }; + + // ── 2. Selection, then the pool's own in-flight create, then cold create. + let rec = selectOnDeck(repoName); + let wasOnDeck = true; + if (!rec) { + const inFlight = opts.creationInFlight(repoName); + if (inFlight) { + await inFlight; + rec = selectOnDeck(repoName); + // Joining a replenish build is still a build the caller waited for, + // so it is reported as a cold create, not as a warm pool hit. + if (rec) wasOnDeck = false; + } + } + if (!rec) { + const created = await createTree({ + repoName, repoPath, emit: opts.emit, log: ctx.log, + }); + if (!created.ok) { + if (created.error === "busy") return { ok: false, error: "busy" }; + return { ok: false, error: `create-failed:${created.failedStep ?? "unknown"}` }; + } + rec = created.tree; + wasOnDeck = false; + } + + const tree = rec; + const onDeckBranch = tree.branch; + + const outcome = await withTreeLock(tree.path, async (): Promise< + { ok: true; data: any } | { ok: false; error: string } + > => { + // The registry may have moved between selection and the lock. + const fresh = loadRegistry(repoName).find((t) => t.path === tree.path); + if (!fresh || fresh.kind !== "ephemeral" || (fresh.state !== "on-deck" && fresh.state !== "claimed")) { + return { ok: false, error: "busy" }; + } + + // ── 3. Claim. + const disposal: DisposalMode = payload.disposal === "job" ? "job" : "merge"; + patchTree(repoName, tree.path, (r) => { + r.state = "claimed"; + r.disposal = disposal; + r.claimedAt = new Date().toISOString(); + if (typeof payload.owner === "string" && payload.owner.length > 0) r.owner = payload.owner; + }); + opts.emit("worktree:claimed", { + repo: repoName, tree: tree.name, branch, owner: payload.owner ?? null, + }); + + // ── 4. Branch resolution matrix, against a fresh targeted fetch. + const fetch = await runGit(tree.path, ["fetch", "origin", branch], { + timeoutMs: PROVISION_FETCH_TIMEOUT_MS, + }); + let remoteHasBranch = true; + if (fetch.exitCode !== 0) { + const detail = (fetch.stderr + fetch.stdout).trim(); + // The two non-zero outcomes mean opposite things: only git's + // ref-not-found signature means "no such branch upstream". An + // unreachable/auth-failed origin must NOT be read as absence — that + // would silently shadow a teammate's branch with an empty one. + if (!NO_REMOTE_REF_RE.test(detail)) { + await rollbackClaim(repoName, tree, onDeckBranch, `checkout-failed:${detail}`); + return { ok: false, error: `checkout-failed:${detail}` }; + } + remoteHasBranch = false; + } + + const localExists = await branchExistsLocalAsync(tree.path, branch); + let branchState: BranchState; + let checkout; + + if (localExists) { + // (c)/(e) local branch wins, checked out untouched — never auto-ff, + // never reset; divergence is reported, not reconciled. + checkout = await runGit(tree.path, ["checkout", branch]); + branchState = "existing-clean"; + if (checkout.exitCode === 0 && (await remoteRefExists(tree.path, branch))) { + const counts = await divergence(tree.path, branch); + if (counts && counts.ahead > 0 && counts.behind > 0) branchState = "diverged"; + else if (counts && counts.behind > 0) branchState = "behind"; + } + } else if (remoteHasBranch) { + // (b) remote-only: base on the remote tip so a teammate's commits + // survive. `origin/` normally exists after the targeted + // fetch (standard refspec); FETCH_HEAD is the fallback when it does not. + const startPoint = (await remoteRefExists(tree.path, branch)) + ? `origin/${branch}` + : "FETCH_HEAD"; + checkout = await runGit(tree.path, ["checkout", "-b", branch, startPoint]); + branchState = "tracking-remote"; + } else { + // (a) nowhere: cut it from the default branch. + const defaultRef = await remoteDefaultRef(tree.path); + checkout = await runGit(tree.path, ["checkout", "-b", branch, defaultRef]); + branchState = "new"; + } + + if (checkout.exitCode !== 0) { + const detail = (checkout.stderr + checkout.stdout).trim(); + await rollbackClaim(repoName, tree, onDeckBranch, `checkout-failed:${detail}`); + return { ok: false, error: `checkout-failed:${detail}` }; + } + + // The pool branch has served its purpose; the tree now carries the work branch. + if (onDeckBranch && onDeckBranch.startsWith("on-deck/")) { + await runGit(tree.path, ["branch", "-D", onDeckBranch]); + } + patchTree(repoName, tree.path, (r) => { r.branch = branch; }); + + // Re-verify readiness: normally every `when` trigger no-ops, but a + // default branch that moved (or a teammate branch just checked out) + // runs the delta. A failing step does NOT destroy the claimed tree — + // the caller has it and can fix it; readyAt simply doesn't advance. + const readySteps = resolveReadySteps(cfg, repoPath); + const stamp = loadRegistry(repoName).find((t) => t.path === tree.path)?.readyStamp; + const changed = stamp ? await changedSince(tree.path, stamp) : null; + const toRun = stepsToRun(readySteps, changed); + if (toRun.length > 0) { + const ready = await runReadySteps(tree.path, toRun); + if (!ready.ok) { + ctx.log.warn( + { repo: repoName, tree: tree.name, failedStep: ready.failedStep }, + "provision: ready step failed after claim", + ); + } else { + patchTree(repoName, tree.path, (r) => { r.readyAt = new Date().toISOString(); }); + } + } + + const final = loadRegistry(repoName).find((t) => t.path === tree.path); + return { + ok: true, + data: { + tree: tree.name, + path: tree.path, + branch, + wasOnDeck, + readyAt: final?.readyAt ?? null, + branchState, + }, + }; + }); + + if (outcome === "busy") return { ok: false, error: "busy" }; + // Claiming shrinks the pool: ask for a replenish pass now rather than + // waiting for the next tick. + if (outcome.ok) opts.kick(); + return outcome; + }, + + "worktree:create": async (payload: any) => { + const repoName: string | undefined = payload?.repoName; + const repoPath = repoName ? ctx.repoIndex()[repoName] : undefined; + if (!repoName || !repoPath) return { ok: false, error: "repo-unknown" }; + + const created = await createTree({ repoName, repoPath, emit: opts.emit, log: ctx.log }); + if (!created.ok) { + if (created.error === "busy") return { ok: false, error: "busy" }; + return { ok: false, error: `create-failed:${created.failedStep ?? "unknown"}` }; + } + + // Default is a tree for the caller to use; `--on-deck` puts it in the + // pool instead. A claimed create keeps its `on-deck/` branch (no + // work branch was named) but is no longer claimable by provision. + if (payload?.onDeck !== true) { + patchTree(repoName, created.tree.path, (r) => { + r.state = "claimed"; + r.claimedAt = new Date().toISOString(); + }); + } + + return { ok: true, data: { tree: created.tree.name, path: created.tree.path } }; + }, + + "worktree:dispose": async (payload: any) => { + const owner: string | undefined = typeof payload?.owner === "string" ? payload.owner : undefined; + const treeName: string | undefined = typeof payload?.tree === "string" ? payload.tree : undefined; + const force = payload?.force === true; + + // `--owner` sweeps globally by default (a run may span repos); `--repo` + // narrows it. A named tree always needs its repo. + const repos = targetRepos(ctx, payload?.repoName); + if (repos.length === 0) return { ok: false, error: "repo-unknown" }; + if (!owner && !treeName) return { ok: false, error: "no-target" }; + + const targets: Array<{ repoName: string; repoPath: string; rec: TreeRecord }> = []; + for (const [name, path] of repos) { + for (const rec of loadRegistry(name).filter((t) => (owner ? t.owner === owner : t.name === treeName))) { + targets.push({ repoName: name, repoPath: path, rec }); + } + } + + // A bare tree name that two repos both answer to is not a target rt gets + // to guess at — disposal is the destructive verb (honesty over magic). + if (!owner && targets.length > 1) return { ok: false, error: "tree-ambiguous" }; + + const disposed: string[] = []; + const refused: Array<{ tree: string; reason: string }> = []; + + for (const { repoName, repoPath, rec } of targets) { + const deps = disposeDeps(ctx, opts, repoName, repoPath); + const outcome = await withTreeLock(rec.path, () => + disposeTree(deps, rec, { force, auto: false }), + ); + if (outcome === "busy") refused.push({ tree: rec.name, reason: "busy" }); + else if (outcome.disposed) disposed.push(rec.name); + else refused.push({ tree: rec.name, reason: outcome.refusal }); + } + + if (targets.length === 0 && treeName) refused.push({ tree: treeName, reason: "unknown" }); + if (disposed.length > 0) opts.kick(); + + return { ok: true, data: { disposed, refused } }; + }, + + "worktree:list": async (payload: any) => { + const repos = targetRepos(ctx, payload?.repoName); + if (repos.length === 0 && payload?.repoName) return { ok: false, error: "repo-unknown" }; + + const entries = ctx.cache.entries; + const rows: Array> = []; + + for (const [repoName] of repos) { + const trees = loadRegistry(repoName); + const branchCounts = new Map(); + for (const t of trees) { + if (t.branch) branchCounts.set(t.branch, (branchCounts.get(t.branch) ?? 0) + 1); + } + + for (const t of trees) { + // The join key is (repoName, branch): a bare-branch join would hand + // a tree another repo's MR when both repos use the same name. + const entry = t.branch ? entries[t.branch] : undefined; + const mr = + entry?.mr && (!entry.repoName || entry.repoName === repoName) + ? { iid: entry.mr.iid, state: entry.mr.state, title: entry.mr.title } + : null; + rows.push({ + ...t, + repoName, + mr, + ...(t.branch && (branchCounts.get(t.branch) ?? 0) > 1 ? { duplicateBranch: true as const } : {}), + }); + } + } + + return { ok: true, data: { trees: rows } }; + }, + + "worktree:freshen": async (payload: any) => { + const treeName: string | undefined = typeof payload?.tree === "string" ? payload.tree : undefined; + const repos = targetRepos(ctx, payload?.repoName); + if (repos.length === 0) return { ok: false, error: "repo-unknown" }; + + const ran: string[] = []; + for (const [repoName, repoPath] of repos) { + const names = await freshenRepo( + { repoName, repoPath, emit: opts.emit, log: ctx.log }, + treeName ? { only: treeName } : {}, + ); + ran.push(...names); + } + return { ok: true, data: { ran } }; + }, + + /** + * One-shot migration sweep (spec §11.2): whatever the repo already has + * becomes registry truth. Reconcile first so every worktree on disk has an + * entry, then classify: the main clone stays main, clean `parking-lot/N` + * trees are disposed through the normal guard (no-MR anchor) with their + * branches, and every other tree is an occupied ephemeral — claimed on the + * branch it is already sitting on. + */ + "worktree:adopt": async (payload: any) => { + const repoName: string | undefined = payload?.repoName; + const repoPath = repoName ? ctx.repoIndex()[repoName] : undefined; + if (!repoName || !repoPath) return { ok: false, error: "repo-unknown" }; + + // Repo-wide lock: adopt rewrites every entry, so no per-tree operation + // may interleave with it. Synthetic key (no tree lives at this path). + const result = await withTreeLock(`${repoPath}#adopt`, async () => { + const trees = await reconcileRepoRegistry({ + repoName, repoPath, emit: opts.emit, log: ctx.log, + }); + + let main = ""; + const claimed: string[] = []; + const disposed: string[] = []; + const refused: Array<{ tree: string; reason: string }> = []; + + for (const rec of trees) { + if (rec.kind === "main" || canon(rec.path) === canon(repoPath)) { + if (rec.kind !== "main") patchTree(repoName, rec.path, (r) => { r.kind = "main"; }); + main = rec.name; + continue; + } + // Trees rt already manages are left exactly as they are. + if (rec.kind === "ephemeral") continue; + + const parked = + rec.branch !== null && + PARKING_LOT_BRANCH_RE.test(rec.branch) && + (await classifyDirtyAsync(rec.path, repoName)).blockers.length === 0; + + if (parked) { + // Ephemeral+claimed first: the guard only ever deletes rt's own + // trees, so the entry has to say so before disposal is even legal. + patchTree(repoName, rec.path, (r) => { + r.kind = "ephemeral"; + r.state = "claimed"; + r.claimedAt = new Date().toISOString(); + }); + const deps = disposeDeps(ctx, opts, repoName, repoPath); + const outcome = await withTreeLock(rec.path, () => + disposeTree(deps, { ...rec, kind: "ephemeral", state: "claimed" }, { auto: false }), + ); + if (outcome === "busy") refused.push({ tree: rec.name, reason: "busy" }); + else if (outcome.disposed) disposed.push(rec.name); + else refused.push({ tree: rec.name, reason: outcome.refusal }); + continue; + } + + patchTree(repoName, rec.path, (r) => { + r.kind = "ephemeral"; + r.state = "claimed"; + r.disposal = "merge"; + r.claimedAt = new Date().toISOString(); + }); + claimed.push(rec.name); + } + + return { ok: true as const, data: { main, claimed, disposed, refused } }; + }); + + if (result === "busy") return { ok: false, error: "busy" }; + return result; + }, + }; +} diff --git a/lib/daemon/worktree-reconciler.ts b/lib/daemon/worktree-reconciler.ts index a3e4f7da..caa78940 100644 --- a/lib/daemon/worktree-reconciler.ts +++ b/lib/daemon/worktree-reconciler.ts @@ -593,7 +593,7 @@ async function freshenCandidate(deps: FreshenDeps, rec: TreeRecord): Promise { +async function freshenOne(deps: FreshenDeps, rec: TreeRecord): Promise { const { repoName, log, emit } = deps; const fields = { repo: repoName, tree: rec.name, path: rec.path }; @@ -615,7 +615,7 @@ async function freshenOne(deps: FreshenDeps, rec: TreeRecord): Promise { if (fetchResult.exitCode !== 0) { log.warn({ ...fields, output: fetchResult.stderr.trim() }, "freshen: fetch failed"); fail(); - return; + return false; } const classify = await classifyDirtyAsync(rec.path, repoName); @@ -654,7 +654,7 @@ async function freshenOne(deps: FreshenDeps, rec: TreeRecord): Promise { log.warn({ ...fields, defaultRef, output: ff.stderr.trim() }, "freshen: fast-forward failed"); await popStash(); fail(); - return; + return false; } await popStash(); @@ -667,7 +667,7 @@ async function freshenOne(deps: FreshenDeps, rec: TreeRecord): Promise { if (!readyResult.ok) { log.warn({ ...fields, failedStep: readyResult.failedStep }, "freshen: ready step failed"); fail(); - return; + return false; } const newStamp = toRun.length > 0 ? await headSha(rec.path) : null; @@ -680,6 +680,7 @@ async function freshenOne(deps: FreshenDeps, rec: TreeRecord): Promise { emit("worktree:freshened", { repo: repoName, tree: rec.name, path: rec.path }); log.debug?.(fields, `worktree ${rec.name} freshened`); + return true; } /** @@ -694,25 +695,34 @@ async function freshenOne(deps: FreshenDeps, rec: TreeRecord): Promise { * window — the alternative is running a ff + ready steps inside a tree a * human just claimed. */ -async function freshenRepo(deps: FreshenDeps): Promise { +export async function freshenRepo( + deps: FreshenDeps, + opts: { only?: string } = {}, +): Promise { const { repoName, log } = deps; const now = Date.now(); const trees = loadRegistry(repoName); + const ran: string[] = []; for (const rec of trees) { - if (rec.nextRetryAt && Date.parse(rec.nextRetryAt) > now) continue; + if (opts.only && rec.name !== opts.only) continue; + // Backoff is a shield for the unattended pass, not for a human who just + // asked for this one tree by name: an explicit `only` retries now. + if (!opts.only && rec.nextRetryAt && Date.parse(rec.nextRetryAt) > now) continue; if (!(await freshenCandidate(deps, rec))) continue; - await withTreeLock(rec.path, async () => { + const outcome = await withTreeLock(rec.path, async () => { const fresh = findByPath(loadRegistry(repoName), rec.path); if (!fresh || fresh.state !== rec.state || fresh.branch !== rec.branch) { log.debug?.( { repo: repoName, tree: rec.name, path: rec.path }, "freshen: skipping — tree changed since candidacy was decided", ); - return; + return false; } - await freshenOne(deps, fresh); + return await freshenOne(deps, fresh); }); + if (outcome === true) ran.push(rec.name); } + return ran; } // ─── Replenish / shrink (spec §6.4) ────────────────────────────────────────── From ba95ab693f045e7619446fd0efc245605cb426c0 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 19:01:42 -0500 Subject: [PATCH 22/31] RT-34: cover provision branch-state matrix for local branches existing-clean / behind / diverged against real upstreams, including the assertion that a diverged local tip is checked out untouched. Co-Authored-By: Claude Fable 5 --- .../__tests__/worktree-handlers.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/lib/daemon/__tests__/worktree-handlers.test.ts b/lib/daemon/__tests__/worktree-handlers.test.ts index 92af5ada..79cc27a7 100644 --- a/lib/daemon/__tests__/worktree-handlers.test.ts +++ b/lib/daemon/__tests__/worktree-handlers.test.ts @@ -221,6 +221,39 @@ describe("worktree:provision", () => { expect(await currentBranchAsync(rec.path)).toBe("mate-branch"); }); + test("local branches report existing-clean, behind, or diverged and are never reconciled", async () => { + const repo = makeRepo(); + const gitId = "git -c user.email=t@t -c user.name=t"; + + // (c) local only, no upstream at all. + sh(`${gitId} branch feat-local origin/main`, repo); + + // (c) local trails its upstream: origin's copy gained a commit the local ref never saw. + sh(`${gitId} checkout -q -b feat-behind origin/main && git push -q origin feat-behind`, repo); + sh(`${gitId} commit -q --allow-empty -m ahead && git push -q origin feat-behind && ${gitId} reset -q --hard HEAD~1 && git checkout -q main`, repo); + + // (e) both sides moved independently. + sh(`${gitId} checkout -q -b tmp-div origin/main && ${gitId} commit -q --allow-empty -m theirs && git push -q origin tmp-div:refs/heads/feat-div && git checkout -q main && git branch -qD tmp-div`, repo); + sh(`${gitId} checkout -q -b feat-div origin/main && ${gitId} commit -q --allow-empty -m mine && git checkout -q main`, repo); + const mineSha = sh("git rev-parse feat-div", repo).trim(); + + seedOnDeck(repo, repoName, "one", new Date().toISOString()); + seedOnDeck(repo, repoName, "two", new Date().toISOString()); + seedOnDeck(repo, repoName, "three", new Date().toISOString()); + const { h } = makeHandlers({ [repoName]: repo }); + + const plain: any = await h["worktree:provision"]!({ repoName, branch: "feat-local" }); + expect(plain.data.branchState).toBe("existing-clean"); + + const behind: any = await h["worktree:provision"]!({ repoName, branch: "feat-behind" }); + expect(behind.data.branchState).toBe("behind"); + + const diverged: any = await h["worktree:provision"]!({ repoName, branch: "feat-div" }); + expect(diverged.data.branchState).toBe("diverged"); + // Reported, never reconciled: the local tip is checked out untouched. + expect(await headSha(diverged.data.path)).toBe(mineSha); + }); + test("a failure after the claim rolls the tree back to on-deck", async () => { const repo = makeRepo(); const rec = seedOnDeck(repo, repoName, "alpha", new Date().toISOString()); From a815f16370b432f9c6c7a9f6ee70896288d81625 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 19:12:21 -0500 Subject: [PATCH 23/31] RT-34: tighten provision claim revalidation and surface degraded readiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes: - The post-lock revalidation accepted `claimed`, which is exactly the state meaning another provision won the race — re-claiming would overwrite that caller's owner/disposal and re-checkout their tree. Narrowed to on-deck through a named `isClaimable` seam, unit-tested across every state (the in-process race has no reachable interleave, since selection already filters locked and non-on-deck trees). - A ready step failing after the claim stays non-fatal, but the caller can now see it: provision data gains additive `readyFailed: true` and `failedStep`. - Adopt's pre-dispose flip of a parking-lot tree now also sets `disposal: "merge"`, so a tree the guard refuses is left a plain adopted claimed tree rather than a hybrid. - The rollback arm's `worktree:disposable` payload carries `branch`, matching the reconciler's event. - Comment at the claim patch recording why `branch` is deliberately not written there (reconcile step (c) owns it as git ground truth; the open window is closed by git's own "already checked out" failure). New tests: the disposable rollback arm (a tree drifted off its on-deck branch before the failure), the degraded-readiness flags, and the claimability matrix. Co-Authored-By: Claude Fable 5 --- .../__tests__/worktree-handlers.test.ts | 66 ++++++++++++++++++- lib/daemon/handlers/worktree.ts | 38 +++++++++-- 2 files changed, 96 insertions(+), 8 deletions(-) diff --git a/lib/daemon/__tests__/worktree-handlers.test.ts b/lib/daemon/__tests__/worktree-handlers.test.ts index 79cc27a7..38a108a4 100644 --- a/lib/daemon/__tests__/worktree-handlers.test.ts +++ b/lib/daemon/__tests__/worktree-handlers.test.ts @@ -14,11 +14,11 @@ import { tmpdir } from "os"; import { basename, join } from "path"; import type { Logger } from "pino"; import { writeJson } from "../../json-store.ts"; -import { rtDir } from "../../rt-paths.ts"; +import { repoDataDir, rtDir } from "../../rt-paths.ts"; import { loadRegistry, saveRegistry, type TreeRecord } from "../../worktree/registry.ts"; import { tryLockTree } from "../../worktree/locks.ts"; import { branchExistsLocalAsync, currentBranchAsync, headSha } from "../../worktree/git-async.ts"; -import { createWorktreeHandlers } from "../handlers/worktree.ts"; +import { createWorktreeHandlers, isClaimable } from "../handlers/worktree.ts"; import type { HandlerContext, HandlerMap } from "../handlers/types.ts"; function sh(cmd: string, cwd?: string): string { @@ -275,6 +275,68 @@ describe("worktree:provision", () => { expect(await currentBranchAsync(rec.path)).toBe("on-deck/alpha"); }); + test("only an on-deck entry is still claimable when the lock is finally taken", () => { + const base: TreeRecord = { + name: "alpha", + path: "/tmp/alpha", + kind: "ephemeral", + state: "on-deck", + branch: "on-deck/alpha", + createdAt: new Date().toISOString(), + }; + expect(isClaimable(base)).toBe(true); + // `claimed` is precisely "another provision got here first": re-claiming + // would overwrite that caller's owner/disposal and re-checkout their tree. + expect(isClaimable({ ...base, state: "claimed" })).toBe(false); + expect(isClaimable({ ...base, state: "creating" })).toBe(false); + expect(isClaimable({ ...base, state: "disposable" })).toBe(false); + expect(isClaimable({ ...base, kind: "unmanaged", state: undefined })).toBe(false); + expect(isClaimable(undefined)).toBe(false); + }); + + test("a tree that has already left its on-deck branch flips disposable on failure", async () => { + const repo = makeRepo(); + const rec = seedOnDeck(repo, repoName, "alpha", new Date().toISOString()); + // Registry drift: the tree is off its pool branch while the registry still + // says on-deck, so the rollback has no on-deck branch to return it to. + sh("git checkout -q -b drifted", rec.path); + sh(`git remote set-url origin ${join(tmpdir(), "rtwh-gone-nowhere.git")}`, repo); + const { h, events } = makeHandlers({ [repoName]: repo }); + + const res: any = await h["worktree:provision"]!({ repoName, branch: "rt-8-boom", owner: "pane-1" }); + + expect(res.ok).toBe(false); + expect(String(res.error).startsWith("checkout-failed:")).toBe(true); + + const stored = loadRegistry(repoName).find((t) => t.name === "alpha")!; + expect(stored.state).toBe("disposable"); + expect(stored.branch).toBe("drifted"); + expect(String(stored.disposableReason).startsWith("checkout-failed:")).toBe(true); + + const flipped = events.find((e) => e.type === "worktree:disposable"); + expect(flipped).toBeDefined(); + expect(flipped!.data).toMatchObject({ repo: repoName, tree: "alpha", branch: "drifted" }); + expect(String(flipped!.data.reason).startsWith("checkout-failed:")).toBe(true); + }); + + test("a ready step that fails after the claim hands the tree over flagged", async () => { + const repo = makeRepo(); + writeJson(join(repoDataDir(repoName), "config.json"), { + worktrees: { ready: [{ run: "exit 3" }] }, + }); + seedOnDeck(repo, repoName, "alpha", new Date().toISOString()); + const { h } = makeHandlers({ [repoName]: repo }); + + const res: any = await h["worktree:provision"]!({ repoName, branch: "rt-9-degraded" }); + + // Non-fatal: the caller has a usable tree, but must be able to see that + // its readiness is degraded. + expect(res.ok).toBe(true); + expect(res.data.readyFailed).toBe(true); + expect(res.data.failedStep).toBe("exit 3"); + expect(loadRegistry(repoName).find((t) => t.name === "alpha")!.state).toBe("claimed"); + }); + test("skips a locked on-deck tree and picks the next best", async () => { const repo = makeRepo(); const older = new Date(Date.now() - 60_000).toISOString(); diff --git a/lib/daemon/handlers/worktree.ts b/lib/daemon/handlers/worktree.ts index 8ed91e05..1fac9fa2 100644 --- a/lib/daemon/handlers/worktree.ts +++ b/lib/daemon/handlers/worktree.ts @@ -159,6 +159,19 @@ async function divergence( return { ahead: ahead!, behind: behind! }; } +/** + * Whether the entry a provision selected may still be claimed by it. + * + * ONLY `on-deck` qualifies. `claimed` is exactly the state that means another + * provision won the race: accepting it would overwrite that caller's owner and + * disposal mode and re-checkout their tree underneath them. `creating` and + * `disposable` are equally not ours to take, and a vanished entry means the + * reconciler pruned the tree while we were selecting it. + */ +export function isClaimable(rec: TreeRecord | undefined): boolean { + return rec !== undefined && rec.kind === "ephemeral" && rec.state === "on-deck"; +} + export function createWorktreeHandlers( ctx: HandlerContext, opts: WorktreeHandlerOpts, @@ -192,7 +205,7 @@ export function createWorktreeHandlers( r.disposableReason = reason; }); opts.emit("worktree:disposable", { - repo: repoName, tree: rec.name, path: rec.path, reason, + repo: repoName, tree: rec.name, path: rec.path, branch: current, reason, }); } @@ -261,11 +274,16 @@ export function createWorktreeHandlers( > => { // The registry may have moved between selection and the lock. const fresh = loadRegistry(repoName).find((t) => t.path === tree.path); - if (!fresh || fresh.kind !== "ephemeral" || (fresh.state !== "on-deck" && fresh.state !== "claimed")) { - return { ok: false, error: "busy" }; - } - - // ── 3. Claim. + if (!isClaimable(fresh)) return { ok: false, error: "busy" }; + + // ── 3. Claim. `branch` is deliberately NOT written here: reconcile + // step (c) owns that field as git ground truth and would reset it to + // `on-deck/` on its next pass anyway, so recording the work + // branch before the checkout only invents a fact git disagrees with. + // The window that leaves open (another provision naming the same + // branch between claim and checkout) is closed by git itself — the + // second checkout fails with "already checked out", which rolls that + // caller back rather than handing two trees the same branch. const disposal: DisposalMode = payload.disposal === "job" ? "job" : "merge"; patchTree(repoName, tree.path, (r) => { r.state = "claimed"; @@ -345,9 +363,11 @@ export function createWorktreeHandlers( const stamp = loadRegistry(repoName).find((t) => t.path === tree.path)?.readyStamp; const changed = stamp ? await changedSince(tree.path, stamp) : null; const toRun = stepsToRun(readySteps, changed); + let readyFailure: string | null = null; if (toRun.length > 0) { const ready = await runReadySteps(tree.path, toRun); if (!ready.ok) { + readyFailure = ready.failedStep; ctx.log.warn( { repo: repoName, tree: tree.name, failedStep: ready.failedStep }, "provision: ready step failed after claim", @@ -367,6 +387,9 @@ export function createWorktreeHandlers( wasOnDeck, readyAt: final?.readyAt ?? null, branchState, + // Additive, and only present when it happened: the tree is usable + // and handed over, but its dependencies may be stale. + ...(readyFailure ? { readyFailed: true as const, failedStep: readyFailure } : {}), }, }; }); @@ -538,6 +561,9 @@ export function createWorktreeHandlers( patchTree(repoName, rec.path, (r) => { r.kind = "ephemeral"; r.state = "claimed"; + // Same shape as the else-branch below, so a tree the guard + // refuses is left a plain adopted claimed tree, not a hybrid. + r.disposal = "merge"; r.claimedAt = new Date().toISOString(); }); const deps = disposeDeps(ctx, opts, repoName, repoPath); From f2420491ac6d50b39bbbeee68a9fbb36450e9873 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 19:28:57 -0500 Subject: [PATCH 24/31] RT-34: rt worktree CLI (provision/create/dispose/list/freshen/adopt, nav picker, each re-pointed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commands/worktree.ts grows the six daemon-backed lifecycle verbs plus the bare `rt worktree` nav picker (rows: name state branch owner, enter prints the path — same contract as `rt cd`). Every verb supports --json and treats a null daemonQuery as a hard stop (no inline fallback); worktreeDispose and worktreeFreshen fall back to an fzf picker over worktree:list when no target is given and stdin is a TTY. worktreeEach is re-pointed off the doomed lib/daemon/parking-lot.ts: bindings now come from the daemon's worktree:list (state-aware), with a read-only git-worktrees.ts fallback when the daemon is down (each never mutates, so the no-fallback rule doesn't apply to it). --parked is kept as a hidden alias for the renamed --on-deck flag. lib/command-tree-def.ts: the worktree group node itself now carries a handler (worktreeNav) alongside its subcommands, and the old park subtree collapses to a single deprecated entry pointing at `rt worktree`. Also fixes a dispatch bug found while wiring the new --repo flags: command-tree.ts's global --repo extraction ran unconditionally for every leaf node and silently discarded the flag+value whenever the node didn't declare context:"worktree" (verified empirically with a throwaway probe script). Scoped the extraction to context:"worktree" nodes only, so the new lifecycle verbs' own --repo payload flag survives to their handlers instead of vanishing before commands/worktree.ts ever sees it. Co-Authored-By: Claude Fable 5 --- commands/worktree.ts | 471 +++++++++++++++++- lib/__tests__/worktree-cli-args.test.ts | 116 +++++ lib/__tests__/worktree-each.test.ts | 49 +- lib/command-tree-def.ts | 115 +++-- lib/command-tree.ts | 22 +- lib/worktree-each.ts | 51 +- website/docs/reference/park.mdx | 20 + website/docs/reference/park/disable.mdx | 20 - website/docs/reference/park/enable.mdx | 20 - website/docs/reference/park/index.mdx | 29 -- website/docs/reference/park/pick.mdx | 20 - website/docs/reference/park/scan.mdx | 20 - website/docs/reference/park/status.mdx | 20 - website/docs/reference/park/this.mdx | 20 - website/docs/reference/worktree/adopt.mdx | 27 + website/docs/reference/worktree/create.mdx | 28 ++ website/docs/reference/worktree/dispose.mdx | 30 ++ website/docs/reference/worktree/each.mdx | 6 +- website/docs/reference/worktree/freshen.mdx | 28 ++ website/docs/reference/worktree/index.mdx | 12 +- website/docs/reference/worktree/list.mdx | 27 + website/docs/reference/worktree/provision.mdx | 31 ++ 22 files changed, 923 insertions(+), 259 deletions(-) create mode 100644 lib/__tests__/worktree-cli-args.test.ts create mode 100644 website/docs/reference/park.mdx delete mode 100644 website/docs/reference/park/disable.mdx delete mode 100644 website/docs/reference/park/enable.mdx delete mode 100644 website/docs/reference/park/index.mdx delete mode 100644 website/docs/reference/park/pick.mdx delete mode 100644 website/docs/reference/park/scan.mdx delete mode 100644 website/docs/reference/park/status.mdx delete mode 100644 website/docs/reference/park/this.mdx create mode 100644 website/docs/reference/worktree/adopt.mdx create mode 100644 website/docs/reference/worktree/create.mdx create mode 100644 website/docs/reference/worktree/dispose.mdx create mode 100644 website/docs/reference/worktree/freshen.mdx create mode 100644 website/docs/reference/worktree/list.mdx create mode 100644 website/docs/reference/worktree/provision.mdx diff --git a/commands/worktree.ts b/commands/worktree.ts index 12c2d073..3a3c62e6 100644 --- a/commands/worktree.ts +++ b/commands/worktree.ts @@ -1,22 +1,26 @@ #!/usr/bin/env bun /** - * rt worktree each — run a command in each worktree of the current repo. + * rt worktree — the worktree lifecycle CLI (spec §3): provision, create, + * dispose, list, freshen, adopt, plus the bare `rt worktree` nav picker and + * `each` (run a command across worktrees). * - * rt worktree each '' interactive multi-select, then run - * rt worktree each --all '' run in every worktree (no picker) - * rt worktree each --parked '' run only in parked worktrees - * - * Sequential, continue-on-failure. Exits non-zero if any target failed. + * Every mutating verb here is a thin wrapper over the daemon's `worktree:*` + * handlers (`lib/daemon/handlers/worktree.ts`) — the daemon is the single + * writer of the registry, so there is no inline fallback when it's down + * (`daemonQuery` returning null is a hard stop, not a "do it locally" + * signal). The one exception is `worktreeEach`, which is read-only and falls + * back to enumerating worktrees straight from git. */ import { spawnSync } from "child_process"; import { existsSync, readFileSync } from "fs"; import { join } from "path"; -import { bold, dim, green, red, reset } from "../lib/tui.ts"; +import { bold, cyan, dim, green, red, reset, yellow } from "../lib/tui.ts"; import { RT_DIR } from "../lib/daemon-config.ts"; import { getRepoIdentity } from "../lib/repo.ts"; -import { describeRepoBindings, type WorktreeBinding } from "../lib/daemon/parking-lot.ts"; +import { daemonQuery, type DaemonResponse } from "../lib/daemon-client.ts"; +import { listWorktrees } from "../lib/git-worktrees.ts"; import { parseEachArgs, filterTargets, @@ -24,8 +28,439 @@ import { formatSummary, hasFailures, type EachResult, + type WorktreeBinding, } from "../lib/worktree-each.ts"; +// Provision does a targeted `git fetch` (up to 5 min server-side per +// lib/daemon/handlers/worktree.ts) — give the round trip room beyond that. +const PROVISION_TIMEOUT_MS = 6 * 60_000; +const ADOPT_TIMEOUT_MS = 2 * 60_000; + +// ─── Arg parsing (pure — unit tested in lib/__tests__/worktree-cli-args.test.ts) ── + +function takeFlag(args: string[], flag: string): { value: string | undefined; rest: string[] } { + const idx = args.indexOf(flag); + if (idx === -1 || args[idx + 1] === undefined) return { value: undefined, rest: args }; + return { value: args[idx + 1], rest: [...args.slice(0, idx), ...args.slice(idx + 2)] }; +} + +function takeBoolFlag(args: string[], flag: string): { present: boolean; rest: string[] } { + const idx = args.indexOf(flag); + if (idx === -1) return { present: false, rest: args }; + return { present: true, rest: [...args.slice(0, idx), ...args.slice(idx + 1)] }; +} + +export interface ProvisionArgs { + repoName?: string; + ticket?: string; + title?: string; + branch?: string; + owner?: string; + disposal?: string; + json: boolean; +} + +export function parseProvisionArgs(args: string[]): ProvisionArgs { + let rest = args; + const json = takeBoolFlag(rest, "--json"); rest = json.rest; + const repo = takeFlag(rest, "--repo"); rest = repo.rest; + const ticket = takeFlag(rest, "--ticket"); rest = ticket.rest; + const title = takeFlag(rest, "--title"); rest = title.rest; + const branch = takeFlag(rest, "--branch"); rest = branch.rest; + const owner = takeFlag(rest, "--owner"); rest = owner.rest; + const disposal = takeFlag(rest, "--disposal"); rest = disposal.rest; + return { + repoName: repo.value, + ticket: ticket.value, + title: title.value, + branch: branch.value, + owner: owner.value, + disposal: disposal.value, + json: json.present, + }; +} + +export interface CreateArgs { + repoName?: string; + onDeck: boolean; + json: boolean; +} + +export function parseCreateArgs(args: string[]): CreateArgs { + let rest = args; + const json = takeBoolFlag(rest, "--json"); rest = json.rest; + const onDeck = takeBoolFlag(rest, "--on-deck"); rest = onDeck.rest; + const repo = takeFlag(rest, "--repo"); rest = repo.rest; + return { repoName: repo.value, onDeck: onDeck.present, json: json.present }; +} + +export interface DisposeArgs { + tree?: string; + owner?: string; + repoName?: string; + force: boolean; + json: boolean; +} + +export function parseDisposeArgs(args: string[]): DisposeArgs { + let rest = args; + const json = takeBoolFlag(rest, "--json"); rest = json.rest; + const force = takeBoolFlag(rest, "--force"); rest = force.rest; + const owner = takeFlag(rest, "--owner"); rest = owner.rest; + const repo = takeFlag(rest, "--repo"); rest = repo.rest; + const tree = rest.find((a) => !a.startsWith("--")); + return { tree, owner: owner.value, repoName: repo.value, force: force.present, json: json.present }; +} + +export interface ListArgs { + repoName?: string; + json: boolean; +} + +export function parseListArgs(args: string[]): ListArgs { + let rest = args; + const json = takeBoolFlag(rest, "--json"); rest = json.rest; + const repo = takeFlag(rest, "--repo"); rest = repo.rest; + return { repoName: repo.value, json: json.present }; +} + +export interface FreshenArgs { + tree?: string; + repoName?: string; + json: boolean; +} + +export function parseFreshenArgs(args: string[]): FreshenArgs { + let rest = args; + const json = takeBoolFlag(rest, "--json"); rest = json.rest; + const repo = takeFlag(rest, "--repo"); rest = repo.rest; + const tree = rest.find((a) => !a.startsWith("--")); + return { tree, repoName: repo.value, json: json.present }; +} + +export interface AdoptArgs { + repoName?: string; + json: boolean; +} + +export function parseAdoptArgs(args: string[]): AdoptArgs { + let rest = args; + const json = takeBoolFlag(rest, "--json"); rest = json.rest; + const repo = takeFlag(rest, "--repo"); rest = repo.rest; + return { repoName: repo.value, json: json.present }; +} + +// ─── Shared IO helpers ─────────────────────────────────────────────────────── + +const DAEMON_DOWN_MESSAGE = "daemon unavailable — worktree lifecycle needs the daemon (rt daemon start)"; + +/** `daemonQuery` returned null: hard stop, no inline fallback (spec §3). */ +function daemonUnavailable(): never { + console.log(`\n ${red}✗${reset} ${DAEMON_DOWN_MESSAGE}\n`); + process.exit(1); +} + +function failText(json: boolean, message: string): never { + if (json) console.log(JSON.stringify({ error: message })); + else console.log(`\n ${red}✗${reset} ${message}\n`); + process.exit(1); +} + +function explainError(error: string): string { + if (error === "busy") return "that worktree is locked by another operation right now — try again shortly"; + if (error === "repo-unknown") return "unknown repo — pass --repo or run from inside a registered repo"; + if (error === "branch-unresolved") return "need --branch or --ticket to name the work branch"; + if (error === "no-target") return "need a tree name or --owner to know what to dispose"; + if (error === "tree-ambiguous") return "that tree name matches worktrees in more than one repo — pass --repo to disambiguate"; + if (error === "branch-duplicated") return "that branch is already checked out in more than one worktree — run `rt worktree adopt`"; + if (error.startsWith("branch-attached:")) { + return `branch is already checked out in worktree "${error.slice("branch-attached:".length)}"`; + } + if (error.startsWith("checkout-failed:")) return `checkout failed: ${error.slice("checkout-failed:".length)}`; + if (error.startsWith("create-failed:")) return `worktree creation failed at step "${error.slice("create-failed:".length)}"`; + return error; +} + +function failResult(json: boolean, error: string): never { + if (json) console.log(JSON.stringify({ error })); + else console.log(`\n ${red}✗${reset} ${explainError(error)}\n`); + process.exit(1); +} + +function requireQueryResult(json: boolean, res: DaemonResponse | null): DaemonResponse { + if (res === null) daemonUnavailable(); + if (!res.ok) failResult(json, res.error ?? "unknown error"); + return res; +} + +function currentRepoName(): string | undefined { + return getRepoIdentity()?.repoName ?? undefined; +} + +// ─── Tree rows (worktree:list) shared by list / nav / the dispose+freshen pickers ── + +interface TreeRow { + name: string; + path: string; + kind: string; + state?: string; + branch: string | null; + owner?: string; + disposableReason?: string; + repoName: string; + mr?: { iid: number; state: string; title: string } | null; + duplicateBranch?: boolean; +} + +async function fetchTreeRows(json: boolean, repoName?: string): Promise { + const res = await daemonQuery("worktree:list", repoName ? { repoName } : undefined); + const ok = requireQueryResult(json, res); + return (ok.data?.trees ?? []) as TreeRow[]; +} + +async function pickOneTree(rows: TreeRow[], message: string): Promise { + if (rows.length === 0) return null; + const { filterableSelect } = await import("../lib/rt-render.tsx"); + const nameWidth = Math.max(...rows.map((r) => r.name.length)); + const options = rows.map((r) => { + const state = r.state ?? r.kind; + const hint = + state === "disposable" + ? r.disposableReason + ? `disposable — ${r.disposableReason}` + : "disposable" + : `${state}${r.branch ? ` ${r.branch}` : ""}${r.owner ? ` ${r.owner}` : ""}`; + return { value: r.path, label: r.name.padEnd(nameWidth), hint }; + }); + const picked = await filterableSelect({ message, options, stderr: true }); + if (!picked) return null; + return rows.find((r) => r.path === picked) ?? null; +} + +/** Disposable first (with their reason as the hint), then everything else. */ +function sortDisposableFirst(rows: TreeRow[]): TreeRow[] { + const rank = (r: TreeRow) => (r.state === "disposable" ? 0 : 1); + return [...rows].sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name)); +} + +// ─── provision ─────────────────────────────────────────────────────────────── + +export async function worktreeProvision(args: string[], _ctx: unknown): Promise { + const parsed = parseProvisionArgs(args); + const repoName = parsed.repoName ?? currentRepoName(); + if (!repoName) failText(parsed.json, "no repo — pass --repo or run from inside a registered repo"); + + const payload: Record = { repoName }; + if (parsed.owner) payload.owner = parsed.owner; + if (parsed.disposal) payload.disposal = parsed.disposal; + if (parsed.branch) { + payload.branch = parsed.branch; + } else if (parsed.ticket) { + payload.ticket = parsed.ticket; + if (parsed.title) payload.ticketTitle = parsed.title; + } + + const res = await daemonQuery("worktree:provision", payload, PROVISION_TIMEOUT_MS); + const ok = requireQueryResult(parsed.json, res); + + if (parsed.json) { console.log(JSON.stringify(ok.data, null, 2)); return; } + + const d = ok.data; + console.log(""); + console.log(` ${green}✓${reset} ${bold}${d.tree}${reset} ${dim}${d.path}${reset}`); + console.log(` branch ${cyan}${d.branch}${reset} ${dim}(${d.branchState}${d.wasOnDeck ? ", from the on-deck pool" : ""})${reset}`); + if (d.readyFailed) { + console.log(` ${yellow}⚠${reset} ready step "${d.failedStep}" failed — tree is usable but dependencies may be stale`); + } + console.log(""); +} + +// ─── create ────────────────────────────────────────────────────────────────── + +export async function worktreeCreate(args: string[], _ctx: unknown): Promise { + const parsed = parseCreateArgs(args); + const repoName = parsed.repoName ?? currentRepoName(); + if (!repoName) failText(parsed.json, "no repo — pass --repo or run from inside a registered repo"); + + const res = await daemonQuery("worktree:create", { repoName, onDeck: parsed.onDeck }); + const ok = requireQueryResult(parsed.json, res); + + if (parsed.json) { console.log(JSON.stringify(ok.data, null, 2)); return; } + + console.log(""); + console.log(` ${green}✓${reset} ${bold}${ok.data.tree}${reset} ${dim}${ok.data.path}${reset}${parsed.onDeck ? ` ${dim}(on-deck)${reset}` : ""}`); + console.log(""); +} + +// ─── dispose ───────────────────────────────────────────────────────────────── + +export async function worktreeDispose(args: string[], _ctx: unknown): Promise { + const parsed = parseDisposeArgs(args); + let treeName = parsed.tree; + let repoName = parsed.repoName; + + if (!treeName && !parsed.owner) { + if (!process.stdin.isTTY) { + failText(parsed.json, "no target — pass a tree name or --owner (no TTY for the picker)"); + } + const rows = sortDisposableFirst(await fetchTreeRows(parsed.json, repoName)); + const picked = await pickOneTree(rows, "Dispose which worktree?"); + if (!picked) { console.log(`\n ${dim}nothing selected${reset}\n`); return; } + treeName = picked.name; + repoName = picked.repoName; + } + + const payload: Record = { force: parsed.force }; + if (repoName) payload.repoName = repoName; + if (parsed.owner) payload.owner = parsed.owner; + if (treeName) payload.tree = treeName; + + const res = await daemonQuery("worktree:dispose", payload); + const ok = requireQueryResult(parsed.json, res); + + if (parsed.json) { console.log(JSON.stringify(ok.data, null, 2)); return; } + + const { disposed, refused } = ok.data as { disposed: string[]; refused: Array<{ tree: string; reason: string }> }; + console.log(""); + for (const name of disposed) console.log(` ${green}✓${reset} ${name} disposed`); + for (const r of refused) { + const hint = r.reason === "remove-failed" ? " — transient, try again" : ""; + console.log(` ${red}✗${reset} ${r.tree} ${dim}(${r.reason}${hint})${reset}`); + } + if (disposed.length === 0 && refused.length === 0) console.log(` ${dim}nothing to dispose${reset}`); + console.log(""); + if (refused.length > 0) process.exitCode = 1; +} + +// ─── list ──────────────────────────────────────────────────────────────────── + +export async function worktreeList(args: string[], _ctx: unknown): Promise { + const parsed = parseListArgs(args); + const rows = await fetchTreeRows(parsed.json, parsed.repoName); + + if (parsed.json) { console.log(JSON.stringify({ trees: rows }, null, 2)); return; } + + if (rows.length === 0) { console.log(`\n ${dim}no worktrees${reset}\n`); return; } + + console.log(""); + for (const r of rows) { + const mrPart = r.mr ? ` ${dim}!${r.mr.iid} ${r.mr.state}${reset}` : ""; + const dupPart = r.duplicateBranch ? ` ${yellow}duplicate branch${reset}` : ""; + const ownerPart = r.owner ? ` ${dim}${r.owner}${reset}` : ""; + console.log( + ` ${bold}${r.repoName}/${r.name}${reset} ${dim}${r.state ?? r.kind}${reset} ${cyan}${r.branch ?? "(detached)"}${reset}${ownerPart}${mrPart}${dupPart}`, + ); + } + console.log(""); +} + +// ─── freshen ───────────────────────────────────────────────────────────────── + +export async function worktreeFreshen(args: string[], _ctx: unknown): Promise { + const parsed = parseFreshenArgs(args); + let treeName = parsed.tree; + let repoName = parsed.repoName; + + if (!treeName && process.stdin.isTTY) { + const rows = (await fetchTreeRows(parsed.json, repoName)) + .filter((r) => r.kind === "ephemeral" && r.state !== "disposable") + .sort((a, b) => a.name.localeCompare(b.name)); + const picked = await pickOneTree(rows, "Freshen which worktree?"); + if (!picked) { console.log(`\n ${dim}nothing selected${reset}\n`); return; } + treeName = picked.name; + repoName = picked.repoName; + } + + const payload: Record = {}; + if (repoName) payload.repoName = repoName; + if (treeName) payload.tree = treeName; + + const res = await daemonQuery("worktree:freshen", payload); + const ok = requireQueryResult(parsed.json, res); + + if (parsed.json) { console.log(JSON.stringify(ok.data, null, 2)); return; } + + const ran = (ok.data?.ran ?? []) as string[]; + console.log(""); + if (ran.length === 0) console.log(` ${dim}nothing needed freshening${reset}`); + else for (const name of ran) console.log(` ${green}✓${reset} ${name} freshened`); + console.log(""); +} + +// ─── adopt ─────────────────────────────────────────────────────────────────── + +export async function worktreeAdopt(args: string[], _ctx: unknown): Promise { + const parsed = parseAdoptArgs(args); + // Deliberately no cwd fallback: adopt rewrites the whole repo's registry in + // one sweep, so it must be pointed at explicitly rather than guessed from + // wherever the shell happens to be. + if (!parsed.repoName) failText(parsed.json, "--repo is required for adopt"); + + const res = await daemonQuery("worktree:adopt", { repoName: parsed.repoName }, ADOPT_TIMEOUT_MS); + const ok = requireQueryResult(parsed.json, res); + + if (parsed.json) { console.log(JSON.stringify(ok.data, null, 2)); return; } + + const d = ok.data as { main: string; claimed: string[]; disposed: string[]; refused: Array<{ tree: string; reason: string }> }; + console.log(""); + console.log(` ${green}✓${reset} adopted ${d.main ? `main=${d.main}, ` : ""}${d.claimed.length} claimed, ${d.disposed.length} disposed`); + for (const r of d.refused) console.log(` ${yellow}⚠${reset} ${r.tree} not disposed: ${r.reason}`); + console.log(""); +} + +// ─── bare `rt worktree` nav picker ──────────────────────────────────────────── + +/** + * Bare `rt worktree` — same contract as `rt cd`: redirect stdout while the + * picker is up, print only the selected path to stdout on success so a shell + * wrapper can `cd` into it. + */ +export async function worktreeNav(_args: string[], _ctx: unknown): Promise { + const res = await daemonQuery("worktree:list"); + if (res === null) daemonUnavailable(); + if (!res.ok) { + console.log(`\n ${red}✗${reset} ${explainError(res.error ?? "unknown error")}\n`); + process.exit(1); + } + const rows = (res.data?.trees ?? []) as TreeRow[]; + if (rows.length === 0) { console.log(`\n ${dim}no worktrees${reset}\n`); return; } + + const realStdoutWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = process.stderr.write.bind(process.stderr) as typeof process.stdout.write; + + let selected: string | null; + try { + const { filterableSelect } = await import("../lib/rt-render.tsx"); + const nameWidth = Math.max(...rows.map((r) => r.name.length)); + const stateWidth = Math.max(...rows.map((r) => (r.state ?? r.kind).length)); + const branchWidth = Math.max(...rows.map((r) => (r.branch ?? "(detached)").length)); + const options = rows.map((r) => ({ + value: r.path, + label: `${r.name.padEnd(nameWidth)} ${(r.state ?? r.kind).padEnd(stateWidth)} ${(r.branch ?? "(detached)").padEnd(branchWidth)} ${r.owner ?? ""}`, + })); + selected = await filterableSelect({ message: "Jump to worktree", options, stderr: true }); + } finally { + process.stdout.write = realStdoutWrite; + } + + if (!selected) process.exit(0); + realStdoutWrite(selected + "\n"); +} + +// ─── park is gone ──────────────────────────────────────────────────────────── + +export async function parkDeprecated(_args: string[], _ctx: unknown): Promise { + console.log(`\n ${red}✗${reset} rt park is gone — the parking lot was replaced by rt worktree (provision/dispose/list). See RT-34.\n`); + process.exit(1); +} + +// ─── each ──────────────────────────────────────────────────────────────────── + +function fail(msg: string): never { + console.log(` ${red}✗${reset} ${msg}\n`); + process.exit(1); +} + function loadRepos(): Record { try { return JSON.parse(readFileSync(join(RT_DIR, "repos.json"), "utf8")); @@ -34,9 +469,17 @@ function loadRepos(): Record { } } -function fail(msg: string): never { - console.log(` ${red}✗${reset} ${msg}\n`); - process.exit(1); +/** Bindings from the daemon's registry-aware worktree:list, when it's up. */ +async function bindingsFromDaemon(repoName: string): Promise { + const res = await daemonQuery("worktree:list", { repoName }); + if (res === null || !res.ok) return null; + const rows = (res.data?.trees ?? []) as TreeRow[]; + return rows.map((r) => ({ path: r.path, branch: r.branch, state: r.state })); +} + +/** Read-only git fallback — each is the one lifecycle command allowed this, since it never mutates. */ +function bindingsFromGit(repoPath: string): WorktreeBinding[] { + return listWorktrees(repoPath).map((w) => ({ path: w.path, branch: w.branch || null })); } export async function worktreeEach(args: string[], _ctx: unknown): Promise { @@ -50,7 +493,7 @@ export async function worktreeEach(args: string[], _ctx: unknown): Promise const repoPath = repos[identity.repoName]; if (!repoPath) fail(`repo "${identity.repoName}" not registered in ~/.rt/repos.json`); - const bindings = describeRepoBindings(identity.repoName, repoPath); + const bindings = (await bindingsFromDaemon(identity.repoName)) ?? bindingsFromGit(repoPath); if (bindings.length === 0) { console.log(`\n ${dim}no worktrees in ${identity.repoName}${reset}\n`); return; @@ -59,7 +502,7 @@ export async function worktreeEach(args: string[], _ctx: unknown): Promise let targets: WorktreeBinding[]; if (parsed.mode === "pick") { if (!process.stdin.isTTY) { - fail("no --all/--parked flag and no TTY for the picker — pass --all or --parked"); + fail("no --all/--on-deck flag and no TTY for the picker — pass --all or --on-deck"); } const widest = Math.max(...bindings.map(b => relWorktreeName(repoPath, b.path).length)); const options = bindings.map(b => ({ @@ -81,7 +524,7 @@ export async function worktreeEach(args: string[], _ctx: unknown): Promise } else { targets = filterTargets(bindings, parsed.mode); if (targets.length === 0) { - const what = parsed.mode === "parked" ? "parked worktrees" : "worktrees"; + const what = parsed.mode === "on-deck" ? "on-deck worktrees" : "worktrees"; console.log(`\n ${dim}no ${what} to run in${reset}\n`); return; } diff --git a/lib/__tests__/worktree-cli-args.test.ts b/lib/__tests__/worktree-cli-args.test.ts new file mode 100644 index 00000000..8bc97b66 --- /dev/null +++ b/lib/__tests__/worktree-cli-args.test.ts @@ -0,0 +1,116 @@ +/** + * Pure arg-parsing tests for the `rt worktree` CLI verbs. Daemon-path + * behavior (provision/create/dispose/list/freshen/adopt actually talking to + * the daemon) is covered by Task 13's handler tests — this file only checks + * that raw CLI args are sliced into the right payload shape. + */ +import { describe, expect, test } from "bun:test"; +import { + parseProvisionArgs, + parseCreateArgs, + parseDisposeArgs, + parseListArgs, + parseFreshenArgs, + parseAdoptArgs, +} from "../../commands/worktree.ts"; + +describe("parseProvisionArgs", () => { + test("bare --ticket", () => { + expect(parseProvisionArgs(["--ticket", "RT-40"])).toEqual({ + repoName: undefined, ticket: "RT-40", title: undefined, branch: undefined, + owner: undefined, disposal: undefined, json: false, + }); + }); + test("--ticket with --title", () => { + const r = parseProvisionArgs(["--ticket", "RT-40", "--title", "Prune verbs"]); + expect(r.ticket).toBe("RT-40"); + expect(r.title).toBe("Prune verbs"); + }); + test("--branch takes precedence path is left to the caller — both parsed", () => { + const r = parseProvisionArgs(["--branch", "feature/x", "--repo", "repo-tools"]); + expect(r.branch).toBe("feature/x"); + expect(r.repoName).toBe("repo-tools"); + }); + test("--owner, --disposal, --json all parsed", () => { + const r = parseProvisionArgs(["--ticket", "RT-1", "--owner", "matt", "--disposal", "job", "--json"]); + expect(r.owner).toBe("matt"); + expect(r.disposal).toBe("job"); + expect(r.json).toBe(true); + }); + test("no flags → all undefined, json false", () => { + expect(parseProvisionArgs([])).toEqual({ + repoName: undefined, ticket: undefined, title: undefined, branch: undefined, + owner: undefined, disposal: undefined, json: false, + }); + }); +}); + +describe("parseCreateArgs", () => { + test("defaults", () => { + expect(parseCreateArgs([])).toEqual({ repoName: undefined, onDeck: false, json: false }); + }); + test("--repo, --on-deck, --json", () => { + expect(parseCreateArgs(["--repo", "repo-tools", "--on-deck", "--json"])).toEqual({ + repoName: "repo-tools", onDeck: true, json: true, + }); + }); +}); + +describe("parseDisposeArgs", () => { + test("positional tree name", () => { + const r = parseDisposeArgs(["my-tree"]); + expect(r.tree).toBe("my-tree"); + expect(r.owner).toBeUndefined(); + expect(r.force).toBe(false); + }); + test("--owner sweep, no positional", () => { + const r = parseDisposeArgs(["--owner", "matt"]); + expect(r.tree).toBeUndefined(); + expect(r.owner).toBe("matt"); + }); + test("tree + --repo + --force + --json", () => { + const r = parseDisposeArgs(["my-tree", "--repo", "repo-tools", "--force", "--json"]); + expect(r).toEqual({ tree: "my-tree", owner: undefined, repoName: "repo-tools", force: true, json: true }); + }); + test("no args at all", () => { + const r = parseDisposeArgs([]); + expect(r.tree).toBeUndefined(); + expect(r.owner).toBeUndefined(); + expect(r.force).toBe(false); + expect(r.json).toBe(false); + }); +}); + +describe("parseListArgs", () => { + test("defaults", () => { + expect(parseListArgs([])).toEqual({ repoName: undefined, json: false }); + }); + test("--repo + --json", () => { + expect(parseListArgs(["--repo", "repo-tools", "--json"])).toEqual({ repoName: "repo-tools", json: true }); + }); +}); + +describe("parseFreshenArgs", () => { + test("positional tree only", () => { + const r = parseFreshenArgs(["my-tree"]); + expect(r.tree).toBe("my-tree"); + }); + test("no args", () => { + expect(parseFreshenArgs([])).toEqual({ tree: undefined, repoName: undefined, json: false }); + }); + test("tree + --repo + --json", () => { + expect(parseFreshenArgs(["my-tree", "--repo", "repo-tools", "--json"])).toEqual({ + tree: "my-tree", repoName: "repo-tools", json: true, + }); + }); +}); + +describe("parseAdoptArgs", () => { + test("--repo required, --json optional", () => { + expect(parseAdoptArgs(["--repo", "repo-tools"])).toEqual({ repoName: "repo-tools", json: false }); + expect(parseAdoptArgs(["--repo", "repo-tools", "--json"])).toEqual({ repoName: "repo-tools", json: true }); + }); + test("no --repo → repoName undefined (caller decides how to fail)", () => { + expect(parseAdoptArgs([])).toEqual({ repoName: undefined, json: false }); + }); +}); diff --git a/lib/__tests__/worktree-each.test.ts b/lib/__tests__/worktree-each.test.ts index 9d5a1842..3841f922 100644 --- a/lib/__tests__/worktree-each.test.ts +++ b/lib/__tests__/worktree-each.test.ts @@ -1,16 +1,16 @@ import { describe, expect, test } from "bun:test"; import { parseEachArgs, - isParked, + isOnDeck, filterTargets, relWorktreeName, formatSummary, hasFailures, + type WorktreeBinding, } from "../worktree-each.ts"; -import type { WorktreeBinding } from "../daemon/parking-lot.ts"; -const wt = (path: string, branch: string | null, index: number): WorktreeBinding => - ({ path, branch, index }); +const wt = (path: string, branch: string | null, state?: string): WorktreeBinding => + ({ path, branch, ...(state ? { state } : {}) }); describe("parseEachArgs", () => { test("bare args → pick mode, command joined", () => { @@ -19,12 +19,18 @@ describe("parseEachArgs", () => { test("--all flag → all mode, flag stripped from command", () => { expect(parseEachArgs(["--all", "pnpm", "install"])).toEqual({ mode: "all", command: "pnpm install" }); }); - test("--parked flag → parked mode", () => { - expect(parseEachArgs(["--parked", "git", "status"])).toEqual({ mode: "parked", command: "git status" }); + test("--on-deck flag → on-deck mode", () => { + expect(parseEachArgs(["--on-deck", "git", "status"])).toEqual({ mode: "on-deck", command: "git status" }); + }); + test("--parked is a hidden alias for --on-deck", () => { + expect(parseEachArgs(["--parked", "git", "status"])).toEqual({ mode: "on-deck", command: "git status" }); }); test("flag after command is still recognized", () => { expect(parseEachArgs(["pnpm", "install", "--all"]).mode).toBe("all"); }); + test("both --all and --on-deck → error", () => { + expect(parseEachArgs(["--all", "--on-deck", "ls"]).error).toMatch(/mutually exclusive/i); + }); test("both --all and --parked → error", () => { expect(parseEachArgs(["--all", "--parked", "ls"]).error).toMatch(/mutually exclusive/i); }); @@ -33,35 +39,32 @@ describe("parseEachArgs", () => { }); }); -describe("isParked", () => { - test("branch matches its slot → parked", () => { - expect(isParked(wt("/a", "parking-lot/3", 3))).toBe(true); - }); - test("branch is a different parking slot → not parked", () => { - expect(isParked(wt("/a", "parking-lot/9", 3))).toBe(false); +describe("isOnDeck", () => { + test("state on-deck → true", () => { + expect(isOnDeck(wt("/a", "on-deck/3", "on-deck"))).toBe(true); }); - test("feature branch → not parked", () => { - expect(isParked(wt("/a", "feature/x", 3))).toBe(false); + test("state claimed → false", () => { + expect(isOnDeck(wt("/a", "feature/x", "claimed"))).toBe(false); }); - test("detached (null branch) → not parked", () => { - expect(isParked(wt("/a", null, 3))).toBe(false); + test("no state (git-only fallback) → false", () => { + expect(isOnDeck(wt("/a", "feature/x"))).toBe(false); }); - test("no index (0) → not parked even if branch looks parked", () => { - expect(isParked(wt("/a", "parking-lot/0", 0))).toBe(false); + test("detached (null branch), on-deck state → still true", () => { + expect(isOnDeck(wt("/a", null, "on-deck"))).toBe(true); }); }); describe("filterTargets", () => { const bindings = [ - wt("/repo/wt0", "feature/a", 1), - wt("/repo/wt1", "parking-lot/2", 2), - wt("/repo/wt2", "parking-lot/3", 3), + wt("/repo/wt0", "feature/a", "claimed"), + wt("/repo/wt1", "on-deck/wt1", "on-deck"), + wt("/repo/wt2", "on-deck/wt2", "on-deck"), ]; test("all → every binding", () => { expect(filterTargets(bindings, "all")).toHaveLength(3); }); - test("parked → only parked", () => { - expect(filterTargets(bindings, "parked").map(b => b.path)).toEqual(["/repo/wt1", "/repo/wt2"]); + test("on-deck → only on-deck", () => { + expect(filterTargets(bindings, "on-deck").map(b => b.path)).toEqual(["/repo/wt1", "/repo/wt2"]); }); test("pick → returned unchanged (picker selects later)", () => { expect(filterTargets(bindings, "pick")).toHaveLength(3); diff --git a/lib/command-tree-def.ts b/lib/command-tree-def.ts index 5a5ec20b..67f09739 100644 --- a/lib/command-tree-def.ts +++ b/lib/command-tree-def.ts @@ -525,61 +525,90 @@ export const TREE: Record = { }, park: { - description: "Auto-park worktrees when their MR merges or closes", + description: "Deprecated — replaced by rt worktree", + module: "./commands/worktree.ts", + fn: "parkDeprecated", + args: [], + }, + + worktree: { + description: "Worktree lifecycle (provision/dispose/list) + worktree-wide operations", + module: "./commands/worktree.ts", + fn: "worktreeNav", + requiresTTY: true, + args: [], subcommands: { - status: { - description: "Show whether auto-park is enabled + worktree bindings", - module: "./commands/parking-lot.ts", - fn: "statusCommand", - args: [], + provision: { + description: "Claim a worktree for a ticket or branch (from the on-deck pool, or freshly created)", + module: "./commands/worktree.ts", + fn: "worktreeProvision", + args: [ + { name: "Repo", flag: "--repo", type: "text", placeholder: "repo-tools", hint: "Registered repo name (defaults to the current repo)" }, + { name: "Ticket", flag: "--ticket", type: "text", placeholder: "RT-40", hint: "Linear ticket id — derives the branch name" }, + { name: "Branch", flag: "--branch", type: "text", placeholder: "feature/my-branch", hint: "Explicit branch name (overrides --ticket)" }, + { name: "Owner", flag: "--owner", type: "text", placeholder: "matt", hint: "Who's claiming this tree" }, + { name: "Disposal", flag: "--disposal", type: "text", placeholder: "merge", hint: "Disposal mode: merge (default) or job" }, + { name: "JSON", flag: "--json", type: "boolean", default: false, hint: "Print the raw result as JSON" }, + ], }, - enable: { - description: "Turn on auto-park", - module: "./commands/parking-lot.ts", - fn: "enableCommand", - args: [], + create: { + description: "Create a fresh worktree (optionally straight into the on-deck pool)", + module: "./commands/worktree.ts", + fn: "worktreeCreate", + args: [ + { name: "Repo", flag: "--repo", type: "text", placeholder: "repo-tools", hint: "Registered repo name (defaults to the current repo)" }, + { name: "On-deck", flag: "--on-deck", type: "boolean", default: false, hint: "Put the new tree in the on-deck pool instead of claiming it" }, + { name: "JSON", flag: "--json", type: "boolean", default: false, hint: "Print the raw result as JSON" }, + ], }, - disable: { - description: "Turn off auto-park (daemon scans become no-ops)", - module: "./commands/parking-lot.ts", - fn: "disableCommand", - args: [], + dispose: { + description: "Dispose a worktree (no target + TTY → picker)", + module: "./commands/worktree.ts", + fn: "worktreeDispose", + args: [ + { name: "Tree", type: "text", placeholder: "my-tree", hint: "Tree name to dispose; omit to pick interactively" }, + { name: "Owner", flag: "--owner", type: "text", placeholder: "matt", hint: "Dispose every tree owned by this owner (can span repos)" }, + { name: "Repo", flag: "--repo", type: "text", placeholder: "repo-tools", hint: "Narrow to this registered repo" }, + { name: "Force", flag: "--force", type: "boolean", default: false, hint: "Override the dirty/unpushed guard" }, + { name: "JSON", flag: "--json", type: "boolean", default: false, hint: "Print the raw result as JSON" }, + ], }, - scan: { - description: "Run the park check immediately against the live cache", - module: "./commands/parking-lot.ts", - fn: "scanCommand", - args: [], + list: { + description: "List worktrees", + module: "./commands/worktree.ts", + fn: "worktreeList", + args: [ + { name: "Repo", flag: "--repo", type: "text", placeholder: "repo-tools", hint: "Narrow to this registered repo (default: every registered repo)" }, + { name: "JSON", flag: "--json", type: "boolean", default: false, hint: "Print the raw result as JSON" }, + ], }, - this: { - description: "Park the current worktree now (manual override; ignores enabled flag)", - module: "./commands/parking-lot.ts", - fn: "parkThisCommand", - context: "worktree", - args: [], + freshen: { + description: "Freshen worktrees (no arg + TTY → picker over freshenable trees)", + module: "./commands/worktree.ts", + fn: "worktreeFreshen", + args: [ + { name: "Tree", type: "text", placeholder: "my-tree", hint: "Tree name to freshen; omit to pick interactively (or run for every repo, headless)" }, + { name: "Repo", flag: "--repo", type: "text", placeholder: "repo-tools", hint: "Narrow to this registered repo" }, + { name: "JSON", flag: "--json", type: "boolean", default: false, hint: "Print the raw result as JSON" }, + ], }, - pick: { - description: "Pick worktrees in this repo to park (multi-select)", - module: "./commands/parking-lot.ts", - fn: "parkPickCommand", - context: "repo", - requiresTTY: true, - args: [], + adopt: { + description: "One-shot migration: adopt an unmanaged repo's worktrees into the registry", + module: "./commands/worktree.ts", + fn: "worktreeAdopt", + args: [ + { name: "Repo", flag: "--repo", type: "text", placeholder: "repo-tools", hint: "Registered repo name (required)" }, + { name: "JSON", flag: "--json", type: "boolean", default: false, hint: "Print the raw result as JSON" }, + ], }, - }, - }, - - worktree: { - description: "Worktree-wide operations", - subcommands: { each: { - description: "Run a command in each worktree (--all | --parked, else pick)", + description: "Run a command in each worktree (--all | --on-deck, else pick)", module: "./commands/worktree.ts", fn: "worktreeEach", context: "repo", args: [ - { name: "All", flag: "--all", type: "boolean", default: false, hint: "Run in every worktree (mutually exclusive with --parked)" }, - { name: "Parked", flag: "--parked", type: "boolean", default: false, hint: "Run only in parked worktrees" }, + { name: "All", flag: "--all", type: "boolean", default: false, hint: "Run in every worktree (mutually exclusive with --on-deck)" }, + { name: "On-deck", flag: "--on-deck", type: "boolean", default: false, hint: "Run only in on-deck worktrees (alias --parked)" }, { name: "Command", type: "text", placeholder: "git status", hint: "Command to run in each selected worktree; omit both flags to pick interactively" }, ], }, diff --git a/lib/command-tree.ts b/lib/command-tree.ts index 04422444..bc617e2d 100644 --- a/lib/command-tree.ts +++ b/lib/command-tree.ts @@ -222,18 +222,22 @@ export async function dispatch( const commandLabel = breadcrumb.slice(1).concat(resolvedName).join(" "); beginCommand(commandLabel, rest); - // Extract --repo flag if present (allows callers to pre-select the repo - // but still trigger the worktree picker) - let repoFlag: string | null = null; - const repoFlagIdx = rest.indexOf("--repo"); - if (repoFlagIdx !== -1 && rest[repoFlagIdx + 1]) { - repoFlag = rest[repoFlagIdx + 1]!; - rest.splice(repoFlagIdx, 2); - } - if (node.context === "worktree") { const cwdBefore = process.cwd(); + // Extract --repo flag if present (allows callers to pre-select the repo + // but still trigger the worktree picker). Scoped to context:"worktree" nodes + // only — a node without this context (e.g. the daemon-backed worktree + // lifecycle verbs, which take their own `--repo ` payload + // flag) must see `--repo` untouched in its own args, not have it silently + // consumed here. + let repoFlag: string | null = null; + const repoFlagIdx = rest.indexOf("--repo"); + if (repoFlagIdx !== -1 && rest[repoFlagIdx + 1]) { + repoFlag = rest[repoFlagIdx + 1]!; + rest.splice(repoFlagIdx, 2); + } + if (repoFlag) { // --repo provided: resolve that repo and show worktree picker (skip repo picker + cwd detection) const { getKnownRepos, pickWorktreeFromRepo, getRepoIdentity } = await import("./repo.ts"); diff --git a/lib/worktree-each.ts b/lib/worktree-each.ts index 728b1efb..6e41ca1b 100644 --- a/lib/worktree-each.ts +++ b/lib/worktree-each.ts @@ -2,12 +2,11 @@ * Pure logic for `rt worktree each` — arg parsing, target selection, name * formatting, and result summary. No IO: everything here is deterministic and * unit-tested. The command module (commands/worktree.ts) supplies the real - * worktree list, picker, and process execution. + * worktree list (sourced from the daemon's `worktree:list`, or the read-only + * git fallback when the daemon is down), the picker, and process execution. */ -import type { WorktreeBinding } from "./daemon/parking-lot.ts"; - -export type SelectionMode = "all" | "parked" | "pick"; +export type SelectionMode = "all" | "on-deck" | "pick"; export interface ParsedEachArgs { mode: SelectionMode; @@ -26,27 +25,47 @@ export interface EachResult { } /** - * Split raw CLI args into a selection mode and the command string. `--all` and - * `--parked` are recognized anywhere in the args; every other token is part of - * the command, joined with spaces. Neither flag → interactive pick mode. + * A worktree binding as `worktree each` needs it. `{path, branch}` always + * comes from either the daemon's `worktree:list` (registry-aware — `state` + * present) or the read-only git fallback (`lib/git-worktrees.ts`, no `state`) + * used when the daemon is unreachable — `each` is the one lifecycle command + * allowed that fallback, since it's read-only. + */ +export interface WorktreeBinding { + path: string; + branch: string | null; + /** Registry state ("on-deck", "claimed", ...); absent from the git-only fallback. */ + state?: string; +} + +/** + * Split raw CLI args into a selection mode and the command string. `--all` + * and `--on-deck` are recognized anywhere in the args; every other token is + * part of the command, joined with spaces. `--parked` is a hidden alias for + * `--on-deck`, kept for one release so muscle memory doesn't break mid- + * migration off the old parking-lot terminology. Neither flag → interactive + * pick mode. */ export function parseEachArgs(args: string[]): ParsedEachArgs { const all = args.includes("--all"); - const parked = args.includes("--parked"); - if (all && parked) { - return { mode: "all", command: "", error: "--all and --parked are mutually exclusive" }; + const onDeck = args.includes("--on-deck") || args.includes("--parked"); + if (all && onDeck) { + return { mode: "all", command: "", error: "--all and --on-deck are mutually exclusive" }; } - const mode: SelectionMode = all ? "all" : parked ? "parked" : "pick"; - const command = args.filter(a => a !== "--all" && a !== "--parked").join(" ").trim(); + const mode: SelectionMode = all ? "all" : onDeck ? "on-deck" : "pick"; + const command = args + .filter((a) => a !== "--all" && a !== "--on-deck" && a !== "--parked") + .join(" ") + .trim(); if (!command) { return { mode, command: "", error: "no command given — usage: rt worktree each ''" }; } return { mode, command }; } -/** A worktree is parked when it sits on its own parking-lot/ slot. */ -export function isParked(b: WorktreeBinding): boolean { - return b.index > 0 && b.branch === `parking-lot/${b.index}`; +/** A binding is on-deck when the registry says so (never true for the git-only fallback). */ +export function isOnDeck(b: WorktreeBinding): boolean { + return b.state === "on-deck"; } /** @@ -54,7 +73,7 @@ export function isParked(b: WorktreeBinding): boolean { * unchanged — the caller runs the picker over the full list. */ export function filterTargets(bindings: WorktreeBinding[], mode: SelectionMode): WorktreeBinding[] { - if (mode === "parked") return bindings.filter(isParked); + if (mode === "on-deck") return bindings.filter(isOnDeck); return bindings; // "all" and "pick" both start from the full list } diff --git a/website/docs/reference/park.mdx b/website/docs/reference/park.mdx new file mode 100644 index 00000000..9698f085 --- /dev/null +++ b/website/docs/reference/park.mdx @@ -0,0 +1,20 @@ +--- +title: rt park +sidebar_label: park +--- + +# rt park + +`rt › park` + +Deprecated — replaced by rt worktree + +## Usage + +```bash +rt park +``` + +_See code: [commands/worktree.ts › parkDeprecated](https://github.com/m4ttheweric/repo-tools/blob/main/commands/worktree.ts)_ + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/park/disable.mdx b/website/docs/reference/park/disable.mdx deleted file mode 100644 index d815218c..00000000 --- a/website/docs/reference/park/disable.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: rt park disable -sidebar_label: disable ---- - -# rt park disable - -`rt › park › disable` - -Turn off auto-park (daemon scans become no-ops) - -## Usage - -```bash -rt park disable -``` - -_See code: [commands/parking-lot.ts › disableCommand](https://github.com/m4ttheweric/repo-tools/blob/main/commands/parking-lot.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/park/enable.mdx b/website/docs/reference/park/enable.mdx deleted file mode 100644 index f8c416a3..00000000 --- a/website/docs/reference/park/enable.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: rt park enable -sidebar_label: enable ---- - -# rt park enable - -`rt › park › enable` - -Turn on auto-park - -## Usage - -```bash -rt park enable -``` - -_See code: [commands/parking-lot.ts › enableCommand](https://github.com/m4ttheweric/repo-tools/blob/main/commands/parking-lot.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/park/index.mdx b/website/docs/reference/park/index.mdx deleted file mode 100644 index 840a79a8..00000000 --- a/website/docs/reference/park/index.mdx +++ /dev/null @@ -1,29 +0,0 @@ ---- -title: rt park -sidebar_label: park ---- - -# rt park - -`rt › park` - -Auto-park worktrees when their MR merges or closes - -## Usage - -```bash -rt park -``` - -## Subcommands - -| Command | Description | -| --- | --- | -| [`status`](status) | Show whether auto-park is enabled + worktree bindings | -| [`enable`](enable) | Turn on auto-park | -| [`disable`](disable) | Turn off auto-park (daemon scans become no-ops) | -| [`scan`](scan) | Run the park check immediately against the live cache | -| [`this`](this) | Park the current worktree now (manual override; ignores enabled flag) | -| [`pick`](pick) | Pick worktrees in this repo to park (multi-select) | - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/park/pick.mdx b/website/docs/reference/park/pick.mdx deleted file mode 100644 index 50a54a77..00000000 --- a/website/docs/reference/park/pick.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: rt park pick -sidebar_label: pick ---- - -# rt park pick - -`rt › park › pick` - -Pick worktrees in this repo to park (multi-select) - -## Usage - -```bash -rt park pick -``` - -_See code: [commands/parking-lot.ts › parkPickCommand](https://github.com/m4ttheweric/repo-tools/blob/main/commands/parking-lot.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/park/scan.mdx b/website/docs/reference/park/scan.mdx deleted file mode 100644 index f498956f..00000000 --- a/website/docs/reference/park/scan.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: rt park scan -sidebar_label: scan ---- - -# rt park scan - -`rt › park › scan` - -Run the park check immediately against the live cache - -## Usage - -```bash -rt park scan -``` - -_See code: [commands/parking-lot.ts › scanCommand](https://github.com/m4ttheweric/repo-tools/blob/main/commands/parking-lot.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/park/status.mdx b/website/docs/reference/park/status.mdx deleted file mode 100644 index c179668c..00000000 --- a/website/docs/reference/park/status.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: rt park status -sidebar_label: status ---- - -# rt park status - -`rt › park › status` - -Show whether auto-park is enabled + worktree bindings - -## Usage - -```bash -rt park status -``` - -_See code: [commands/parking-lot.ts › statusCommand](https://github.com/m4ttheweric/repo-tools/blob/main/commands/parking-lot.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/park/this.mdx b/website/docs/reference/park/this.mdx deleted file mode 100644 index ef602c05..00000000 --- a/website/docs/reference/park/this.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: rt park this -sidebar_label: this ---- - -# rt park this - -`rt › park › this` - -Park the current worktree now (manual override; ignores enabled flag) - -## Usage - -```bash -rt park this -``` - -_See code: [commands/parking-lot.ts › parkThisCommand](https://github.com/m4ttheweric/repo-tools/blob/main/commands/parking-lot.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/worktree/adopt.mdx b/website/docs/reference/worktree/adopt.mdx new file mode 100644 index 00000000..864f9e9d --- /dev/null +++ b/website/docs/reference/worktree/adopt.mdx @@ -0,0 +1,27 @@ +--- +title: rt worktree adopt +sidebar_label: adopt +--- + +# rt worktree adopt + +`rt › worktree › adopt` + +One-shot migration: adopt an unmanaged repo's worktrees into the registry + +## Usage + +```bash +rt worktree adopt [flags] +``` + +## Arguments & flags + +| Flag / Arg | Type | Default | Description | +| --- | --- | --- | --- | +| [`--repo`](/guides/common-flags) | text | | Registered repo name (required) | +| [`--json`](/guides/common-flags) | boolean | `false` | Print the raw result as JSON | + +_See code: [commands/worktree.ts › worktreeAdopt](https://github.com/m4ttheweric/repo-tools/blob/main/commands/worktree.ts)_ + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/worktree/create.mdx b/website/docs/reference/worktree/create.mdx new file mode 100644 index 00000000..fa32be85 --- /dev/null +++ b/website/docs/reference/worktree/create.mdx @@ -0,0 +1,28 @@ +--- +title: rt worktree create +sidebar_label: create +--- + +# rt worktree create + +`rt › worktree › create` + +Create a fresh worktree (optionally straight into the on-deck pool) + +## Usage + +```bash +rt worktree create [flags] +``` + +## Arguments & flags + +| Flag / Arg | Type | Default | Description | +| --- | --- | --- | --- | +| [`--repo`](/guides/common-flags) | text | | Registered repo name (defaults to the current repo) | +| `--on-deck` | boolean | `false` | Put the new tree in the on-deck pool instead of claiming it | +| [`--json`](/guides/common-flags) | boolean | `false` | Print the raw result as JSON | + +_See code: [commands/worktree.ts › worktreeCreate](https://github.com/m4ttheweric/repo-tools/blob/main/commands/worktree.ts)_ + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/worktree/dispose.mdx b/website/docs/reference/worktree/dispose.mdx new file mode 100644 index 00000000..ec8d669a --- /dev/null +++ b/website/docs/reference/worktree/dispose.mdx @@ -0,0 +1,30 @@ +--- +title: rt worktree dispose +sidebar_label: dispose +--- + +# rt worktree dispose + +`rt › worktree › dispose` + +Dispose a worktree (no target + TTY → picker) + +## Usage + +```bash +rt worktree dispose [flags] +``` + +## Arguments & flags + +| Flag / Arg | Type | Default | Description | +| --- | --- | --- | --- | +| `` | text | | Tree name to dispose; omit to pick interactively | +| `--owner` | text | | Dispose every tree owned by this owner (can span repos) | +| [`--repo`](/guides/common-flags) | text | | Narrow to this registered repo | +| `--force` | boolean | `false` | Override the dirty/unpushed guard | +| [`--json`](/guides/common-flags) | boolean | `false` | Print the raw result as JSON | + +_See code: [commands/worktree.ts › worktreeDispose](https://github.com/m4ttheweric/repo-tools/blob/main/commands/worktree.ts)_ + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/worktree/each.mdx b/website/docs/reference/worktree/each.mdx index 602db336..2826b884 100644 --- a/website/docs/reference/worktree/each.mdx +++ b/website/docs/reference/worktree/each.mdx @@ -7,7 +7,7 @@ sidebar_label: each `rt › worktree › each` -Run a command in each worktree (--all | --parked, else pick) +Run a command in each worktree (--all | --on-deck, else pick) ## Usage @@ -19,8 +19,8 @@ rt worktree each [flags] | Flag / Arg | Type | Default | Description | | --- | --- | --- | --- | -| `--all` | boolean | `false` | Run in every worktree (mutually exclusive with --parked) | -| `--parked` | boolean | `false` | Run only in parked worktrees | +| `--all` | boolean | `false` | Run in every worktree (mutually exclusive with --on-deck) | +| `--on-deck` | boolean | `false` | Run only in on-deck worktrees (alias --parked) | | `` | text | | Command to run in each selected worktree; omit both flags to pick interactively | _See code: [commands/worktree.ts › worktreeEach](https://github.com/m4ttheweric/repo-tools/blob/main/commands/worktree.ts)_ diff --git a/website/docs/reference/worktree/freshen.mdx b/website/docs/reference/worktree/freshen.mdx new file mode 100644 index 00000000..552d0822 --- /dev/null +++ b/website/docs/reference/worktree/freshen.mdx @@ -0,0 +1,28 @@ +--- +title: rt worktree freshen +sidebar_label: freshen +--- + +# rt worktree freshen + +`rt › worktree › freshen` + +Freshen worktrees (no arg + TTY → picker over freshenable trees) + +## Usage + +```bash +rt worktree freshen [flags] +``` + +## Arguments & flags + +| Flag / Arg | Type | Default | Description | +| --- | --- | --- | --- | +| `` | text | | Tree name to freshen; omit to pick interactively (or run for every repo, headless) | +| [`--repo`](/guides/common-flags) | text | | Narrow to this registered repo | +| [`--json`](/guides/common-flags) | boolean | `false` | Print the raw result as JSON | + +_See code: [commands/worktree.ts › worktreeFreshen](https://github.com/m4ttheweric/repo-tools/blob/main/commands/worktree.ts)_ + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/worktree/index.mdx b/website/docs/reference/worktree/index.mdx index 7e55bc91..4408528d 100644 --- a/website/docs/reference/worktree/index.mdx +++ b/website/docs/reference/worktree/index.mdx @@ -7,7 +7,7 @@ sidebar_label: worktree `rt › worktree` -Worktree-wide operations +Worktree lifecycle (provision/dispose/list) + worktree-wide operations ## Usage @@ -19,6 +19,14 @@ rt worktree | Command | Description | | --- | --- | -| [`each`](each) | Run a command in each worktree (--all | --parked, else pick) | +| [`provision`](provision) | Claim a worktree for a ticket or branch (from the on-deck pool, or freshly created) | +| [`create`](create) | Create a fresh worktree (optionally straight into the on-deck pool) | +| [`dispose`](dispose) | Dispose a worktree (no target + TTY → picker) | +| [`list`](list) | List worktrees | +| [`freshen`](freshen) | Freshen worktrees (no arg + TTY → picker over freshenable trees) | +| [`adopt`](adopt) | One-shot migration: adopt an unmanaged repo's worktrees into the registry | +| [`each`](each) | Run a command in each worktree (--all | --on-deck, else pick) | + +_See code: [commands/worktree.ts › worktreeNav](https://github.com/m4ttheweric/repo-tools/blob/main/commands/worktree.ts)_ {/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/worktree/list.mdx b/website/docs/reference/worktree/list.mdx new file mode 100644 index 00000000..c8a63360 --- /dev/null +++ b/website/docs/reference/worktree/list.mdx @@ -0,0 +1,27 @@ +--- +title: rt worktree list +sidebar_label: list +--- + +# rt worktree list + +`rt › worktree › list` + +List worktrees + +## Usage + +```bash +rt worktree list [flags] +``` + +## Arguments & flags + +| Flag / Arg | Type | Default | Description | +| --- | --- | --- | --- | +| [`--repo`](/guides/common-flags) | text | | Narrow to this registered repo (default: every registered repo) | +| [`--json`](/guides/common-flags) | boolean | `false` | Print the raw result as JSON | + +_See code: [commands/worktree.ts › worktreeList](https://github.com/m4ttheweric/repo-tools/blob/main/commands/worktree.ts)_ + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/worktree/provision.mdx b/website/docs/reference/worktree/provision.mdx new file mode 100644 index 00000000..94720a0f --- /dev/null +++ b/website/docs/reference/worktree/provision.mdx @@ -0,0 +1,31 @@ +--- +title: rt worktree provision +sidebar_label: provision +--- + +# rt worktree provision + +`rt › worktree › provision` + +Claim a worktree for a ticket or branch (from the on-deck pool, or freshly created) + +## Usage + +```bash +rt worktree provision [flags] +``` + +## Arguments & flags + +| Flag / Arg | Type | Default | Description | +| --- | --- | --- | --- | +| [`--repo`](/guides/common-flags) | text | | Registered repo name (defaults to the current repo) | +| `--ticket` | text | | Linear ticket id — derives the branch name | +| `--branch` | text | | Explicit branch name (overrides --ticket) | +| `--owner` | text | | Who's claiming this tree | +| `--disposal` | text | | Disposal mode: merge (default) or job | +| [`--json`](/guides/common-flags) | boolean | `false` | Print the raw result as JSON | + +_See code: [commands/worktree.ts › worktreeProvision](https://github.com/m4ttheweric/repo-tools/blob/main/commands/worktree.ts)_ + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file From 1ff927af64e43560e8536714c45cd8db174a209b Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 19:49:07 -0500 Subject: [PATCH 25/31] RT-34: fix worktree CLI review findings (timeouts, dispose exit code, nav stdout leak, freshen picker, dispatch test) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical: worktree:create/:dispose/:freshen were using daemonQuery's 2s default timeout even though create budgets the same 6min as provision and freshen's server-side fetch alone is budgeted 5min — a slow-but-working daemon read as "daemon unavailable". Gave each an explicit generous timeout (create: PROVISION_TIMEOUT_MS, dispose: 2min, freshen: 10min) and made daemonUnavailable() check lastQueryTimedOut() to print a "timed out — the daemon may still be working" message instead of the down message when that's what actually happened. Important fixes: - freshen's picker now filters to freshenCandidate semantics (ephemeral on-deck + the main clone), not "any non-disposable ephemeral tree" — claimed trees always came back ran:[]. - dispose sets process.exitCode before either the --json or text return path, so --json no longer exits 0 when some trees were refused. - worktreeNav now redirects stdout before ANY output, not just before the picker, so the daemon-down/refusal/empty-list messages don't leak onto real stdout (which the shell wrapper reads as the cd target). commands/ cd.ts's generated shell function now also intercepts bare `rt worktree` (no further args) as a cd-style jump, same as cd/nav, with upgrade detection for existing installs — without this the nav picker printed a path nobody consumed. - Added a dispatch-level test (lib/__tests__/command-tree.test.ts) for the --repo scoping fix from the prior commit: a context:"worktree" node gets --repo stripped and resolved onto ctx.identity; a node without context still receives --repo verbatim in its own args. The worktree-context case mocks the three repo.ts functions dispatch dynamically imports (bare os.homedir() can't be redirected from inside a test — confirmed empirically it's resolved once at process start, unlike lib/rt-paths.ts's call-time HOME check), restored via mock.module in afterEach. Minors: dispose's picker now filters to kind==="ephemeral" (the main clone always refused with kind-main); --title is now declared on the provision node's args so docs/alt-enter form see it; takeFlag now refuses a following flag as a value (e.g. `--repo --json` no longer swallows --json as --repo's value). Co-Authored-By: Claude Fable 5 --- commands/cd.ts | 22 ++++- commands/worktree.ts | 84 +++++++++++++----- lib/__tests__/command-tree.test.ts | 86 ++++++++++++++++++- lib/command-tree-def.ts | 1 + website/docs/reference/worktree/provision.mdx | 1 + 5 files changed, 166 insertions(+), 28 deletions(-) diff --git a/commands/cd.ts b/commands/cd.ts index bfb48d67..5e2012b6 100644 --- a/commands/cd.ts +++ b/commands/cd.ts @@ -52,6 +52,12 @@ const SHELL_FUNCTION = [ ` elif [ "$1" = "nav" ]; then`, ` local dir`, ` dir="$(COLUMNS=$COLUMNS "$rt_bin" nav "\${@:2}")" && [ -n "$dir" ] && builtin cd "$dir"`, + ` elif [ "$1" = "worktree" ] && [ -z "$2" ]; then`, + ` # Bare 'rt worktree' is the nav picker (same contract as cd/nav) — jump`, + ` # into the selected tree. 'rt worktree ' passes straight`, + ` # through below.`, + ` local dir`, + ` dir="$(COLUMNS=$COLUMNS "$rt_bin" worktree)" && [ -n "$dir" ] && builtin cd "$dir"`, ` elif [ "$1" = "x" ]; then`, ` "$rt_bin" "$@"`, ` local rt_cwd`, @@ -79,7 +85,13 @@ async function ensureShellFunction(): Promise { } catch { /* no rc file yet */ } // Latest version marker: whence -p / type -P PATH-only lookup (fixes FUNCNEST recursion) - if (rcContent.includes('rt() {') && rcContent.includes('whence -p rt') && rcContent.includes('"$rt_bin" nav')) return; + // + bare 'rt worktree' cd-jump support. + if ( + rcContent.includes('rt() {') && + rcContent.includes('whence -p rt') && + rcContent.includes('"$rt_bin" nav') && + rcContent.includes('"$1" = "worktree"') + ) return; // Redirect stdout → stderr before showing prompts const origWrite = process.stdout.write.bind(process.stdout); @@ -95,7 +107,9 @@ async function ensureShellFunction(): Promise { const hasHashCacheBug = rcContent.includes("rt() {") && rcContent.includes("command rt cd") && !rcContent.includes("local rt_bin"); // Uses command -v which returns the function name in zsh, causing infinite recursion const hasFuncnestBug = rcContent.includes("rt() {") && rcContent.includes("command -v rt") && !rcContent.includes("whence -p rt"); - const hasOldFunction = hasLegacyRtcd || hasOldRtWrapper || hasPreRehashWrapper || hasHashCacheBug || hasFuncnestBug; + // Function exists and is otherwise current, but predates bare 'rt worktree' cd-jump support. + const hasNoWorktreeNav = rcContent.includes("rt() {") && rcContent.includes('"$rt_bin" nav') && !rcContent.includes('"$1" = "worktree"'); + const hasOldFunction = hasLegacyRtcd || hasOldRtWrapper || hasPreRehashWrapper || hasHashCacheBug || hasFuncnestBug || hasNoWorktreeNav; if (hasFuncnestBug) { console.error(`\n ${yellow}Upgrading rt shell wrapper: fix FUNCNEST recursion in zsh${reset}`); @@ -103,6 +117,8 @@ async function ensureShellFunction(): Promise { console.error(`\n ${yellow}Upgrading rt shell wrapper: auto-rehash after dev-mode toggle${reset}`); } else if (hasNoNav) { console.error(`\n ${yellow}Upgrading rt shell wrapper: adding rt nav cd support${reset}`); + } else if (hasNoWorktreeNav) { + console.error(`\n ${yellow}Upgrading rt shell wrapper: adding rt worktree cd support${reset}`); } else if (hasHashCacheBug) { console.error(`\n ${yellow}Upgrading rt shell wrapper: resolve dev-mode binary by absolute path${reset}`); } else if (hasOldRtWrapper) { @@ -131,7 +147,7 @@ async function ensureShellFunction(): Promise { process.exit(0); } - if (hasOldRtWrapper || hasPreRehashWrapper || hasNoNav || hasHashCacheBug || hasFuncnestBug) { + if (hasOldRtWrapper || hasPreRehashWrapper || hasNoNav || hasHashCacheBug || hasFuncnestBug || hasNoWorktreeNav) { rcContent = rcContent .replace(/\n?# rt — shell wrapper \(enables rt cd to change directory\)\n?/g, "") .replace(/\n?rt\(\) \{[\s\S]*?\n\}\n?/g, "\n"); diff --git a/commands/worktree.ts b/commands/worktree.ts index 3a3c62e6..a36c6517 100644 --- a/commands/worktree.ts +++ b/commands/worktree.ts @@ -19,7 +19,7 @@ import { join } from "path"; import { bold, cyan, dim, green, red, reset, yellow } from "../lib/tui.ts"; import { RT_DIR } from "../lib/daemon-config.ts"; import { getRepoIdentity } from "../lib/repo.ts"; -import { daemonQuery, type DaemonResponse } from "../lib/daemon-client.ts"; +import { daemonQuery, lastQueryTimedOut, type DaemonResponse } from "../lib/daemon-client.ts"; import { listWorktrees } from "../lib/git-worktrees.ts"; import { parseEachArgs, @@ -31,16 +31,31 @@ import { type WorktreeBinding, } from "../lib/worktree-each.ts"; -// Provision does a targeted `git fetch` (up to 5 min server-side per -// lib/daemon/handlers/worktree.ts) — give the round trip room beyond that. +// Provision and create both do a targeted `git fetch` / cold clone (up to +// 5 min server-side per lib/daemon/handlers/worktree.ts) — give the round +// trip room beyond that. Dispose and freshen get their own generous budgets +// (freshen's fetch alone is budgeted 5min server-side, and a repo-wide sweep +// with no `tree` given can touch many trees in one call). The default 2s +// daemonQuery timeout used elsewhere is a client-not-a-daemon-op number — +// using it here would make the CLI report "daemon unavailable" while the +// daemon is still working. const PROVISION_TIMEOUT_MS = 6 * 60_000; +const DISPOSE_TIMEOUT_MS = 2 * 60_000; +const FRESHEN_TIMEOUT_MS = 10 * 60_000; const ADOPT_TIMEOUT_MS = 2 * 60_000; // ─── Arg parsing (pure — unit tested in lib/__tests__/worktree-cli-args.test.ts) ── +/** A value token that itself looks like a flag (starts with "-") is never a value — the flag was passed bare. */ +function isFlagLike(token: string | undefined): boolean { + return token !== undefined && token.startsWith("-"); +} + function takeFlag(args: string[], flag: string): { value: string | undefined; rest: string[] } { const idx = args.indexOf(flag); - if (idx === -1 || args[idx + 1] === undefined) return { value: undefined, rest: args }; + if (idx === -1 || args[idx + 1] === undefined || isFlagLike(args[idx + 1])) { + return { value: undefined, rest: args }; + } return { value: args[idx + 1], rest: [...args.slice(0, idx), ...args.slice(idx + 2)] }; } @@ -153,10 +168,18 @@ export function parseAdoptArgs(args: string[]): AdoptArgs { // ─── Shared IO helpers ─────────────────────────────────────────────────────── const DAEMON_DOWN_MESSAGE = "daemon unavailable — worktree lifecycle needs the daemon (rt daemon start)"; +const DAEMON_TIMEOUT_MESSAGE = "timed out — the daemon may still be working; check rt worktree list"; -/** `daemonQuery` returned null: hard stop, no inline fallback (spec §3). */ +/** + * `daemonQuery` returned null: hard stop, no inline fallback (spec §3). Two + * very different reasons collapse to null — genuinely down, or a slow + * operation (provision's fetch, a repo-wide freshen sweep) outran its + * timeout while the daemon kept working — so `lastQueryTimedOut()` picks the + * message that doesn't lie about which one happened. + */ function daemonUnavailable(): never { - console.log(`\n ${red}✗${reset} ${DAEMON_DOWN_MESSAGE}\n`); + const message = lastQueryTimedOut() ? DAEMON_TIMEOUT_MESSAGE : DAEMON_DOWN_MESSAGE; + console.log(`\n ${red}✗${reset} ${message}\n`); process.exit(1); } @@ -282,7 +305,7 @@ export async function worktreeCreate(args: string[], _ctx: unknown): Promise or run from inside a registered repo"); - const res = await daemonQuery("worktree:create", { repoName, onDeck: parsed.onDeck }); + const res = await daemonQuery("worktree:create", { repoName, onDeck: parsed.onDeck }, PROVISION_TIMEOUT_MS); const ok = requireQueryResult(parsed.json, res); if (parsed.json) { console.log(JSON.stringify(ok.data, null, 2)); return; } @@ -303,7 +326,11 @@ export async function worktreeDispose(args: string[], _ctx: unknown): Promise r.kind === "ephemeral"), + ); const picked = await pickOneTree(rows, "Dispose which worktree?"); if (!picked) { console.log(`\n ${dim}nothing selected${reset}\n`); return; } treeName = picked.name; @@ -315,12 +342,15 @@ export async function worktreeDispose(args: string[], _ctx: unknown): Promise }; + // Set before either return path — --json must not exit 0 on a partial failure. + if (refused.length > 0) process.exitCode = 1; + if (parsed.json) { console.log(JSON.stringify(ok.data, null, 2)); return; } - const { disposed, refused } = ok.data as { disposed: string[]; refused: Array<{ tree: string; reason: string }> }; console.log(""); for (const name of disposed) console.log(` ${green}✓${reset} ${name} disposed`); for (const r of refused) { @@ -329,7 +359,6 @@ export async function worktreeDispose(args: string[], _ctx: unknown): Promise 0) process.exitCode = 1; } // ─── list ──────────────────────────────────────────────────────────────────── @@ -362,8 +391,11 @@ export async function worktreeFreshen(args: string[], _ctx: unknown): Promise r.kind === "ephemeral" && r.state !== "disposable") + .filter((r) => (r.kind === "ephemeral" && r.state === "on-deck") || r.kind === "main") .sort((a, b) => a.name.localeCompare(b.name)); const picked = await pickOneTree(rows, "Freshen which worktree?"); if (!picked) { console.log(`\n ${dim}nothing selected${reset}\n`); return; } @@ -375,7 +407,7 @@ export async function worktreeFreshen(args: string[], _ctx: unknown): Promise { - const res = await daemonQuery("worktree:list"); - if (res === null) daemonUnavailable(); - if (!res.ok) { - console.log(`\n ${red}✗${reset} ${explainError(res.error ?? "unknown error")}\n`); - process.exit(1); - } - const rows = (res.data?.trees ?? []) as TreeRow[]; - if (rows.length === 0) { console.log(`\n ${dim}no worktrees${reset}\n`); return; } - + // Redirect FIRST, before any output at all — real stdout is reserved for + // the one line the shell wrapper reads (the selected path). Every early + // exit below (daemon down, refusal, empty list) goes through console.log, + // which now lands on stderr too, same as cd.ts's contract. const realStdoutWrite = process.stdout.write.bind(process.stdout); process.stdout.write = process.stderr.write.bind(process.stderr) as typeof process.stdout.write; + const restore = (): void => { process.stdout.write = realStdoutWrite; }; let selected: string | null; try { + const res = await daemonQuery("worktree:list"); + if (res === null) { restore(); daemonUnavailable(); } + if (!res.ok) { + restore(); + console.log(`\n ${red}✗${reset} ${explainError(res.error ?? "unknown error")}\n`); + process.exit(1); + } + const rows = (res.data?.trees ?? []) as TreeRow[]; + if (rows.length === 0) { restore(); console.log(`\n ${dim}no worktrees${reset}\n`); return; } + const { filterableSelect } = await import("../lib/rt-render.tsx"); const nameWidth = Math.max(...rows.map((r) => r.name.length)); const stateWidth = Math.max(...rows.map((r) => (r.state ?? r.kind).length)); @@ -440,7 +478,7 @@ export async function worktreeNav(_args: string[], _ctx: unknown): Promise })); selected = await filterableSelect({ message: "Jump to worktree", options, stderr: true }); } finally { - process.stdout.write = realStdoutWrite; + restore(); } if (!selected) process.exit(0); diff --git a/lib/__tests__/command-tree.test.ts b/lib/__tests__/command-tree.test.ts index fb6891b9..6cab6ce3 100644 --- a/lib/__tests__/command-tree.test.ts +++ b/lib/__tests__/command-tree.test.ts @@ -1,5 +1,6 @@ -import { describe, test, expect } from "bun:test"; -import { walkTree, type CommandNode } from "../command-tree.ts"; +import { describe, test, expect, afterEach, mock } from "bun:test"; +import { dispatch, walkTree, type CommandContext, type CommandNode } from "../command-tree.ts"; +import type { KnownRepo, RepoIdentity } from "../repo.ts"; const noop = async () => {}; @@ -57,3 +58,84 @@ describe("walkTree", () => { expect(walkTree(TREE, ["branch", "switch"])).toBeNull(); }); }); + +// ─── dispatch's --repo flag scoping (RT-34 fix) ────────────────────────────── +// +// command-tree.ts's global `--repo ` extraction used to run +// unconditionally for every leaf node, before checking node.context, and +// silently discarded the flag+value whenever the node didn't declare +// context:"worktree" — e.g. the worktree-lifecycle verbs' own `--repo +// ` payload flag would vanish before commands/worktree.ts +// ever saw it. The fix scopes the extraction to context:"worktree" nodes. +describe("dispatch --repo flag scoping", () => { + // Real repo resolution (getKnownRepos/getRepoIdentity) reads ~/.rt/repos.json + // via bare os.homedir(), which — unlike lib/rt-paths.ts's call-time + // `process.env.HOME ?? homedir()` — is resolved once at process start and + // can't be redirected from inside a test. So the context:"worktree" case + // mocks the three functions dispatch dynamically imports from "./repo.ts", + // restored via mock.module in afterEach so nothing else in the process sees + // the fake repo past this one test. + afterEach(async () => { + const real = await import("../repo.ts"); + mock.module("../repo.ts", () => real); + }); + + test('context:"worktree" node: --repo is stripped from args and resolved onto ctx.identity', async () => { + const real = await import("../repo.ts"); + // chdir to the CURRENT cwd (a real, always-existing directory) so + // dispatch's real process.chdir() call is a harmless no-op — no fake + // filesystem path needed, and no process-wide cwd side effect to undo. + const fakeRepo: KnownRepo = { + repoName: "acme", + worktrees: [{ path: process.cwd(), branch: "main", isBare: false }], + dataDir: "/fake/acme-data", + }; + const fakeIdentity: RepoIdentity = { + repoName: "acme", + repoRoot: process.cwd(), + dataDir: "/fake/acme-data", + remoteUrl: "", + baseUrl: "", + }; + mock.module("../repo.ts", () => ({ + ...real, + getKnownRepos: () => [fakeRepo], + pickWorktreeFromRepo: async () => null, + getRepoIdentity: () => fakeIdentity, + })); + + let capturedArgs: string[] | undefined; + let capturedCtx: CommandContext | undefined; + const tree: Record = { + cmd: { + description: "test", + context: "worktree", + handler: async (args, ctx) => { + capturedArgs = args; + capturedCtx = ctx; + }, + }, + }; + + await dispatch(tree, ["cmd", "--repo", "acme", "--flag", "x"]); + + expect(capturedArgs).toEqual(["--flag", "x"]); + expect(capturedCtx?.identity?.repoName).toBe("acme"); + }); + + test('node without context: --repo survives untouched in its own args (no repo.ts mocking needed)', async () => { + let capturedArgs: string[] | undefined; + const tree: Record = { + cmd: { + description: "test", + handler: async (args) => { + capturedArgs = args; + }, + }, + }; + + await dispatch(tree, ["cmd", "--repo", "foo", "--ticket", "bar"]); + + expect(capturedArgs).toEqual(["--repo", "foo", "--ticket", "bar"]); + }); +}); diff --git a/lib/command-tree-def.ts b/lib/command-tree-def.ts index 67f09739..344b7586 100644 --- a/lib/command-tree-def.ts +++ b/lib/command-tree-def.ts @@ -545,6 +545,7 @@ export const TREE: Record = { args: [ { name: "Repo", flag: "--repo", type: "text", placeholder: "repo-tools", hint: "Registered repo name (defaults to the current repo)" }, { name: "Ticket", flag: "--ticket", type: "text", placeholder: "RT-40", hint: "Linear ticket id — derives the branch name" }, + { name: "Title", flag: "--title", type: "text", placeholder: "Prune the parking lot", hint: "Ticket title, used with --ticket to derive the branch slug" }, { name: "Branch", flag: "--branch", type: "text", placeholder: "feature/my-branch", hint: "Explicit branch name (overrides --ticket)" }, { name: "Owner", flag: "--owner", type: "text", placeholder: "matt", hint: "Who's claiming this tree" }, { name: "Disposal", flag: "--disposal", type: "text", placeholder: "merge", hint: "Disposal mode: merge (default) or job" }, diff --git a/website/docs/reference/worktree/provision.mdx b/website/docs/reference/worktree/provision.mdx index 94720a0f..a518b888 100644 --- a/website/docs/reference/worktree/provision.mdx +++ b/website/docs/reference/worktree/provision.mdx @@ -21,6 +21,7 @@ rt worktree provision [flags] | --- | --- | --- | --- | | [`--repo`](/guides/common-flags) | text | | Registered repo name (defaults to the current repo) | | `--ticket` | text | | Linear ticket id — derives the branch name | +| `--title` | text | | Ticket title, used with --ticket to derive the branch slug | | `--branch` | text | | Explicit branch name (overrides --ticket) | | `--owner` | text | | Who's claiming this tree | | `--disposal` | text | | Disposal mode: merge (default) or job | From 8a07cbc3890dce75f67a53bdba666ddbb77a7674 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 20:09:17 -0500 Subject: [PATCH 26/31] RT-34: delete the parking lot (replaced by the worktree lifecycle) Removes the parking-lot daemon module, its IPC handler, the parking-lot CLI command, and the legacy parking-lot config loader, now that the worktree lifecycle (Tasks 1-14) has replaced it end to end. Also drops the module-registry entry so the compiled binary has no dangling import. worktree:adopt now cleans up the two files the parking lot owned that the new worktree lifecycle no longer reads: the per-repo index (~/.rt/repos//parking-lot.json) and the app-level transition state (~/.rt/parking-lot-state.json). ~/.rt/parking-lot.json (no "-state" suffix) is left alone -- it's the app CONFIG file, which loadWorktreeAppConfig still compat-reads once to seed worktrees.json. The generated pre-commit hook guard in commands/hooks.ts rewrites from blocking commits on parking-lot/* branches to blocking commits on on-deck/* branches, the analogous mistake under the new model. Installed hook shims regenerate on the next `rt hooks` run -- this only changes what future shim generation writes. The migration sweep in worktree:adopt still recognizes literal parking-lot/N branches on disk (to auto-dispose them from repos never adopted before) -- left unchanged, since that's the one place the old naming convention still needs to be matched, not a dangling reference. Co-Authored-By: Claude Fable 5 --- commands/hooks.ts | 12 +- commands/parking-lot.ts | 356 ---------- lib/daemon/__tests__/parking-lot.test.ts | 340 ---------- .../__tests__/worktree-handlers.test.ts | 8 + lib/daemon/handlers/parking-lot.ts | 44 -- lib/daemon/handlers/worktree.ts | 13 +- lib/daemon/parking-lot.ts | 634 ------------------ lib/module-registry.ts | 2 - lib/parking-lot-config.ts | 39 -- 9 files changed, 26 insertions(+), 1422 deletions(-) delete mode 100644 commands/parking-lot.ts delete mode 100644 lib/daemon/__tests__/parking-lot.test.ts delete mode 100644 lib/daemon/handlers/parking-lot.ts delete mode 100644 lib/daemon/parking-lot.ts delete mode 100644 lib/parking-lot-config.ts diff --git a/commands/hooks.ts b/commands/hooks.ts index 3b986fb4..1e33f6cb 100644 --- a/commands/hooks.ts +++ b/commands/hooks.ts @@ -88,18 +88,18 @@ function generateShims(dataDir: string, discoveredHooks: string[]): void { const configFile = hooksConfigPath(dataDir); - // Always include pre-commit so the parking-lot guard runs even if the repo + // Always include pre-commit so the on-deck guard runs even if the repo // has no .husky/pre-commit of its own. const hookNames = [...new Set(["pre-commit", ...discoveredHooks])]; for (const hookName of hookNames) { const shimPath = join(hooksDir, hookName); - const parkingLotGuard = hookName === "pre-commit" ? ` -# Parking-lot guard: block commits on parking-lot/* branches. + const onDeckGuard = hookName === "pre-commit" ? ` +# On-deck guard: block commits on on-deck/* branches. CURRENT_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null) -if [[ "$CURRENT_BRANCH" == parking-lot/* ]]; then - echo "rt: commits are not allowed on parking-lot branches (on \\"$CURRENT_BRANCH\\")" +if [[ "$CURRENT_BRANCH" == on-deck/* ]]; then + echo "rt: commits are not allowed on on-deck branches (on \\"$CURRENT_BRANCH\\")" echo "rt: switch to a feature branch before committing" exit 1 fi @@ -108,7 +108,7 @@ fi const shim = `#!/bin/bash # rt hook shim — checks ~/.rt config before running the real hook # Fail-safe: if config is missing or unreadable, the real hook runs -${parkingLotGuard} +${onDeckGuard} HOOKS_CONFIG="${configFile}" HOOK_NAME="${hookName}" diff --git a/commands/parking-lot.ts b/commands/parking-lot.ts deleted file mode 100644 index 8da74860..00000000 --- a/commands/parking-lot.ts +++ /dev/null @@ -1,356 +0,0 @@ -#!/usr/bin/env bun - -/** - * rt parking-lot — inspect and control the daemon's auto-park feature. - * - * The daemon watches tracked worktree branches for MRs that transition to - * `merged` / `closed` and auto-parks the worktree onto `parking-lot/` - * (stash → fast-forward from origin/master). This command is the user-facing - * lever: toggle the feature, view the current bindings, or fire a manual scan. - * - * Usage: - * rt parking-lot → same as `status` - * rt parking-lot status → show enabled flag + worktree bindings - * rt parking-lot enable → turn auto-park on - * rt parking-lot disable → turn auto-park off - * rt parking-lot scan → run the park check once against live cache - * rt park this → park the current worktree now - * rt park pick → multi-select worktrees in this repo to park - */ - -import { existsSync, readFileSync } from "fs"; -import { join } from "path"; -import { bold, cyan, dim, green, reset, yellow, red } from "../lib/tui.ts"; -import { RT_DIR } from "../lib/daemon-config.ts"; -import { - loadParkingLotConfig, - saveParkingLotConfig, - PARKING_LOT_CONFIG_PATH, -} from "../lib/parking-lot-config.ts"; -import { describeRepoBindings, isParkable, park } from "../lib/daemon/parking-lot.ts"; -import { daemonQuery, lastQueryTimedOut } from "../lib/daemon-client.ts"; -import { getRepoIdentity, requireRepoIdentity } from "../lib/repo.ts"; -import { getCurrentBranch } from "../lib/git-ops.ts"; - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -function loadRepos(): Record { - try { - return JSON.parse(readFileSync(join(RT_DIR, "repos.json"), "utf8")); - } catch { - return {}; - } -} - -function dot(enabled: boolean): string { - return enabled ? `${green}●${reset}` : `${dim}○${reset}`; -} - -/** Render one repo's parking-lot bindings (auto-park slot table). */ -function printRepoBindings(repoName: string, repoPath: string): void { - console.log(` ${bold}${repoName}${reset} ${dim}${repoPath}${reset}`); - - if (!existsSync(repoPath)) { - console.log(` ${yellow}⚠${reset} path missing on disk\n`); - return; - } - - const bindings = describeRepoBindings(repoName, repoPath, { withStaleness: true }) - .sort((a, b) => a.index - b.index); - if (bindings.length === 0) { - console.log(` ${dim}no worktrees${reset}\n`); - return; - } - - // Staleness is only worth showing where it means something is wrong. An - // unparked slot trails master by design... its worktree is off on a feature - // branch, and park() fast-forwards the slot when it comes back. A *parked* - // slot that trails is the real signal: the background sweep isn't keeping it - // current. Annotating both drowns the second in the first. - const STALE_AT = 50; - - const widest = Math.max(...bindings.map(b => String(b.index).length)); - const repoDir = repoPath.replace(/\/[^/]+\/?$/, ""); - const wtNames = bindings.map(b => b.path.startsWith(repoDir + "/") ? b.path.slice(repoDir.length + 1) : b.path); - const widestWt = Math.max(...wtNames.map(s => s.length)); - let staleCount = 0; - for (let i = 0; i < bindings.length; i++) { - const b = bindings[i]!; - const idx = String(b.index).padStart(widest); - const wt = wtNames[i]!.padEnd(widestWt); - const slot = `parking-lot/${b.index}`; - const status = b.branch === null ? `${dim}(detached)${reset}` - : b.branch === slot ? `${green}parked${reset}` - : b.branch; - const behind = b.slotBehind ?? 0; - const stuck = b.branch === slot && behind >= STALE_AT; - if (stuck) staleCount++; - const stale = stuck ? ` ${yellow}${behind} behind${reset}` : ""; - console.log(` ${cyan}park/${idx}${reset} ${dim}${wt}${reset} ${status}${stale}`); - } - console.log(""); - if (staleCount > 0) { - console.log(` ${yellow}⚠${reset} ${staleCount} parked slot${staleCount > 1 ? "s are" : " is"} not being kept current ${dim}... check the daemon: rt daemon logs${reset}\n`); - } -} - -// ─── Commands ──────────────────────────────────────────────────────────────── - -export async function statusCommand(): Promise { - const config = loadParkingLotConfig(); - const repos = loadRepos(); - - console.log(` ${dot(config.enabled)} auto-park ${config.enabled ? `${green}enabled${reset}` : `${dim}disabled${reset}`}`); - console.log(` ${dim}config: ${PARKING_LOT_CONFIG_PATH}${reset}`); - console.log(""); - - if (Object.keys(repos).length === 0) { - console.log(` ${dim}no repos tracked — register one with rt from inside a repo${reset}\n`); - return; - } - - // Scope to a single repo rather than dumping every tracked repo at once: - // the current repo when invoked from inside one, otherwise the shared repo - // picker (auto-selects when only one repo is known). - const identity = await requireRepoIdentity("park status"); - const repoPath = repos[identity.repoName] ?? identity.repoRoot; - printRepoBindings(identity.repoName, repoPath); -} - -export async function enableCommand(): Promise { - const current = loadParkingLotConfig(); - if (current.enabled) { - console.log(`\n ${dim}auto-park is already enabled${reset}\n`); - return; - } - saveParkingLotConfig({ ...current, enabled: true }); - console.log(`\n ${green}✓${reset} auto-park enabled\n`); - console.log(` ${dim}the daemon will resume parking worktrees on the next cache refresh${reset}\n`); -} - -export async function disableCommand(): Promise { - const current = loadParkingLotConfig(); - if (!current.enabled) { - console.log(`\n ${dim}auto-park is already disabled${reset}\n`); - return; - } - saveParkingLotConfig({ ...current, enabled: false }); - console.log(`\n ${green}✓${reset} auto-park disabled\n`); - console.log(` ${dim}daemon scans will no-op until you run: rt parking-lot enable${reset}\n`); -} - -interface ParkOutcome { - result: { ok: boolean; action: string; detail?: string }; - logs: string[]; -} - -/** - * Park one worktree, animating a spinner with `label` while we wait. Routes - * through the daemon so spinner animation isn't blocked by the execSync chain - * inside park(); falls back to in-process if the daemon isn't reachable. - */ -async function runParkWithSpinner( - label: string, - worktreePath: string, - repoPath: string, - branch: string | null, - index: number, -): Promise { - const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠣", "⠏"]; - let fi = 0; - const renderFrame = () => { - process.stderr.write(`\r ${cyan}${frames[fi++ % frames.length]}${reset} ${dim}${label}…${reset}`); - }; - renderFrame(); - const spinner = setInterval(renderFrame, 80); - - let result: ParkOutcome["result"]; - let logs: string[] = []; - - const response = await daemonQuery( - "parking-lot:park-this", - { worktreePath, repoPath, branch, index }, - 60_000, - ); - - if (response?.ok && response.data?.result) { - result = response.data.result as typeof result; - logs = (response.data.lines as string[]) ?? []; - } else if (response === null && lastQueryTimedOut()) { - // The daemon accepted the park but didn't answer within the timeout — it - // may still be executing in this worktree. Running a second park - // concurrently risks duplicate stashes and index.lock contention. - result = { - ok: false, - action: "skip", - detail: "daemon park timed out — check the worktree state before retrying", - }; - } else { - result = park(worktreePath, repoPath, branch, index, { - killProcesses: loadParkingLotConfig().killProcesses, - }); - } - - clearInterval(spinner); - process.stderr.write(`\r\x1b[K`); - - return { result, logs }; -} - -function relWorktreeName(repoPath: string, worktreePath: string): string { - const repoDir = repoPath.replace(/\/[^/]+\/?$/, ""); - return worktreePath.startsWith(repoDir + "/") - ? worktreePath.slice(repoDir.length + 1) - : worktreePath; -} - -export async function parkThisCommand(): Promise { - const identity = getRepoIdentity(); - if (!identity) { - console.log(` ${red}✗${reset} not in a git repo\n`); - process.exit(1); - } - - const repos = loadRepos(); - const repoPath = repos[identity.repoName]; - if (!repoPath) { - console.log(` ${red}✗${reset} repo "${identity.repoName}" not registered in ~/.rt/repos.json\n`); - process.exit(1); - } - - const worktreePath = identity.repoRoot; - // A null branch means the worktree is detached (a warm-pool entry) — that's - // parkable too: we claim it onto its clean parking-lot/N slot. - const branch = getCurrentBranch(worktreePath); - - const bindings = describeRepoBindings(identity.repoName, repoPath); - const binding = bindings.find(b => b.path === worktreePath); - if (!binding || !binding.index) { - console.log(` ${red}✗${reset} no parking-lot index for ${worktreePath}\n`); - process.exit(1); - } - - const parkBranch = `parking-lot/${binding.index}`; - if (branch === parkBranch) { - console.log(` ${dim}already on ${parkBranch} — nothing to park${reset}\n`); - return; - } - - const from = branch ?? "(detached)"; - const { result, logs } = await runParkWithSpinner( - `parking ${from} → ${parkBranch}`, - worktreePath, repoPath, branch, binding.index, - ); - - if (result.ok) { - const defaultRef = result.detail?.match(/@ (\S+)/)?.[1] ?? "origin/master"; - console.log(` ${green}✓${reset} parked ${bold}${from}${reset} ${dim}→${reset} ${cyan}${parkBranch}${reset} ${dim}@ ${defaultRef}${reset}\n`); - } else { - for (const line of logs) console.log(` ${dim}${line}${reset}`); - console.log(` ${red}✗${reset} ${result.action}${result.detail ? ` — ${result.detail}` : ""}\n`); - process.exit(1); - } -} - -export async function parkPickCommand(): Promise { - // Scope to a repo: the current one when inside it, otherwise the shared - // repo picker (auto-selects when only one repo is known). - const identity = await requireRepoIdentity("park pick"); - - const repos = loadRepos(); - const repoPath = repos[identity.repoName] ?? identity.repoRoot; - - const bindings = describeRepoBindings(identity.repoName, repoPath); - - // Eligible: has a slot index and isn't already on it. Detached worktrees - // (warm-pool entries) qualify — see isParkable. - const eligible = bindings.filter(isParkable); - - if (eligible.length === 0) { - console.log(`\n ${dim}no worktrees to park — all are already parked${reset}\n`); - return; - } - - const widestWt = Math.max(...eligible.map(b => relWorktreeName(repoPath, b.path).length)); - const options = eligible.map(b => { - const wt = relWorktreeName(repoPath, b.path).padEnd(widestWt); - return { - value: b.path, - label: wt, - hint: `${b.branch ?? "(detached)"} → parking-lot/${b.index}`, - }; - }); - - const { filterableMultiselect } = await import("../lib/rt-render.tsx"); - const selected = await filterableMultiselect({ - message: `Pick worktrees to park (${identity.repoName})`, - options, - }); - - if (!selected || selected.length === 0) { - console.log(`\n ${dim}nothing selected${reset}\n`); - return; - } - - const selectedSet = new Set(selected); - const targets = eligible.filter(b => selectedSet.has(b.path)); - - console.log(""); - let okCount = 0; - let failCount = 0; - - for (const b of targets) { - const branch = b.branch; - const from = branch ?? "(detached)"; - const parkBranch = `parking-lot/${b.index}`; - const wt = relWorktreeName(repoPath, b.path); - - const { result, logs } = await runParkWithSpinner( - `parking ${wt} (${from} → ${parkBranch})`, - b.path, repoPath, branch, b.index, - ); - - if (result.ok) { - const defaultRef = result.detail?.match(/@ (\S+)/)?.[1] ?? "origin/master"; - console.log(` ${green}✓${reset} ${dim}${wt}${reset} ${bold}${from}${reset} ${dim}→${reset} ${cyan}${parkBranch}${reset} ${dim}@ ${defaultRef}${reset}`); - okCount++; - } else { - for (const line of logs) console.log(` ${dim}${line}${reset}`); - console.log(` ${red}✗${reset} ${dim}${wt}${reset} ${result.action}${result.detail ? ` — ${result.detail}` : ""}`); - failCount++; - } - } - - console.log(`\n ${dim}${okCount} parked${failCount ? `, ${failCount} failed` : ""}${reset}\n`); - if (failCount > 0) process.exit(1); -} - -export async function scanCommand(): Promise { - if (!loadParkingLotConfig().enabled) { - console.log(` ${yellow}⚠${reset} auto-park is disabled — scan is a no-op`); - console.log(` ${dim}run: rt parking-lot enable${reset}\n`); - return; - } - - const response = await daemonQuery("parking-lot:scan"); - if (!response) { - console.log(` ${red}✗${reset} daemon not reachable`); - console.log(` ${dim}run: rt daemon start${reset}\n`); - return; - } - if (!response.ok) { - console.log(` ${red}✗${reset} scan failed: ${response.error ?? "unknown error"}\n`); - return; - } - - const lines = (response.data?.lines as string[] | undefined) ?? []; - const parkingLines = lines.filter(l => l.startsWith("parking-lot:")); - - if (parkingLines.length === 0) { - console.log(` ${green}✓${reset} scan complete — nothing to park\n`); - return; - } - - for (const line of parkingLines) console.log(` ${line}`); - console.log(""); -} diff --git a/lib/daemon/__tests__/parking-lot.test.ts b/lib/daemon/__tests__/parking-lot.test.ts deleted file mode 100644 index e7892f8a..00000000 --- a/lib/daemon/__tests__/parking-lot.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { execFileSync } from "child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; -import { findDesktopStash, getCurrentBranch, hasUncommittedChanges } from "../../git-ops.ts"; - -// The module reads RT_DIR at import time via daemon-config.ts, which pins to -// the user's home ~/.rt. To avoid writing into the real ~/.rt during tests we -// point HOME at a tmpdir BEFORE importing. -const tmpHome = mkdtempSync(join(tmpdir(), "rt-parking-")); -process.env.HOME = tmpHome; - -const { __test__, fastForwardParkedWorktrees, isParkable, park } = await import("../parking-lot.ts"); -// Imported after the HOME override for the same RT_DIR-pinning reason. -const { saveSyncConfig } = await import("../../sync-config.ts"); -const { repoDataDir } = await import("../../rt-paths.ts"); - -describe("reconcileIndexMap", () => { - const repo = "test-repo"; - - afterEach(() => { - try { rmSync(join(tmpHome, ".rt", "repos", repo), { recursive: true, force: true }); } catch { /* */ } - }); - - test("primary worktree gets index 1; later worktrees get 2,3,… in list order", () => { - const worktrees = [ - "/repo/primary", - "/repo/wktree-2", - "/repo/wktree-3", - ]; - const map = __test__.reconcileIndexMap(repo, worktrees); - expect(map).toEqual({ - "/repo/primary": 1, - "/repo/wktree-2": 2, - "/repo/wktree-3": 3, - }); - }); - - test("removing a middle worktree preserves the remaining indexes", () => { - __test__.reconcileIndexMap(repo, [ - "/repo/primary", "/repo/wktree-2", "/repo/wktree-3", "/repo/wktree-4", - ]); - // wktree-3 is gone; wktree-4 should still have its original 4 - const map = __test__.reconcileIndexMap(repo, [ - "/repo/primary", "/repo/wktree-2", "/repo/wktree-4", - ]); - expect(map["/repo/primary"]).toBe(1); - expect(map["/repo/wktree-2"]).toBe(2); - expect(map["/repo/wktree-4"]).toBe(4); - }); - - test("new worktree claims the lowest unused positive integer", () => { - __test__.reconcileIndexMap(repo, [ - "/repo/primary", "/repo/wktree-2", "/repo/wktree-3", - ]); - // wktree-2 removed; a new worktree-A appears — should claim 2, not 4 - const map = __test__.reconcileIndexMap(repo, [ - "/repo/primary", "/repo/wktree-3", "/repo/wktree-A", - ]); - expect(map["/repo/primary"]).toBe(1); - expect(map["/repo/wktree-3"]).toBe(3); - expect(map["/repo/wktree-A"]).toBe(2); - }); - - test("primary keeps 1 even if listed worktrees are empty on first call then populated", () => { - __test__.reconcileIndexMap(repo, []); - const map = __test__.reconcileIndexMap(repo, ["/repo/primary", "/repo/wktree-2"]); - expect(map["/repo/primary"]).toBe(1); - expect(map["/repo/wktree-2"]).toBe(2); - }); - - test("index map persists across reconcile calls via disk", () => { - __test__.reconcileIndexMap(repo, ["/repo/primary", "/repo/wktree-2"]); - const loaded = __test__.loadIndexMap(repo); - expect(loaded).toEqual({ - "/repo/primary": 1, - "/repo/wktree-2": 2, - }); - }); - - test("pre-existing hand-edited file is respected (primary claims 1 if free)", () => { - const dir = join(tmpHome, ".rt", "repos", repo); - try { rmSync(dir, { recursive: true, force: true }); } catch { /* */ } - // User manually assigned wktree-9 to 9. Primary should still claim 1. - __test__.saveIndexMap(repo, { "/repo/wktree-9": 9 }); - const map = __test__.reconcileIndexMap(repo, [ - "/repo/primary", "/repo/wktree-9", "/repo/wktree-new", - ]); - expect(map["/repo/primary"]).toBe(1); - expect(map["/repo/wktree-9"]).toBe(9); - expect(map["/repo/wktree-new"]).toBe(2); - }); -}); - -describe("isParkable", () => { - // Detached worktrees (e.g. herdr warm-pool entries) have no branch but still - // get a slot index — they should be parkable so the user can claim them onto - // their parking-lot/N branch manually. - test("a detached worktree with an allocated slot is parkable", () => { - expect(isParkable({ path: "/r/wt", branch: null, index: 5 })).toBe(true); - }); - - test("a worktree on a feature branch is parkable", () => { - expect(isParkable({ path: "/r/wt", branch: "feature/x", index: 2 })).toBe(true); - }); - - test("a worktree already on its parking-lot slot is not parkable", () => { - expect(isParkable({ path: "/r/wt", branch: "parking-lot/3", index: 3 })).toBe(false); - }); - - test("a worktree with no allocated slot is not parkable", () => { - expect(isParkable({ path: "/r/wt", branch: null, index: 0 })).toBe(false); - }); -}); - -describe("park (detached worktree)", () => { - let root: string; - let primary: string; - let wt: string; - - beforeEach(() => { - // Resolve symlinks (macOS /var → /private/var) so paths match git output. - root = realpathSync(mkdtempSync(join(tmpdir(), "rt-park-detached-"))); - const origin = join(root, "origin.git"); - mkdirSync(origin); - execFileSync("git", ["init", "--bare", "-b", "master", origin], { stdio: "pipe" }); - execFileSync("git", ["clone", origin, "primary"], { cwd: root, stdio: "pipe" }); - primary = join(root, "primary"); - const g = (args: string[]) => execFileSync("git", args, { cwd: primary, stdio: "pipe" }); - g(["config", "user.email", "t@example.com"]); - g(["config", "user.name", "Test"]); - writeFileSync(join(primary, "README.md"), "hi\n"); - g(["add", "."]); - g(["commit", "-m", "init"]); - g(["push", "origin", "master"]); - // A linked worktree in detached HEAD — the warm-pool shape. - wt = join(root, "wt5"); - g(["worktree", "add", "--detach", wt, "HEAD"]); - }); - - afterEach(() => { - try { rmSync(root, { recursive: true, force: true }); } catch { /* */ } - }); - - test("parks a clean detached worktree onto its slot branch", () => { - const result = park(wt, primary, null, 5); - expect(result.ok).toBe(true); - expect(getCurrentBranch(wt)).toBe("parking-lot/5"); - }); - - test("stashes a dirty detached worktree under the slot branch, not ", () => { - writeFileSync(join(wt, "scratch.txt"), "wip\n"); - const result = park(wt, primary, null, 5); - expect(result.ok).toBe(true); - expect(getCurrentBranch(wt)).toBe("parking-lot/5"); - expect(hasUncommittedChanges(wt)).toBe(false); - // The stash must be recoverable by the slot branch name. The pre-fix code - // passed sourceBranch=null straight through, labeling the stash "". - expect(findDesktopStash(wt, "parking-lot/5")).not.toBeNull(); - }); - - // Spawn a long-lived process with cwd inside the worktree, orphaned to - // ppid 1 via double-fork — the shape of a leaked dev server. A direct - // Bun.spawn child would be a descendant of the test process, which the - // kill logic deliberately protects. - function spawnOrphanInWorktree(): number { - const out = execFileSync("sh", ["-c", "sleep 300 >/dev/null 2>&1 & echo $!"], { - cwd: wt, encoding: "utf8", - }); - return parseInt(out.trim(), 10); - } - - function isAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } - } - - test("kills a workload process rooted in the worktree when killProcesses is on", async () => { - const pid = spawnOrphanInWorktree(); - try { - expect(isAlive(pid)).toBe(true); - - const result = park(wt, primary, null, 5, { killProcesses: true }); - expect(result.ok).toBe(true); - - // SIGTERM lands before park() returns, but give the exit a moment. - const deadline = Date.now() + 3000; - while (isAlive(pid) && Date.now() < deadline) { - await Bun.sleep(50); - } - expect(isAlive(pid)).toBe(false); - } finally { - try { process.kill(pid, "SIGKILL"); } catch { /* already dead */ } - } - }); - - test("leaves worktree processes alone when killProcesses is off", async () => { - const pid = spawnOrphanInWorktree(); - try { - const result = park(wt, primary, null, 5); - expect(result.ok).toBe(true); - - await Bun.sleep(300); - expect(isAlive(pid)).toBe(true); - } finally { - try { process.kill(pid, "SIGKILL"); } catch { /* already dead */ } - } - }); -}); - -describe("fastForwardParkedWorktrees (dirty-tree resolution)", () => { - const repoName = "ff-test-repo"; - const GENERATED = "src/generated/schema.graphql"; - - let root: string; - let primary: string; - let wt: string; - let origin: string; - - /** Run git in a given worktree. */ - const git = (cwd: string, args: string[]) => - execFileSync("git", args, { cwd, stdio: "pipe", encoding: "utf8" }); - - beforeEach(() => { - root = realpathSync(mkdtempSync(join(tmpdir(), "rt-park-ff-"))); - origin = join(root, "origin.git"); - mkdirSync(origin); - execFileSync("git", ["init", "--bare", "-b", "master", origin], { stdio: "pipe" }); - execFileSync("git", ["clone", origin, "primary"], { cwd: root, stdio: "pipe" }); - primary = join(root, "primary"); - git(primary, ["config", "user.email", "t@example.com"]); - git(primary, ["config", "user.name", "Test"]); - - // The generated file ends WITHOUT a trailing newline, mirroring the real - // schema.graphql that froze hogwarts. - mkdirSync(join(primary, "src", "generated"), { recursive: true }); - writeFileSync(join(primary, GENERATED), "type Query {\n id: ID!\n}"); - writeFileSync(join(primary, "README.md"), "hi\n"); - git(primary, ["add", "."]); - git(primary, ["commit", "-m", "init"]); - git(primary, ["push", "origin", "master"]); - - // A linked worktree parked on its slot branch. - wt = join(root, "wt7"); - git(primary, ["worktree", "add", "-b", "parking-lot/7", wt, "HEAD"]); - - // origin/master advances by one commit so there is something to fast-forward to. - writeFileSync(join(primary, "NEW.md"), "new\n"); - git(primary, ["add", "."]); - git(primary, ["commit", "-m", "advance"]); - git(primary, ["push", "origin", "master"]); - git(primary, ["checkout", "-q", "HEAD~1"]); // keep master off the primary's HEAD - - // Declare the generated file as auto-resolvable, exactly like assured-dev's - // sync.json does. postResolve is present to prove the sweep does NOT run it. - saveSyncConfig(repoDataDir(repoName), { - autoResolve: [ - { glob: [`**/generated/**`], strategy: "theirs", postResolve: ["exit 1"] }, - ], - }); - }); - - afterEach(() => { - try { rmSync(root, { recursive: true, force: true }); } catch { /* */ } - try { rmSync(repoDataDir(repoName), { recursive: true, force: true }); } catch { /* */ } - }); - - function behindCount(): number { - return parseInt(git(wt, ["rev-list", "--count", "HEAD..origin/master"]).trim(), 10); - } - - test("fast-forwards a clean parked worktree", () => { - expect(behindCount()).toBe(1); - fastForwardParkedWorktrees(repoName, primary, [{ path: wt, branch: "parking-lot/7" }]); - expect(behindCount()).toBe(0); - }); - - test("discards a whitespace-only drift on a declared generated file, then fast-forwards", () => { - // The exact hogwarts shape: a trailing newline appended to a generated file. - writeFileSync(join(wt, GENERATED), "type Query {\n id: ID!\n}\n"); - expect(hasUncommittedChanges(wt)).toBe(true); - - fastForwardParkedWorktrees(repoName, primary, [{ path: wt, branch: "parking-lot/7" }]); - - expect(behindCount()).toBe(0); - expect(hasUncommittedChanges(wt)).toBe(false); - }); - - test("stashes and restores undeclared dirty work across the fast-forward", () => { - writeFileSync(join(wt, "sheep.toml"), "name = 'mine'\n"); - git(wt, ["add", "sheep.toml"]); - - fastForwardParkedWorktrees(repoName, primary, [{ path: wt, branch: "parking-lot/7" }]); - - expect(behindCount()).toBe(0); - // The user's own work must survive the sweep. - expect(existsSync(join(wt, "sheep.toml"))).toBe(true); - expect(readFileSync(join(wt, "sheep.toml"), "utf8")).toBe("name = 'mine'\n"); - }); - - test("handles the mixed case: discard the generated drift, stash/restore the rest", () => { - writeFileSync(join(wt, GENERATED), "type Query {\n id: ID!\n}\n"); - writeFileSync(join(wt, "sheep.toml"), "name = 'mine'\n"); - git(wt, ["add", "sheep.toml"]); - - fastForwardParkedWorktrees(repoName, primary, [{ path: wt, branch: "parking-lot/7" }]); - - expect(behindCount()).toBe(0); - expect(existsSync(join(wt, "sheep.toml"))).toBe(true); - // The generated drift is gone (discarded, not stashed back on top). - expect(git(wt, ["status", "--porcelain", "--", GENERATED]).trim()).toBe(""); - }); - - test("a substantive change to a declared file is stashed, never discarded", () => { - // Not whitespace, a real edit. The declaration says "upstream wins", but a - // background daemon must not destroy content it didn't generate. - writeFileSync(join(wt, GENERATED), "type Query {\n id: ID!\n extra: String\n}"); - - fastForwardParkedWorktrees(repoName, primary, [{ path: wt, branch: "parking-lot/7" }]); - - expect(behindCount()).toBe(0); - expect(readFileSync(join(wt, GENERATED), "utf8")).toContain("extra: String"); - }); - - test("leaves a diverged parked branch alone rather than forcing it", () => { - writeFileSync(join(wt, "local.md"), "local\n"); - git(wt, ["add", "."]); - git(wt, ["commit", "-m", "local commit"]); - const headBefore = git(wt, ["rev-parse", "HEAD"]).trim(); - - fastForwardParkedWorktrees(repoName, primary, [{ path: wt, branch: "parking-lot/7" }]); - - expect(git(wt, ["rev-parse", "HEAD"]).trim()).toBe(headBefore); - }); -}); diff --git a/lib/daemon/__tests__/worktree-handlers.test.ts b/lib/daemon/__tests__/worktree-handlers.test.ts index 38a108a4..ae6694f6 100644 --- a/lib/daemon/__tests__/worktree-handlers.test.ts +++ b/lib/daemon/__tests__/worktree-handlers.test.ts @@ -477,6 +477,11 @@ describe("worktree:adopt", () => { sh(`git worktree add -b parking-lot/1 ${parked} origin/main`, repo); sh(`git worktree add -b cv-1-feature ${feature} origin/main`, repo); + const repoIndexPath = join(repoDataDir(repoName), "parking-lot.json"); + const appStatePath = join(rtDir(), "parking-lot-state.json"); + writeJson(repoIndexPath, { [parked]: { branch: "parking-lot/1" } }); + writeJson(appStatePath, { transitions: [] }); + const { h } = makeHandlers({ [repoName]: repo }); const res: any = await h["worktree:adopt"]!({ repoName }); @@ -497,5 +502,8 @@ describe("worktree:adopt", () => { expect(feat.state).toBe("claimed"); expect(feat.disposal).toBe("merge"); expect(feat.branch).toBe("cv-1-feature"); + + expect(existsSync(repoIndexPath)).toBe(false); + expect(existsSync(appStatePath)).toBe(false); }); }); diff --git a/lib/daemon/handlers/parking-lot.ts b/lib/daemon/handlers/parking-lot.ts deleted file mode 100644 index f43c47b1..00000000 --- a/lib/daemon/handlers/parking-lot.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Parking-lot IPC handlers. - * - * parking-lot:scan — run the auto-park check immediately. - * parking-lot:park-this — park a specific worktree on demand. The CLI - * routes manual `rt park this` through here so the - * caller can animate a spinner while awaiting the - * result (the work itself is execSync-blocking). - */ - -import { checkAndPark, park } from "../parking-lot.ts"; -import { loadParkingLotConfig } from "../../parking-lot-config.ts"; -import type { HandlerContext, HandlerMap } from "./types.ts"; - -export function createParkingLotHandlers(ctx: HandlerContext): HandlerMap { - return { - "parking-lot:scan": async () => { - try { - checkAndPark({ cache: ctx.cache, repoIndex: ctx.repoIndex }); - return { ok: true, data: { lines: [] } }; - } catch (err) { - return { ok: false, error: String(err) }; - } - }, - - "parking-lot:park-this": async (payload: any) => { - const { worktreePath, repoPath, branch, index } = payload ?? {}; - // `branch` is intentionally optional — null/undefined means the worktree - // is detached (a warm-pool entry being claimed onto its slot). - if (!worktreePath || !repoPath || typeof index !== "number") { - return { ok: false, error: "missing payload fields" }; - } - - try { - const result = park(worktreePath, repoPath, branch ?? null, index, { - killProcesses: loadParkingLotConfig().killProcesses, - }); - return { ok: true, data: { result, lines: [] } }; - } catch (err) { - return { ok: false, error: String(err) }; - } - }, - }; -} diff --git a/lib/daemon/handlers/worktree.ts b/lib/daemon/handlers/worktree.ts index 1fac9fa2..e414daf7 100644 --- a/lib/daemon/handlers/worktree.ts +++ b/lib/daemon/handlers/worktree.ts @@ -23,7 +23,8 @@ * nothing here logs request/response. */ -import { realpathSync } from "fs"; +import { realpathSync, rmSync } from "fs"; +import { join } from "path"; import type { HandlerContext, HandlerMap } from "./types.ts"; import { @@ -51,6 +52,7 @@ import { } from "../../worktree/config.ts"; import { changedSince, runReadySteps, stepsToRun } from "../../worktree/ready.ts"; import { freshenRepo, reconcileRepoRegistry } from "../worktree-reconciler.ts"; +import { repoDataDir, rtDir } from "../../rt-paths.ts"; const PROVISION_FETCH_TIMEOUT_MS = 5 * 60_000; @@ -589,6 +591,15 @@ export function createWorktreeHandlers( }); if (result === "busy") return { ok: false, error: "busy" }; + if (result.ok) { + // Adopt supersedes the parking lot: its per-repo index and app-level + // transition state are dead once every tree is registry-tracked. The + // app CONFIG file (~/.rt/parking-lot.json, no "-state" suffix) is left + // alone — loadWorktreeAppConfig still compat-reads it once to seed + // worktrees.json. + rmSync(join(repoDataDir(repoName), "parking-lot.json"), { force: true }); + rmSync(join(rtDir(), "parking-lot-state.json"), { force: true }); + } return result; }, }; diff --git a/lib/daemon/parking-lot.ts b/lib/daemon/parking-lot.ts deleted file mode 100644 index b8b22094..00000000 --- a/lib/daemon/parking-lot.ts +++ /dev/null @@ -1,634 +0,0 @@ -/** - * Parking-lot subsystem for the rt daemon. - * - * When a worktree's branch has an MR that transitions `opened → merged|closed`, - * this module "parks" the worktree: stash any dirty tree, check out the - * worktree's bound `parking-lot/` branch (creating it from origin/master if - * absent), then fast-forward that branch to the remote default branch. - * - * Worktree → parking-lot index mapping is per-repo, 1-based, primary worktree - * first, persisted at `~/.rt/repos//parking-lot.json` so indexes stay - * stable across worktree adds/removes. New worktrees claim the next unused - * positive integer. - * - * Transition detection piggybacks on the cache refresh (same `mr.state` - * signals the notifier uses). We keep our own state file so we only act once - * per MR and never park on a cold-boot `merged` cache entry. - */ - -import { execFileSync, execSync } from "child_process"; -import { existsSync } from "fs"; -import { join } from "path"; - -import { getDaemonLogger } from "../daemon-logger.ts"; -import { RT_DIR } from "../daemon-config.ts"; -import { repoDataDir } from "../rt-paths.ts"; -import { readJson, writeJson } from "../json-store.ts"; -import { - findDesktopStash, - getCurrentBranch, - getRemoteDefaultBranch, - hasUncommittedChanges, - popStash, - stashChanges, -} from "../git-ops.ts"; -import { loadParkingLotConfig } from "../parking-lot-config.ts"; -import { loadSyncConfig, matchRule } from "../sync-config.ts"; -// Namespace import so tests can spy on the kill step. -import * as wtKill from "./worktree-process-kill.ts"; -import type { CacheEntry, RepoIndex } from "./handlers/types.ts"; - -const log = (await getDaemonLogger()).childLogger("parking-lot"); - -// ─── Persistence ───────────────────────────────────────────────────────────── - -const STATE_PATH = join(RT_DIR, "parking-lot-state.json"); - -interface ParkingLotState { - /** Last-seen MR state per branch (keyed exactly like the cache). */ - mrState: Record; - /** Keys we've already parked on, to avoid re-running if cache churns. */ - fired: string[]; -} - -function loadState(): ParkingLotState { - const raw = readJson>(STATE_PATH, {}); - return { - mrState: raw?.mrState ?? {}, - fired: Array.isArray(raw?.fired) ? raw.fired : [], - }; -} - -function saveState(state: ParkingLotState): void { - try { - writeJson(STATE_PATH, state); - } catch (err) { - log.debug({ err }, "failed to persist parking-lot state (best-effort)"); - } -} - -// ─── Worktree → index mapping (per repo) ───────────────────────────────────── - -interface IndexMap { [worktreePath: string]: number; } - -function indexFilePath(repoName: string): string { - return join(repoDataDir(repoName), "parking-lot.json"); -} - -function loadIndexMap(repoName: string): IndexMap { - const raw = readJson<{ indexes?: IndexMap }>(indexFilePath(repoName), {}); - return raw?.indexes ?? {}; -} - -function saveIndexMap(repoName: string, indexes: IndexMap): void { - try { - writeJson(indexFilePath(repoName), { indexes }); - } catch (err) { - log.debug({ err }, "failed to persist parking-lot index map (best-effort)"); - } -} - -/** - * Reconcile the on-disk index map with the current `git worktree list`. - * Primary (listed first by git) gets 1 if unassigned; others claim the next - * unused positive integer in listing order. Existing entries are preserved - * so indexes stay stable if a middle worktree is removed. - */ -function reconcileIndexMap(repoName: string, worktreePaths: string[]): IndexMap { - const map = loadIndexMap(repoName); - - // Drop entries whose worktree no longer exists on disk — keeps the file - // from growing forever, but we still preserve the numbers of live - // worktrees. - for (const p of Object.keys(map)) { - if (!worktreePaths.includes(p) && !existsSync(p)) delete map[p]; - } - - const used = new Set(Object.values(map)); - let mutated = false; - - for (let i = 0; i < worktreePaths.length; i++) { - const path = worktreePaths[i]!; - if (map[path]) continue; - - // Primary worktree (index 0 in git's output) gets 1 by preference. - let claim = i === 0 && !used.has(1) ? 1 : 0; - if (!claim) { - let n = 1; - while (used.has(n)) n++; - claim = n; - } - map[path] = claim; - used.add(claim); - mutated = true; - } - - if (mutated) saveIndexMap(repoName, map); - return map; -} - -// ─── git helpers (local — narrow-purpose, no execSync wrapper lib) ─────────── - -// rt drives git inside target repos but must never fire their hooks (repo -// stealth). A broken husky post-checkout/post-merge hook otherwise makes the -// checkout exit non-zero *after* it already succeeded, surfacing as a spurious -// "checkout-failed" even though the branch switched fine. Disable hooks on every -// mutating command by pointing hooksPath at a nonexistent dir. -const NO_HOOKS = "-c core.hooksPath=/dev/null"; - -interface WorktreeInfo { path: string; branch: string | null; } - -function listWorktrees(repoPath: string): WorktreeInfo[] { - try { - const out = execSync("git worktree list --porcelain", { - cwd: repoPath, encoding: "utf8", stdio: "pipe", - }); - const results: WorktreeInfo[] = []; - let curPath = ""; - let curBranch: string | null = null; - for (const line of out.split("\n")) { - if (line.startsWith("worktree ")) { - if (curPath) results.push({ path: curPath, branch: curBranch }); - curPath = line.slice("worktree ".length).trim(); - curBranch = null; - } else if (line.startsWith("branch ")) { - curBranch = line.slice("branch refs/heads/".length).trim(); - } else if (line.startsWith("detached")) { - curBranch = null; - } - } - if (curPath) results.push({ path: curPath, branch: curBranch }); - return results; - } catch (err) { - log.debug({ err }, "git worktree list failed"); - return []; - } -} - -function branchExistsLocal(cwd: string, branch: string): boolean { - try { - execSync(`git rev-parse --verify "refs/heads/${branch}"`, { - cwd, stdio: "pipe", - }); - return true; - } catch { - return false; - } -} - -function branchCheckedOutElsewhere(repoPath: string, branch: string, selfPath: string): string | null { - for (const wt of listWorktrees(repoPath)) { - if (wt.path === selfPath) continue; - if (wt.branch === branch) return wt.path; - } - return null; -} - -// ─── Parking action ────────────────────────────────────────────────────────── - -export interface ParkResult { - ok: boolean; - action: string; - detail?: string; -} - -export interface ParkOptions { - /** Kill workload processes rooted in the worktree before parking. - * Callers pass loadParkingLotConfig().killProcesses. */ - killProcesses?: boolean; -} - -export function park( - worktreePath: string, - repoPath: string, - sourceBranch: string | null, - index: number, - opts: ParkOptions = {}, -): ParkResult { - const parkBranch = `parking-lot/${index}`; - - // 1. Confirm the worktree is still in the state we expect. `sourceBranch` is - // null when parking a detached worktree (e.g. a herdr warm-pool entry) — - // in that case we expect it to still be detached. Either way, if the user - // has switched away, bail rather than clobber their current state. - const current = getCurrentBranch(worktreePath); - if (current !== sourceBranch) { - const detail = sourceBranch - ? `worktree is on "${current}", not "${sourceBranch}"` - : `worktree is no longer detached (on "${current}")`; - return { ok: false, action: "skip", detail }; - } - - // 2. Refuse to touch the parking-lot branch if another worktree already - // has it checked out — git would reject the checkout, but erroring out - // cleanly gives a better log. - const holder = branchCheckedOutElsewhere(repoPath, parkBranch, worktreePath); - if (holder) { - return { ok: false, action: "skip", detail: `${parkBranch} is checked out at ${holder}` }; - } - - // 2.5. The feature is done — stop its workload (dev servers, watchers) - // before touching the tree. Failure here never blocks the park. - if (opts.killProcesses) { - try { - wtKill.killWorktreeProcesses(worktreePath); - } catch (err) { - log.warn({ err, worktreePath }, "worktree process kill failed; parking anyway"); - } - } - - // 3. Stash if dirty, using the GitHub Desktop-compatible marker so the - // existing rt / GitHub Desktop flows can find it later. A detached - // worktree has no source branch to key the stash to, so fall back to the - // parking-lot slot — that's where the worktree is headed and keeps the - // stash recoverable (rather than labeling it ""). - const stashLabel = sourceBranch ?? parkBranch; - try { - if (hasUncommittedChanges(worktreePath)) { - stashChanges(worktreePath, stashLabel); - log.info({ stashLabel }, `stashed uncommitted changes on "${stashLabel}"`); - } - } catch (err) { - return { ok: false, action: "stash-failed", detail: String(err) }; - } - - // 4. Fetch the default branch so the fast-forward can actually advance. - const defaultRef = getRemoteDefaultBranch(worktreePath) ?? "origin/master"; - const defaultBranch = defaultRef.replace(/^origin\//, ""); - try { - execSync(`git fetch origin "${defaultBranch}"`, { cwd: worktreePath, stdio: "pipe" }); - } catch (err) { - return { ok: false, action: "fetch-failed", detail: String(err) }; - } - - // 5. Check out parking-lot/N, creating it off the default branch if missing. - try { - if (branchExistsLocal(worktreePath, parkBranch)) { - execSync(`git ${NO_HOOKS} checkout "${parkBranch}"`, { cwd: worktreePath, stdio: "pipe" }); - } else { - execSync(`git ${NO_HOOKS} checkout -b "${parkBranch}" "${defaultRef}"`, { cwd: worktreePath, stdio: "pipe" }); - log.info({ parkBranch, defaultRef }, `created ${parkBranch} from ${defaultRef}`); - } - } catch (err) { - return { ok: false, action: "checkout-failed", detail: String(err) }; - } - - // 6. Fast-forward. If parking-lot/N was just created off defaultRef this is - // a no-op; if it existed already we advance it. - try { - execSync(`git ${NO_HOOKS} merge --ff-only "${defaultRef}"`, { cwd: worktreePath, stdio: "pipe" }); - } catch (err) { - return { ok: false, action: "ff-failed", detail: String(err) }; - } - - return { ok: true, action: "parked", detail: `${sourceBranch ?? "(detached)"} → ${parkBranch} @ ${defaultRef}` }; -} - -/** - * Whether a worktree binding can be manually parked onto its slot. - * - * Parkable = it has an allocated slot index and isn't already sitting on that - * `parking-lot/` branch. Both feature-branch worktrees and detached - * worktrees (branch === null, e.g. herdr warm-pool entries) qualify — parking a - * detached worktree claims it onto a clean slot branch off origin/master. - * - * Note this is the manual-park predicate. Auto-park (checkAndPark) is - * deliberately narrower: it only fires on MR open→terminal transitions, which - * detached worktrees never have, so they are never auto-parked. - */ -export function isParkable(binding: WorktreeBinding): boolean { - if (!binding.index) return false; - if (binding.branch === `parking-lot/${binding.index}`) return false; - return true; -} - -// ─── Transition detection (called after each cache refresh) ────────────────── - -export interface ParkingEnv { - cache: { entries: Record }; - repoIndex: () => RepoIndex; -} - -const TERMINAL_STATES = new Set(["merged", "closed"]); - -// ─── Fast-forward already-parked worktrees ──────────────────────────────────── - -function isParkedBranch(branch: string): boolean { - return /^parking-lot\/\d+$/.test(branch); -} - -/** One entry from `git status --porcelain`. */ -interface DirtyEntry { status: string; path: string; } - -function listDirtyEntries(cwd: string): DirtyEntry[] { - let out: string; - try { - out = execSync("git status --porcelain", { cwd, encoding: "utf8", stdio: "pipe" }); - } catch (err) { - log.warn({ err, cwd }, "git status failed during ff-sweep"); - return []; - } - - const entries: DirtyEntry[] = []; - for (const line of out.split("\n")) { - if (line.length < 4) continue; - const status = line.slice(0, 2); - let path = line.slice(3); - // Rename/copy entries read "old -> new"; the destination is what's dirty. - const arrow = path.indexOf(" -> "); - if (arrow !== -1) path = path.slice(arrow + 4); - // git quotes paths containing specials (C-style escapes). - if (path.startsWith('"') && path.endsWith('"')) { - try { path = JSON.parse(path) as string; } catch { path = path.slice(1, -1); } - } - entries.push({ status, path }); - } - return entries; -} - -/** - * Whether a tracked file's local change is pure whitespace relative to HEAD. - * Compared against HEAD (not the index) so a staged whitespace-only edit counts. - */ -function isWhitespaceOnlyChange(cwd: string, path: string): boolean { - try { - execFileSync("git", ["diff", "HEAD", "--ignore-all-space", "--exit-code", "--", path], { - cwd, stdio: "pipe", - }); - return true; // exit 0 → nothing left once whitespace is ignored - } catch { - return false; - } -} - -/** - * Decide what to do with each dirty path before a fast-forward. - * - * `discard` covers tracked modifications to files the repo's sync.json - * declares auto-resolvable with `strategy: "theirs"`, whose local diff is pure - * whitespace. These are generated artifacts that drift by a trailing newline - * and are rebuilt by the next build; the declaration says upstream wins. - * - * Everything else is stashed and restored, never discarded... a background - * sweep must not destroy content it didn't generate. That deliberately - * includes substantive changes to declared files: the declaration is about - * regenerable drift, not about giving the daemon licence to delete real edits. - */ -function classifyDirtyForFastForward( - worktreePath: string, - repoName: string, - entries: DirtyEntry[], -): { discard: string[]; stashRest: boolean } { - const rules = loadSyncConfig(repoDataDir(repoName)).autoResolve; - const discard: string[] = []; - let stashRest = false; - - for (const e of entries) { - const untracked = e.status === "??"; - const modified = !untracked && e.status.includes("M"); - if ( - modified && - matchRule(e.path, rules)?.strategy === "theirs" && - isWhitespaceOnlyChange(worktreePath, e.path) - ) { - discard.push(e.path); - } else { - stashRest = true; - } - } - - return { discard, stashRest }; -} - -export function fastForwardParkedWorktrees( - repoName: string, - repoPath: string, - worktrees: WorktreeInfo[], -): void { - const parked = worktrees.filter(w => w.branch && isParkedBranch(w.branch)); - if (parked.length === 0) return; - - const defaultRef = getRemoteDefaultBranch(repoPath) ?? "origin/master"; - const defaultBranch = defaultRef.replace(/^origin\//, ""); - - try { - execSync(`git fetch origin "${defaultBranch}"`, { cwd: repoPath, stdio: "pipe" }); - } catch (err) { - log.debug({ err, repoPath }, "fetch failed during ff-sweep, skipping"); - return; - } - - for (const wt of parked) { - const entries = listDirtyEntries(wt.path); - let discarded: string[] = []; - let stashName: string | null = null; - - if (entries.length > 0) { - const { discard, stashRest } = classifyDirtyForFastForward(wt.path, repoName, entries); - - // Reset declared generated drift to HEAD (covers staged + unstaged). - for (const path of discard) { - try { - execFileSync("git", ["checkout", "HEAD", "--", path], { cwd: wt.path, stdio: "pipe" }); - discarded.push(path); - } catch (err) { - log.warn({ err, worktree: wt.path, path }, "failed to reset declared generated file; will stash instead"); - } - } - - // Anything left gets stashed under the slot's Desktop-compatible marker - // so it is recoverable by hand even if the restore below fails. - if (stashRest || hasUncommittedChanges(wt.path)) { - try { - stashChanges(wt.path, wt.branch!); - stashName = findDesktopStash(wt.path, wt.branch!)?.name ?? "stash@{0}"; - } catch (err) { - log.warn({ err, branch: wt.branch, worktree: wt.path }, `ff-sweep: could not stash ${wt.branch}, leaving it behind`); - continue; - } - } - } - - let advanced = false; - try { - execSync(`git ${NO_HOOKS} merge --ff-only "${defaultRef}"`, { cwd: wt.path, stdio: "pipe" }); - advanced = true; - } catch (err) { - // Branch has diverged or is already up to date... expected, not a failure. - log.debug({ err, branch: wt.branch, worktree: wt.path }, "ff-only skipped (diverged or already up to date)"); - } - - // Always attempt the restore, including when the ff was a no-op: we took - // the user's changes, so we owe them back regardless of the merge outcome. - if (stashName) { - try { - popStash(wt.path, stashName); - } catch (err) { - log.warn( - { err, branch: wt.branch, worktree: wt.path, stashName }, - `ff-sweep: stash ${stashName} did not reapply cleanly in ${wt.path}... it is preserved, restore it with: git stash pop ${stashName}`, - ); - continue; - } - } - - if (advanced) { - log.info( - { branch: wt.branch, worktree: wt.path, defaultRef, discarded, stashed: Boolean(stashName) }, - `fast-forwarded ${wt.branch} → ${defaultRef}` - + (discarded.length ? ` (reset ${discarded.length} generated file${discarded.length > 1 ? "s" : ""})` : "") - + (stashName ? " (stashed and restored local changes)" : ""), - ); - } - } -} - -export function checkAndPark(env: ParkingEnv): void { - const config = loadParkingLotConfig(); - if (!config.enabled) return; - - const state = loadState(); - const fired = new Set(state.fired); - const nextMrState: Record = {}; - - const repoIndex = env.repoIndex(); - - // Build a quick lookup of (repoPath → worktree-path → branch) from git. - // We do this lazily, only for repos that actually have a live cache entry, - // so we don't shell out to every repo on every tick. - const worktreeByRepo = new Map(); - const indexMapByRepo = new Map(); - - for (const [branch, entry] of Object.entries(env.cache.entries)) { - const mrState = entry.mr?.state ?? null; - nextMrState[branch] = mrState; - - if (!entry.repoName) continue; - const repoPath = repoIndex[entry.repoName]; - if (!repoPath || !existsSync(repoPath)) continue; - - const prev = state.mrState[branch] ?? null; - if (prev !== "opened") continue; - if (!mrState || !TERMINAL_STATES.has(mrState)) continue; - - const fireKey = `parked:${entry.repoName}:${branch}:${mrState}`; - if (fired.has(fireKey)) continue; - - // Lazily discover worktrees + indexes for this repo. - if (!worktreeByRepo.has(repoPath)) { - const worktrees = listWorktrees(repoPath); - worktreeByRepo.set(repoPath, worktrees); - indexMapByRepo.set(entry.repoName, reconcileIndexMap(entry.repoName, worktrees.map(w => w.path))); - } - - const worktrees = worktreeByRepo.get(repoPath)!; - const indexes = indexMapByRepo.get(entry.repoName)!; - - // Find the worktree currently (or most recently per git) bound to this branch. - const wt = worktrees.find(w => w.branch === branch); - if (!wt) { - log.info({ repoName: entry.repoName, branch, mrState }, `${entry.repoName}/${branch} ${mrState} — no matching worktree, skipping`); - fired.add(fireKey); // don't re-check forever - continue; - } - - const idx = indexes[wt.path]; - if (!idx) { - log.info({ repoName: entry.repoName, branch, mrState, worktree: wt.path }, `${entry.repoName}/${branch} ${mrState} — no index for ${wt.path}, skipping`); - fired.add(fireKey); - continue; - } - - log.info({ repoName: entry.repoName, branch, mrState, worktree: wt.path, idx }, `${entry.repoName}/${branch} ${mrState} → parking at ${wt.path} (space ${idx})`); - const result = park(wt.path, repoPath, branch, idx, { killProcesses: config.killProcesses }); - if (result.ok) { - log.info({ result }, `parked: ${result.detail}`); - fired.add(fireKey); - } else { - log.warn({ result }, `park failed: ${result.action}${result.detail ? ` — ${result.detail}` : ""}`); - // Don't mark fired on failure — we'll retry next tick. - } - } - - // Persist fresh MR state snapshot so the next tick has something to compare - // against. Absent branches (stale cache entries removed) are dropped. - saveState({ mrState: nextMrState, fired: [...fired] }); - - // Fast-forward any worktree already sitting on a parking-lot branch. - // We do this for every known repo regardless of whether a park transition - // fired this tick — these branches substitute for master and must stay current. - for (const [repoName, repoPath] of Object.entries(repoIndex)) { - if (!existsSync(repoPath)) continue; - const worktrees = worktreeByRepo.get(repoPath) ?? listWorktrees(repoPath); - try { - fastForwardParkedWorktrees(repoName, repoPath, worktrees); - } catch (err) { - log.warn({ err, repoName }, `ff-sweep failed for ${repoName}`); - } - } -} - -// ─── CLI introspection ─────────────────────────────────────────────────────── - -export interface WorktreeBinding { - path: string; - branch: string | null; - index: number; - /** - * Commits the worktree's `parking-lot/` branch trails the local - * `origin/master` ref by, or null when the slot branch doesn't exist yet or - * staleness wasn't requested. Reads the already-fetched remote ref rather - * than fetching, so it costs one rev-list per slot and no network. - */ - slotBehind?: number | null; -} - -/** How far `ref` trails `defaultRef`, or null if either ref is unresolvable. */ -function countBehind(repoPath: string, ref: string, defaultRef: string): number | null { - try { - const out = execFileSync("git", ["rev-list", "--count", `${ref}..${defaultRef}`], { - cwd: repoPath, encoding: "utf8", stdio: "pipe", - }); - const n = parseInt(out.trim(), 10); - return Number.isNaN(n) ? null : n; - } catch { - return null; - } -} - -/** - * Current worktree → parking-lot-index bindings for a single repo, reconciling - * against `git worktree list` on the fly. Used by `rt parking-lot status`. - * - * `withStaleness` adds a rev-list per slot; the park pickers skip it so their - * hot path stays a single `git worktree list`. - */ -export function describeRepoBindings( - repoName: string, - repoPath: string, - opts: { withStaleness?: boolean } = {}, -): WorktreeBinding[] { - const worktrees = listWorktrees(repoPath); - const indexes = reconcileIndexMap(repoName, worktrees.map(w => w.path)); - const defaultRef = opts.withStaleness - ? (getRemoteDefaultBranch(repoPath) ?? "origin/master") - : null; - - return worktrees.map(w => { - const index = indexes[w.path] ?? 0; - const binding: WorktreeBinding = { path: w.path, branch: w.branch, index }; - if (defaultRef && index) { - binding.slotBehind = countBehind(repoPath, `parking-lot/${index}`, defaultRef); - } - return binding; - }); -} - -// ─── Exposed for tests ─────────────────────────────────────────────────────── - -export const __test__ = { - reconcileIndexMap, - loadIndexMap, - saveIndexMap, - STATE_PATH, -}; diff --git a/lib/module-registry.ts b/lib/module-registry.ts index 318e08ad..1a405894 100644 --- a/lib/module-registry.ts +++ b/lib/module-registry.ts @@ -34,7 +34,6 @@ import * as verify from "../commands/verify.ts"; import * as update from "../commands/update.ts"; import * as doppler from "../commands/doppler.ts"; import * as nav from "../commands/nav.ts"; -import * as parkingLot from "../commands/parking-lot.ts"; import * as sdm from "../commands/sdm.ts"; import * as plugin from "../commands/plugin.ts"; import * as worktree from "../commands/worktree.ts"; @@ -68,7 +67,6 @@ export const MODULE_REGISTRY: Record = { "./commands/update.ts": update, "./commands/doppler.ts": doppler, "./commands/nav.ts": nav, - "./commands/parking-lot.ts": parkingLot, "./commands/sdm.ts": sdm, "./commands/plugin.ts": plugin, "./commands/worktree.ts": worktree, diff --git a/lib/parking-lot-config.ts b/lib/parking-lot-config.ts deleted file mode 100644 index b216d466..00000000 --- a/lib/parking-lot-config.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Parking-lot user config — whether the auto-park scan runs on each cache - * refresh. Separate from `parking-lot-state.json`, which tracks transition - * dedup internally. - * - * Defaults to enabled so behavior matches the shipped feature; `rt parking-lot - * disable` is the escape hatch. - */ - -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; -import { join } from "path"; -import { RT_DIR } from "./daemon-config.ts"; - -export const PARKING_LOT_CONFIG_PATH = join(RT_DIR, "parking-lot.json"); - -export interface ParkingLotConfig { - enabled: boolean; - /** Kill workload processes (dev servers, watchers) in a worktree when it parks. */ - killProcesses: boolean; -} - -export function loadParkingLotConfig(): ParkingLotConfig { - try { - const raw = JSON.parse(readFileSync(PARKING_LOT_CONFIG_PATH, "utf8")); - return { - enabled: raw?.enabled !== false, - killProcesses: raw?.killProcesses !== false, - }; - } catch { - return { enabled: true, killProcesses: true }; - } -} - -export function saveParkingLotConfig(config: ParkingLotConfig): void { - try { - mkdirSync(RT_DIR, { recursive: true }); - writeFileSync(PARKING_LOT_CONFIG_PATH, JSON.stringify(config, null, 2)); - } catch { /* best-effort */ } -} From e144347c6f6e91fcfbdb2274d7a2b4c08f433f70 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 20:19:37 -0500 Subject: [PATCH 27/31] RT-34: fix e2e picker-identity regression from the rt worktree shell-wrapper marker fix-round-1 (1ff927a) added a fourth required substring ('"$1" = "worktree"') to ensureShellFunction()'s "already up to date" check in commands/cd.ts, so a generated wrapper is only recognized as current once it has the new bare `rt worktree` cd-jump branch. e2e/fixtures.ts's ensureShellWrapper() writes a hand-maintained, condensed stand-in for that same generated function (used by picker-identity.test.ts to simulate "user already has rt cd installed") -- it never picked up the new branch, so after the marker change it read as stale. Every `rt cd` invocation in those two tests hit the interactive "Upgrade rt shell wrapper?" confirm prompt (worktreePicker() calls ensureShellFunction() first thing), which the tests' PTY driver never expected -- hence the ctrl-up mistiming and the wait_for_text("Pick a repo") timeout. Root-caused by reproducing directly (`rm -f dist/rt && bun run test:e2e`), noting only the two ensureShellWrapper()-calling tests in picker-identity.test.ts failed while the third test in the same file (which never runs `rt cd`) passed, and tracing worktreePicker()'s unconditional `await ensureShellFunction()` call against the marker diff in 1ff927a. Fix: add the same worktree elif branch to the fixture's condensed wrapper so it satisfies the updated marker and no longer looks stale. No test assertions changed. Co-Authored-By: Claude Fable 5 --- e2e/fixtures.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 23208ba2..a7108830 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -145,6 +145,8 @@ export function ensureShellWrapper(home: string): void { ' local dir; dir="$("$rt_bin" cd "${@:2}")" && [ -n "$dir" ] && builtin cd "$dir"', ' elif [ "$1" = "nav" ]; then', ' local dir; dir="$("$rt_bin" nav "${@:2}")" && [ -n "$dir" ] && builtin cd "$dir"', + ' elif [ "$1" = "worktree" ] && [ -z "$2" ]; then', + ' local dir; dir="$("$rt_bin" worktree)" && [ -n "$dir" ] && builtin cd "$dir"', " else", ' "$rt_bin" "$@"', " fi", From ab524895f1a17afe0ac76e40e461859e3da57b9c Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 20:44:02 -0500 Subject: [PATCH 28/31] =?UTF-8?q?RT-34:=20final-review=20fixes=20=E2=80=94?= =?UTF-8?q?=20cross-pass=20create=20backoff,=20merged-MR=20sha=20anchor=20?= =?UTF-8?q?for=20manual=20dispose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings from the whole-branch review: - Replenish now carries a per-repo create backoff (spec §6.4). A failed createTree scraps its own registry row, so nothing on disk survived to hold retry bookkeeping and a persistently failing ready step burned up to onDeck multi-minute builds on every cache tick, forever. The in-memory map (same lifetime as the in-flight creation map) records failures + nextRetryAt, doubling from one 5-minute pass and capping at 30 minutes; it is checked per iteration, so the first failure also ends that pass's replenish. - Dispose guard 3 picks the MR sha anchor on the MR's state instead of on `auto`. Manually disposing a squash-merged tree whose source branch was deleted returned "unpushed" and pushed the user toward --force, which also strips the dirty guard. Open/other states and sha-absent MRs keep the remote anchor. - create-failed now carries the step output: truncated tail in the daemon warn line, last 10 lines in the handler's error detail (matching the checkout-failed: convention). - `git worktree remove --force` runs with an explicit 5-minute timeout in both dispose and scrapTree; removing a pnpm-scale node_modules outruns the 60s default on APFS. Co-Authored-By: Claude Fable 5 --- .../__tests__/worktree-reconciler.test.ts | 69 +++++++++++++- lib/daemon/handlers/worktree.ts | 24 ++++- lib/daemon/worktree-reconciler.ts | 95 ++++++++++++++++--- lib/worktree/__tests__/dispose.test.ts | 51 ++++++++-- lib/worktree/create.ts | 24 ++++- lib/worktree/dispose.ts | 23 ++++- 6 files changed, 256 insertions(+), 30 deletions(-) diff --git a/lib/daemon/__tests__/worktree-reconciler.test.ts b/lib/daemon/__tests__/worktree-reconciler.test.ts index e074334d..1e94f5d0 100644 --- a/lib/daemon/__tests__/worktree-reconciler.test.ts +++ b/lib/daemon/__tests__/worktree-reconciler.test.ts @@ -57,6 +57,7 @@ describe("reconcileRepoRegistry", () => { beforeEach(() => { process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtrecon-home-"))); + __test__.createBackoff.clear(); repo = makeRepo(); repoName = "acme"; events = []; @@ -164,6 +165,7 @@ describe("createWorktreeReconciler", () => { beforeEach(() => { process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtrecon-home-"))); + __test__.createBackoff.clear(); repo = makeRepo(); repoName = "acme"; }); @@ -279,6 +281,7 @@ describe("merge reactor (detectTransitions)", () => { beforeEach(() => { process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtreact-home-"))); + __test__.createBackoff.clear(); repo = makeRepo(); addBareOrigin(repo); // killProcesses off: the reactor must not go scanning this machine's @@ -651,6 +654,7 @@ describe("freshen", () => { beforeEach(() => { process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtfreshen-home-"))); + __test__.createBackoff.clear(); repo = makeRepo(); addBareOrigin(repo); writeJson(join(rtDir(), "worktrees.json"), { enabled: true, killProcesses: false }); @@ -849,6 +853,7 @@ describe("replenish / shrink", () => { beforeEach(() => { process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtpool-home-"))); + __test__.createBackoff.clear(); repo = makeRepo(); addBareOrigin(repo); }); @@ -889,10 +894,67 @@ describe("replenish / shrink", () => { ); const trees = loadRegistry(repoName).filter((t) => t.kind === "ephemeral"); - expect(trees.length).toBe(0); // every attempt failed and self-scrapped + expect(trees.length).toBe(0); // the attempt failed and self-scrapped - // Bounded to the cap: exactly onDeck attempts, never more (no runaway loop). - expect(warns.filter((w) => w.includes("replenish create failed")).length).toBe(2); + // Bounded twice over: never more than onDeck attempts, and the first + // failure's backoff ends the pass before the second is even tried. + expect(warns.filter((w) => w.includes("replenish create failed")).length).toBe(1); + }); + + test("a failed create backs off, and the next pass skips replenish for that repo", async () => { + writeJson(join(repoDataDir(repoName), "config.json"), { + worktrees: { onDeck: 2, root: join(repo, ".worktrees"), ready: [{ run: "exit 1" }] }, + }); + + const warns: string[] = []; + const log = { + info: () => {}, + error: () => {}, + debug: () => {}, + warn: (_fields: unknown, msg?: string) => warns.push(msg ?? ""), + } as unknown as Logger; + const deps = { repoName, repoPath: repo, emit: () => {}, log }; + + await __test__.replenishAndShrink(deps, new Map(), fakeAppConfig()); + expect(warns.filter((w) => w.includes("replenish create failed")).length).toBe(1); + + const backoff = __test__.createBackoff.get(repoName); + expect(backoff?.failures).toBe(1); + expect(Date.parse(backoff!.nextRetryAt)).toBeGreaterThan(Date.now()); + + // The very next pass (the next cache tick, seconds later) must not burn + // another multi-minute build on the same broken step. + await __test__.replenishAndShrink(deps, new Map(), fakeAppConfig()); + expect(warns.filter((w) => w.includes("replenish create failed")).length).toBe(1); + expect(__test__.createBackoff.get(repoName)?.failures).toBe(1); + }); + + test("a successful create clears the backoff", async () => { + writeJson(join(repoDataDir(repoName), "config.json"), { + worktrees: { onDeck: 1, root: join(repo, ".worktrees") }, + }); + // An expired backoff from earlier failures: the pass runs, and success wipes it. + __test__.createBackoff.set(repoName, { + failures: 3, + nextRetryAt: new Date(Date.now() - 1_000).toISOString(), + }); + + await __test__.replenishAndShrink( + { repoName, repoPath: repo, emit: () => {}, log: fakeLog() }, + new Map(), + fakeAppConfig(), + ); + + expect(loadRegistry(repoName).filter((t) => t.state === "on-deck").length).toBe(1); + expect(__test__.createBackoff.has(repoName)).toBe(false); + }); + + test("backoff doubles one pass at a time and caps at 30 minutes", () => { + expect(__test__.backoffDelayMs(1)).toBe(5 * 60_000); + expect(__test__.backoffDelayMs(2)).toBe(10 * 60_000); + expect(__test__.backoffDelayMs(3)).toBe(20 * 60_000); + expect(__test__.backoffDelayMs(4)).toBe(30 * 60_000); // 40 min, capped + expect(__test__.backoffDelayMs(12)).toBe(30 * 60_000); }); test("lowering onDeck disposes the stalest ready entry", async () => { @@ -947,6 +1009,7 @@ async function waitFor(predicate: () => boolean, timeoutMs: number): Promise { test("kick() returns synchronously, coalesces a second kick during the pass, and creationInFlight tracks it", async () => { process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtkick-home-"))); + __test__.createBackoff.clear(); const repoName = "acme"; const repo = makeRepo(); addBareOrigin(repo); diff --git a/lib/daemon/handlers/worktree.ts b/lib/daemon/handlers/worktree.ts index e414daf7..20867706 100644 --- a/lib/daemon/handlers/worktree.ts +++ b/lib/daemon/handlers/worktree.ts @@ -61,6 +61,9 @@ const NO_REMOTE_REF_RE = /couldn't find remote ref/i; const PARKING_LOT_BRANCH_RE = /^parking-lot\/\d+$/; +/** Lines of failed-step output carried into a `create-failed:` refusal. */ +const CREATE_FAILED_TAIL_LINES = 10; + export type BranchState = "new" | "tracking-remote" | "existing-clean" | "diverged" | "behind"; export interface WorktreeHandlerOpts { @@ -91,6 +94,23 @@ function patchTree(repoName: string, path: string, patch: (rec: TreeRecord) => v saveRegistry(repoName, trees); } +/** + * `create-failed:` carrying the tail of that step's output, same shape as + * `checkout-failed:`. The step name alone tells the caller which install + * died but nothing about why, and the output is not otherwise reachable from the + * CLI. + */ +function createFailedError(created: { failedStep?: string; output?: string }): string { + const step = created.failedStep ?? "unknown"; + const tail = (created.output ?? "") + .trim() + .split("\n") + .slice(-CREATE_FAILED_TAIL_LINES) + .join("\n") + .trim(); + return tail.length > 0 ? `create-failed:${step}\n${tail}` : `create-failed:${step}`; +} + /** Every local branch name in the repo, for the sync `exists()` disambiguation predicate. */ async function localBranchNames(repoPath: string): Promise> { const r = await runGit(repoPath, ["for-each-ref", "--format=%(refname:short)", "refs/heads"]); @@ -262,7 +282,7 @@ export function createWorktreeHandlers( }); if (!created.ok) { if (created.error === "busy") return { ok: false, error: "busy" }; - return { ok: false, error: `create-failed:${created.failedStep ?? "unknown"}` }; + return { ok: false, error: createFailedError(created) }; } rec = created.tree; wasOnDeck = false; @@ -411,7 +431,7 @@ export function createWorktreeHandlers( const created = await createTree({ repoName, repoPath, emit: opts.emit, log: ctx.log }); if (!created.ok) { if (created.error === "busy") return { ok: false, error: "busy" }; - return { ok: false, error: `create-failed:${created.failedStep ?? "unknown"}` }; + return { ok: false, error: createFailedError(created) }; } // Default is a tree for the caller to use; `--on-deck` puts it in the diff --git a/lib/daemon/worktree-reconciler.ts b/lib/daemon/worktree-reconciler.ts index caa78940..15424df1 100644 --- a/lib/daemon/worktree-reconciler.ts +++ b/lib/daemon/worktree-reconciler.ts @@ -552,6 +552,15 @@ const FRESHEN_FETCH_TIMEOUT_MS = 5 * 60_000; const FRESHEN_PASS_MS = 5 * 60_000; const FRESHEN_MAX_BACKOFF_MS = 30 * 60_000; +/** + * Delay after the Nth consecutive failure: one pass, doubled N-1 times, capped. + * Shared by the freshen retry stamp and the per-repo create backoff (spec §6.4) + * — both count in passes and both cap at 30 minutes. + */ +function backoffDelayMs(failures: number): number { + return Math.min(FRESHEN_PASS_MS * 2 ** (failures - 1), FRESHEN_MAX_BACKOFF_MS); +} + export interface FreshenDeps { repoName: string; repoPath: string; @@ -599,7 +608,7 @@ async function freshenOne(deps: FreshenDeps, rec: TreeRecord): Promise const fail = (): void => { const failures = (rec.retryFailures ?? 0) + 1; - const backoffMs = Math.min(FRESHEN_PASS_MS * 2 ** (failures - 1), FRESHEN_MAX_BACKOFF_MS); + const backoffMs = backoffDelayMs(failures); patchTree(repoName, rec.path, (r) => { r.retryFailures = failures; r.nextRetryAt = new Date(Date.now() + backoffMs).toISOString(); @@ -727,6 +736,36 @@ export async function freshenRepo( // ─── Replenish / shrink (spec §6.4) ────────────────────────────────────────── +/** + * Per-repo create backoff (spec §6.4): failure N waits one pass doubled N-1 + * times, capped at 30 minutes. + * + * A failed `createTree` scraps its own registry row, so there is no on-disk row + * left to hang retry bookkeeping off — without this, a persistently failing + * ready step (a broken install costing minutes per attempt) is retried on every + * cache tick, forever. The state is deliberately in-memory, same as the + * in-flight creation map above it: a daemon restart clearing the backoff costs + * one wasted attempt, which is cheaper than persisting a transient. + */ +const createBackoff = new Map(); + +/** The active backoff deadline for a repo, or null when creates may run now. */ +function createBlockedUntil(repoName: string): string | null { + const entry = createBackoff.get(repoName); + if (!entry) return null; + return Date.parse(entry.nextRetryAt) > Date.now() ? entry.nextRetryAt : null; +} + +function noteCreateFailure(repoName: string): { failures: number; nextRetryAt: string } { + const failures = (createBackoff.get(repoName)?.failures ?? 0) + 1; + const entry = { + failures, + nextRetryAt: new Date(Date.now() + backoffDelayMs(failures)).toISOString(), + }; + createBackoff.set(repoName, entry); + return entry; +} + /** On-deck / creating counts used to decide whether to grow or shrink the pool. */ function poolCounts(repoName: string): { ready: number; @@ -746,14 +785,14 @@ function poolCounts(repoName: string): { * flight at a time, which `runOnce` awaiting each pass makes natural), then * shrink it back down by disposing the stalest ready entries when it's over. * - * Replenish is bounded to the deficit measured once at the start of the pass, - * not re-derived from live state on every iteration: `createTree` scraps its - * own registry row on failure (no trace, no retry bookkeeping), so an + * Replenish is bounded on both axes. Within a pass it is bounded to the deficit + * measured once at the start, not re-derived from live state on every + * iteration: `createTree` scraps its own registry row on failure, so an * always-failing config would otherwise re-read "still short" forever and spin - * this pass indefinitely. Bounding to the initial deficit caps attempts at - * `onDeck` per pass either way — every attempt succeeds and fills a slot, or - * fails and wastes one of the budgeted attempts — and lets the next pass pick - * up any remaining shortfall. + * this pass indefinitely. Across passes it is bounded by `createBackoff` — the + * first failure of a pass ends replenish for that repo and holds it off for the + * doubling backoff window, so a broken ready step costs one multi-minute + * attempt per window instead of `onDeck` attempts per cache tick. */ async function replenishAndShrink( deps: FreshenDeps, @@ -767,15 +806,39 @@ async function replenishAndShrink( let { ready, totalUnclaimed } = poolCounts(repoName); let budget = Math.max(0, cfg.onDeck - totalUnclaimed); while (budget > 0 && ready < cfg.onDeck && totalUnclaimed < cfg.onDeck) { + // Checked per iteration, not once per pass: a failure recorded by the + // attempt above must end this pass's replenish too, or the pass still + // burns `onDeck` full builds on the same broken step. + const blockedUntil = createBlockedUntil(repoName); + if (blockedUntil) { + log.debug?.( + { repo: repoName, nextRetryAt: blockedUntil }, + "replenish: skipped — create backoff in effect", + ); + break; + } budget--; const p: Promise = createTree({ repoName, repoPath, emit, log }) .then((result) => { - if (!result.ok) { - log.warn({ repo: repoName, error: result.error }, "worktree reconciler: replenish create failed"); + if (result.ok) { + createBackoff.delete(repoName); + return; } + // "busy" is another holder of the tree lock, not a failing build: it + // neither earns a backoff nor clears one. + if (result.error === "busy") return; + const { failures, nextRetryAt } = noteCreateFailure(repoName); + log.warn( + { repo: repoName, error: result.error, failedStep: result.failedStep, failures, nextRetryAt }, + "worktree reconciler: replenish create failed", + ); }) .catch((err) => { - log.warn({ err, repo: repoName }, "worktree reconciler: replenish create threw"); + const { failures, nextRetryAt } = noteCreateFailure(repoName); + log.warn( + { err, repo: repoName, failures, nextRetryAt }, + "worktree reconciler: replenish create threw", + ); }) .finally(() => { if (creationPromises.get(repoName) === p) creationPromises.delete(repoName); @@ -921,4 +984,12 @@ export function createWorktreeReconciler(deps: ReconcilerDeps): { return { kick, runOnce, creationInFlight, passInFlight }; } -export const __test__ = { detectTransitions, reactorStatePath, freshenRepo, replenishAndShrink, poolCounts }; +export const __test__ = { + detectTransitions, + reactorStatePath, + freshenRepo, + replenishAndShrink, + poolCounts, + backoffDelayMs, + createBackoff, +}; diff --git a/lib/worktree/__tests__/dispose.test.ts b/lib/worktree/__tests__/dispose.test.ts index 6079fa49..00330ced 100644 --- a/lib/worktree/__tests__/dispose.test.ts +++ b/lib/worktree/__tests__/dispose.test.ts @@ -6,7 +6,7 @@ import { join } from "path"; import { repoDataDir } from "../../rt-paths.ts"; import { saveSyncConfig } from "../../sync-config.ts"; import { loadRegistry, saveRegistry, type TreeRecord } from "../registry.ts"; -import { branchExistsLocalAsync, listWorktreesAsync } from "../git-async.ts"; +import { branchExistsLocalAsync, listWorktreesAsync, remoteRefExists } from "../git-async.ts"; import { hasFreshAttendantLease } from "../lease.ts"; import { classifyDirtyAsync, @@ -363,7 +363,7 @@ describe("disposeTree", () => { })); const deps = makeDeps({ - cacheEntries: { "feature-a": { mr: { iid: 42, sha }, repoName } }, + cacheEntries: { "feature-a": { mr: { iid: 42, sha, state: "merged" }, repoName } }, }); const result = await disposeTree(deps, rec, { auto: true }); expect(result).toEqual({ disposed: true }); @@ -378,7 +378,7 @@ describe("disposeTree", () => { const deps = makeDeps({ cacheEntries: { - "feature-a": { mr: { iid: 42, sha: "0".repeat(40) }, repoName }, + "feature-a": { mr: { iid: 42, sha: "0".repeat(40), state: "merged" }, repoName }, }, }); const result = await disposeTree(deps, rec, { auto: true }); @@ -393,7 +393,7 @@ describe("disposeTree", () => { })); // sha absent entirely (the projection drops it for many merged MRs) - const deps = makeDeps({ cacheEntries: { "feature-a": { mr: { iid: 42 }, repoName } } }); + const deps = makeDeps({ cacheEntries: { "feature-a": { mr: { iid: 42, state: "merged" }, repoName } } }); const result = await disposeTree(deps, rec, { auto: true }); expect(result).toEqual({ disposed: true }); expect(existsSync(path)).toBe(false); @@ -407,7 +407,7 @@ describe("disposeTree", () => { })); const deps = makeDeps({ - cacheEntries: { "feature-a": { mr: { iid: 42, sha: null }, repoName } }, + cacheEntries: { "feature-a": { mr: { iid: 42, sha: null, state: "merged" }, repoName } }, }); const result = await disposeTree(deps, rec, { auto: true }); expect(result).toEqual({ disposed: false, refusal: "unpushed" }); @@ -423,12 +423,47 @@ describe("disposeTree", () => { })); const deps = makeDeps({ - cacheEntries: { "feature-a": { mr: { iid: 42, sha }, repoName } }, + cacheEntries: { "feature-a": { mr: { iid: 42, sha, state: "merged" }, repoName } }, }); const result = await disposeTree(deps, rec, { auto: true }); expect(result).toEqual({ disposed: false, refusal: "unpushed" }); }); + test("MANUAL disposal of a squash-merged tree uses the MR sha anchor", async () => { + // The shape that broke: squash-merged upstream, source branch deleted, so + // the tip is an ancestor of nothing the remote still has. Before the fix a + // human got "unpushed" here and was pushed toward --force, which also + // strips the dirty guard. + const path = addTree(repo, "tree-a", "feature-a"); + commitIn(path, "new.txt", "squash-merged upstream\n"); + const sha = execSync(`git -C ${path} rev-parse HEAD`, { encoding: "utf8" }).trim(); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + expect(await remoteRefExists(path, "feature-a")).toBe(false); + + const deps = makeDeps({ + cacheEntries: { "feature-a": { mr: { iid: 42, sha, state: "merged" }, repoName } }, + }); + const result = await disposeTree(deps, rec, { auto: false }); + expect(result).toEqual({ disposed: true }); + expect(existsSync(path)).toBe(false); + }); + + test("manual disposal with an OPEN MR still uses the remote anchor", async () => { + // An open MR's head sha would happily contain HEAD; the tree is still the + // author's live work, so it must be judged on what is actually pushed. + const path = addTree(repo, "tree-a", "feature-a"); + commitIn(path, "new.txt", "not pushed yet\n"); + const sha = execSync(`git -C ${path} rev-parse HEAD`, { encoding: "utf8" }).trim(); + const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); + + const deps = makeDeps({ + cacheEntries: { "feature-a": { mr: { iid: 42, sha, state: "opened" }, repoName } }, + }); + const result = await disposeTree(deps, rec, { auto: false }); + expect(result).toEqual({ disposed: false, refusal: "unpushed" }); + expect(existsSync(path)).toBe(true); + }); + test("a fresh attendant lease on the joined MR refuses with \"attended\"", async () => { const path = addTree(repo, "tree-a", "feature-a"); const rec = register(repoName, ephemeral("tree-a", path, "feature-a")); @@ -566,7 +601,9 @@ describe("disposeTree", () => { }); const sha = execSync(`git -C ${path} rev-parse HEAD`, { encoding: "utf8" }).trim(); - const deps = makeDeps({ cacheEntries: { "feature-a": { mr: { iid: 42, sha }, repoName } } }); + const deps = makeDeps({ + cacheEntries: { "feature-a": { mr: { iid: 42, sha, state: "merged" }, repoName } }, + }); const result = await disposeTree(deps, rec, { auto: true }); expect(result).toEqual({ disposed: false, refusal: "attended" }); }); diff --git a/lib/worktree/create.ts b/lib/worktree/create.ts index 98c31d77..5afc4da9 100644 --- a/lib/worktree/create.ts +++ b/lib/worktree/create.ts @@ -31,6 +31,19 @@ import { reconcileForRepo } from "../daemon/doppler-sync.ts"; const CREATE_TIMEOUT_MS = 5 * 60_000; +/** `git worktree remove` deletes node_modules by hand; a pnpm-scale tree on + * APFS routinely outruns runGit's 60s default. */ +const REMOVE_TIMEOUT_MS = 5 * 60_000; + +/** Longest slice of a failed step's output carried into the log line. */ +const MAX_LOGGED_OUTPUT = 2000; + +/** The tail of a step's output — a failing install reports at the end, not the start. */ +function outputTail(output: string, maxChars: number): string { + const trimmed = output.trim(); + return trimmed.length <= maxChars ? trimmed : `…${trimmed.slice(-maxChars)}`; +} + export interface CreateDeps { repoName: string; repoPath: string; @@ -86,7 +99,12 @@ async function runCreate( saveRegistry(repoName, trees); const fail = async (failedStep: string, output: string): Promise => { - log.warn({ repo: repoName, tree: name, failedStep }, "worktree create failed"); + // The output is the whole diagnosis (which install died, and why); without + // it the log says only which step's name failed. + log.warn( + { repo: repoName, tree: name, failedStep, output: outputTail(output, MAX_LOGGED_OUTPUT) }, + "worktree create failed", + ); await scrapTree(deps, rec); return { ok: false, error: "create-failed", failedStep, output }; }; @@ -143,7 +161,9 @@ async function runCreate( * branch may not exist either. */ export async function scrapTree(deps: CreateDeps, rec: TreeRecord): Promise { - await runGit(deps.repoPath, ["worktree", "remove", "--force", rec.path]); + await runGit(deps.repoPath, ["worktree", "remove", "--force", rec.path], { + timeoutMs: REMOVE_TIMEOUT_MS, + }); if (rec.branch) { await runGit(deps.repoPath, ["branch", "-D", rec.branch]); } diff --git a/lib/worktree/dispose.ts b/lib/worktree/dispose.ts index 719c3a58..e69391a0 100644 --- a/lib/worktree/dispose.ts +++ b/lib/worktree/dispose.ts @@ -24,6 +24,10 @@ const GRACE_MS = 10 * 60_000; const FETCH_TIMEOUT_MS = 2 * 60_000; +/** `git worktree remove` deletes node_modules by hand; a pnpm-scale tree on + * APFS routinely outruns runGit's 60s default. */ +const REMOVE_TIMEOUT_MS = 5 * 60_000; + // ─── Dirty classification (harvested from parking-lot.ts, execSync → runGit) ── /** One entry from `git status --porcelain`. */ @@ -129,14 +133,17 @@ export interface DisposeDeps { export type DisposeOutcome = { disposed: true } | { disposed: false; refusal: string }; /** The MR joined to this tree, if the branch cache knows one for this repo. */ -function joinedMr(deps: DisposeDeps, rec: TreeRecord): { iid?: number; sha?: string | null } | null { +function joinedMr( + deps: DisposeDeps, + rec: TreeRecord, +): { iid?: number; sha?: string | null; state?: string | null } | null { if (!rec.branch) return null; const entry = deps.cacheEntries[rec.branch]; if (!entry || !entry.mr) return null; // Entries carry repoName once freshness has attributed them; an unattributed // entry is accepted (single-repo caches predate the field). if (entry.repoName && entry.repoName !== deps.repoName) return null; - return entry.mr as { iid?: number; sha?: string | null }; + return entry.mr as { iid?: number; sha?: string | null; state?: string | null }; } /** @@ -146,6 +153,12 @@ function joinedMr(deps: DisposeDeps, rec: TreeRecord): { iid?: number; sha?: str * branch cache is the only anchor that survives both. A sha that is present but * unknown locally → fetch once, then refuse rather than guess. * + * Selected on the MR's state, NOT on `auto`: the anchor is a fact about the + * branch's history, not about who asked. A human disposing a squash-merged tree + * needs exactly the same anchor the reactor does — gating it on `auto` handed + * them "unpushed" and pushed them toward `--force`, which also strips the dirty + * guard. + * * Only reached when the MR actually carries a sha: roughly a quarter of merged * entries in the live cache have no `sha` field at all, and refusing those * would strand every one of them as disposable. Those fall back to the remote @@ -212,7 +225,7 @@ export async function disposeTree( // back to the remote anchor. "mr-sha-unresolvable" is reserved for a sha // that is present and still unknown after a fetch. const anchorRefusal = - auto && mrSha + mrSha && mr?.state === "merged" ? await mrAnchorRefusal(deps, rec, mrSha) : await remoteAnchorRefusal(rec); if (anchorRefusal) return refuse(anchorRefusal); @@ -242,7 +255,9 @@ export async function disposeTree( // --force here is plumbing, not a safety statement: `git worktree remove` // refuses on any untracked file, and the guard above (or the caller's // explicit force) already made the decision. - const removal = await runGit(repoPath, ["worktree", "remove", "--force", rec.path]); + const removal = await runGit(repoPath, ["worktree", "remove", "--force", rec.path], { + timeoutMs: REMOVE_TIMEOUT_MS, + }); if (removal.exitCode !== 0) { const output = (removal.stdout + removal.stderr).trim(); log.warn( From 9bade798d902c04cbf60f4f2879da4f950dda52f Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Mon, 17 Aug 2026 22:21:52 -0500 Subject: [PATCH 29/31] =?UTF-8?q?RT-34:=20PR=20review=20fixes=20=E2=80=94?= =?UTF-8?q?=20fail-closed=20worktree=20listing,=20tilde=20roots,=20ticket?= =?UTF-8?q?=20sanitization,=20tmp-file=20hygiene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../__tests__/worktree-reconciler.test.ts | 18 ++++++++++++- lib/daemon/worktree-reconciler.ts | 12 ++++++++- lib/json-store.ts | 18 ++++++++++--- lib/worktree/__tests__/config.test.ts | 20 ++++++++++++++- lib/worktree/__tests__/create.test.ts | 4 +-- lib/worktree/__tests__/dispose.test.ts | 2 +- lib/worktree/__tests__/git-async.test.ts | 7 +++++- lib/worktree/__tests__/names.test.ts | 20 +++++++++++++++ lib/worktree/__tests__/registry.test.ts | 17 +++++++++++-- lib/worktree/branch-name.ts | 25 +++++++++++++------ lib/worktree/config.ts | 15 ++++++++++- lib/worktree/create.ts | 9 +++++-- lib/worktree/git-async.ts | 8 +++++- lib/worktree/registry.ts | 4 +-- 14 files changed, 152 insertions(+), 27 deletions(-) diff --git a/lib/daemon/__tests__/worktree-reconciler.test.ts b/lib/daemon/__tests__/worktree-reconciler.test.ts index 1e94f5d0..731daa8c 100644 --- a/lib/daemon/__tests__/worktree-reconciler.test.ts +++ b/lib/daemon/__tests__/worktree-reconciler.test.ts @@ -154,9 +154,25 @@ describe("reconcileRepoRegistry", () => { expect(existsSync(ghostPath)).toBe(false); expect(await branchExistsLocalAsync(repo, "on-deck/ghost")).toBe(false); - const worktrees = await listWorktreesAsync(repo); + const worktrees = (await listWorktreesAsync(repo))!; expect(worktrees.some((w) => w.path === ghostPath)).toBe(false); }); + + test("a broken git dir leaves an existing registry row untouched instead of pruning it", async () => { + // Adopt the main clone into the registry on a healthy pass first. + await reconcileRepoRegistry(makeDeps(repoName, repo, events)); + const before = loadRegistry(repoName); + expect(before.length).toBe(1); + expect(before[0]!.kind).toBe("main"); + + // Break the git dir so `git worktree list --porcelain` fails. + rmSync(join(repo, ".git"), { recursive: true, force: true }); + + const trees = await reconcileRepoRegistry(makeDeps(repoName, repo, events)); + + expect(trees).toEqual(before); + expect(loadRegistry(repoName)).toEqual(before); + }); }); describe("createWorktreeReconciler", () => { diff --git a/lib/daemon/worktree-reconciler.ts b/lib/daemon/worktree-reconciler.ts index 15424df1..aa2a86bb 100644 --- a/lib/daemon/worktree-reconciler.ts +++ b/lib/daemon/worktree-reconciler.ts @@ -114,6 +114,11 @@ export async function reconcileRepoRegistry(deps: { trees = afterScrap; const gitEntries = await listWorktreesAsync(repoPath); + if (gitEntries === null) { + if (changed) saveRegistry(repoName, trees); + log.warn({ repo: repoName, repoPath }, "reconcile: git worktree list failed; skipping this repo's pass"); + return trees; + } const gitByCanon = new Map(); for (const entry of gitEntries) { gitByCanon.set(canon(entry.path), entry); @@ -334,7 +339,12 @@ async function autoReturnMain( // (b) git refuses to check out a branch another worktree holds. park() // refused up front for exactly this; without the check the checkout // fails after the stash and retries every pass. - const holder = (await listWorktreesAsync(deps.repoPath)).find( + const gitEntries = await listWorktreesAsync(deps.repoPath); + if (gitEntries === null) { + log.warn({ ...fields }, "auto-return: git worktree list failed; retrying next pass"); + return "retry"; + } + const holder = gitEntries.find( (w) => w.branch === defaultBranch && canon(w.path) !== canon(rec.path), ); if (holder) { diff --git a/lib/json-store.ts b/lib/json-store.ts index a403142c..1cc1a984 100644 --- a/lib/json-store.ts +++ b/lib/json-store.ts @@ -11,8 +11,9 @@ * sweep, not part of the path refactor. */ -import { mkdirSync, readFileSync, writeFileSync, renameSync } from "fs"; +import { mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "fs"; import { dirname } from "path"; +import { randomBytes } from "crypto"; /** * Read and parse a JSON file. Returns `fallback` on any failure — missing file, @@ -34,7 +35,16 @@ export function readJson(path: string, fallback: T): T { */ export function writeJson(path: string, value: unknown): void { mkdirSync(dirname(path), { recursive: true }); - const tmp = `${path}.tmp`; - writeFileSync(tmp, JSON.stringify(value, null, 2)); - renameSync(tmp, path); + const tmp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; + try { + writeFileSync(tmp, JSON.stringify(value, null, 2)); + renameSync(tmp, path); + } catch (err) { + try { + unlinkSync(tmp); + } catch { + // tmp file never got created, or was already cleaned up... nothing to do + } + throw err; + } } diff --git a/lib/worktree/__tests__/config.test.ts b/lib/worktree/__tests__/config.test.ts index 76c4ae96..ee0fd019 100644 --- a/lib/worktree/__tests__/config.test.ts +++ b/lib/worktree/__tests__/config.test.ts @@ -55,7 +55,7 @@ describe("worktree config", () => { const declared = { onDeck: 2, namePool: ["hogwarts", "bellatrix"], - root: "~/Documents/GitHub/assured", + root: "/absolute/path/to/assured", branchFormat: "", ready: [ { run: "pnpm genTypes", when: "changed:db/schema/**" }, @@ -68,6 +68,24 @@ describe("worktree config", () => { const cfg = loadWorktreeRepoConfig("myrepo", repoPath); expect(cfg).toEqual(declared); }); + + test("expands a leading ~/ in root against call-time HOME", () => { + const repoPath = tmpRepoPath("rtcfg-repo-"); + writeJson(join(repoDataDir("myrepo"), "config.json"), { + worktrees: { root: "~/wt-root" }, + }); + const cfg = loadWorktreeRepoConfig("myrepo", repoPath); + expect(cfg.root).toBe(join(process.env.HOME!, "wt-root")); + }); + + test("leaves an absolute root unchanged", () => { + const repoPath = tmpRepoPath("rtcfg-repo-"); + writeJson(join(repoDataDir("myrepo"), "config.json"), { + worktrees: { root: "/absolute/wt-root" }, + }); + const cfg = loadWorktreeRepoConfig("myrepo", repoPath); + expect(cfg.root).toBe("/absolute/wt-root"); + }); }); describe("resolveImplicitInstall", () => { diff --git a/lib/worktree/__tests__/create.test.ts b/lib/worktree/__tests__/create.test.ts index f70d27db..0f880de2 100644 --- a/lib/worktree/__tests__/create.test.ts +++ b/lib/worktree/__tests__/create.test.ts @@ -68,7 +68,7 @@ describe("createTree", () => { expect(registry[0]!.state).toBe("on-deck"); expect(registry[0]!.name).toBe(result.tree.name); - const worktrees = await listWorktreesAsync(repo); + const worktrees = (await listWorktreesAsync(repo))!; const entry = worktrees.find((w) => w.path === result.tree.path); expect(entry).toBeDefined(); expect(entry!.branch).toBe(`on-deck/${result.tree.name}`); @@ -106,7 +106,7 @@ describe("createTree", () => { const path = join(repo, ".worktrees", "failtree"); expect(existsSync(path)).toBe(false); - const worktrees = await listWorktreesAsync(repo); + const worktrees = (await listWorktreesAsync(repo))!; expect(worktrees.some((w) => w.path === path)).toBe(false); expect(await branchExistsLocalAsync(repo, "on-deck/failtree")).toBe(false); diff --git a/lib/worktree/__tests__/dispose.test.ts b/lib/worktree/__tests__/dispose.test.ts index 00330ced..761030af 100644 --- a/lib/worktree/__tests__/dispose.test.ts +++ b/lib/worktree/__tests__/dispose.test.ts @@ -345,7 +345,7 @@ describe("disposeTree", () => { // worktree, branch, and registry entry all gone expect(existsSync(path)).toBe(false); - expect((await listWorktreesAsync(repo)).some((w) => w.path === path)).toBe(false); + expect((await listWorktreesAsync(repo))!.some((w) => w.path === path)).toBe(false); expect(await branchExistsLocalAsync(repo, "feature-a")).toBe(false); expect(loadRegistry(repoName).length).toBe(0); diff --git a/lib/worktree/__tests__/git-async.test.ts b/lib/worktree/__tests__/git-async.test.ts index 9d3b2061..3d78ddce 100644 --- a/lib/worktree/__tests__/git-async.test.ts +++ b/lib/worktree/__tests__/git-async.test.ts @@ -56,11 +56,16 @@ describe("git-async", () => { test("listWorktreesAsync lists main + added tree with branches", async () => { execSync(`git -C ${repo} worktree add ${repo}-wt -b side`, { shell: "/bin/zsh" }); - const trees = await listWorktreesAsync(repo); + const trees = (await listWorktreesAsync(repo))!; expect(trees.length).toBe(2); expect(trees[1]).toEqual({ path: `${repo}-wt`, branch: "side" }); }); + test("listWorktreesAsync returns null on a nonzero git exit", async () => { + const notARepo = mkdtempSync(join(tmpdir(), "rtgit-notrepo-")); + expect(await listWorktreesAsync(notARepo)).toBeNull(); + }); + test("runGit captures stderr", async () => { const r = await runGit(repo, ["checkout", "definitely-not-a-ref"]); expect(r.exitCode).not.toBe(0); diff --git a/lib/worktree/__tests__/names.test.ts b/lib/worktree/__tests__/names.test.ts index f026beeb..e412734e 100644 --- a/lib/worktree/__tests__/names.test.ts +++ b/lib/worktree/__tests__/names.test.ts @@ -94,6 +94,26 @@ describe("slugifyTicketTitle", () => { expect(result).not.toMatch(/^x-1-\s/); expect(result).not.toMatch(/\s$/); }); + + it("sanitizes a ticket ID with spaces the same way as the slug", () => { + const result = slugifyTicketTitle("RT 34", "Worktree lifecycle", "-"); + expect(result).toBe("rt-34-worktree-lifecycle"); + }); + + it("sanitizes a ticket ID with non-alphanumeric punctuation", () => { + const result = slugifyTicketTitle("RT^9", "Fix it", "-"); + expect(result).toBe("rt-9-fix-it"); + }); + + it("substitutes every occurrence of a repeated placeholder", () => { + const result = slugifyTicketTitle("RT-34", "Worktrees", "/-"); + expect(result).toBe("rt-34/rt-34-worktrees"); + }); + + it("a title containing $& does not corrupt the substitution", () => { + const result = slugifyTicketTitle("RT-34", "Fix $& in prod", "-"); + expect(result).toBe("rt-34-fix-in-prod"); + }); }); describe("disambiguate", () => { diff --git a/lib/worktree/__tests__/registry.test.ts b/lib/worktree/__tests__/registry.test.ts index 7bd8e8b0..51639904 100644 --- a/lib/worktree/__tests__/registry.test.ts +++ b/lib/worktree/__tests__/registry.test.ts @@ -1,11 +1,12 @@ import { describe, test, expect, beforeEach } from "bun:test"; -import { mkdtempSync } from "fs"; +import { mkdirSync, mkdtempSync, writeFileSync } from "fs"; import { tmpdir } from "os"; -import { join } from "path"; +import { dirname, join } from "path"; import { loadRegistry, saveRegistry, findByBranch, + registryPath, usedNames, type TreeRecord, } from "../registry.ts"; @@ -39,4 +40,16 @@ describe("worktree registry", () => { test("usedNames includes creating", () => { expect(usedNames([rec({ state: "creating" })]).has("bellatrix")).toBe(true); }); + test("malformed registry file ({}) loads as []", () => { + const path = registryPath("r"); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "{}"); + expect(loadRegistry("r")).toEqual([]); + }); + test("malformed registry file ({\"trees\": null}) loads as []", () => { + const path = registryPath("r"); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify({ trees: null })); + expect(loadRegistry("r")).toEqual([]); + }); }); diff --git a/lib/worktree/branch-name.ts b/lib/worktree/branch-name.ts index 4b43fdfa..1b6c177d 100644 --- a/lib/worktree/branch-name.ts +++ b/lib/worktree/branch-name.ts @@ -6,26 +6,35 @@ * Example: slugifyTicketTitle("RT-34", "Ephemeral Worktrees: rule!", "-") * → "rt-34-ephemeral-worktrees-rule" */ +/** Lowercase, non-alphanumerics -> dash, collapsed, trimmed at both edges. */ +function sanitize(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + export function slugifyTicketTitle( ticketId: string, title: string, format: string ): string { - const ticketLower = ticketId.toLowerCase(); - const titleLower = title.toLowerCase(); + const ticketSlug = sanitize(ticketId); // Replace non-alphanumeric characters with dashes - let titleSlug = titleLower - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); // Trim dashes from start/end + let titleSlug = sanitize(title); // Cap the slug portion at 40 characters BEFORE assembling titleSlug = titleSlug.substring(0, 40).replace(/-+$/, ""); // Trim trailing dashes after cap - // Build the result based on format + // Build the result based on format. replaceAll (not replace): String.replace + // only substitutes the first occurrence of a repeated placeholder. A + // function replacer (not a plain string) because $-sequences in a string + // replacement value are interpreted as patterns by both replace AND + // replaceAll... a slug containing "$&" would otherwise get mangled. const result = format - .replace("", ticketLower) - .replace("", titleSlug); + .replaceAll("", () => ticketSlug) + .replaceAll("", () => titleSlug); return result; } diff --git a/lib/worktree/config.ts b/lib/worktree/config.ts index dc0b939e..3bb9d7a0 100644 --- a/lib/worktree/config.ts +++ b/lib/worktree/config.ts @@ -13,10 +13,23 @@ */ import { existsSync, readFileSync } from "fs"; +import { homedir } from "os"; import { join } from "path"; import { readJson, writeJson } from "../json-store.ts"; import { repoDataDir, rtDir } from "../rt-paths.ts"; +/** + * Expand a leading `~` against call-time HOME (matching rt-paths convention: + * `process.env.HOME ?? homedir()`, resolved at call time so tests can repoint + * the whole tree by setting process.env.HOME before calling). Only a leading + * `~` or `~/...` is special; `~foo` and mid-string `~` are left alone. + */ +function expandHome(path: string): string { + if (path === "~") return process.env.HOME ?? homedir(); + if (path.startsWith("~/")) return join(process.env.HOME ?? homedir(), path.slice(2)); + return path; +} + // ─── Types ─────────────────────────────────────────────────────────────────── export interface ReadyStep { @@ -54,7 +67,7 @@ export function loadWorktreeRepoConfig(repoName: string, repoPath: string): Work const cfg: WorktreeRepoConfig = { onDeck: declared.onDeck ?? 0, - root: declared.root ?? join(repoPath, ".worktrees"), + root: declared.root ? expandHome(declared.root) : join(repoPath, ".worktrees"), branchFormat: declared.branchFormat ?? "-", ready: declared.ready ?? [], }; diff --git a/lib/worktree/create.ts b/lib/worktree/create.ts index 5afc4da9..79e39e1a 100644 --- a/lib/worktree/create.ts +++ b/lib/worktree/create.ts @@ -134,8 +134,13 @@ async function runCreate( return fail(readyResult.failedStep, readyResult.output); } - const worktreeRoots = (await listWorktreesAsync(repoPath)).map((w) => w.path); - await reconcileForRepo({ repoName, worktreeRoots }); + const gitEntries = await listWorktreesAsync(repoPath); + if (gitEntries === null) { + log.warn({ repo: repoName, tree: name, path }, "worktree create: git worktree list failed; skipping doppler sync"); + } else { + const worktreeRoots = gitEntries.map((w) => w.path); + await reconcileForRepo({ repoName, worktreeRoots }); + } const readyStamp = await headSha(path); const updated: TreeRecord = { diff --git a/lib/worktree/git-async.ts b/lib/worktree/git-async.ts index b22d91c7..1d994b66 100644 --- a/lib/worktree/git-async.ts +++ b/lib/worktree/git-async.ts @@ -108,12 +108,18 @@ export async function headSha(cwd: string): Promise { * that exist on disk (a worktree removed via `rm -rf` still shows up in * git's porcelain output, but it's not one we can operate on). * + * Returns null on a nonzero exit (e.g. a broken or nonexistent git dir) so + * callers can tell "git failed" apart from "git succeeded and reported no + * worktrees" -- treating a failed listing as an empty one is how a registry + * gets mass-pruned against a repo git briefly couldn't read. + * * NOTE: returns git's canonicalized paths (/private/var/... on macOS * tmpdirs); callers comparing against stored paths rely on the * canonical-fixtures rule in Global Constraints. */ -export async function listWorktreesAsync(repoPath: string): Promise { +export async function listWorktreesAsync(repoPath: string): Promise { const r = await runGit(repoPath, ["worktree", "list", "--porcelain"]); + if (r.exitCode !== 0) return null; const results: WorktreeEntry[] = []; let curPath: string | null = null; let curBranch: string | null = null; diff --git a/lib/worktree/registry.ts b/lib/worktree/registry.ts index 22d74657..0f12c5c2 100644 --- a/lib/worktree/registry.ts +++ b/lib/worktree/registry.ts @@ -33,8 +33,8 @@ export function registryPath(repoName: string): string { export function loadRegistry(repoName: string): TreeRecord[] { const path = registryPath(repoName); - const data = readJson(path, { trees: [] }); - return data.trees; + const data = readJson>(path, { trees: [] }); + return Array.isArray(data?.trees) ? data.trees : []; } export function saveRegistry(repoName: string, trees: TreeRecord[]): void { From 841b9743a78db378d56acb8af5a3d7c2844ef99d Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Tue, 18 Aug 2026 08:36:24 -0500 Subject: [PATCH 30/31] =?UTF-8?q?RT-34:=20reconcile=20epoch=20check=20?= =?UTF-8?q?=E2=80=94=20stale-snapshot=20save=20can=20no=20longer=20clobber?= =?UTF-8?q?=20concurrent=20registry=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../__tests__/worktree-reconciler.test.ts | 93 +++++++++++++++++++ lib/daemon/worktree-reconciler.ts | 82 ++++++++++++++-- lib/worktree/__tests__/registry.test.ts | 15 +++ lib/worktree/registry.ts | 20 ++++ 4 files changed, 200 insertions(+), 10 deletions(-) diff --git a/lib/daemon/__tests__/worktree-reconciler.test.ts b/lib/daemon/__tests__/worktree-reconciler.test.ts index 731daa8c..82df31db 100644 --- a/lib/daemon/__tests__/worktree-reconciler.test.ts +++ b/lib/daemon/__tests__/worktree-reconciler.test.ts @@ -173,6 +173,99 @@ describe("reconcileRepoRegistry", () => { expect(trees).toEqual(before); expect(loadRegistry(repoName)).toEqual(before); }); + + /** + * The concurrency contract: reconcile loads a whole-registry snapshot and + * then awaits git for a long time, so every other writer (provision's claim, + * dispose's prune, freshen's patch) runs on the same event loop inside that + * window. `onAfterLoad` is the deterministic stand-in for that writer — it + * fires right after reconcile captures its snapshot and epoch. + */ + function claimConcurrently(path: string, owner: string): void { + const cur = loadRegistry(repoName); + const rec = cur.find((t) => t.path === path); + if (!rec) throw new Error(`no registry row at ${path}`); + rec.state = "claimed"; + rec.owner = owner; + rec.claimedAt = new Date().toISOString(); + saveRegistry(repoName, cur); + } + + function onDeckRow(path: string): TreeRecord { + return { + name: basename(path), + path, + kind: "ephemeral", + state: "on-deck", + branch: `on-deck/${basename(path)}`, + createdAt: new Date().toISOString(), + }; + } + + test("a claim landing mid-pass survives; reconcile retries instead of saving its stale snapshot", async () => { + const treePath = join(repo, ".worktrees", "claimable"); + execSync(`git worktree add -b on-deck/claimable ${treePath}`, { cwd: repo, shell: "/bin/zsh" }); + // Only the ephemeral tree is registered, so reconcile's own correction this + // pass is adopting the main clone (changed -> a whole-snapshot save). + saveRegistry(repoName, [onDeckRow(treePath)]); + + let fired = false; + const trees = await reconcileRepoRegistry({ + ...makeDeps(repoName, repo, events), + onAfterLoad: () => { + if (fired) return; + fired = true; + claimConcurrently(treePath, "matt"); + }, + }); + + const final = loadRegistry(repoName); + const claim = findByPath(final, treePath); + expect(claim).toBeDefined(); + expect(claim!.state).toBe("claimed"); // NOT reverted to on-deck + expect(claim!.owner).toBe("matt"); + + // ...and reconcile's own correction still landed, on the retry pass. + expect(findByPath(final, repo)?.kind).toBe("main"); + expect(findByPath(trees, repo)?.kind).toBe("main"); + expect(findByPath(trees, treePath)?.state).toBe("claimed"); + }); + + test("a writer landing on every attempt exhausts the retries: reconcile warns and skips its save", async () => { + const treePath = join(repo, ".worktrees", "contended"); + execSync(`git worktree add -b on-deck/contended ${treePath}`, { cwd: repo, shell: "/bin/zsh" }); + saveRegistry(repoName, [onDeckRow(treePath)]); + + const warns: unknown[][] = []; + let attempts = 0; + const log = { + info: () => {}, + warn: (...args: unknown[]) => warns.push(args), + error: () => {}, + debug: () => {}, + } as unknown as Logger; + + const trees = await reconcileRepoRegistry({ + repoName, + repoPath: repo, + emit: (type: string, data: unknown) => events.push({ type, data }), + log, + onAfterLoad: () => { + attempts++; + claimConcurrently(treePath, `w${attempts}`); + }, + }); + + expect(attempts).toBe(3); // bounded: three attempts, then give up + expect(warns.length).toBe(1); + + const final = loadRegistry(repoName); + // The last competing write stands; reconcile's adoption of main is dropped + // rather than written over it. The next pass picks it up again. + expect(findByPath(final, treePath)!.owner).toBe("w3"); + expect(findByPath(final, repo)).toBeUndefined(); + expect(findByPath(trees, treePath)!.owner).toBe("w3"); + }); }); describe("createWorktreeReconciler", () => { diff --git a/lib/daemon/worktree-reconciler.ts b/lib/daemon/worktree-reconciler.ts index aa2a86bb..b70fa38e 100644 --- a/lib/daemon/worktree-reconciler.ts +++ b/lib/daemon/worktree-reconciler.ts @@ -17,6 +17,7 @@ import { findByBranch, findByPath, loadRegistry, + registryEpoch, saveRegistry, type TreeKind, type TreeRecord, @@ -61,6 +62,26 @@ function canon(path: string): string { } } +export interface ReconcileDeps { + repoName: string; + repoPath: string; + emit: (type: string, data: unknown) => void; + log: Logger; + /** + * Test-only seam, invoked right after an attempt captures its registry + * snapshot and epoch — the exact window a competing writer (a provision + * claim, a dispose prune) lands in on the shared event loop. Production + * callers never pass it. + */ + onAfterLoad?: (attempt: number) => void; +} + +/** One attempt's outcome: its trees, or "a concurrent write invalidated me". */ +type PassResult = { trees: TreeRecord[] } | { conflict: true }; + +/** Attempts before a contended pass gives up and leaves the work to the next tick. */ +const RECONCILE_MAX_ATTEMPTS = 3; + /** * Reconcile one repo's worktree registry against git ground truth (spec §4). * @@ -77,18 +98,43 @@ function canon(path: string): string { * truth; kind/state/owner are left untouched. * 6. (e) duplicate branches across registered trees are left as-is — * surfaced elsewhere (findByBranch / T13's list handler). + * + * Concurrency: this is the one registry writer that saves a WHOLE snapshot + * taken before a long run of git awaits. Every other writer is a synchronous + * fresh-load → mutate → save of one row, so per-tree locks are enough for them; + * they are not enough here, because reconcile holds no lock on the trees it + * rewrites (and taking a repo-wide one would reintroduce the coarse locking + * this design avoids). Instead each attempt captures `registryEpoch` with its + * snapshot and re-checks it in the same synchronous block as its save: if + * anyone else wrote in between, the snapshot is stale and the whole pass is + * retried against fresh state rather than overwriting them. Retries are bounded + * — a pass that keeps losing simply skips its save and lets the next tick redo + * it, since every correction here is derived from ground truth and idempotent. */ -export async function reconcileRepoRegistry(deps: { - repoName: string; - repoPath: string; - emit: (type: string, data: unknown) => void; - log: Logger; -}): Promise { +export async function reconcileRepoRegistry(deps: ReconcileDeps): Promise { + for (let attempt = 1; attempt <= RECONCILE_MAX_ATTEMPTS; attempt++) { + const result = await reconcilePass(deps, attempt); + if (!("conflict" in result)) return result.trees; + deps.log.debug?.( + { repo: deps.repoName, attempt }, + "reconcile: registry changed mid-pass; retrying against a fresh snapshot", + ); + } + deps.log.warn( + { repo: deps.repoName, attempts: RECONCILE_MAX_ATTEMPTS }, + "reconcile: registry kept changing mid-pass; skipping this pass's save", + ); + return loadRegistry(deps.repoName); +} + +async function reconcilePass(deps: ReconcileDeps, attempt: number): Promise { const { repoName, repoPath, emit, log } = deps; await runGit(repoPath, ["worktree", "prune"]); let trees = loadRegistry(repoName); + let epoch = registryEpoch(repoName); + deps.onAfterLoad?.(attempt); let changed = false; const createDeps: CreateDeps = { repoName, repoPath, emit, log }; @@ -102,22 +148,35 @@ export async function reconcileRepoRegistry(deps: { // truth) it is gated on the app-level enabled flag same as freshen/replenish. const appConfig = loadWorktreeAppConfig(); const afterScrap: TreeRecord[] = []; + let scrapped = false; for (const rec of trees) { if (appConfig.enabled && rec.state === "creating" && !isTreeLocked(rec.path)) { log.info({ repo: repoName, tree: rec.name, path: rec.path }, "reconcile: scrapping orphaned creating tree"); await scrapTree(createDeps, rec); - changed = true; + scrapped = true; continue; } afterScrap.push(rec); } trees = afterScrap; + if (scrapped) { + // scrapTree persists its own removal (fresh-load → filter → save), so the + // scrap is already on disk and has already bumped the epoch. Re-read from + // that write instead of carrying the pre-scrap snapshot forward: anything + // another writer landed during the scrap's git awaits is in the file now, + // and re-capturing the epoch here is what keeps our own intentional write + // from reading as somebody else's. + trees = loadRegistry(repoName); + epoch = registryEpoch(repoName); + } + const gitEntries = await listWorktreesAsync(repoPath); if (gitEntries === null) { - if (changed) saveRegistry(repoName, trees); + // Nothing to save: the scrap above already persisted itself, and writing + // this snapshot back would be exactly the stale-snapshot clobber. log.warn({ repo: repoName, repoPath }, "reconcile: git worktree list failed; skipping this repo's pass"); - return trees; + return { trees }; } const gitByCanon = new Map(); for (const entry of gitEntries) { @@ -179,10 +238,13 @@ export async function reconcileRepoRegistry(deps: { // (e) duplicate branches across registered trees: leave records as-is. if (changed) { + // Check and save in one synchronous block — an await between them would + // reopen the very window this closes. + if (registryEpoch(repoName) !== epoch) return { conflict: true }; saveRegistry(repoName, trees); } - return trees; + return { trees }; } // ─── Merge reactor (spec §6.2) ─────────────────────────────────────────────── diff --git a/lib/worktree/__tests__/registry.test.ts b/lib/worktree/__tests__/registry.test.ts index 51639904..06c59faa 100644 --- a/lib/worktree/__tests__/registry.test.ts +++ b/lib/worktree/__tests__/registry.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "os"; import { dirname, join } from "path"; import { loadRegistry, + registryEpoch, saveRegistry, findByBranch, registryPath, @@ -40,6 +41,20 @@ describe("worktree registry", () => { test("usedNames includes creating", () => { expect(usedNames([rec({ state: "creating" })]).has("bellatrix")).toBe(true); }); + test("registryEpoch bumps on every save, per repo", () => { + const before = registryEpoch("r"); + const otherBefore = registryEpoch("other"); + + saveRegistry("r", [rec({})]); + const afterFirst = registryEpoch("r"); + expect(afterFirst).not.toBe(before); + + saveRegistry("r", [rec({ name: "dobby" })]); + expect(registryEpoch("r")).not.toBe(afterFirst); + + // A write to one repo never disturbs another repo's epoch. + expect(registryEpoch("other")).toBe(otherBefore); + }); test("malformed registry file ({}) loads as []", () => { const path = registryPath("r"); mkdirSync(dirname(path), { recursive: true }); diff --git a/lib/worktree/registry.ts b/lib/worktree/registry.ts index 0f12c5c2..36c7386e 100644 --- a/lib/worktree/registry.ts +++ b/lib/worktree/registry.ts @@ -37,9 +37,29 @@ export function loadRegistry(repoName: string): TreeRecord[] { return Array.isArray(data?.trees) ? data.trees : []; } +/** + * Per-repo write counter, bumped by every `saveRegistry`. + * + * The registry has exactly one writer process (the daemon), but not one writer + * *task*: provision, dispose, freshen and reconcile all interleave on the same + * event loop. Anything that loads a whole-registry snapshot, awaits, and then + * saves that snapshot back would silently overwrite whatever landed in between. + * An in-memory counter is enough to detect that (no cross-process concern) and + * lives here because `saveRegistry` is the seam every write already funnels + * through. Callers compare `registryEpoch(repo)` captured right after their + * load against its value in the same synchronous block as their save. + */ +const epochs = new Map(); + +/** How many times this repo's registry has been saved in this process. */ +export function registryEpoch(repoName: string): number { + return epochs.get(repoName) ?? 0; +} + export function saveRegistry(repoName: string, trees: TreeRecord[]): void { const path = registryPath(repoName); writeJson(path, { trees }); + epochs.set(repoName, registryEpoch(repoName) + 1); } export function findByPath( From 292739b0cce1569cff8162426aff70a49977088e Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Tue, 18 Aug 2026 08:41:08 -0500 Subject: [PATCH 31/31] RT-34: assert exact epoch increments in the registry test Co-Authored-By: Claude Fable 5 --- lib/worktree/__tests__/registry.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/worktree/__tests__/registry.test.ts b/lib/worktree/__tests__/registry.test.ts index 06c59faa..a8de4932 100644 --- a/lib/worktree/__tests__/registry.test.ts +++ b/lib/worktree/__tests__/registry.test.ts @@ -46,11 +46,10 @@ describe("worktree registry", () => { const otherBefore = registryEpoch("other"); saveRegistry("r", [rec({})]); - const afterFirst = registryEpoch("r"); - expect(afterFirst).not.toBe(before); + expect(registryEpoch("r")).toBe(before + 1); saveRegistry("r", [rec({ name: "dobby" })]); - expect(registryEpoch("r")).not.toBe(afterFirst); + expect(registryEpoch("r")).toBe(before + 2); // A write to one repo never disturbs another repo's epoch. expect(registryEpoch("other")).toBe(otherBefore);