From 64edafa97b781c18997d7f18344580a6bcbab59f Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Tue, 25 Aug 2026 13:30:22 -0500 Subject: [PATCH 01/20] docs: repo locate + registry merge spec and plan Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-25-repo-locate.md | 2775 +++++++++++++++++ .../specs/2026-08-25-repo-locate-design.md | 95 + 2 files changed, 2870 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-25-repo-locate.md create mode 100644 docs/superpowers/specs/2026-08-25-repo-locate-design.md diff --git a/docs/superpowers/plans/2026-08-25-repo-locate.md b/docs/superpowers/plans/2026-08-25-repo-locate.md new file mode 100644 index 00000000..635f9410 --- /dev/null +++ b/docs/superpowers/plans/2026-08-25-repo-locate.md @@ -0,0 +1,2775 @@ +# Repo Locate + Registry Merge Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make a moved repo folder healable in one atomic operation — every literal path rt stores (repo index, worktree registries, endpoint claims, git's own worktree admin files) re-pointed together — and collapse the name/identity registry pairs the RT-62 cutover left behind. + +**Architecture:** A pure core (`lib/repo-locate.ts`) plans and applies the move: one sync `state.db` transaction rewrites index rows, registry paths, and claim paths; `git worktree repair` runs after the commit; a pre-apply snapshot is restored if verification fails. The daemon owns the apply when it is up, running it inside a new reconciler hold so no reconcile pass can observe an index row that healed ahead of its registry (the ordering that destroys claimed/on-deck state today). A registry merge primitive lets `rt repos prune` collapse the split pool the cutover created, and a prune guard stops evicting a missing row that still owns registry data. + +**Tech Stack:** Bun + TypeScript, `bun:test`, `bun:sqlite` via `state.db` (`lib/state/`), `runCapture`-backed async git (`lib/worktree/git-async.ts`), the daemon's unix-socket IPC (`lib/daemon-client.ts`). + +**Spec:** `docs/superpowers/specs/2026-08-25-repo-locate-design.md` — binding; read it first. Supporting contracts: `docs/repo-identity.md` (the two string forms), `CLAUDE.md` (logging seams, module registry, operating rules). + +## Global Constraints + +- **The daemon is never stopped for any of this.** Mutations of daemon-owned state go through the daemon whenever its socket answers; a local apply happens only when no daemon answers. Never add a "stop the daemon first" step, a `launchctl` call, or a daemon restart. +- **Identity wire form everywhere.** Every index key, registry key, `endpoint_claims.repo` value, and daemon payload `repo` field is a serialized identity (`remote:gitlab.com%2Fg%2Fr` / `path:%2FUsers%2F…`) produced by `serializeIdentity`/validated by `parseIdentity`, imported from `lib/settings/identity.ts` (never `@mattstack/rt-client`, which does not resolve from `lib/`). The raw `host/path` form appears only in settings-store `repos.` sections — this plan touches none of those. Never hand-assemble or string-split a wire. +- **Registry namespace constant is `worktree-registry`**, mirrored in both `lib/worktree/registry.ts` and `lib/repo-index.ts`. Keep both spellings; they are a parity anchor. +- **`bun:sqlite` transactions are sync-only.** No `await` inside a `db.transaction(...)` callback. All git work (`git worktree repair`, `git worktree list`) happens AFTER the transaction commits. +- **No sync git spawns on any path the daemon can reach.** `lib/repo-index.ts` is reachable from daemon handlers (`resolveIndexPathForIdentity`), so anything added there stays free of `execSync`-shaped git beyond what already exists. +- **Logging via seams only.** `dispatch()` (`lib/command-tree.ts`) logs every CLI command's outcome; `handleCommand` (`lib/daemon.ts`) logs every daemon command's ok/rejected/threw. New code logs NO outcomes, wraps no handler in a logging try/catch, and adds no `console.log` progress narration. Daemon handlers that must record a domain event use `ctx.log`; below a seam, an empty catch is acceptable only for a genuinely expected condition. +- **Module registry: no new command module.** `rt repos locate` lives inside the existing `commands/repos.ts`, already thunked at `lib/module-registry.ts:47` (`"./commands/repos.ts": () => import("../commands/repos.ts")`). Do not add a registry entry, do not create a new command module, and do not add a static import of `lib/rt-render.tsx` or `ink` anywhere on a command-tree path. +- **The `packages/rt-client/dist/` freshness rule does not apply to this plan.** `packages/rt-client` is untouched: no `bun run build` in that package, no consumer `bun install`, no rt-client catalog entry for `repos:locate` (the daemon's handler map is the only registration needed — `lib/daemon/__tests__/rt-client-commands.test.ts` asserts catalog ⊆ handlers, never the reverse). +- **Comments are constraint-only** (`~/.claude/rules/clean-code-comments.md`): a parity anchor, an ordering trap, a non-obvious invariant, a why that would otherwise be lost. No narration of the next line, no reviewer-facing justification, no ticket numbers (RT-63/RT-68/spec section refs belong in the commit body, not the source). +- **Test HOME discipline:** every new test repoints `process.env.HOME` to a fresh `mkdtempSync` dir in `beforeEach` and calls `closeStateDb()` before and after (the `getStateDb()` singleton binds to the HOME live at its first call). `bunfig.toml`'s preload gives a process-wide throwaway HOME on top of that — never remove it. +- **Canonical fixtures:** `realpathSync` every temp dir a test hands to git. macOS canonicalizes `/var` → `/private/var`, and `git worktree list` returns the canonical spelling; an uncanonicalized fixture path makes path comparisons fail for the wrong reason. +- **Gates per task, FOREGROUND:** the task's own test files (`bun test `) plus `bunx tsc --noEmit`. State any delta from the baseline. The full sweep `bun test lib commands` runs in the final task. +- **One commit per task**, message body ending with the trailer: + `Co-Authored-By: Claude Fable 5 ` + +--- + +## File structure + +**New files:** +- `lib/repo-locate.ts` — the pure core: `planLocate`, `applyLocate`, `findLocateCandidates`, plan/refusal/result types. No daemon, no CLI, no console output. +- `lib/repo-locate-dispatch.ts` — the one place that decides daemon-vs-local for a locate. Imports `lib/daemon-client.ts`; imported by `commands/repos.ts` and (dynamically) by `lib/repo-index.ts`. +- `lib/daemon/handlers/repos.ts` — the `repos:locate` verb. +- `lib/worktree/__tests__/registry-merge.test.ts`, `lib/__tests__/repo-index-missing.test.ts`, `lib/__tests__/repo-locate.test.ts`, `lib/__tests__/repo-locate-heal.test.ts`, `lib/__tests__/repo-locate-e2e.test.ts`, `lib/daemon/__tests__/reconciler-hold.test.ts`, `lib/daemon/__tests__/repos-handlers.test.ts`, `commands/__tests__/repos-locate.test.ts`. + +**Modified files:** +- `lib/worktree/registry.ts` — `mergeRegistries`, `hasRegistry`, `deleteRegistry`. +- `lib/repo-index.ts` — merge-aware `migrateWorktreeRegistry`; prune retains a missing row that owns a registry; `KnownRepo.missing`; raw row primitives (`setIndexPath`, `removeIndexRow`, `refreshRepoIndexMirror`); move-aware `updateRepoIndex` + `updateRepoIndexAsync`. +- `lib/pickers.ts`, `lib/repo.ts` — refuse to cd into a missing repo. +- `lib/daemon/worktree-reconciler.ts` — `withReconcilerHeld`. +- `lib/daemon/command-router.ts`, `lib/daemon.ts` — wire the repos handlers. +- `lib/daemon/__tests__/rt-client-commands.test.ts` — new `buildRoutedHandlers` opt. +- `lib/__tests__/repo-index-rename.test.ts` — one existing assertion inverts (refuse → merge). +- `commands/repos.ts`, `lib/command-tree-def.ts` — the `rt repos locate` verb and its prune output changes. + +**Task → spec scope item:** T1→1, T2→2, T3→3, T4→7, T5→4, T6→5 (the reconciler hold), T7→5 (the verb), T8→6, T9→8, T10→spec "Verification". + +--- + +## Task 1: Registry merge primitive + +**Files:** +- Modify: `lib/worktree/registry.ts` (append after `findByPath`, `lib/worktree/registry.ts:86-91`) +- Test: `lib/worktree/__tests__/registry-merge.test.ts` (create) + +**Interfaces:** +- Consumes: `TreeRecord`, `TreeKind` (already exported from `lib/worktree/registry.ts`). +- Produces: `mergeRegistries(winner: TreeRecord[], loser: TreeRecord[]): TreeRecord[]` — pure, no I/O beyond a guarded `realpathSync` for path canonicalization. Union by canonical path; winner-side records keep their relative order and come first, loser-only records follow in their own order. + +- [ ] **Step 1: Write the failing test** + +Create `lib/worktree/__tests__/registry-merge.test.ts`: + +```ts +import { describe, expect, test } from "bun:test"; +import { mergeRegistries, type TreeRecord } from "../registry.ts"; + +function rec(over: Partial & { path: string }): TreeRecord { + return { + name: over.path.split("/").pop()!, + kind: "unmanaged", + branch: null, + createdAt: "2026-01-01T00:00:00.000Z", + ...over, + }; +} + +describe("mergeRegistries", () => { + test("unions disjoint paths, winner side first", () => { + const merged = mergeRegistries([rec({ path: "/a/main" })], [rec({ path: "/a/tree-1" })]); + expect(merged.map((t) => t.path)).toEqual(["/a/main", "/a/tree-1"]); + }); + + test("an empty loser returns the winner unchanged", () => { + const winner = [rec({ path: "/a/main" }), rec({ path: "/a/tree-1" })]; + expect(mergeRegistries(winner, [])).toEqual(winner); + }); + + test("an empty winner returns the loser's records", () => { + const loser = [rec({ path: "/a/main", kind: "main" })]; + expect(mergeRegistries([], loser)).toEqual(loser); + }); + + test("on a shared path the managed record wins, whichever side it is on", () => { + const claimed = rec({ path: "/a/tree-1", kind: "ephemeral", state: "claimed", owner: "matt" }); + const adopted = rec({ path: "/a/tree-1", kind: "unmanaged" }); + + expect(mergeRegistries([adopted], [claimed])[0]).toEqual(claimed); + expect(mergeRegistries([claimed], [adopted])[0]).toEqual(claimed); + }); + + test("two managed records on one path: the later createdAt wins", () => { + const older = rec({ path: "/a/tree-1", kind: "ephemeral", state: "on-deck", createdAt: "2026-01-01T00:00:00.000Z" }); + const newer = rec({ path: "/a/tree-1", kind: "ephemeral", state: "claimed", createdAt: "2026-02-01T00:00:00.000Z" }); + + expect(mergeRegistries([older], [newer])[0]).toEqual(newer); + expect(mergeRegistries([newer], [older])[0]).toEqual(newer); + }); + + test("an equal createdAt keeps the winner side", () => { + const w = rec({ path: "/a/tree-1", kind: "ephemeral", state: "on-deck", owner: "winner" }); + const l = rec({ path: "/a/tree-1", kind: "ephemeral", state: "on-deck", owner: "loser" }); + expect(mergeRegistries([w], [l])[0]!.owner).toBe("winner"); + }); + + test("an unparseable createdAt never displaces the winner", () => { + const w = rec({ path: "/a/tree-1", kind: "ephemeral", createdAt: "2026-01-01T00:00:00.000Z", owner: "winner" }); + const l = rec({ path: "/a/tree-1", kind: "ephemeral", createdAt: "not a date", owner: "loser" }); + expect(mergeRegistries([w], [l])[0]!.owner).toBe("winner"); + }); + + test("a duplicate path inside one side keeps its first occurrence", () => { + const first = rec({ path: "/a/tree-1", kind: "ephemeral", owner: "first" }); + const second = rec({ path: "/a/tree-1", kind: "ephemeral", owner: "second" }); + expect(mergeRegistries([first, second], [])).toEqual([first]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test lib/worktree/__tests__/registry-merge.test.ts` +Expected: FAIL — `mergeRegistries` is not exported from `../registry.ts`. + +- [ ] **Step 3: Write the implementation** + +In `lib/worktree/registry.ts`, add `realpathSync` to the `fs` imports (the file currently imports nothing from `fs`; add `import { realpathSync } from "fs";` above the `path` import), then append after `findByPath`: + +```ts +/** Canonical path key: a tree that no longer exists compares by its own spelling rather than throwing. */ +function canonPath(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +const MANAGED_KINDS: ReadonlySet = new Set(["main", "ephemeral"]); + +/** + * Total order for two records of the same canonical path: a managed record + * carries claim/ready state no git repository has another copy of, so it beats + * `unmanaged`; within one class the later `createdAt` wins; an equal or + * unparseable stamp keeps the winner side. + */ +function heldRecordWins(held: TreeRecord, challenger: TreeRecord): boolean { + const heldManaged = MANAGED_KINDS.has(held.kind); + const challengerManaged = MANAGED_KINDS.has(challenger.kind); + if (heldManaged !== challengerManaged) return heldManaged; + return !(Date.parse(challenger.createdAt) > Date.parse(held.createdAt)); +} + +/** + * Union two registries of the SAME repo by canonical path — the collapse a + * name/identity index pair needs, where each side owns half of one on-deck + * pool. Name collisions across the two sides are left standing: the union is + * by path, and a record's name is only ever consulted for display and for + * `usedNames` disambiguation, both of which tolerate a duplicate. + */ +export function mergeRegistries(winner: TreeRecord[], loser: TreeRecord[]): TreeRecord[] { + const byPath = new Map(); + const order: string[] = []; + for (const rec of [...winner, ...loser]) { + const key = canonPath(rec.path); + const held = byPath.get(key); + if (!held) { + byPath.set(key, rec); + order.push(key); + continue; + } + if (!heldRecordWins(held, rec)) byPath.set(key, rec); + } + return order.map((key) => byPath.get(key)!); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test lib/worktree/__tests__/registry-merge.test.ts` +Expected: PASS (8 tests) + +- [ ] **Step 5: Typecheck** + +Run: `bunx tsc --noEmit` +Expected: no new errors. + +- [ ] **Step 6: Commit** + +```bash +git add lib/worktree/registry.ts lib/worktree/__tests__/registry-merge.test.ts +git commit -m "feat(worktree): mergeRegistries — union two registries of one repo by canonical path" +``` + +--- + +## Task 2: Prune merges the split registry instead of refusing + +**Files:** +- Modify: `lib/repo-index.ts:290-311` (`DataMigration.registry` doc + union), `lib/repo-index.ts:334-369` (`migrateWorktreeRegistry`) +- Modify: `commands/repos.ts:164-175` (`describeDataMove`) +- Test: `lib/__tests__/repo-index-rename.test.ts:385-464` (one existing assertion inverts; two tests added) + +**Interfaces:** +- Consumes: `mergeRegistries(winner: TreeRecord[], loser: TreeRecord[]): TreeRecord[]` from `lib/worktree/registry.ts` (Task 1). +- Produces: `DataMigration["registry"]` widens to `"moved" | "merged" | "refused" | "none"`. `migrationIncomplete(d: DataMigration): boolean` is unchanged — `"merged"` is a COMPLETE outcome, so a merged pair's index row is evicted like any other duplicate. + +- [ ] **Step 1: Write the failing tests** + +In `lib/__tests__/repo-index-rename.test.ts`, inside `describe("worktree registry migration", …)`, REPLACE the existing test `"refuses when the live name already has one — both hold real claim state"` (currently at line 399) with: + +```ts + test("merges when the live name already has one — one pool, both halves", () => { + setKvValue(WT_NS, "repo-tools", [ + { name: "t1", path: "/x/t1", kind: "ephemeral", state: "on-deck", branch: "on-deck/t1", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + setKvValue(WT_NS, "rt", [ + { name: "main", path: "/x/main", kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + + const result = migrateRepoData("repo-tools", "rt"); + + expect(result.registry).toBe("merged"); + expect((listKvValues(WT_NS)["rt"] as Array<{ path: string }>).map((t) => t.path)).toEqual(["/x/main", "/x/t1"]); + expect(listKvValues(WT_NS)["repo-tools"]).toBeUndefined(); + }); + + test("a merged registry is a COMPLETE migration — the retired index row is evicted", () => { + const dir = realRepo("repo-tools"); + indexRepoAt("repo-tools", dir, 1_000); + indexRepoAt("rt", dir, 2_000); + setKvValue(WT_NS, "repo-tools", tree("/x/retired")); + setKvValue(WT_NS, "rt", tree("/x/live")); + + const removed = pruneRepoIndex(); + + expect(removed.find((r) => r.repoName === "repo-tools")?.data?.registry).toBe("merged"); + expect(removed.find((r) => r.repoName === "repo-tools")?.retained).toBeUndefined(); + expect(loadRepoIndexEntries().map((e) => e.repoName)).toEqual(["rt"]); + }); + + test("--dry-run reports the merge without performing it", () => { + setKvValue(WT_NS, "repo-tools", tree("/x/retired")); + setKvValue(WT_NS, "rt", tree("/x/live")); + + expect(migrateRepoData("repo-tools", "rt", { dryRun: true }).registry).toBe("merged"); + + expect(listKvValues(WT_NS)["repo-tools"]).toEqual(tree("/x/retired")); + expect(listKvValues(WT_NS)["rt"]).toEqual(tree("/x/live")); + }); +``` + +Also REPLACE the existing test `"a refused registry KEEPS the index row — eviction is what makes a leftover unreachable"` (line 435) — the registry can no longer be the cause of a refusal; a refused FILE still is, and the test below it already covers that. Delete that test. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test lib/__tests__/repo-index-rename.test.ts` +Expected: FAIL — `expected "merged", got "refused"`. + +- [ ] **Step 3: Write the implementation** + +In `lib/repo-index.ts`, import the merge (registry.ts imports only `path`, `rt-paths`, and `state/index.ts`, all of which repo-index already loads — no new module weight beyond registry.ts itself): + +```ts +import { mergeRegistries, type TreeRecord } from "./worktree/registry.ts"; +``` + +Widen the `DataMigration.registry` field (`lib/repo-index.ts:310`) and its doc: + +```ts + /** + * The retired name's worktree registry: `"moved"` onto the live name, + * `"merged"` into the live name's own registry (the name/identity pair the + * identity cutover left, each side owning half of one on-deck pool), + * `"refused"` because the write could not be verified, or `"none"` if it + * had none. + * + * This lives in state.db's kv, not the data dir, so it is invisible to a + * directory walk — and it is the record the daemon keys by, so a retired + * name that keeps it while the index row goes away leaves the reconciler + * silently skipping the repo. + */ + registry: "moved" | "merged" | "refused" | "none"; +``` + +Replace the body of `migrateWorktreeRegistry` (`lib/repo-index.ts:349-369`), keeping its existing doc comment's first paragraph and replacing the final "A live name that ALREADY has a registry is refused" paragraph with the merge rule: + +```ts +/** + * Moves the retired name's worktree registry onto the live name. + * + * The daemon keys registries by the INDEX name + * (`lib/daemon/worktree-reconciler.ts` iterates the repo index), while the CLI + * looks them up by git identity. A rename splits those two, and evicting the + * retired index row then makes the registry unreachable: + * `repoHasWorktreeActivity` sees an empty registry under the live name and + * skips the repo, so the reconciler quietly stops managing its worktrees. + * That is why this moves with the data dir instead of being left behind. + * + * A live name that already has a registry is MERGED, not refused: both sides + * describe the same repo's trees, so the union by path (`mergeRegistries`) + * loses neither half of a pool that a name/identity pair split. + */ +function migrateWorktreeRegistry(from: string, to: string, opts: { dryRun?: boolean }): DataMigration["registry"] { + let outcome: "moved" | "merged"; + try { + if (!hasKvValue(WORKTREE_REGISTRY_NS, from)) return "none"; + outcome = hasKvValue(WORKTREE_REGISTRY_NS, to) ? "merged" : "moved"; + if (opts.dryRun) return outcome; + + const retired = getKvValue(WORKTREE_REGISTRY_NS, from, []); + const live = outcome === "merged" ? getKvValue(WORKTREE_REGISTRY_NS, to, []) : []; + const next = outcome === "merged" ? mergeRegistries(live, retired) : retired; + setKvValue(WORKTREE_REGISTRY_NS, to, next); + + // persistOrWarn swallows SQLITE_BUSY, so a returned write is not a landed + // one — and on a merge the destination row already existed, so its mere + // presence proves nothing. Compare the readback. + if (JSON.stringify(getKvValue(WORKTREE_REGISTRY_NS, to, [])) !== JSON.stringify(next)) { + console.warn(`rt: ${from}'s worktree registry did not persist under ${to} — leaving it in place`); + return "refused"; + } + } catch (err) { + console.warn(`rt: could not move ${from}'s worktree registry to ${to} (${(err as Error).message})`); + return "refused"; + } + deleteKvValue(WORKTREE_REGISTRY_NS, from); + return outcome; +} +``` + +In `commands/repos.ts`, extend `describeDataMove` (`commands/repos.ts:164-175`) — add the merge clause immediately after the `"moved"` clause: + +```ts + if (d.registry === "merged") parts.push(`${dryRun ? "would merge" : "merged"} the worktree registry into ${r.keptAs}'s`); + if (d.registry === "refused") parts.push(`${r.keptAs}'s worktree registry could not be written — both kept`); +``` + +(The existing `d.registry === "refused"` line is REPLACED by the wording above; `"refused"` now only ever means a failed write.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test lib/__tests__/repo-index-rename.test.ts commands/__tests__/repos.test.ts` +Expected: PASS. + +- [ ] **Step 5: Typecheck** + +Run: `bunx tsc --noEmit` +Expected: no new errors. + +- [ ] **Step 6: Commit** + +```bash +git add lib/repo-index.ts commands/repos.ts lib/__tests__/repo-index-rename.test.ts +git commit -m "feat(repos): prune merges a split worktree registry instead of refusing" +``` + +--- + +## Task 3: Prune retains a missing row that still owns a registry + +**Files:** +- Modify: `lib/repo-index.ts:273-311` (`PrunedEntry`), `lib/repo-index.ts:494-524` (`pruneRepoIndex`) +- Modify: `commands/repos.ts:187-214` (`reposPrune` output) +- Test: `lib/__tests__/repo-index-rename.test.ts` (append to `describe("pruneRepoIndex", …)`), `commands/__tests__/repos.test.ts` (append to `describe("reposPrune", …)`) + +**Interfaces:** +- Consumes: `WORKTREE_REGISTRY_NS` and `hasKvValue` (both already in `lib/repo-index.ts`). +- Produces: `PrunedEntry` gains `hint?: string`, and `retained?: true` is now set for two reasons — an incomplete duplicate migration (existing) and a `missing` row that owns a worktree registry (new). `PruneReason` is unchanged (`"missing" | "duplicate"`). + +- [ ] **Step 1: Write the failing tests** + +Append to `describe("pruneRepoIndex", …)` in `lib/__tests__/repo-index-rename.test.ts`: + +```ts + test("a missing row that still owns a worktree registry is KEPT, not evicted", () => { + indexRepoAt("moved", join(scratch, "gone-away"), 1_000); + setKvValue("worktree-registry", "moved", [ + { name: "t1", path: join(scratch, "gone-away", ".worktrees", "t1"), kind: "ephemeral", state: "on-deck", branch: "on-deck/t1", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + + const removed = pruneRepoIndex(); + const row = removed.find((r) => r.repoName === "moved"); + + expect(row).toMatchObject({ reason: "missing", retained: true, hint: "rt repos locate" }); + expect(loadRepoIndexEntries().map((e) => e.repoName)).toEqual(["moved"]); + expect(listKvValues("worktree-registry")["moved"]).toBeDefined(); + }); + + test("a missing row with no registry is still evicted", () => { + indexRepoAt("gone", join(scratch, "never-existed"), 1_000); + + const removed = pruneRepoIndex(); + + expect(removed.find((r) => r.repoName === "gone")?.retained).toBeUndefined(); + expect(loadRepoIndexEntries()).toEqual([]); + }); +``` + +Append to `describe("reposPrune", …)` in `commands/__tests__/repos.test.ts`: + +```ts + test("a retained missing row tells the operator to locate it", async () => { + const { setKvValue } = await import("../../lib/state/index.ts"); + updateRepoIndex("moved-repo", join(home, "gone-away")); + setKvValue("worktree-registry", "moved-repo", [ + { name: "t1", path: join(home, "gone-away", ".worktrees", "t1"), kind: "ephemeral", state: "on-deck", branch: "on-deck/t1", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + const deps = testDeps(); + + await reposPrune([], {}, deps); + + expect(deps.lines.join("\n")).toContain("kept moved-repo"); + expect(deps.lines.join("\n")).toContain("rt repos locate"); + expect(loadRepoIndex()["moved-repo"]).toBeDefined(); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test lib/__tests__/repo-index-rename.test.ts commands/__tests__/repos.test.ts` +Expected: FAIL — the missing row is evicted and no `hint` field exists. + +- [ ] **Step 3: Write the implementation** + +In `lib/repo-index.ts`, extend `PrunedEntry` (replacing the existing `retained` doc): + +```ts + /** + * Set when the row is KEPT despite qualifying for eviction: a `duplicate` + * whose migration could not finish, or a `missing` row that still owns a + * worktree registry. Eviction is exactly what makes those leftovers + * unreachable. + */ + retained?: true; + /** Set with `retained`: the verb that resolves this row. */ + hint?: string; +``` + +Replace the missing/live split at the top of `pruneRepoIndex` (`lib/repo-index.ts:499-502`): + +```ts + for (const entry of entries) { + if (existsSync(entry.path)) { + live.push(entry); + continue; + } + // A gone path whose registry is still here is a MOVE, not a deletion: + // dropping the row orphans the pool's claim state under a key nothing + // iterates any more. + let ownsRegistry = false; + try { + ownsRegistry = hasKvValue(WORKTREE_REGISTRY_NS, entry.repoName); + } catch { /* unreadable db — treat as no registry and prune as before */ } + removed.push({ + repoName: entry.repoName, + path: entry.path, + reason: "missing", + ...(ownsRegistry ? { retained: true as const, hint: "rt repos locate" } : {}), + }); + } +``` + +In `commands/repos.ts`, replace the `why` expression inside `reposPrune`'s print loop (`commands/repos.ts:209-211`): + +```ts + const why = r.retained + ? r.reason === "missing" + ? `${describeReason(r)} but it still owns a worktree registry — keeping the row; run: ${r.hint} --repo ${r.repoName}` + : `${describeReason(r)}, but its data could not all move${describeDataMove(r, dryRun)} — keeping the row so nothing is orphaned` + : `${describeReason(r)}${describeDataMove(r, dryRun)}`; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test lib/__tests__/repo-index-rename.test.ts commands/__tests__/repos.test.ts` +Expected: PASS. + +- [ ] **Step 5: Typecheck** + +Run: `bunx tsc --noEmit` +Expected: no new errors. + +- [ ] **Step 6: Commit** + +```bash +git add lib/repo-index.ts commands/repos.ts lib/__tests__/repo-index-rename.test.ts commands/__tests__/repos.test.ts +git commit -m "fix(repos): keep a missing index row that still owns a worktree registry" +``` + +--- + +## Task 4: Lost rows stay visible and are never a cd target + +**Files:** +- Modify: `lib/repo-index.ts:34-42` (`KnownRepo`), `lib/repo-index.ts:659-726` (`getKnownRepos`), `lib/repo-index.ts:896-909` (`repoOption`) +- Modify: `lib/pickers.ts:87-115` (`pickFromAllRepos`) +- Modify: `lib/repo.ts:194-232` (`requireRepoIdentity`), `lib/repo.ts:240-284` (`pickWorktree`) +- Test: `lib/__tests__/repo-index-missing.test.ts` (create) + +**Interfaces:** +- Consumes: `partitionByRealpath(entries: RepoIndexEntry[]): IndexPartition`, `loadRepoIndexEntries(): RepoIndexEntry[]`, `repoDataDir(key: string): string` (all existing). +- Produces: + - `interface KnownRepo` gains `missing?: true`. + - `missingRepoRefusal(r: KnownRepo): string` — exported from `lib/repo-index.ts` and re-exported through `lib/repo.ts`'s existing re-export line. + - `repoOption(r: KnownRepo)` return shape is unchanged (`{ value, label, hint, color? }`); a missing repo gets `hint: "missing — rt repos locate"` and the `dim` color. + +- [ ] **Step 1: Write the failing test** + +Create `lib/__tests__/repo-index-missing.test.ts`: + +```ts +/** + * A moved repo's index row must stay visible: hiding it makes the repo look + * unregistered and re-registers it under a second row at the new path, which + * is the split `rt repos locate` exists to prevent. + */ + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { execSync } from "child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { closeStateDb, setKvValue } from "../state/index.ts"; +import { getKnownRepos, missingRepoRefusal, repoOption } from "../repo-index.ts"; +import { pickFromAllRepos } from "../pickers.ts"; + +describe("missing index rows", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-missing-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-missing-repos-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + function realRepo(name: string): string { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + return dir; + } + + test("a row whose path is gone survives getKnownRepos, marked missing", () => { + setKvValue("repo-index", "moved", join(scratch, "gone-away")); + + const row = getKnownRepos().find((r) => r.repoName === "moved"); + + expect(row?.missing).toBe(true); + expect(row?.worktrees[0]?.path).toBe(join(scratch, "gone-away")); + }); + + test("a live row is never marked missing", () => { + setKvValue("repo-index", "alive", realRepo("alive")); + + expect(getKnownRepos().find((r) => r.repoName === "alive")?.missing).toBeUndefined(); + }); + + test("two lost rows for one directory collapse to a single missing entry", () => { + setKvValue("repo-index", "legacy-name", join(scratch, "gone-away")); + setKvValue("repo-index", "remote:gitlab.com%2Fg%2Fgone", join(scratch, "gone-away")); + + expect(getKnownRepos().filter((r) => r.missing).length).toBe(1); + }); + + test("the picker row says what to run", () => { + const opt = repoOption({ repoName: "moved", worktrees: [{ path: "/x/gone", branch: "", isBare: false }], dataDir: "/d", missing: true }); + expect(opt.hint).toBe("missing — rt repos locate"); + expect(opt.color).toBeDefined(); + }); + + test("the refusal names the repo, the gone path, and the fix", () => { + const msg = missingRepoRefusal({ repoName: "moved", worktrees: [{ path: "/x/gone", branch: "", isBare: false }], dataDir: "/d", missing: true }); + expect(msg).toContain("/x/gone"); + expect(msg).toContain("rt repos locate"); + expect(msg).toContain("--repo moved"); + }); + + test("pickFromAllRepos refuses to cd into a missing repo instead of auto-selecting it", async () => { + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit sentinel"); + }); + const errSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + await pickFromAllRepos( + [{ repoName: "moved", worktrees: [{ path: "/x/gone", branch: "", isBare: false }], dataDir: "/d", missing: true }], + { stderr: true }, + ); + throw new Error("expected pickFromAllRepos to exit"); + } catch (err) { + expect((err as Error).message).toBe("process.exit sentinel"); + expect(exitSpy.mock.calls.at(-1)?.[0]).toBe(1); + expect(errSpy.mock.calls.flat().join(" ")).toContain("rt repos locate"); + } finally { + errSpy.mockRestore(); + exitSpy.mockRestore(); + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test lib/__tests__/repo-index-missing.test.ts` +Expected: FAIL — `missingRepoRefusal` is not exported and lost rows are dropped by `getKnownRepos`. + +- [ ] **Step 3: Write the implementation** + +`lib/repo-index.ts` — extend `KnownRepo`: + +```ts + /** False for repos discovered by scanning sibling directories, never + * explicitly visited by rt. Omitted (implicitly true) for indexed repos. */ + registered?: boolean; + /** The indexed path no longer exists. The row is kept so `rt repos locate` + * can move it as one unit with its registry; it is never a cd target. */ + missing?: true; +``` + +Replace `getKnownRepos`'s partition and tail (`lib/repo-index.ts:670-725`): + +```ts + const repos: KnownRepo[] = []; + + const liveEntries: RepoIndexEntry[] = []; + const lostEntries: RepoIndexEntry[] = []; + for (const e of entries) (existsSync(e.path) ? liveEntries : lostEntries).push(e); + + // Hidden here, not evicted — see partitionByRealpath. + const { keep } = partitionByRealpath(liveEntries); + + for (const { repoName, path: mainPath } of keep) { + // …unchanged worktree enumeration… + } + + const known = repos.filter(r => r.worktrees.length > 0); + // A pair of rows for one gone directory is one lost repo, not two. + const lost: KnownRepo[] = partitionByRealpath(lostEntries).keep.map((e) => ({ + repoName: e.repoName, + worktrees: [{ path: e.path, branch: "", isBare: false }], + dataDir: repoDataDir(e.repoName), + missing: true as const, + })); + const knownNames = new Set([...known, ...lost].map(r => r.repoName)); + // realpath'd for set-membership ONLY — a symlinked path component (macOS + // /tmp → /private/tmp being the canonical case) must not let the same + // directory double-emit under two spellings. `known` itself keeps its + // original, user-visible spellings untouched. Lost paths are deliberately + // absent: the scan must be free to surface the moved repo's NEW directory. + const knownPaths = new Set(known.flatMap(r => r.worktrees.map(w => safeRealpath(w.path)))); + + return [...known, ...lost, ...scanUnregisteredRepos([...known, ...lost], knownNames, knownPaths)]; +``` + +Replace `repoOption` (`lib/repo-index.ts:896-909`) — add the missing branch first: + +```ts +export function repoOption(r: KnownRepo): { value: string; label: string; hint: string; color?: string } { + if (r.missing) { + return { value: r.repoName, label: r.repoName, hint: "missing — rt repos locate", color: dim }; + } + + const location = r.worktrees.length > 1 + ? `${r.worktrees.length} worktrees` + : r.worktrees[0]?.path.replace(homedir(), "~") || ""; + + return { + value: r.repoName, + label: r.repoName, + hint: r.registered === false + ? (location ? `${location} · unregistered` : "unregistered") + : location, + ...(r.registered === false ? { color: dim } : {}), + }; +} +``` + +Add next to it: + +```ts +/** The one-line refusal every picker prints instead of cd-ing into a repo whose indexed path is gone. */ +export function missingRepoRefusal(r: KnownRepo): string { + const gone = r.worktrees[0]?.path ?? "its indexed path"; + return `${r.repoName} is no longer at ${gone} — run: rt repos locate --repo ${r.repoName}`; +} +``` + +`lib/repo.ts` — add `missingRepoRefusal` to BOTH the re-export line (`lib/repo.ts:17`) and the internal import (`lib/repo.ts:22`), then add above `requireRepoIdentity`: + +```ts +/** Never chdir into a repo whose indexed path is gone — locate it first. */ +function refuseIfMissing(repo: KnownRepo): void { + if (!repo.missing) return; + console.log(`\n ${missingRepoRefusal(repo)}\n`); + process.exit(1); +} +``` + +In `requireRepoIdentity`, insert `refuseIfMissing(selectedRepo);` immediately before `process.chdir(selectedRepo.worktrees[0]!.path);`. + +In `pickWorktree`, insert `refuseIfMissing(repos[0]!);` as the first statement inside the `if (totalWorktrees === 1)` block, and `refuseIfMissing(selectedRepo);` immediately after the `if (repos.length === 1) … else … ` block that assigns `selectedRepo`. + +`lib/pickers.ts` — add `missingRepoRefusal` to the existing `./repo.ts` import, then in `pickFromAllRepos` insert the single-repo guard BEFORE the `await import("./rt-render.tsx")` line (so a refusal never pays for loading ink) and the picked-repo guard inside the loop: + +```ts +export async function pickFromAllRepos( + repos: KnownRepo[], + opts?: { stderr?: boolean; errorMessage?: string; includePackages?: boolean }, +): Promise { + const writer = opts?.stderr ? console.error : console.log; + + if (repos.length === 0) { + const msg = opts?.errorMessage || "no known repos found — run rt from inside a git repo first"; + writer(`\n ${msg}\n`); + process.exit(1); + } + + /** Refusing before the picker loads keeps a lost-repo-only index off the ink path entirely. */ + const refuse = (repo: KnownRepo): never => { + writer(`\n ${missingRepoRefusal(repo)}\n`); + process.exit(1); + }; + if (repos.length === 1 && repos[0]!.missing) refuse(repos[0]!); + + const { filterableSelect, BackNavigation } = await import("./rt-render.tsx"); + + // Loop: back from worktree/package picker restarts at repo picker + while (true) { + let selectedRepo: KnownRepo; + + if (repos.length === 1) { + selectedRepo = repos[0]!; + } else { + const picked = await filterableSelect({ + message: "Pick a repo", + options: repoOptionsFromList(repos), + ...(opts?.stderr ? { stderr: true } : {}), + }); + if (!picked) process.exit(1); + selectedRepo = repos.find(r => r.repoName === picked)!; + } + if (selectedRepo.missing) refuse(selectedRepo); + // …unchanged worktree resolution… +``` + +(The pre-existing `const writer = …` inside the `repos.length === 0` block is replaced by the hoisted one above.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test lib/__tests__/repo-index-missing.test.ts lib/__tests__/repo-index-rename.test.ts commands/__tests__/repos.test.ts` +Expected: PASS. + +- [ ] **Step 5: Typecheck** + +Run: `bunx tsc --noEmit` +Expected: no new errors. + +- [ ] **Step 6: Commit** + +```bash +git add lib/repo-index.ts lib/repo.ts lib/pickers.ts lib/__tests__/repo-index-missing.test.ts +git commit -m "feat(repos): keep lost index rows visible and refuse to cd into them" +``` + +--- + +## Task 5: Locate core — planLocate / applyLocate + +**Files:** +- Create: `lib/repo-locate.ts` +- Modify: `lib/worktree/registry.ts` (add `hasRegistry`, `deleteRegistry`) +- Modify: `lib/repo-index.ts` (export `setIndexPath`, `removeIndexRow`, `refreshRepoIndexMirror`; export `REPO_INDEX_NS`) +- Test: `lib/__tests__/repo-locate.test.ts` (create) + +**Interfaces:** +- Consumes: `mergeRegistries(winner, loser)` (Task 1); `loadRegistry(repoName: string): TreeRecord[]`, `saveRegistry(repoName: string, trees: TreeRecord[]): void` (existing); `loadClaims(repoName: string): EndpointClaim[]`, `saveClaims(repoName: string, claims: EndpointClaim[]): void` from `lib/endpoint/store.ts`; `loadRepoIndexEntries(): RepoIndexEntry[]`, `migrateRepoData(from, to, opts?): DataMigration`, `migrationIncomplete(d): boolean`, `getKnownRepos(): KnownRepo[]` (existing); `deriveRepoIdentity`, `serializeIdentity`, `parseIdentity` from `lib/settings/identity.ts`; `runGit(cwd, args, opts?)`, `listWorktreesAsync(repoPath): Promise` from `lib/worktree/git-async.ts`; `getStateDb(): Database` from `lib/state/index.ts`. +- Produces (from `lib/repo-locate.ts`): + - `type LocateRefusalCode = "not-a-git-repo" | "nothing-lost" | "old-path-exists" | "identity-mismatch" | "identity-changed"` + - `interface LocateRefusal { refusal: LocateRefusalCode; message: string }` + - `interface RegistryRewrite { repoKey: string; trees: TreeRecord[]; movedPaths: string[] }` + - `interface ClaimRewrite { repoKey: string; worktree: string; newWorktree: string }` + - `interface LocatePlan { identity: string; oldPath: string; newPath: string; indexKeys: string[]; legacyKeys: string[]; registryRewrites: RegistryRewrite[]; claimRewrites: ClaimRewrite[]; gitRepairPaths: string[] }` + - `interface LocateResult { ok: boolean; identity: string; from: string; to: string; indexKeys: string[]; treesRewritten: number; claimsRewritten: number; repaired: string[]; stalePaths: string[]; legacyRows: { key: string; outcome: "collapsed" | "retained" }[]; restored?: true; error?: string }` + - `interface LocateCandidate { path: string; identity: string }` + - `planLocate(opts: { newPath: string; repo?: string }): Promise` + - `applyLocate(plan: LocatePlan): Promise` + - `findLocateCandidates(): Promise` + - `isRefusal(x: LocatePlan | LocateRefusal): x is LocateRefusal` +- Produces (from `lib/worktree/registry.ts`): `hasRegistry(repoName: string): boolean`, `deleteRegistry(repoName: string): void`. +- Produces (from `lib/repo-index.ts`): `REPO_INDEX_NS` (exported const, value `"repo-index"`), `setIndexPath(key: string, mainPath: string): void`, `removeIndexRow(key: string): void`, `refreshRepoIndexMirror(): void`. + +- [ ] **Step 1: Write the failing test** + +Create `lib/__tests__/repo-locate.test.ts`: + +```ts +/** + * The locate core: plan a move by identity, then apply index + registry + + * claim + git-admin rewrites as one unit. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execSync } from "child_process"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { closeStateDb, listEndpointClaims, setKvValue } from "../state/index.ts"; +import { loadRepoIndex } from "../repo-index.ts"; +import { loadRegistry, saveRegistry, type TreeRecord } from "../worktree/registry.ts"; +import { saveClaims } from "../endpoint/store.ts"; +import { deriveRepoIdentity, serializeIdentity } from "../settings/identity.ts"; +import { applyLocate, isRefusal, planLocate } from "../repo-locate.ts"; + +describe("repo locate", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-repos-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + /** A repo with an origin remote, so its identity is remote-kind and survives the move. */ + function repoWithRemote(name: string): string { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync(`git remote add origin https://gitlab.com/g/${name}.git`, { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + return realpathSync(dir); + } + + function localRepo(name: string): string { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + return realpathSync(dir); + } + + function rec(over: Partial & { path: string }): TreeRecord { + return { name: "t", kind: "unmanaged", branch: null, createdAt: "2026-01-01T00:00:00.000Z", ...over }; + } + + test("a directory that is not a git repo is refused", async () => { + const plain = join(scratch, "plain"); + mkdirSync(plain); + const out = await planLocate({ newPath: plain }); + expect(isRefusal(out) && out.refusal).toBe("not-a-git-repo"); + }); + + test("nothing lost in the index is refused", async () => { + const repo = repoWithRemote("alpha"); + const out = await planLocate({ newPath: repo }); + expect(isRefusal(out) && out.refusal).toBe("nothing-lost"); + }); + + test("a derived identity matching no lost row refuses and names both sides", async () => { + setKvValue("repo-index", "remote:gitlab.com%2Fg%2Fsomething-else", join(scratch, "gone")); + const repo = repoWithRemote("beta"); + + const out = await planLocate({ newPath: repo }); + + expect(isRefusal(out) && out.refusal).toBe("identity-mismatch"); + expect(isRefusal(out) && out.message).toContain("remote:gitlab.com%2Fg%2Fbeta"); + expect(isRefusal(out) && out.message).toContain("remote:gitlab.com%2Fg%2Fsomething-else"); + }); + + test("a remote-less repo is refused: its identity IS its path, so a move mints a new one", async () => { + setKvValue("repo-index", `path:${encodeURIComponent(join(scratch, "gone"))}`, join(scratch, "gone")); + const repo = localRepo("gamma"); + + const out = await planLocate({ newPath: repo }); + + expect(isRefusal(out) && out.refusal).toBe("identity-changed"); + expect(isRefusal(out) && out.message).toContain("rt repos register"); + }); + + test("an old path that still exists is a second clone, not a move", async () => { + const original = repoWithRemote("delta"); + const clone = join(scratch, "delta-clone"); + execSync(`git clone -q ${original} ${clone}`, { stdio: "pipe" }); + execSync(`git remote set-url origin https://gitlab.com/g/delta.git`, { cwd: clone, stdio: "pipe" }); + setKvValue("repo-index", serializeIdentity(await deriveRepoIdentity(original)), original); + + const out = await planLocate({ newPath: realpathSync(clone) }); + + expect(isRefusal(out) && out.refusal).toBe("old-path-exists"); + }); + + test("plans the index keys, registry rewrite, claim rewrite and repair paths of a moved repo", async () => { + const repo = repoWithRemote("epsilon"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue("repo-index", identity, repo); + setKvValue("repo-index", "epsilon-legacy", repo); + saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); + saveRegistry("epsilon-legacy", [rec({ name: "t1", path: treePath, kind: "ephemeral", state: "on-deck", branch: "feat" })]); + saveClaims(identity, [{ worktree: treePath, role: "web", port: 4001, ts: "2026-01-01T00:00:00.000Z" }]); + + const moved = join(scratch, "epsilon-moved"); + renameSync(repo, moved); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + + expect(plan.identity).toBe(identity); + expect(plan.oldPath).toBe(repo); + expect(plan.newPath).toBe(moved); + expect(plan.indexKeys.sort()).toEqual([identity, "epsilon-legacy"].sort()); + expect(plan.legacyKeys).toEqual(["epsilon-legacy"]); + expect(plan.gitRepairPaths).toEqual([join(moved, ".worktrees", "t1")]); + expect(plan.claimRewrites).toEqual([ + { repoKey: identity, worktree: treePath, newWorktree: join(moved, ".worktrees", "t1") }, + ]); + }); + + test("apply re-points the index, merges the pair's registries, rewrites claims and repairs git", async () => { + const repo = repoWithRemote("zeta"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue("repo-index", identity, repo); + setKvValue("repo-index", "zeta-legacy", repo); + saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); + saveRegistry("zeta-legacy", [rec({ name: "t1", path: treePath, kind: "ephemeral", state: "claimed", owner: "matt", branch: "feat" })]); + saveClaims(identity, [{ worktree: treePath, role: "web", port: 4001, ts: "2026-01-01T00:00:00.000Z" }]); + + const moved = join(scratch, "zeta-moved"); + renameSync(repo, moved); + const newTree = join(moved, ".worktrees", "t1"); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + const result = await applyLocate(plan); + + expect(result.ok).toBe(true); + expect(loadRepoIndex()[identity]).toBe(moved); + expect(loadRepoIndex()["zeta-legacy"]).toBeUndefined(); + expect(loadRegistry(identity).map((t) => t.path).sort()).toEqual([moved, newTree].sort()); + expect(loadRegistry(identity).find((t) => t.path === newTree)).toMatchObject({ state: "claimed", owner: "matt" }); + expect(listEndpointClaims(identity)[0]?.worktree).toBe(newTree); + expect( + execSync("git worktree list --porcelain", { cwd: moved, encoding: "utf8" }), + ).toContain(newTree); + expect(result.legacyRows).toEqual([{ key: "zeta-legacy", outcome: "collapsed" }]); + }); + + test("a registry record whose tree is gone is reported stale, not a failure", async () => { + const repo = repoWithRemote("eta"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + setKvValue("repo-index", identity, repo); + saveRegistry(identity, [ + rec({ name: "main", path: repo, kind: "main", branch: "main" }), + rec({ name: "ghost", path: join(repo, ".worktrees", "ghost"), kind: "ephemeral", state: "on-deck" }), + ]); + + const moved = join(scratch, "eta-moved"); + renameSync(repo, moved); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + const result = await applyLocate(plan); + + expect(result.ok).toBe(true); + expect(result.stalePaths).toEqual([join(moved, ".worktrees", "ghost")]); + expect(loadRepoIndex()[identity]).toBe(moved); + }); + + test("a failed verification restores the pre-apply rows", async () => { + const repo = repoWithRemote("theta"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue("repo-index", identity, repo); + saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); + + const moved = join(scratch, "theta-moved"); + renameSync(repo, moved); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + // A directory that exists but git will never list: the exact shape a + // failed `git worktree repair` leaves behind. + const decoy = join(moved, "decoy"); + mkdirSync(decoy, { recursive: true }); + plan.registryRewrites[0]!.movedPaths.push(decoy); + + const result = await applyLocate(plan); + + expect(result.ok).toBe(false); + expect(result.restored).toBe(true); + expect(loadRepoIndex()[identity]).toBe(repo); + expect(loadRegistry(identity)[0]?.path).toBe(repo); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test lib/__tests__/repo-locate.test.ts` +Expected: FAIL — `lib/repo-locate.ts` does not exist. + +- [ ] **Step 3: Add the store primitives the core needs** + +In `lib/worktree/registry.ts`, add `deleteKvValue` to the `../state/index.ts` import and append: + +```ts +/** Whether this repo has a registry row at all — distinct from an empty registry. */ +export function hasRegistry(repoName: string): boolean { + return hasKvValue(WORKTREE_REGISTRY_NS, repoName); +} + +/** Drop a whole registry row. Only ever the retired half of a pair, after its records have been merged onto the survivor. */ +export function deleteRegistry(repoName: string): void { + deleteKvValue(WORKTREE_REGISTRY_NS, repoName); + epochs.set(repoName, registryEpoch(repoName) + 1); +} +``` + +In `lib/repo-index.ts`, export the namespace constant (`lib/repo-index.ts:50` — change `const REPO_INDEX_NS` to `export const REPO_INDEX_NS`) and add, next to `updateRepoIndex`: + +```ts +/** + * Raw index-row write: no git probe, no move detection. `updateRepoIndex` is + * the caller-facing path that DERIVES the main path; this is the primitive for + * a caller that has already decided what the row must say, and it is the only + * index write that is safe to run inside a state.db transaction. + */ +export function setIndexPath(key: string, mainPath: string): void { + setKvValue(REPO_INDEX_NS, key, mainPath); +} + +/** Drop one index row. */ +export function removeIndexRow(key: string): void { + deleteKvValue(REPO_INDEX_NS, key); +} + +/** Rewrite ~/.mattstack/rt/repos.json from the current rows — a FILE write, so it runs after a transaction commits, never inside one. */ +export function refreshRepoIndexMirror(): void { + try { + writeRepoIndexCompat(loadRepoIndex()); + } catch { /* best effort — see repoIndexCompatPath's doc comment */ } +} +``` + +- [ ] **Step 4: Write `lib/repo-locate.ts`** + +```ts +/** + * Repo locate: re-point every literal path rt stores for a repo whose folder + * moved, as one unit. + * + * Ordering is the whole point. The reconciler prunes a registry row whose path + * is absent from `git worktree list`, so an index row that heals ahead of the + * registry destroys claimed/on-deck state and replenish then mints replacement + * trees. Everything that can be written atomically goes in one state.db + * transaction; `git worktree repair` and the verification run after it, and a + * verification failure puts the pre-apply rows back. + * + * Pure of the daemon and the CLI: `lib/daemon/handlers/repos.ts` and + * `commands/repos.ts` both drive these functions, and neither the caller nor + * the transport is visible from here. + */ + +import { existsSync, realpathSync } from "fs"; +import { join, resolve as resolvePath } from "path"; +import { + getKnownRepos, + loadRepoIndexEntries, + migrateRepoData, + migrationIncomplete, + refreshRepoIndexMirror, + removeIndexRow, + setIndexPath, + type RepoIndexEntry, +} from "./repo-index.ts"; +import { + deleteRegistry, + hasRegistry, + loadRegistry, + mergeRegistries, + saveRegistry, + type TreeRecord, +} from "./worktree/registry.ts"; +import { loadClaims, saveClaims, type EndpointClaim } from "./endpoint/store.ts"; +import { deriveRepoIdentity, parseIdentity, serializeIdentity } from "./settings/identity.ts"; +import { getStateDb } from "./state/index.ts"; +import { listWorktreesAsync, runGit } from "./worktree/git-async.ts"; + +export type LocateRefusalCode = + | "not-a-git-repo" + | "nothing-lost" + | "old-path-exists" + | "identity-mismatch" + | "identity-changed"; + +export interface LocateRefusal { + refusal: LocateRefusalCode; + message: string; +} + +export interface RegistryRewrite { + /** Index key this registry belongs to: the identity, or a legacy-name half of a healed pair. */ + repoKey: string; + /** The whole registry after the re-root, in its original order. */ + trees: TreeRecord[]; + /** New spellings of the records this move re-rooted — what verification checks. */ + movedPaths: string[]; +} + +export interface ClaimRewrite { + repoKey: string; + worktree: string; + newWorktree: string; +} + +export interface LocatePlan { + identity: string; + oldPath: string; + newPath: string; + indexKeys: string[]; + /** Every `indexKeys` entry that is not the identity — collapsed after a verified apply. */ + legacyKeys: string[]; + registryRewrites: RegistryRewrite[]; + claimRewrites: ClaimRewrite[]; + /** In-tree worktree paths (new spellings, main excluded) handed to `git worktree repair`. */ + gitRepairPaths: string[]; +} + +export interface LocateResult { + ok: boolean; + identity: string; + from: string; + to: string; + indexKeys: string[]; + treesRewritten: number; + claimsRewritten: number; + repaired: string[]; + /** Re-rooted registry paths with nothing on disk — a record the reconciler will prune, never a locate failure. */ + stalePaths: string[]; + legacyRows: { key: string; outcome: "collapsed" | "retained" }[]; + restored?: true; + error?: string; +} + +export interface LocateCandidate { + path: string; + identity: string; +} + +export function isRefusal(x: LocatePlan | LocateRefusal): x is LocateRefusal { + return "refusal" in x; +} + +function refuse(refusal: LocateRefusalCode, message: string): LocateRefusal { + return { refusal, message }; +} + +/** realpathSync, degrading to the literal spelling — a gone path must compare, not throw. */ +function canon(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +/** `path` re-rooted onto `newPath`, or null when it lives outside the moved tree (an external worktree keeps its own path). */ +function relocatePath(path: string, oldPath: string, newPath: string): string | null { + if (path === oldPath) return newPath; + if (path.startsWith(`${oldPath}/`)) return newPath + path.slice(oldPath.length); + return null; +} + +/** + * Resolve which index rows a move touches, matching by IDENTITY only. + * + * A legacy-name row joins the plan through the identity row it shares a lost + * directory with — never by name, which is exactly the drift identities exist + * to end. + */ +export async function planLocate(opts: { newPath: string; repo?: string }): Promise { + const newPath = canon(resolvePath(opts.newPath)); + if (!existsSync(join(newPath, ".git"))) { + return refuse("not-a-git-repo", `${newPath} is not a git repository`); + } + + const identity = serializeIdentity(await deriveRepoIdentity(newPath)); + const entries = loadRepoIndexEntries(); + const lost = entries.filter((e) => !existsSync(e.path)); + + const named: RepoIndexEntry | null = opts.repo ? entries.find((e) => e.repoName === opts.repo) ?? null : null; + if (opts.repo && !named) { + return refuse("nothing-lost", `--repo ${opts.repo} is not in the repo index`); + } + if (named && existsSync(named.path)) { + return refuse("old-path-exists", `${opts.repo} is indexed at ${named.path}, which still exists — that is a second clone, not a move`); + } + + const identityRow = entries.find((e) => e.repoName === identity) ?? null; + if (identityRow && existsSync(identityRow.path)) { + return canon(identityRow.path) === newPath + ? refuse("nothing-lost", `${identity} is already indexed at ${newPath}`) + : refuse("old-path-exists", `${identity} is already indexed at ${identityRow.path}, which still exists — that is a second clone, not a move`); + } + + if (!identityRow) { + if (parseIdentity(identity)?.kind === "path") { + return refuse( + "identity-changed", + `${newPath} derives ${identity}, and no index row is keyed by it. A repo with no origin remote is identified BY its main worktree's path, so moving it mints a new identity rather than keeping the old one — locate re-points paths, it never re-keys a repo. Register the new path instead: rt repos register ${newPath}`, + ); + } + return refuse( + "identity-mismatch", + `${newPath} derives ${identity}, which matches no indexed repo whose path is missing (lost rows: ${lost.map((e) => e.repoName).join(", ") || "none"})`, + ); + } + if (named && canon(named.path) !== canon(identityRow.path)) { + return refuse( + "identity-mismatch", + `${newPath} derives ${identity} (indexed at ${identityRow.path}), but --repo names ${named.repoName} at ${named.path} — locate matches by identity, never by name`, + ); + } + + const oldPath = identityRow.path; + const indexKeys = lost.filter((e) => e.path === oldPath).map((e) => e.repoName); + const legacyKeys = indexKeys.filter((key) => key !== identity); + + const registryRewrites: RegistryRewrite[] = []; + const repairPaths = new Set(); + for (const key of indexKeys) { + if (!hasRegistry(key)) continue; + const movedPaths: string[] = []; + const trees = loadRegistry(key).map((rec) => { + const moved = relocatePath(rec.path, oldPath, newPath); + if (moved === null) return rec; + movedPaths.push(moved); + if (moved !== newPath) repairPaths.add(moved); + return { ...rec, path: moved }; + }); + registryRewrites.push({ repoKey: key, trees, movedPaths }); + } + + const claimRewrites: ClaimRewrite[] = []; + for (const key of indexKeys) { + for (const claim of loadClaims(key)) { + const moved = relocatePath(claim.worktree, oldPath, newPath); + if (moved === null) continue; + claimRewrites.push({ repoKey: key, worktree: claim.worktree, newWorktree: moved }); + } + } + + return { + identity, + oldPath, + newPath, + indexKeys, + legacyKeys, + registryRewrites, + claimRewrites, + gitRepairPaths: [...repairPaths], + }; +} + +interface LocateSnapshot { + index: { key: string; path: string | null }[]; + registries: { key: string; trees: TreeRecord[]; existed: boolean }[]; + claims: { key: string; claims: EndpointClaim[] }[]; +} + +function captureSnapshot(plan: LocatePlan): LocateSnapshot { + const claimKeys = [...new Set(plan.claimRewrites.map((c) => c.repoKey))]; + const entries = loadRepoIndexEntries(); + return { + index: [...new Set([...plan.indexKeys, plan.identity])].map((key) => ({ + key, + path: entries.find((e) => e.repoName === key)?.path ?? null, + })), + registries: [...new Set([...plan.registryRewrites.map((r) => r.repoKey), plan.identity])].map((key) => ({ + key, + trees: loadRegistry(key), + existed: hasRegistry(key), + })), + claims: claimKeys.map((key) => ({ key, claims: loadClaims(key) })), + }; +} + +function restoreSnapshot(snapshot: LocateSnapshot): void { + getStateDb().transaction(() => { + for (const row of snapshot.index) { + if (row.path === null) removeIndexRow(row.key); + else setIndexPath(row.key, row.path); + } + for (const reg of snapshot.registries) { + if (reg.existed) saveRegistry(reg.key, reg.trees); + else deleteRegistry(reg.key); + } + for (const c of snapshot.claims) saveClaims(c.key, c.claims); + })(); +} + +/** + * The registry half of the apply: the pair's registries are merged onto the + * IDENTITY key and every legacy registry row is dropped, so the reconciler + * (which iterates identity keys) sees one pool instead of two halves. + */ +function writeRegistries(plan: LocatePlan): void { + const byKey = new Map(plan.registryRewrites.map((r) => [r.repoKey, r.trees])); + let merged = byKey.get(plan.identity) ?? loadRegistry(plan.identity); + let touched = byKey.has(plan.identity); + for (const key of plan.legacyKeys) { + const legacy = byKey.get(key); + if (!legacy) continue; + merged = mergeRegistries(merged, legacy); + deleteRegistry(key); + touched = true; + } + if (touched) saveRegistry(plan.identity, merged); +} + +function writeClaims(plan: LocatePlan): void { + for (const key of new Set(plan.claimRewrites.map((c) => c.repoKey))) { + const moves = new Map( + plan.claimRewrites.filter((c) => c.repoKey === key).map((c) => [c.worktree, c.newWorktree]), + ); + saveClaims( + key, + loadClaims(key).map((c) => { + const moved = moves.get(c.worktree); + return moved === undefined ? c : { ...c, worktree: moved }; + }), + ); + } +} + +/** + * Every re-rooted tree that exists on disk must also be one git knows about; + * a re-rooted tree with nothing on disk is a stale record, which the + * reconciler prunes on its own and which must not fail an otherwise correct + * move. + */ +async function verifyLocate(plan: LocatePlan): Promise<{ error: string | null; stalePaths: string[] }> { + const listed = await listWorktreesAsync(plan.newPath); + if (listed === null) return { error: `git worktree list failed in ${plan.newPath}`, stalePaths: [] }; + const known = new Set(listed.map((w) => canon(w.path))); + if (!known.has(canon(plan.newPath))) { + return { error: `${plan.newPath} is not the main worktree git reports`, stalePaths: [] }; + } + + const stalePaths: string[] = []; + for (const rewrite of plan.registryRewrites) { + for (const path of rewrite.movedPaths) { + if (!existsSync(path)) { + stalePaths.push(path); + continue; + } + if (!known.has(canon(path))) { + return { error: `${path} exists but git does not list it as a worktree of ${plan.newPath}`, stalePaths }; + } + } + } + return { error: null, stalePaths }; +} + +/** + * Collapse the legacy half of a healed pair, on prune's rules: the row is + * dropped only once its data dir has fully moved, because eviction is what + * makes a leftover unreachable. + */ +function collapseLegacyRows(plan: LocatePlan): LocateResult["legacyRows"] { + const out: LocateResult["legacyRows"] = []; + for (const key of plan.legacyKeys) { + const data = migrateRepoData(key, plan.identity); + if (migrationIncomplete(data)) { + out.push({ key, outcome: "retained" }); + continue; + } + removeIndexRow(key); + out.push({ key, outcome: "collapsed" }); + } + return out; +} + +export async function applyLocate(plan: LocatePlan): Promise { + const snapshot = captureSnapshot(plan); + const base = { + identity: plan.identity, + from: plan.oldPath, + to: plan.newPath, + indexKeys: plan.indexKeys, + treesRewritten: plan.registryRewrites.reduce((n, r) => n + r.movedPaths.length, 0), + claimsRewritten: plan.claimRewrites.length, + }; + + // bun:sqlite transactions are sync-only: every git call lives below this + // block, never inside it. + getStateDb().transaction(() => { + for (const key of plan.indexKeys) setIndexPath(key, plan.newPath); + setIndexPath(plan.identity, plan.newPath); + writeRegistries(plan); + writeClaims(plan); + })(); + refreshRepoIndexMirror(); + + if (plan.gitRepairPaths.length > 0) { + await runGit(plan.newPath, ["worktree", "repair", ...plan.gitRepairPaths]); + } + await runGit(plan.newPath, ["worktree", "repair"]); + + const { error, stalePaths } = await verifyLocate(plan); + if (error !== null) { + restoreSnapshot(snapshot); + refreshRepoIndexMirror(); + return { ...base, ok: false, repaired: plan.gitRepairPaths, stalePaths, legacyRows: [], restored: true, error }; + } + + const legacyRows = collapseLegacyRows(plan); + refreshRepoIndexMirror(); + return { ...base, ok: true, repaired: plan.gitRepairPaths, stalePaths, legacyRows }; +} + +/** + * Directories the `rt.repoRoots` scan surfaced whose derived identity is one + * of the index's lost rows — the candidate set `rt repos locate` offers when + * it is given no path. Never auto-picked: this only proposes. + */ +export async function findLocateCandidates(): Promise { + const repos = getKnownRepos(); + const lostKeys = new Set(repos.filter((r) => r.missing).map((r) => r.repoName)); + if (lostKeys.size === 0) return []; + + const candidates: LocateCandidate[] = []; + for (const repo of repos) { + if (repo.registered !== false) continue; + const path = repo.worktrees[0]?.path; + if (!path) continue; + let identity: string; + try { + identity = serializeIdentity(await deriveRepoIdentity(path)); + } catch { + continue; + } + if (!lostKeys.has(identity)) continue; + candidates.push({ path, identity }); + } + return candidates; +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `bun test lib/__tests__/repo-locate.test.ts` +Expected: PASS (9 tests) + +- [ ] **Step 6: Typecheck and re-run the neighbours** + +Run: `bunx tsc --noEmit && bun test lib/__tests__/repo-index-rename.test.ts lib/__tests__/repo-index-missing.test.ts lib/worktree/__tests__/registry-merge.test.ts` +Expected: no errors; all PASS. + +- [ ] **Step 7: Commit** + +```bash +git add lib/repo-locate.ts lib/repo-index.ts lib/worktree/registry.ts lib/__tests__/repo-locate.test.ts +git commit -m "feat(repos): locate core — plan and apply a moved repo's path rewrites atomically" +``` + +--- + +## Task 6: `withReconcilerHeld` on the worktree reconciler + +**Files:** +- Modify: `lib/daemon/worktree-reconciler.ts:1034-1129` (`createWorktreeReconciler`) +- Test: `lib/daemon/__tests__/reconciler-hold.test.ts` (create) + +**Interfaces:** +- Consumes: nothing new. +- Produces: `createWorktreeReconciler(deps: ReconcilerDeps)` return type gains + `withReconcilerHeld: (fn: () => Promise) => Promise` — awaits any pass already in flight, blocks `kick()` from starting a new pass until `fn` settles (a kick arriving during the hold is queued and fires once, on release), and serializes concurrent holders. Existing members (`kick`, `runOnce`, `creationInFlight`, `passInFlight`) are unchanged. + +- [ ] **Step 1: Write the failing test** + +Create `lib/daemon/__tests__/reconciler-hold.test.ts`: + +```ts +/** + * The hold `repos:locate` runs inside: a reconcile pass that observed a healed + * index path against un-rewritten registry paths prunes every registry row as + * "no matching worktree", taking the pool's claim state with it. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, realpathSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import type { Logger } from "pino"; +import { closeStateDb } from "../../state/index.ts"; +import { createWorktreeReconciler } from "../worktree-reconciler.ts"; + +const silentLog = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as unknown as Logger; + +/** An empty index makes each pass a no-op with real awaits — enough to observe pass boundaries without any git. */ +function harness(order: string[]) { + return createWorktreeReconciler({ + cache: { entries: {} }, + repoIndex: () => { + order.push("pass"); + return {}; + }, + emit: () => {}, + log: silentLog, + }); +} + +async function settle(reconciler: { passInFlight: () => boolean }): Promise { + for (let i = 0; i < 200 && reconciler.passInFlight(); i++) await Bun.sleep(5); +} + +describe("withReconcilerHeld", () => { + const origHome = process.env.HOME; + let home: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-hold-home-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(async () => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + }); + + test("a pass already in flight finishes before the held fn runs", async () => { + const order: string[] = []; + const reconciler = harness(order); + + reconciler.kick(); + await reconciler.withReconcilerHeld(async () => { + order.push("fn"); + }); + + expect(order).toEqual(["pass", "fn"]); + }); + + test("a kick during the hold starts no pass until the fn settles", async () => { + const order: string[] = []; + const reconciler = harness(order); + + await reconciler.withReconcilerHeld(async () => { + reconciler.kick(); + await Bun.sleep(10); + expect(order).toEqual([]); + order.push("fn-done"); + }); + + await settle(reconciler); + expect(order).toEqual(["fn-done", "pass"]); + }); + + test("two holders serialize", async () => { + const order: string[] = []; + const reconciler = harness(order); + + await Promise.all([ + reconciler.withReconcilerHeld(async () => { + order.push("a-start"); + await Bun.sleep(10); + order.push("a-end"); + }), + reconciler.withReconcilerHeld(async () => { + order.push("b-start"); + await Bun.sleep(1); + order.push("b-end"); + }), + ]); + + expect(order).toEqual(["a-start", "a-end", "b-start", "b-end"]); + }); + + test("a throwing fn releases the hold", async () => { + const order: string[] = []; + const reconciler = harness(order); + + await expect( + reconciler.withReconcilerHeld(async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + + reconciler.kick(); + await settle(reconciler); + expect(order).toEqual(["pass"]); + }); + + test("the fn's value comes back to the caller", async () => { + const reconciler = harness([]); + expect(await reconciler.withReconcilerHeld(async () => 42)).toBe(42); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test lib/daemon/__tests__/reconciler-hold.test.ts` +Expected: FAIL — `reconciler.withReconcilerHeld is not a function`. + +- [ ] **Step 3: Write the implementation** + +In `lib/daemon/worktree-reconciler.ts`, extend the return type of `createWorktreeReconciler` (after `passInFlight`): + +```ts + /** + * Run `fn` with the reconciler held: any pass in flight is awaited first, + * and `kick()` starts no new pass until `fn` settles (one queued kick fires + * on release). A holder rewrites registry paths that a concurrent pass would + * read as "no matching worktree" and prune, taking the pool's claim state + * with it. Holders serialize. + */ + withReconcilerHeld: (fn: () => Promise) => Promise; +``` + +Replace the closure state and `kick`, and add the holder, inside the function body: + +```ts + let inFlight: Promise | null = null; + /** Non-null while a holder owns the reconciler. */ + let hold: Promise | null = null; + let kickQueued = false; + const creationPromises = new Map>(); +``` + +```ts + function kick(): void { + if (hold) { + kickQueued = true; + return; + } + if (inFlight) return; + const p = runOnce() + .catch((err) => { + deps.log.warn({ err }, "worktree reconciler: kick failed"); + }) + .finally(() => { + if (inFlight === p) inFlight = null; + }); + inFlight = p; + } + + async function withReconcilerHeld(fn: () => Promise): Promise { + while (hold) await hold; + let release!: () => void; + hold = new Promise((resolve) => { + release = resolve; + }); + try { + // A pass that started before the hold was taken still reads the rows the + // holder is about to rewrite, so it has to finish first. + while (inFlight) await inFlight; + return await fn(); + } finally { + hold = null; + release(); + if (kickQueued) { + kickQueued = false; + kick(); + } + } + } +``` + +And add `withReconcilerHeld` to the returned object: + +```ts + return { kick, runOnce, creationInFlight, passInFlight, withReconcilerHeld }; +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test lib/daemon/__tests__/reconciler-hold.test.ts` +Expected: PASS (5 tests) + +- [ ] **Step 5: Re-run the reconciler suite and typecheck** + +Run: `bunx tsc --noEmit && bun test lib/daemon/__tests__/worktree-reconciler.test.ts` +Expected: no errors; PASS with no delta from baseline. + +- [ ] **Step 6: Commit** + +```bash +git add lib/daemon/worktree-reconciler.ts lib/daemon/__tests__/reconciler-hold.test.ts +git commit -m "feat(daemon): withReconcilerHeld — exclusive access to the worktree registry" +``` + +--- + +## Task 7: The `repos:locate` daemon verb + +**Files:** +- Create: `lib/daemon/handlers/repos.ts` +- Modify: `lib/daemon/command-router.ts:37-89` (opts + spread) +- Modify: `lib/daemon.ts:386-398` (`buildRoutedHandlers` call) +- Modify: `lib/daemon/__tests__/rt-client-commands.test.ts:36-44` (stub the new opt) +- Test: `lib/daemon/__tests__/repos-handlers.test.ts` (create) + +**Interfaces:** +- Consumes: `planLocate(opts: { newPath: string; repo?: string })`, `applyLocate(plan)`, `isRefusal(x)`, `LocatePlan`, `LocateResult` (Task 5); `withReconcilerHeld: (fn: () => Promise) => Promise` (Task 6); `hooksGuard.refreshWatchedRepos(): void` (`lib/daemon/hooks-guard.ts:100`); the router's local `emitEvent(topic: string, payload: unknown): void`. +- Produces: + - `interface ReposHandlerOpts { withReconcilerHeld: (fn: () => Promise) => Promise; refreshWatchedRepos: () => void; emitEvent: (topic: string, payload: unknown) => void }` + - `createReposHandlers(opts: ReposHandlerOpts): Record<"repos:locate", (payload: any) => Promise> & HandlerMap` + - `buildRoutedHandlers` opts gain `repos: { withReconcilerHeld: (fn: () => Promise) => Promise; refreshWatchedRepos: () => void }`. + - Wire contract: `POST /repos:locate` with `{ newPath: string; repo?: string; dryRun?: boolean }` → `{ ok: true, data: LocateResult }`, or `{ ok: true, data: { dryRun: true, plan: LocatePlan } }`, or `{ ok: false, error: string }`. `repo` is a serialized identity; a non-identity is rejected `repo-unknown`. + - Event: `repo:moved` with payload `{ identity: string; from: string; to: string }`. + +- [ ] **Step 1: Write the failing test** + +Create `lib/daemon/__tests__/repos-handlers.test.ts`: + +```ts +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execSync } from "child_process"; +import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { closeStateDb, setKvValue } from "../../state/index.ts"; +import { loadRepoIndex } from "../../repo-index.ts"; +import { saveRegistry } from "../../worktree/registry.ts"; +import { deriveRepoIdentity, serializeIdentity } from "../../settings/identity.ts"; +import { createReposHandlers } from "../handlers/repos.ts"; + +describe("repos:locate", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + let order: string[]; + let events: { topic: string; payload: unknown }[]; + let handlers: ReturnType; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-repos-handler-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-repos-handler-repos-"))); + process.env.HOME = home; + closeStateDb(); + order = []; + events = []; + handlers = createReposHandlers({ + withReconcilerHeld: async (fn) => { + order.push("hold-start"); + try { + return await fn(); + } finally { + order.push("hold-end"); + } + }, + refreshWatchedRepos: () => order.push("refresh"), + emitEvent: (topic, payload) => { + order.push(`emit:${topic}`); + events.push({ topic, payload }); + }, + }); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + async function movedRepo(name: string): Promise<{ identity: string; from: string; to: string }> { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync(`git remote add origin https://gitlab.com/g/${name}.git`, { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + const from = realpathSync(dir); + const identity = serializeIdentity(await deriveRepoIdentity(from)); + setKvValue("repo-index", identity, from); + saveRegistry(identity, [{ name: "main", path: from, kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }]); + const to = join(scratch, `${name}-moved`); + renameSync(from, to); + return { identity, from, to }; + } + + test("a missing newPath is rejected", async () => { + expect(await handlers["repos:locate"]({})).toEqual({ ok: false, error: "newPath-required" }); + }); + + test("a non-identity repo key is rejected, not name-resolved", async () => { + const res = await handlers["repos:locate"]({ newPath: scratch, repo: "repo-tools" }); + expect(res).toEqual({ ok: false, error: "repo-unknown" }); + expect(order).toEqual([]); + }); + + test("applies inside the hold, refreshes watchers, then emits repo:moved", async () => { + const { identity, from, to } = await movedRepo("alpha"); + + const res = await handlers["repos:locate"]({ newPath: to }); + + expect(res.ok).toBe(true); + expect(loadRepoIndex()[identity]).toBe(to); + expect(order).toEqual(["hold-start", "refresh", "emit:repo:moved", "hold-end"]); + expect(events[0]!.payload).toEqual({ identity, from, to }); + }); + + test("dryRun returns the plan and writes nothing", async () => { + const { identity, from, to } = await movedRepo("beta"); + + const res = await handlers["repos:locate"]({ newPath: to, dryRun: true }); + + expect(res.ok).toBe(true); + expect(res.data.dryRun).toBe(true); + expect(res.data.plan.identity).toBe(identity); + expect(loadRepoIndex()[identity]).toBe(from); + expect(events).toEqual([]); + }); + + test("a refusal comes back as a typed error, and nothing is emitted", async () => { + const plain = join(scratch, "plain"); + mkdirSync(plain); + + const res = await handlers["repos:locate"]({ newPath: plain }); + + expect(res.ok).toBe(false); + expect(res.error).toContain("not-a-git-repo"); + expect(events).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test lib/daemon/__tests__/repos-handlers.test.ts` +Expected: FAIL — `../handlers/repos.ts` does not exist. + +- [ ] **Step 3: Write the handler** + +Create `lib/daemon/handlers/repos.ts`: + +```ts +/** + * Repo-index IPC verbs. + * + * `repos:locate` runs the whole apply inside the reconciler's hold: a + * reconcile pass that sees a healed index path against un-rewritten registry + * paths prunes every registry row as "no matching worktree", and replenish + * then mints replacement trees for a pool that never lost anything. + */ + +import { parseIdentity } from "../../settings/identity.ts"; +import { applyLocate, isRefusal, planLocate } from "../../repo-locate.ts"; +import type { HandlerMap } from "./types.ts"; + +export interface ReposHandlerOpts { + /** Exclusive access to the worktree registry for the duration of `fn`. */ + withReconcilerHeld: (fn: () => Promise) => Promise; + /** Re-point the hooks guard's per-repo git-config watchers once paths have moved. */ + refreshWatchedRepos: () => void; + /** Events-bus emit — the router's shared `emitEvent`. */ + emitEvent: (topic: string, payload: unknown) => void; +} + +// Named-key return type (not a bare HandlerMap): under +// noUncheckedIndexedAccess a plain Record makes handlers["repos:locate"] +// resolve to `Handler | undefined` for every caller, tests included. +export function createReposHandlers( + opts: ReposHandlerOpts, +): Record<"repos:locate", (payload: any) => Promise> & HandlerMap { + return { + "repos:locate": async (payload) => { + const newPath = payload?.newPath; + if (typeof newPath !== "string" || newPath.length === 0) return { ok: false, error: "newPath-required" }; + const repo = typeof payload?.repo === "string" ? payload.repo : undefined; + if (repo !== undefined && parseIdentity(repo) === null) return { ok: false, error: "repo-unknown" }; + + return opts.withReconcilerHeld(async () => { + const plan = await planLocate({ newPath, repo }); + if (isRefusal(plan)) return { ok: false, error: `${plan.refusal}: ${plan.message}` }; + if (payload?.dryRun === true) return { ok: true, data: { dryRun: true, plan } }; + + const result = await applyLocate(plan); + if (!result.ok) return { ok: false, error: result.error ?? "locate-failed" }; + + opts.refreshWatchedRepos(); + opts.emitEvent("repo:moved", { identity: result.identity, from: result.from, to: result.to }); + return { ok: true, data: result }; + }); + }, + }; +} +``` + +- [ ] **Step 4: Register it** + +In `lib/daemon/command-router.ts`, add the import beside the others: + +```ts +import { createReposHandlers } from "./handlers/repos.ts"; +``` + +Add the opt to `buildRoutedHandlers`'s parameter object (after `homeSnapshot`): + +```ts + /** Reconciler hold + hooks-guard rewire the repos:locate verb drives. */ + repos: { + withReconcilerHeld: (fn: () => Promise) => Promise; + refreshWatchedRepos: () => void; + }; +``` + +And add to the returned object, next to `createSettingsHandlers()`: + +```ts + ...createReposHandlers({ ...opts.repos, emitEvent }), +``` + +In `lib/daemon.ts`, add to the `buildRoutedHandlers({ … })` call (after `homeSnapshot,`): + +```ts + repos: { + withReconcilerHeld: worktreeReconciler.withReconcilerHeld, + refreshWatchedRepos: hooksGuard.refreshWatchedRepos, + }, +``` + +In `lib/daemon/__tests__/rt-client-commands.test.ts`, add to the `buildRoutedHandlers({ … })` call: + +```ts + repos: { withReconcilerHeld: async (fn) => fn(), refreshWatchedRepos: () => {} }, +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `bun test lib/daemon/__tests__/repos-handlers.test.ts lib/daemon/__tests__/rt-client-commands.test.ts lib/state/__tests__/source-guards.test.ts` +Expected: PASS. + +- [ ] **Step 6: Typecheck** + +Run: `bunx tsc --noEmit` +Expected: no new errors. + +- [ ] **Step 7: Commit** + +```bash +git add lib/daemon/handlers/repos.ts lib/daemon/command-router.ts lib/daemon.ts lib/daemon/__tests__/repos-handlers.test.ts lib/daemon/__tests__/rt-client-commands.test.ts +git commit -m "feat(daemon): repos:locate verb, applied under the reconciler hold" +``` + +--- + +## Task 8: `rt repos locate` CLI + +**Files:** +- Create: `lib/repo-locate-dispatch.ts` +- Modify: `commands/repos.ts` (append the `locate` verb) +- Modify: `lib/command-tree-def.ts:998-1007` (add the subcommand after `prune`) +- Test: `commands/__tests__/repos-locate.test.ts` (create) + +**Interfaces:** +- Consumes: `planLocate`, `applyLocate`, `findLocateCandidates`, `isRefusal`, `LocatePlan`, `LocateResult`, `LocateCandidate` (Task 5); `missingRepoRefusal` / `KnownRepo.missing` (Task 4); `resolveRepoArg(arg: string, fail: (msg: string) => never): Promise` from `lib/repo-arg.ts`; `envelope(body)` from `lib/setup/contract.ts`; `UserActionableError`, `exitUserError` from `lib/setup/errors.ts`; `getKnownRepos()` from `lib/repo-index.ts`. +- Consumes (daemon transport): **`isDaemonRunning(): Promise`** and **`daemonSocketQuery(cmd, payload?, timeoutMs?): Promise`**, both from `lib/daemon-client.ts`. `daemonSocketQuery` is the read-only variant deliberately: unlike `daemonQuery` it never POSTs `/daemon/start` to the tray and never prints a "daemon down" warning, so a locate can probe without starting anything. +- Produces: + - `lib/repo-locate-dispatch.ts`: `type LocateOutcome = { via: "daemon" | "local"; ok: true; dryRun: false; result: LocateResult } | { via: "daemon" | "local"; ok: true; dryRun: true; plan: LocatePlan } | { via: "daemon" | "local"; ok: false; error: string }`; `locateMovedRepo(req: { newPath: string; repo?: string; dryRun?: boolean }): Promise`; `LOCATE_TIMEOUT_MS: number`. + - `commands/repos.ts`: `reposLocate(args: string[], ctx?: CommandContext, deps?: RegisterDeps): Promise` (same `RegisterDeps` = `{ print: (s: string) => void }` the other two verbs take). + +- [ ] **Step 1: Write the failing test** + +Create `commands/__tests__/repos-locate.test.ts`: + +```ts +/** + * The CLI runs the local path here: under a throwaway HOME no daemon socket + * exists, so `isDaemonRunning()` is false and the apply happens in-process — + * which is exactly the "no daemon, nothing to race" branch. + */ + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { execSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { closeStateDb, setKvValue } from "../../lib/state/index.ts"; +import { loadRepoIndex } from "../../lib/repo-index.ts"; +import { saveRegistry, loadRegistry } from "../../lib/worktree/registry.ts"; +import { deriveRepoIdentity, serializeIdentity } from "../../lib/settings/identity.ts"; +import { reposLocate, type RegisterDeps } from "../repos.ts"; + +function testDeps(): RegisterDeps & { lines: string[] } { + const lines: string[] = []; + return { print: (s) => lines.push(s), lines }; +} + +async function runExpectingProcessExit(fn: () => Promise): Promise { + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit sentinel"); + }); + try { + await fn(); + return undefined; + } catch { + return exitSpy.mock.calls.at(-1)?.[0] as number | undefined; + } finally { + exitSpy.mockRestore(); + } +} + +describe("reposLocate", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-cli-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-cli-repos-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + async function movedRepo(name: string): Promise<{ identity: string; from: string; to: string }> { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync(`git remote add origin https://gitlab.com/g/${name}.git`, { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + const from = realpathSync(dir); + const identity = serializeIdentity(await deriveRepoIdentity(from)); + setKvValue("repo-index", identity, from); + saveRegistry(identity, [{ name: "main", path: from, kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }]); + const to = join(scratch, `${name}-moved`); + renameSync(from, to); + return { identity, from, to }; + } + + test("locates a moved repo and says where it went", async () => { + const { identity, from, to } = await movedRepo("alpha"); + const deps = testDeps(); + + await reposLocate([to], {}, deps); + + expect(loadRepoIndex()[identity]).toBe(to); + expect(loadRegistry(identity)[0]?.path).toBe(to); + expect(deps.lines.join("\n")).toContain(from); + expect(deps.lines.join("\n")).toContain(to); + }); + + test("--dry-run reports the plan and writes nothing", async () => { + const { identity, from, to } = await movedRepo("beta"); + const deps = testDeps(); + + await reposLocate([to, "--dry-run"], {}, deps); + + expect(loadRepoIndex()[identity]).toBe(from); + expect(deps.lines.join("\n")).toContain("would move"); + }); + + test("--json emits a contract envelope", async () => { + const { identity, to } = await movedRepo("gamma"); + const deps = testDeps(); + + await reposLocate([to, "--json"], {}, deps); + + const parsed = JSON.parse(deps.lines[0]!); + expect(parsed.contract).toBe(1); + expect(parsed.located.identity).toBe(identity); + expect(parsed.located.to).toBe(to); + }); + + test("--repo resolves to an identity and is honoured", async () => { + const { identity, to } = await movedRepo("delta"); + const deps = testDeps(); + + await reposLocate([to, "--repo", identity], {}, deps); + + expect(loadRepoIndex()[identity]).toBe(to); + }); + + test("a refusal exits 2 with the typed message", async () => { + const plain = join(scratch, "plain"); + mkdirSync(plain); + const deps = testDeps(); + + const code = await runExpectingProcessExit(() => reposLocate([plain], {}, deps)); + + expect(code).toBe(2); + expect(deps.lines.join("\n")).toContain("not a git repository"); + }); + + test("an unknown flag is a usage error", async () => { + const deps = testDeps(); + const code = await runExpectingProcessExit(() => reposLocate(["--nope"], {}, deps)); + expect(code).toBe(2); + expect(deps.lines.join("\n")).toContain("usage: rt repos locate"); + }); + + test("no path and no lost rows exits 1 saying so", async () => { + const deps = testDeps(); + const code = await runExpectingProcessExit(() => reposLocate([], {}, deps)); + expect(code).toBe(1); + expect(deps.lines.join("\n")).toContain("no indexed repo is missing"); + }); + + test("no path, a lost row and no candidate lists the lost row and exits 1", async () => { + setKvValue("repo-index", "remote:gitlab.com%2Fg%2Fghost", join(scratch, "ghost")); + const deps = testDeps(); + + const code = await runExpectingProcessExit(() => reposLocate([], {}, deps)); + + expect(code).toBe(1); + expect(deps.lines.join("\n")).toContain("remote:gitlab.com%2Fg%2Fghost"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test commands/__tests__/repos-locate.test.ts` +Expected: FAIL — `reposLocate` is not exported from `../repos.ts`. + +- [ ] **Step 3: Write the dispatcher** + +Create `lib/repo-locate-dispatch.ts`: + +```ts +/** + * The one place that decides whether a locate runs in the daemon or in this + * process. + * + * The daemon is the single writer of the worktree registry, so a locate must + * never run locally while it answers: a reconcile pass landing between the + * index write and the registry write is exactly the prune this feature exists + * to prevent. A daemon that is up but does not answer is a hard stop, not a + * fall-through — `daemonSocketQuery` is the read-only client, so probing never + * starts a daemon or warns. + */ + +import { daemonSocketQuery, isDaemonRunning } from "./daemon-client.ts"; +import { applyLocate, isRefusal, planLocate, type LocatePlan, type LocateResult } from "./repo-locate.ts"; + +/** git worktree repair across a large pool is the slow part; the 2s default IPC timeout is a client number, not a daemon-op one. */ +export const LOCATE_TIMEOUT_MS = 2 * 60_000; + +export type LocateOutcome = + | { via: "daemon" | "local"; ok: true; dryRun: false; result: LocateResult } + | { via: "daemon" | "local"; ok: true; dryRun: true; plan: LocatePlan } + | { via: "daemon" | "local"; ok: false; error: string }; + +export async function locateMovedRepo(req: { + newPath: string; + repo?: string; + dryRun?: boolean; +}): Promise { + const dryRun = req.dryRun === true; + + if (await isDaemonRunning()) { + const res = await daemonSocketQuery( + "repos:locate", + { newPath: req.newPath, ...(req.repo ? { repo: req.repo } : {}), dryRun }, + LOCATE_TIMEOUT_MS, + ); + if (!res) { + return { + via: "daemon", + ok: false, + error: "the rt daemon is running but did not answer repos:locate — not applying locally, which would race the worktree reconciler", + }; + } + if (!res.ok) return { via: "daemon", ok: false, error: res.error ?? "repos:locate failed" }; + return dryRun + ? { via: "daemon", ok: true, dryRun: true, plan: res.data.plan as LocatePlan } + : { via: "daemon", ok: true, dryRun: false, result: res.data as LocateResult }; + } + + const plan = await planLocate({ newPath: req.newPath, repo: req.repo }); + if (isRefusal(plan)) return { via: "local", ok: false, error: `${plan.refusal}: ${plan.message}` }; + if (dryRun) return { via: "local", ok: true, dryRun: true, plan }; + + const result = await applyLocate(plan); + return result.ok + ? { via: "local", ok: true, dryRun: false, result } + : { via: "local", ok: false, error: result.error ?? "locate failed" }; +} +``` + +- [ ] **Step 4: Write the CLI verb** + +Append to `commands/repos.ts` (and add the imports it needs to the existing import block: `getKnownRepos` from `../lib/repo-index.ts`, `findLocateCandidates` and the plan/result types from `../lib/repo-locate.ts`, `locateMovedRepo` from `../lib/repo-locate-dispatch.ts`, `resolveRepoArg` from `../lib/repo-arg.ts`): + +```ts +// ─── locate ────────────────────────────────────────────────────────────────── + +const LOCATE_USAGE = "usage: rt repos locate [] [--repo ] [--dry-run] [--json]"; +const LOCATE_FLAGS = ["--json", "--dry-run", "--repo"]; + +/** Every non-flag token that is not `--repo`'s value. */ +function locatePositionals(args: string[]): string[] { + const out: string[] = []; + for (let i = 0; i < args.length; i++) { + const a = args[i]!; + if (a === "--repo") { + i++; + continue; + } + if (a.startsWith("--")) continue; + out.push(a); + } + return out; +} + +/** + * rt repos locate — tell rt where a repo moved to. + * + * A folder move keeps the repo identity but leaves every stored path stale. + * The daemon owns the apply whenever it answers; a local apply only happens + * when nothing is up to race. + */ +export async function reposLocate(args: string[], _ctx: CommandContext = {}, deps: RegisterDeps = realRegisterDeps()): Promise { + const json = args.includes("--json"); + const dryRun = args.includes("--dry-run"); + for (const a of args) { + if (a.startsWith("--") && !LOCATE_FLAGS.includes(a)) { + exitUserError(new UserActionableError("usage", `unknown flag "${a}" — ${LOCATE_USAGE}`), json, "repos locate", deps.print); + } + } + + const repoArg = flagValue(args, "--repo"); + const repo = repoArg + ? await resolveRepoArg(repoArg, (msg) => + exitUserError(new UserActionableError("repo-unknown", msg), json, "repos locate", deps.print)) + : undefined; + + const newPath = locatePositionals(args)[0] ?? (await pickLocateTarget(json, deps)); + + const outcome = await locateMovedRepo({ newPath, ...(repo ? { repo } : {}), dryRun }); + if (!outcome.ok) { + exitUserError(new UserActionableError("refused", outcome.error), json, "repos locate", deps.print); + } + + if (outcome.dryRun) { + const p = outcome.plan; + if (json) { + deps.print(JSON.stringify(envelope({ plan: p, dryRun: true }))); + return; + } + deps.print(`would move ${p.identity} from ${p.oldPath} to ${p.newPath}`); + deps.print(` index rows: ${p.indexKeys.join(", ")}`); + deps.print(` worktree records: ${p.registryRewrites.reduce((n, r) => n + r.movedPaths.length, 0)}`); + deps.print(` endpoint claims: ${p.claimRewrites.length}`); + deps.print(` git worktree repair: ${p.gitRepairPaths.length === 0 ? "(main worktree only)" : p.gitRepairPaths.join(", ")}`); + return; + } + + const r = outcome.result; + if (json) { + deps.print(JSON.stringify(envelope({ located: r, via: outcome.via }))); + return; + } + deps.print(`located ${r.identity}: ${r.from} → ${r.to}`); + deps.print(` ${r.treesRewritten} worktree record${r.treesRewritten === 1 ? "" : "s"}, ${r.claimsRewritten} endpoint claim${r.claimsRewritten === 1 ? "" : "s"}, ${r.repaired.length} tree${r.repaired.length === 1 ? "" : "s"} repaired`); + for (const stale of r.stalePaths) deps.print(` stale record kept for the reconciler to prune: ${stale}`); + for (const row of r.legacyRows) { + deps.print(row.outcome === "collapsed" + ? ` collapsed the legacy row ${row.key}` + : ` kept the legacy row ${row.key} — its data dir could not all move`); + } +} + +/** + * No ``: propose, never auto-pick. One candidate still asks; several + * open a picker; none is a hard stop that names what is lost. + */ +async function pickLocateTarget(json: boolean, deps: RegisterDeps): Promise { + const lost = getKnownRepos().filter((r) => r.missing); + if (lost.length === 0) { + deps.print(json ? JSON.stringify(envelope({ lost: [], candidates: [] })) : "no indexed repo is missing — nothing to locate"); + process.exit(1); + } + + const candidates = await findLocateCandidates(); + if (candidates.length === 0 || !process.stdin.isTTY) { + if (json) { + deps.print(JSON.stringify(envelope({ lost: lost.map((r) => ({ repo: r.repoName, path: r.worktrees[0]?.path })), candidates }))); + } else { + deps.print("missing repos:"); + for (const r of lost) deps.print(` ${r.repoName} — last seen at ${r.worktrees[0]?.path}`); + deps.print(candidates.length === 0 + ? `pass the new path: ${LOCATE_USAGE}` + : "run interactively to pick a candidate, or pass the new path"); + } + process.exit(1); + } + + if (candidates.length === 1) { + const only = candidates[0]!; + const { confirm } = await import("../lib/rt-render.tsx"); + const ok = await confirm({ message: `Locate ${only.identity} at ${only.path}?`, stderr: true }); + if (!ok) process.exit(0); + return only.path; + } + + const { filterableSelect } = await import("../lib/rt-render.tsx"); + const picked = await filterableSelect({ + message: "Which directory did it move to?", + options: candidates.map((c) => ({ value: c.path, label: c.path, hint: c.identity })), + stderr: true, + }); + if (!picked) process.exit(0); + return picked; +} +``` + +- [ ] **Step 5: Register the subcommand** + +In `lib/command-tree-def.ts`, add after the `prune` entry (`lib/command-tree-def.ts:1006`), still inside `repos.subcommands`: + +```ts + locate: { + description: "Tell rt where a repo moved to — re-points the index, worktree registry, endpoint claims and git's worktree admin files together", + module: "./commands/repos.ts", + fn: "reposLocate", + args: [ + { name: "New path", type: "text", placeholder: "/path/to/moved-repo", hint: "Where the repo lives now; omit to pick from candidates under rt.repoRoots" }, + { name: "Repo", flag: "--repo", type: "text", placeholder: "repo-tools", hint: "Which indexed repo moved (identity, path, or name); omit to match by the new path's own identity" }, + { name: "Dry run", flag: "--dry-run", type: "boolean", default: false, hint: "Print what would be re-pointed without writing" }, + SETUP_JSON_ARG, + ], + }, +``` + +`commands/repos.ts` is already thunked in `lib/module-registry.ts` — no registry change. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `bun test commands/__tests__/repos-locate.test.ts commands/__tests__/repos.test.ts` +Expected: PASS. + +- [ ] **Step 7: Typecheck and the command-tree guards** + +Run: `bunx tsc --noEmit && bun test lib/__tests__/no-eager-tui.test.ts lib/__tests__/command-tree.test.ts lib/__tests__/command-tree-def.test.ts` +Expected: no errors; PASS. `no-eager-tui` is the guard that a command module stays lazily reachable — a failure there means a static `lib/rt-render.tsx`/`ink` import crept into a command-tree path. + +- [ ] **Step 8: Commit** + +```bash +git add lib/repo-locate-dispatch.ts commands/repos.ts lib/command-tree-def.ts commands/__tests__/repos-locate.test.ts +git commit -m "feat(repos): rt repos locate — daemon-first, local when nothing answers" +``` + +--- + +## Task 9: The implicit heal is move-aware + +**Files:** +- Modify: `lib/repo-index.ts:133-153` (`updateRepoIndex`) +- Modify: `commands/repos.ts:125-134` (`reposRegister` uses the async seam) +- Test: `lib/__tests__/repo-locate-heal.test.ts` (create) + +**Interfaces:** +- Consumes: `locateMovedRepo(req: { newPath: string; repo?: string; dryRun?: boolean }): Promise` (Task 8). +- Produces: + - `updateRepoIndex(repoName: string, repoRoot: string): void` — unchanged signature and unchanged behavior EXCEPT that it no longer overwrites a stored path that has stopped existing (that row is a move, and re-pointing it alone is the destructive ordering). + - `updateRepoIndexAsync(repoName: string, repoRoot: string): Promise` — the same write, plus the move heal for callers that can await. + +- [ ] **Step 1: Write the failing test** + +Create `lib/__tests__/repo-locate-heal.test.ts`: + +```ts +/** + * The implicit heal must move a repo, not re-point one row of it: the sync + * seam is reachable from the daemon thread (no sync git there) and cannot + * await `git worktree repair`, so it declines the write and the async seam + * performs the whole locate. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execSync } from "child_process"; +import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { closeStateDb, setKvValue } from "../state/index.ts"; +import { loadRepoIndex, updateRepoIndex, updateRepoIndexAsync } from "../repo-index.ts"; +import { loadRegistry, saveRegistry } from "../worktree/registry.ts"; +import { deriveRepoIdentity, serializeIdentity } from "../settings/identity.ts"; + +describe("move-aware index heal", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-heal-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-heal-repos-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + async function movedRepo(name: string): Promise<{ identity: string; from: string; to: string; tree: string }> { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync(`git remote add origin https://gitlab.com/g/${name}.git`, { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + const from = realpathSync(dir); + const tree = join(from, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${tree}`, { cwd: from, stdio: "pipe" }); + const identity = serializeIdentity(await deriveRepoIdentity(from)); + setKvValue("repo-index", identity, from); + saveRegistry(identity, [ + { name: "main", path: from, kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }, + { name: "t1", path: tree, kind: "ephemeral", state: "on-deck", branch: "feat", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + const to = join(scratch, `${name}-moved`); + renameSync(from, to); + return { identity, from, to, tree }; + } + + test("the sync seam refuses to re-point a row whose stored path is gone", async () => { + const { identity, from, to } = await movedRepo("alpha"); + + updateRepoIndex(identity, to); + + expect(loadRepoIndex()[identity]).toBe(from); + }); + + test("the sync seam still writes a live path and a brand-new row", async () => { + const dir = join(scratch, "beta"); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + const live = realpathSync(dir); + + updateRepoIndex("beta-key", live); + expect(loadRepoIndex()["beta-key"]).toBe(live); + + updateRepoIndex("beta-key", live); + expect(loadRepoIndex()["beta-key"]).toBe(live); + }); + + test("the async seam heals the move as one unit", async () => { + const { identity, to } = await movedRepo("gamma"); + + await updateRepoIndexAsync(identity, to); + + expect(loadRepoIndex()[identity]).toBe(to); + expect(loadRegistry(identity).map((t) => t.path).sort()).toEqual( + [to, join(to, ".worktrees", "t1")].sort(), + ); + expect(loadRegistry(identity).find((t) => t.path === join(to, ".worktrees", "t1"))?.state).toBe("on-deck"); + expect(execSync("git worktree list --porcelain", { cwd: to, encoding: "utf8" })).toContain(join(to, ".worktrees", "t1")); + }); + + test("the async seam is a plain write when nothing moved", async () => { + const dir = join(scratch, "delta"); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + const live = realpathSync(dir); + + await updateRepoIndexAsync("delta-key", live); + + expect(loadRepoIndex()["delta-key"]).toBe(live); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test lib/__tests__/repo-locate-heal.test.ts` +Expected: FAIL — `updateRepoIndexAsync` is not exported, and the sync seam overwrites the row. + +- [ ] **Step 3: Write the implementation** + +In `lib/repo-index.ts`, replace `updateRepoIndex` (`lib/repo-index.ts:133-153`) with the extracted probe plus the two seams: + +```ts +/** The repo's MAIN worktree path as git reports it, degrading to `repoRoot`. */ +function observedMainPath(repoRoot: string): string { + try { + const listed = execSync("git worktree list --porcelain", { + cwd: repoRoot, + encoding: "utf8", + stdio: "pipe", + }); + return listed.split("\n")[0]?.replace("worktree ", "").trim() || repoRoot; + } catch { + return repoRoot; + } +} + +/** True when the stored row names a directory that is gone and the repo is now somewhere else — a MOVE, not a second clone. */ +function storedPathMoved(stored: string | undefined, mainPath: string): stored is string { + return stored !== undefined && stored !== mainPath && !existsSync(stored); +} + +export function updateRepoIndex(repoName: string, repoRoot: string): void { + const mainPath = observedMainPath(repoRoot); + try { + // loadRepoIndex() can throw (an unopenable state.db — e.g. root-owned + // after a sudo invocation) — inside the try along with the write it + // depends on, so getRepoIdentity() (which every in-repo command calls) + // degrades to skipping the index update rather than crashing the command. + // + // A moved repo is NOT written here: re-pointing the index row ahead of the + // worktree registry is what makes the reconciler prune every claimed tree, + // and the repair this seam would owe is async git — forbidden on the + // daemon thread, which reaches this function through + // resolveIndexPathForIdentity. The row stays lost (visible as `missing`) + // until `updateRepoIndexAsync` or `rt repos locate` moves it as one unit. + if (storedPathMoved(loadRepoIndex()[repoName], mainPath)) return; + setKvValue(REPO_INDEX_NS, repoName, mainPath); + writeRepoIndexCompat(loadRepoIndex()); + } catch { /* best effort */ } +} + +/** + * `updateRepoIndex` for callers that can await: the same write, plus the move + * heal the sync seam cannot perform. The locate runs in the daemon whenever it + * answers — imported lazily so this module's sync path never pulls the daemon + * client into every rt command's startup. + */ +export async function updateRepoIndexAsync(repoName: string, repoRoot: string): Promise { + const mainPath = observedMainPath(repoRoot); + let stored: string | undefined; + try { + stored = loadRepoIndex()[repoName]; + } catch { + stored = undefined; + } + if (!storedPathMoved(stored, mainPath)) { + updateRepoIndex(repoName, mainPath); + return; + } + const { locateMovedRepo } = await import("./repo-locate-dispatch.ts"); + const outcome = await locateMovedRepo({ newPath: mainPath, repo: repoName }); + if (!outcome.ok) console.warn(`rt: ${repoName} moved to ${mainPath} but could not be located (${outcome.error})`); +} +``` + +In `commands/repos.ts`, switch `reposRegister`'s write to the async seam (`commands/repos.ts:126`) and add `updateRepoIndexAsync` to its `../lib/repo-index.ts` import: + +```ts + await updateRepoIndexAsync(identity, real); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test lib/__tests__/repo-locate-heal.test.ts commands/__tests__/repos.test.ts commands/__tests__/repos-locate.test.ts` +Expected: PASS. + +- [ ] **Step 5: Typecheck and re-run the index suites** + +Run: `bunx tsc --noEmit && bun test lib/__tests__/repo-index.test.ts lib/__tests__/repo-index-rename.test.ts lib/__tests__/repo-index-missing.test.ts lib/__tests__/repo.test.ts` +Expected: no errors; PASS. + +- [ ] **Step 6: Commit** + +```bash +git add lib/repo-index.ts commands/repos.ts lib/__tests__/repo-locate-heal.test.ts +git commit -m "feat(repos): implicit index heal moves a repo instead of re-pointing one row" +``` + +--- + +## Task 10: Real-state verification + +**Files:** +- Test: `lib/__tests__/repo-locate-e2e.test.ts` (create) + +**Interfaces:** +- Consumes everything the earlier tasks produced: `planLocate`, `applyLocate`, `isRefusal` (Task 5); `loadRegistry`/`saveRegistry` (existing); `loadClaims`/`saveClaims` (existing); `pruneRepoIndex` (Task 3); `reconcileRepoRegistry(deps: { repoName: string; repoPath: string; emit: (type: string, data: unknown) => void; log: Logger }): Promise` from `lib/daemon/worktree-reconciler.ts`. +- Produces: no source changes — this task adds the end-to-end proof and runs the full gate. + +- [ ] **Step 1: Write the test** + +Create `lib/__tests__/repo-locate-e2e.test.ts`: + +```ts +/** + * The whole story against real state: a repo with a linked worktree under + * `.worktrees/`, an ephemeral on-deck record, and a live endpoint claim, moved + * on disk and then located. The assertion that matters most is the last one — + * a reconcile pass over the located repo must prune nothing. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execSync } from "child_process"; +import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import type { Logger } from "pino"; +import { closeStateDb, listEndpointClaims, setKvValue } from "../state/index.ts"; +import { loadRepoIndex, pruneRepoIndex } from "../repo-index.ts"; +import { loadRegistry, saveRegistry } from "../worktree/registry.ts"; +import { saveClaims } from "../endpoint/store.ts"; +import { deriveRepoIdentity, serializeIdentity } from "../settings/identity.ts"; +import { applyLocate, isRefusal, planLocate } from "../repo-locate.ts"; +import { reconcileRepoRegistry } from "../daemon/worktree-reconciler.ts"; + +const silentLog = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as unknown as Logger; + +describe("repo locate — real state", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-e2e-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-e2e-repos-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + test("a moved repo with a claimed pool survives locate intact", async () => { + // ── a throwaway repo with a linked worktree under .worktrees/ + const dir = join(scratch, "acme-dev"); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync("git remote add origin https://gitlab.com/acme/acme-dev.git", { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + const from = realpathSync(dir); + const oldTree = join(from, ".worktrees", "tree-1"); + execSync(`git worktree add -q -b on-deck/tree-1 ${oldTree}`, { cwd: from, stdio: "pipe" }); + + // ── registered in an isolated HOME's state.db: index row, registry with an + // ephemeral on-deck record, and an endpoint_claims row + const identity = serializeIdentity(await deriveRepoIdentity(from)); + setKvValue("repo-index", identity, from); + saveRegistry(identity, [ + { name: "main", path: from, kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }, + { + name: "tree-1", + path: oldTree, + kind: "ephemeral", + state: "on-deck", + branch: "on-deck/tree-1", + createdAt: "2026-01-02T00:00:00.000Z", + readyAt: "2026-01-02T01:00:00.000Z", + readyStamp: "abc123", + }, + ]); + saveClaims(identity, [{ worktree: oldTree, role: "web", port: 4010, pid: 4242, ts: "2026-01-02T02:00:00.000Z" }]); + + // ── mv the repo + const to = join(scratch, "moved", "acme-dev"); + mkdirSync(join(scratch, "moved"), { recursive: true }); + renameSync(from, to); + const newTree = join(to, ".worktrees", "tree-1"); + + // ── locate it, locally + const plan = await planLocate({ newPath: to }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + const result = await applyLocate(plan); + expect(result.ok).toBe(true); + expect(result.error).toBeUndefined(); + + // index path updated + expect(loadRepoIndex()[identity]).toBe(to); + + // registry record path updated, state intact + const trees = loadRegistry(identity); + expect(trees.map((t) => t.path).sort()).toEqual([to, newTree].sort()); + expect(trees.find((t) => t.path === newTree)).toMatchObject({ + kind: "ephemeral", + state: "on-deck", + branch: "on-deck/tree-1", + readyStamp: "abc123", + }); + + // claim row updated + expect(listEndpointClaims(identity)).toEqual([ + { worktree: newTree, role: "web", port: 4010, pid: 4242, ts: "2026-01-02T02:00:00.000Z" }, + ]); + + // git worktree list shows the new path (and not the old one) + const listed = execSync("git worktree list --porcelain", { cwd: to, encoding: "utf8" }); + expect(listed).toContain(newTree); + expect(listed).not.toContain(oldTree); + + // no prunable entries + expect(pruneRepoIndex({ dryRun: true })).toEqual([]); + + // and the reconciler prunes nothing: the ordering this whole feature exists for + const reconciled = await reconcileRepoRegistry({ + repoName: identity, + repoPath: to, + emit: () => {}, + log: silentLog, + }); + expect(reconciled.map((t) => t.path).sort()).toEqual([to, newTree].sort()); + expect(reconciled.find((t) => t.path === newTree)).toMatchObject({ state: "on-deck", readyStamp: "abc123" }); + }); +}); +``` + +- [ ] **Step 2: Run the test** + +Run: `bun test lib/__tests__/repo-locate-e2e.test.ts` +Expected: PASS. If the reconcile assertion fails because git still lists the old path, the repair arguments are wrong — fix `planLocate`'s `gitRepairPaths`, never the assertion. + +- [ ] **Step 3: Run the full gate** + +Run: `bunx tsc --noEmit && bun test lib commands` +Expected: no type errors; no test delta from the baseline other than the suites this plan added. Record the counts in the commit body. + +- [ ] **Step 4: Commit** + +```bash +git add lib/__tests__/repo-locate-e2e.test.ts +git commit -m "test(repos): end-to-end locate against real git state and a live reconcile" +``` + +--- + +## Self-review notes (already applied) + +- **Spec coverage:** 1→T1, 2→T2, 3→T3, 4→T5, 5→T6+T7, 6→T8, 7→T4, 8→T9, "Verification"→T10. Parity anchors: wire keys via `lib/settings/identity.ts` (T5, T7), legacy pair discovered through the index rows the additive heal leaves (T5, never by basename), `worktree-registry` mirrored in both modules (T2 keeps both), `ctx.log`-only logging (T7 adds none), module registry untouched (T8), constraint-only comments throughout. +- **Out of scope, deliberately absent:** gitq's commonDir-hash store, `uow.json`, `board.cwds`, `rt.workspacePrefs.workspaces` — those owners react to `repo:moved`, which T7 emits and nothing here consumes. +- **Type consistency:** `LocatePlan`/`LocateResult`/`LocateRefusal` field names are used identically in T5 (definition), T7 (handler), T8 (dispatcher and CLI), T9 (heal), T10 (assertions). `withReconcilerHeld`'s generic signature is identical in T6 (implementation), T7 (`ReposHandlerOpts`, `buildRoutedHandlers` opt, daemon wiring, and the `rt-client-commands` stub). `DataMigration["registry"]` gains exactly one member (`"merged"`) in T2 and is read in T2's CLI branch only. diff --git a/docs/superpowers/specs/2026-08-25-repo-locate-design.md b/docs/superpowers/specs/2026-08-25-repo-locate-design.md new file mode 100644 index 00000000..4d7bbeab --- /dev/null +++ b/docs/superpowers/specs/2026-08-25-repo-locate-design.md @@ -0,0 +1,95 @@ +# Repo locate + registry merge (RT-63, RT-68) — design + +Status: ratified 2026-08-25 (Matt: "get it done"). Binding constraints in **bold**. + +## Why + +A folder move keeps the repo identity (RT-62) but leaves literal paths stale in +rt's stores. The worktree reconciler (`lib/daemon/worktree-reconciler.ts`, +step (a)) prunes registry rows whose path is absent from `git worktree list`, +so an index path that heals before the registry is rewritten destroys +claimed/on-deck state and replenish mints replacement trees. Separately, the +RT-62 cutover left a name/identity index pair whose two registries each own +half of one on-deck pool, and `rt repos prune` cannot collapse it. + +**The daemon is never stopped for any of this.** Mutations of daemon-owned +state go through the daemon, never around it. + +## Scope + +1. **Registry merge primitive** (`lib/worktree/registry.ts` or sibling): + `mergeRegistries(winner: TreeRecord[], loser: TreeRecord[]): TreeRecord[]` + — union by canonical path; on a path present in both, the managed record + wins (`main`/`ephemeral` beat `unmanaged`; two managed → later `createdAt`; + tie → winner side). Pure, unit-tested. +2. **Prune uses the merge** (`migrateWorktreeRegistry` in `lib/repo-index.ts`): + when both names own a registry, merge into the live name, verify persisted, + delete the retired registry — `registry: "merged"` outcome replaces + `"refused"` for that case. `rt repos prune` output names the merge. +3. **Prune guard**: a `missing` row that owns a worktree registry is retained + (reported `retained`, reason `missing`, hint `rt repos locate`) — never + evicted while it owns data. +4. **Locate core** (`lib/repo-locate.ts`, pure of daemon/CLI): + `planLocate({ newPath, repoArg? })` → `{ identity, oldPath, indexKeys, + registryRewrites, claimRewrites, gitRepairPaths }` or a typed refusal; + `applyLocate(plan)` performs, in one `state.db` transaction: index rows + (identity + legacy pair) → `newPath`; every registry of the pair: + prefix-replace `path` for records under `oldPath` (external trees + untouched), then merge the pair's registries via (1) and delete the legacy + registry + legacy index row; `endpoint_claims.worktree` prefix; `repos.json` + mirror. After commit: `git worktree repair ` from + `newPath`, then a no-arg pass. Verify every registry path exists on disk and + appears in `git worktree list --porcelain`; on failure restore the pre-apply + snapshot of the touched rows (captured before the transaction) and report. + - **Match by identity, never by name**: `serializeIdentity(await + deriveRepoIdentity(newPath))` must equal a lost index key, or the derived + identity of a lost legacy-name row's pair. Mismatch → refusal printing + both identities. + - `oldPath` must not exist on disk; if it does, refuse ("second clone, not + a move"). +5. **Daemon verb** `repos:locate` (`lib/daemon/handlers/repos.ts`): payload + `{ newPath, repo?, dryRun? }`; runs `applyLocate` **inside the reconciler's + in-flight guard** (new `withReconcilerHeld(fn)` on the reconciler object: + awaits any pass in flight, blocks `kick` from starting a pass until `fn` + settles), then `hooksGuard.refreshWatchedRepos()`, then emits + `repo:moved { identity, from, to }` on the events bus. Registered in + `lib/daemon/command-router.ts` like every other handler. +6. **CLI** `rt repos locate [--repo ] [--dry-run] [--json]` + (`commands/repos.ts`): resolves `--repo` via `resolveRepoArg`; calls the + daemon verb when the socket answers, otherwise runs `applyLocate` locally + (no daemon → nothing to race). No ``: scan `rt.repoRoots` + (existing scanner in `lib/repo-index.ts`) for candidates whose derived + identity matches a lost row; one match → confirm; several → picker; none → + list lost rows and exit 1. Never auto-pick. +7. **Lost rows visible**: `getKnownRepos` keeps missing-path rows and marks + them `missing: true`; `rt cd` / pickers render `name (missing — rt repos + locate)` and refuse to cd into them. +8. **Implicit heal is move-aware** (RT-65 seam): `updateRepoIndex(identity, + root)` — when the stored path differs from the observed main path AND the + stored path no longer exists → route through `applyLocate` (local, or via + the daemon when it answers) instead of a bare `setKvValue`. Stored path + still exists → second clone; leave it alone (today's behavior). + +## Out of scope + +gitq's commonDir-hash store, claimview `uow.json`, `board.cwds`, +`rt.workspacePrefs.workspaces` keys — those owners react to `repo:moved` +(follow-up tickets). The RT-65 `rt worktree list` no-heal bug itself. + +## Parity anchors + +- Wire keys everywhere: `parseIdentity`/`serializeIdentity` from + `lib/settings/identity.ts`; legacy pair discovered via + `resolveIndexPathForIdentity`'s scan, never by basename. +- Registry namespace constant `worktree-registry` (`repo-index.ts` mirrors + `registry.ts`; keep both). +- Logging: handlers use `ctx.log`; no outcome logging (seams cover it). +- Module registry: any new command module referenced from `cli.ts` must be + thunked in `lib/module-registry.ts`. +- Comments: constraint-only (clean-code rule). + +## Verification + +`bunx tsc --noEmit`; `bun test lib commands packages`; a real-state dry run: +`rt repos locate --dry-run ~/Documents/GitHub/assured-dev` against a copy of +`~/.mattstack` (isolated `HOME`) after `mv`-ing a throwaway clone. From 4ba1f1fe34c363379cfdc89b3845dc062e0b8137 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Tue, 25 Aug 2026 13:32:39 -0500 Subject: [PATCH 02/20] =?UTF-8?q?feat(worktree):=20mergeRegistries=20?= =?UTF-8?q?=E2=80=94=20union=20two=20registries=20of=20one=20repo=20by=20c?= =?UTF-8?q?anonical=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- lib/worktree/__tests__/registry-merge.test.ts | 63 +++++++++++++++++++ lib/worktree/registry.ts | 48 ++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 lib/worktree/__tests__/registry-merge.test.ts diff --git a/lib/worktree/__tests__/registry-merge.test.ts b/lib/worktree/__tests__/registry-merge.test.ts new file mode 100644 index 00000000..04518d6e --- /dev/null +++ b/lib/worktree/__tests__/registry-merge.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; +import { mergeRegistries, type TreeRecord } from "../registry.ts"; + +function rec(over: Partial & { path: string }): TreeRecord { + return { + name: over.path.split("/").pop()!, + kind: "unmanaged", + branch: null, + createdAt: "2026-01-01T00:00:00.000Z", + ...over, + }; +} + +describe("mergeRegistries", () => { + test("unions disjoint paths, winner side first", () => { + const merged = mergeRegistries([rec({ path: "/a/main" })], [rec({ path: "/a/tree-1" })]); + expect(merged.map((t) => t.path)).toEqual(["/a/main", "/a/tree-1"]); + }); + + test("an empty loser returns the winner unchanged", () => { + const winner = [rec({ path: "/a/main" }), rec({ path: "/a/tree-1" })]; + expect(mergeRegistries(winner, [])).toEqual(winner); + }); + + test("an empty winner returns the loser's records", () => { + const loser = [rec({ path: "/a/main", kind: "main" })]; + expect(mergeRegistries([], loser)).toEqual(loser); + }); + + test("on a shared path the managed record wins, whichever side it is on", () => { + const claimed = rec({ path: "/a/tree-1", kind: "ephemeral", state: "claimed", owner: "matt" }); + const adopted = rec({ path: "/a/tree-1", kind: "unmanaged" }); + + expect(mergeRegistries([adopted], [claimed])[0]).toEqual(claimed); + expect(mergeRegistries([claimed], [adopted])[0]).toEqual(claimed); + }); + + test("two managed records on one path: the later createdAt wins", () => { + const older = rec({ path: "/a/tree-1", kind: "ephemeral", state: "on-deck", createdAt: "2026-01-01T00:00:00.000Z" }); + const newer = rec({ path: "/a/tree-1", kind: "ephemeral", state: "claimed", createdAt: "2026-02-01T00:00:00.000Z" }); + + expect(mergeRegistries([older], [newer])[0]).toEqual(newer); + expect(mergeRegistries([newer], [older])[0]).toEqual(newer); + }); + + test("an equal createdAt keeps the winner side", () => { + const w = rec({ path: "/a/tree-1", kind: "ephemeral", state: "on-deck", owner: "winner" }); + const l = rec({ path: "/a/tree-1", kind: "ephemeral", state: "on-deck", owner: "loser" }); + expect(mergeRegistries([w], [l])[0]!.owner).toBe("winner"); + }); + + test("an unparseable createdAt never displaces the winner", () => { + const w = rec({ path: "/a/tree-1", kind: "ephemeral", createdAt: "2026-01-01T00:00:00.000Z", owner: "winner" }); + const l = rec({ path: "/a/tree-1", kind: "ephemeral", createdAt: "not a date", owner: "loser" }); + expect(mergeRegistries([w], [l])[0]!.owner).toBe("winner"); + }); + + test("a duplicate path inside one side keeps its first occurrence", () => { + const first = rec({ path: "/a/tree-1", kind: "ephemeral", owner: "first" }); + const second = rec({ path: "/a/tree-1", kind: "ephemeral", owner: "second" }); + expect(mergeRegistries([first, second], [])).toEqual([first]); + }); +}); diff --git a/lib/worktree/registry.ts b/lib/worktree/registry.ts index 762bbe14..22471415 100644 --- a/lib/worktree/registry.ts +++ b/lib/worktree/registry.ts @@ -1,3 +1,4 @@ +import { realpathSync } from "fs"; import { join } from "path"; import { repoDataDir } from "../rt-paths.ts"; import { getKvValue, hasKvValue, importLegacyJsonFile, setKvValue } from "../state/index.ts"; @@ -97,3 +98,50 @@ export function findByBranch(trees: TreeRecord[], branch: string): TreeRecord[] export function usedNames(trees: TreeRecord[]): Set { return new Set(trees.map((t) => t.name)); } + +/** Canonical path key: a tree that no longer exists compares by its own spelling rather than throwing. */ +function canonPath(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +const MANAGED_KINDS: ReadonlySet = new Set(["main", "ephemeral"]); + +/** + * Total order for two records of the same canonical path: a managed record + * carries claim/ready state no git repository has another copy of, so it beats + * `unmanaged`; within one class the later `createdAt` wins; an equal or + * unparseable stamp keeps the winner side. + */ +function heldRecordWins(held: TreeRecord, challenger: TreeRecord): boolean { + const heldManaged = MANAGED_KINDS.has(held.kind); + const challengerManaged = MANAGED_KINDS.has(challenger.kind); + if (heldManaged !== challengerManaged) return heldManaged; + return !(Date.parse(challenger.createdAt) > Date.parse(held.createdAt)); +} + +/** + * Union two registries of the SAME repo by canonical path — the collapse a + * name/identity index pair needs, where each side owns half of one on-deck + * pool. Name collisions across the two sides are left standing: the union is + * by path, and a record's name is only ever consulted for display and for + * `usedNames` disambiguation, both of which tolerate a duplicate. + */ +export function mergeRegistries(winner: TreeRecord[], loser: TreeRecord[]): TreeRecord[] { + const byPath = new Map(); + const order: string[] = []; + for (const rec of [...winner, ...loser]) { + const key = canonPath(rec.path); + const held = byPath.get(key); + if (!held) { + byPath.set(key, rec); + order.push(key); + continue; + } + if (!heldRecordWins(held, rec)) byPath.set(key, rec); + } + return order.map((key) => byPath.get(key)!); +} From 023b65415ae4b1a895fa2eb9585731409dd6be41 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Tue, 25 Aug 2026 13:38:53 -0500 Subject: [PATCH 03/20] feat(repos): prune merges a split worktree registry instead of refusing Co-Authored-By: Claude Fable 5 --- commands/repos.ts | 3 +- lib/__tests__/repo-index-rename.test.ts | 33 +++++++++++------ lib/repo-index.ts | 48 ++++++++++++++----------- 3 files changed, 53 insertions(+), 31 deletions(-) diff --git a/commands/repos.ts b/commands/repos.ts index 1ace8033..1e866720 100644 --- a/commands/repos.ts +++ b/commands/repos.ts @@ -169,7 +169,8 @@ function describeDataMove(r: PrunedEntry, dryRun: boolean): string { if (carried > 0) parts.push(`${dryRun ? "would carry" : "carried"} ${carried} file${carried === 1 ? "" : "s"} to ${r.keptAs}`); if (d.merged.length > 0) parts.push(`merged ${d.merged.join(", ")}`); if (d.registry === "moved") parts.push(`${dryRun ? "would move" : "moved"} the worktree registry to ${r.keptAs}`); - if (d.registry === "refused") parts.push(`${r.keptAs} already has a worktree registry — both kept`); + if (d.registry === "merged") parts.push(`${dryRun ? "would merge" : "merged"} the worktree registry into ${r.keptAs}'s`); + if (d.registry === "refused") parts.push(`${r.keptAs}'s worktree registry could not be written — both kept`); if (d.refused.length > 0) parts.push(`kept both copies of ${d.refused.join(", ")}`); return parts.length > 0 ? `; ${parts.join("; ")}` : ""; } diff --git a/lib/__tests__/repo-index-rename.test.ts b/lib/__tests__/repo-index-rename.test.ts index b077bb09..18ea1d01 100644 --- a/lib/__tests__/repo-index-rename.test.ts +++ b/lib/__tests__/repo-index-rename.test.ts @@ -396,15 +396,19 @@ describe("repo-index — rename drift (RT-60)", () => { expect(Object.keys(listKvValues(WT_NS))).toEqual(["rt"]); }); - test("refuses when the live name already has one — both hold real claim state", () => { - setKvValue(WT_NS, "repo-tools", tree("/x/retired")); - setKvValue(WT_NS, "rt", tree("/x/live")); + test("merges when the live name already has one — one pool, both halves", () => { + setKvValue(WT_NS, "repo-tools", [ + { name: "t1", path: "/x/t1", kind: "ephemeral", state: "on-deck", branch: "on-deck/t1", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + setKvValue(WT_NS, "rt", [ + { name: "main", path: "/x/main", kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); const result = migrateRepoData("repo-tools", "rt"); - expect(result.registry).toBe("refused"); - expect(listKvValues(WT_NS)["rt"]).toEqual(tree("/x/live")); - expect(listKvValues(WT_NS)["repo-tools"]).toEqual(tree("/x/retired")); + expect(result.registry).toBe("merged"); + expect((listKvValues(WT_NS)["rt"] as Array<{ path: string }>).map((t) => t.path)).toEqual(["/x/main", "/x/t1"]); + expect(listKvValues(WT_NS)["repo-tools"]).toBeUndefined(); }); test("no registry under the retired name is 'none', not a failure", () => { @@ -432,7 +436,7 @@ describe("repo-index — rename drift (RT-60)", () => { expect(loadRepoIndexEntries().map((e) => e.repoName)).toEqual(["rt"]); }); - test("a refused registry KEEPS the index row — eviction is what makes a leftover unreachable", () => { + test("a merged registry is a COMPLETE migration — the retired index row is evicted", () => { const dir = realRepo("repo-tools"); indexRepoAt("repo-tools", dir, 1_000); indexRepoAt("rt", dir, 2_000); @@ -440,11 +444,20 @@ describe("repo-index — rename drift (RT-60)", () => { setKvValue(WT_NS, "rt", tree("/x/live")); const removed = pruneRepoIndex(); - const dup = removed.find((r) => r.repoName === "repo-tools"); - expect(dup?.retained).toBe(true); - expect(loadRepoIndexEntries().map((e) => e.repoName).sort()).toEqual(["repo-tools", "rt"]); + expect(removed.find((r) => r.repoName === "repo-tools")?.data?.registry).toBe("merged"); + expect(removed.find((r) => r.repoName === "repo-tools")?.retained).toBeUndefined(); + expect(loadRepoIndexEntries().map((e) => e.repoName)).toEqual(["rt"]); + }); + + test("--dry-run reports the merge without performing it", () => { + setKvValue(WT_NS, "repo-tools", tree("/x/retired")); + setKvValue(WT_NS, "rt", tree("/x/live")); + + expect(migrateRepoData("repo-tools", "rt", { dryRun: true }).registry).toBe("merged"); + expect(listKvValues(WT_NS)["repo-tools"]).toEqual(tree("/x/retired")); + expect(listKvValues(WT_NS)["rt"]).toEqual(tree("/x/live")); }); test("a refused FILE also keeps the row", () => { diff --git a/lib/repo-index.ts b/lib/repo-index.ts index 8d29496f..1cfe9ed9 100644 --- a/lib/repo-index.ts +++ b/lib/repo-index.ts @@ -29,6 +29,7 @@ import { deriveRepoIdentity, parseIdentity, serializeIdentity } from "./settings import { repoLabel, repoLabelFull, repoLabelQualified } from "./repo-label.ts"; import { dim } from "./ansi.ts"; import { getSetting } from "./settings/resolve.ts"; +import { mergeRegistries, type TreeRecord } from "./worktree/registry.ts"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -300,15 +301,17 @@ export interface DataMigration { removedDir: boolean; /** * The retired name's worktree registry: `"moved"` onto the live name, - * `"refused"` because the live name already had one (both are real tree - * records; picking a winner would guess), or `"none"` if it had none. + * `"merged"` into the live name's own registry (the name/identity pair the + * identity cutover left, each side owning half of one on-deck pool), + * `"refused"` because the write could not be verified, or `"none"` if it + * had none. * * This lives in state.db's kv, not the data dir, so it is invisible to a * directory walk — and it is the record the daemon keys by, so a retired * name that keeps it while the index row goes away leaves the reconciler * silently skipping the repo. */ - registry: "moved" | "refused" | "none"; + registry: "moved" | "merged" | "refused" | "none"; } /** True when anything is still keyed to the retired name after a migration. */ @@ -337,36 +340,41 @@ const WORKTREE_REGISTRY_NS = "worktree-registry"; * * The daemon keys registries by the INDEX name * (`lib/daemon/worktree-reconciler.ts` iterates the repo index), while the CLI - * looks them up by git identity (`deriveRepoName`). A rename splits those two, - * and evicting the retired index row then makes the registry unreachable: + * looks them up by git identity. A rename splits those two, and evicting the + * retired index row then makes the registry unreachable: * `repoHasWorktreeActivity` sees an empty registry under the live name and * skips the repo, so the reconciler quietly stops managing its worktrees. * That is why this moves with the data dir instead of being left behind. * - * A live name that ALREADY has a registry is refused, never merged — both - * sides are real tree records carrying claim state and ready stamps that no - * git repository has another record of. + * A live name that already has a registry is MERGED, not refused: both sides + * describe the same repo's trees, so the union by path (`mergeRegistries`) + * loses neither half of a pool that a name/identity pair split. */ function migrateWorktreeRegistry(from: string, to: string, opts: { dryRun?: boolean }): DataMigration["registry"] { - let retired: unknown; + let outcome: "moved" | "merged"; try { if (!hasKvValue(WORKTREE_REGISTRY_NS, from)) return "none"; - if (hasKvValue(WORKTREE_REGISTRY_NS, to)) return "refused"; - if (opts.dryRun) return "moved"; - retired = getKvValue(WORKTREE_REGISTRY_NS, from, null); - setKvValue(WORKTREE_REGISTRY_NS, to, retired); + outcome = hasKvValue(WORKTREE_REGISTRY_NS, to) ? "merged" : "moved"; + if (opts.dryRun) return outcome; + + const retired = getKvValue(WORKTREE_REGISTRY_NS, from, []); + const live = outcome === "merged" ? getKvValue(WORKTREE_REGISTRY_NS, to, []) : []; + const next = outcome === "merged" ? mergeRegistries(live, retired) : retired; + setKvValue(WORKTREE_REGISTRY_NS, to, next); + + // persistOrWarn swallows SQLITE_BUSY, so a returned write is not a landed + // one — and on a merge the destination row already existed, so its mere + // presence proves nothing. Compare the readback. + if (JSON.stringify(getKvValue(WORKTREE_REGISTRY_NS, to, [])) !== JSON.stringify(next)) { + console.warn(`rt: ${from}'s worktree registry did not persist under ${to} — leaving it in place`); + return "refused"; + } } catch (err) { console.warn(`rt: could not move ${from}'s worktree registry to ${to} (${(err as Error).message})`); return "refused"; } - // Delete only after the write is readable: persistOrWarn swallows - // SQLITE_BUSY, so a returned write is not a landed one. - if (!hasKvValue(WORKTREE_REGISTRY_NS, to)) { - console.warn(`rt: ${from}'s worktree registry did not persist under ${to} — leaving it in place`); - return "refused"; - } deleteKvValue(WORKTREE_REGISTRY_NS, from); - return "moved"; + return outcome; } /** From ba72242a73fe9ff750ad70af58842ae164a44677 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Tue, 25 Aug 2026 13:45:37 -0500 Subject: [PATCH 04/20] fix(repos): keep a missing index row that still owns a worktree registry Co-Authored-By: Claude Fable 5 --- commands/__tests__/repos.test.ts | 15 +++++++++++++ commands/repos.ts | 4 +++- lib/__tests__/repo-index-rename.test.ts | 23 +++++++++++++++++++ lib/repo-index.ts | 30 +++++++++++++++++++++---- 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/commands/__tests__/repos.test.ts b/commands/__tests__/repos.test.ts index 7e3ff1c8..828c2512 100644 --- a/commands/__tests__/repos.test.ts +++ b/commands/__tests__/repos.test.ts @@ -250,4 +250,19 @@ describe("reposPrune", () => { expect(code).toBe(2); expect(loadRepoIndexEntries().map((e) => e.repoName)).toEqual(["gone"]); }); + + test("a retained missing row tells the operator to locate it", async () => { + const { setKvValue } = await import("../../lib/state/index.ts"); + updateRepoIndex("moved-repo", join(home, "gone-away")); + setKvValue("worktree-registry", "moved-repo", [ + { name: "t1", path: join(home, "gone-away", ".worktrees", "t1"), kind: "ephemeral", state: "on-deck", branch: "on-deck/t1", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + const deps = testDeps(); + + await reposPrune([], {}, deps); + + expect(deps.lines.join("\n")).toContain("kept moved-repo"); + expect(deps.lines.join("\n")).toContain("rt repos locate"); + expect(loadRepoIndex()["moved-repo"]).toBeDefined(); + }); }); diff --git a/commands/repos.ts b/commands/repos.ts index 1e866720..a4c9d007 100644 --- a/commands/repos.ts +++ b/commands/repos.ts @@ -208,7 +208,9 @@ export async function reposPrune(args: string[], _ctx: CommandContext = {}, deps for (const r of removed) { const verb = r.retained ? "kept" : dryRun ? "would remove" : "removed"; const why = r.retained - ? `${describeReason(r)}, but its data could not all move${describeDataMove(r, dryRun)} — keeping the row so nothing is orphaned` + ? r.reason === "missing" + ? `${describeReason(r)} but it still owns a worktree registry — keeping the row; run: ${r.hint} --repo ${r.repoName}` + : `${describeReason(r)}, but its data could not all move${describeDataMove(r, dryRun)} — keeping the row so nothing is orphaned` : `${describeReason(r)}${describeDataMove(r, dryRun)}`; deps.print(`${verb} ${r.repoName} (${r.path.replace(homedir(), "~")}) — ${why}`); } diff --git a/lib/__tests__/repo-index-rename.test.ts b/lib/__tests__/repo-index-rename.test.ts index 18ea1d01..86e1044b 100644 --- a/lib/__tests__/repo-index-rename.test.ts +++ b/lib/__tests__/repo-index-rename.test.ts @@ -227,6 +227,29 @@ describe("repo-index — rename drift (RT-60)", () => { expect(Object.keys(mirror())).toEqual(["alive"]); }); + + test("a missing row that still owns a worktree registry is KEPT, not evicted", () => { + indexRepoAt("moved", join(scratch, "gone-away"), 1_000); + setKvValue("worktree-registry", "moved", [ + { name: "t1", path: join(scratch, "gone-away", ".worktrees", "t1"), kind: "ephemeral", state: "on-deck", branch: "on-deck/t1", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + + const removed = pruneRepoIndex(); + const row = removed.find((r) => r.repoName === "moved"); + + expect(row).toMatchObject({ reason: "missing", retained: true, hint: "rt repos locate" }); + expect(loadRepoIndexEntries().map((e) => e.repoName)).toEqual(["moved"]); + expect(listKvValues("worktree-registry")["moved"]).toBeDefined(); + }); + + test("a missing row with no registry is still evicted", () => { + indexRepoAt("gone", join(scratch, "never-existed"), 1_000); + + const removed = pruneRepoIndex(); + + expect(removed.find((r) => r.repoName === "gone")?.retained).toBeUndefined(); + expect(loadRepoIndexEntries()).toEqual([]); + }); }); // ─── data migration (RT-60) ──────────────────────────────────────────────── diff --git a/lib/repo-index.ts b/lib/repo-index.ts index 1cfe9ed9..5125b45b 100644 --- a/lib/repo-index.ts +++ b/lib/repo-index.ts @@ -283,10 +283,14 @@ export interface PrunedEntry { /** Set only for `duplicate`: what became of the retired name's data dir. */ data?: DataMigration; /** - * Set on a `duplicate` whose migration could not finish: the row is KEPT so - * whatever is still keyed to the retired name stays reachable. + * Set when the row is KEPT despite qualifying for eviction: a `duplicate` + * whose migration could not finish, or a `missing` row that still owns a + * worktree registry. Eviction is exactly what makes those leftovers + * unreachable. */ retained?: true; + /** Set with `retained`: the verb that resolves this row. */ + hint?: string; } /** Outcome of carrying everything keyed to a retired name onto the live name. */ @@ -499,6 +503,9 @@ export function migrateRepoData(from: string, to: string, opts: { dryRun?: boole * eviction is exactly what makes a leftover unreachable. A `missing` row is * left un-migrated on purpose: its path is gone, so there is no surviving name * to carry it to, and its data dir stays untouched rather than being deleted. + * A `missing` row that still owns a worktree registry is likewise `retained`: + * the registry is the daemon's only handle to that repo's trees, keyed by + * this row's name, so evicting the row would strand it. */ export function pruneRepoIndex(opts: { dryRun?: boolean } = {}): PrunedEntry[] { const entries = loadRepoIndexEntries(); @@ -506,8 +513,23 @@ export function pruneRepoIndex(opts: { dryRun?: boolean } = {}): PrunedEntry[] { const live: RepoIndexEntry[] = []; for (const entry of entries) { - if (existsSync(entry.path)) live.push(entry); - else removed.push({ repoName: entry.repoName, path: entry.path, reason: "missing" }); + if (existsSync(entry.path)) { + live.push(entry); + continue; + } + // A gone path whose registry is still here is a MOVE, not a deletion: + // dropping the row orphans the pool's claim state under a key nothing + // iterates any more. + let ownsRegistry = false; + try { + ownsRegistry = hasKvValue(WORKTREE_REGISTRY_NS, entry.repoName); + } catch { /* unreadable db — treat as no registry and prune as before */ } + removed.push({ + repoName: entry.repoName, + path: entry.path, + reason: "missing", + ...(ownsRegistry ? { retained: true as const, hint: "rt repos locate" } : {}), + }); } for (const dup of partitionByRealpath(live).duplicates) { From 24b96f791872620c06ca954657616e5b6658b5fa Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Tue, 25 Aug 2026 13:55:54 -0500 Subject: [PATCH 05/20] feat(repos): keep lost index rows visible and refuse to cd into them Co-Authored-By: Claude Fable 5 --- lib/__tests__/repo-index-missing.test.ts | 98 ++++++++++++++++++++++++ lib/__tests__/repo-index.test.ts | 9 ++- lib/pickers.ts | 15 +++- lib/repo-index.ts | 33 +++++++- lib/repo.ts | 14 +++- 5 files changed, 156 insertions(+), 13 deletions(-) create mode 100644 lib/__tests__/repo-index-missing.test.ts diff --git a/lib/__tests__/repo-index-missing.test.ts b/lib/__tests__/repo-index-missing.test.ts new file mode 100644 index 00000000..bcabb5d4 --- /dev/null +++ b/lib/__tests__/repo-index-missing.test.ts @@ -0,0 +1,98 @@ +/** + * A moved repo's index row must stay visible: hiding it makes the repo look + * unregistered and re-registers it under a second row at the new path, which + * is the split `rt repos locate` exists to prevent. + */ + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { execSync } from "child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { closeStateDb, setKvValue } from "../state/index.ts"; +import { getKnownRepos, missingRepoRefusal, repoOption } from "../repo-index.ts"; +import { pickFromAllRepos } from "../pickers.ts"; + +describe("missing index rows", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-missing-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-missing-repos-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + function realRepo(name: string): string { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + return dir; + } + + test("a row whose path is gone survives getKnownRepos, marked missing", () => { + setKvValue("repo-index", "moved", join(scratch, "gone-away")); + + const row = getKnownRepos().find((r) => r.repoName === "moved"); + + expect(row?.missing).toBe(true); + expect(row?.worktrees[0]?.path).toBe(join(scratch, "gone-away")); + }); + + test("a live row is never marked missing", () => { + setKvValue("repo-index", "alive", realRepo("alive")); + + expect(getKnownRepos().find((r) => r.repoName === "alive")?.missing).toBeUndefined(); + }); + + test("two lost rows for one directory collapse to a single missing entry", () => { + setKvValue("repo-index", "legacy-name", join(scratch, "gone-away")); + setKvValue("repo-index", "remote:gitlab.com%2Fg%2Fgone", join(scratch, "gone-away")); + + expect(getKnownRepos().filter((r) => r.missing).length).toBe(1); + }); + + test("the picker row says what to run", () => { + const opt = repoOption({ repoName: "moved", worktrees: [{ path: "/x/gone", branch: "", isBare: false }], dataDir: "/d", missing: true }); + expect(opt.hint).toBe("missing — rt repos locate"); + expect(opt.color).toBeDefined(); + }); + + test("the refusal names the repo, the gone path, and the fix", () => { + const msg = missingRepoRefusal({ repoName: "moved", worktrees: [{ path: "/x/gone", branch: "", isBare: false }], dataDir: "/d", missing: true }); + expect(msg).toContain("/x/gone"); + expect(msg).toContain("rt repos locate"); + expect(msg).toContain("--repo moved"); + }); + + test("pickFromAllRepos refuses to cd into a missing repo instead of auto-selecting it", async () => { + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit sentinel"); + }); + const errSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + await pickFromAllRepos( + [{ repoName: "moved", worktrees: [{ path: "/x/gone", branch: "", isBare: false }], dataDir: "/d", missing: true }], + { stderr: true }, + ); + throw new Error("expected pickFromAllRepos to exit"); + } catch (err) { + expect((err as Error).message).toBe("process.exit sentinel"); + expect(exitSpy.mock.calls.at(-1)?.[0]).toBe(1); + expect(errSpy.mock.calls.flat().join(" ")).toContain("rt repos locate"); + } finally { + errSpy.mockRestore(); + exitSpy.mockRestore(); + } + }); +}); diff --git a/lib/__tests__/repo-index.test.ts b/lib/__tests__/repo-index.test.ts index 3a0857e1..3116c94e 100644 --- a/lib/__tests__/repo-index.test.ts +++ b/lib/__tests__/repo-index.test.ts @@ -475,21 +475,22 @@ describe("repo-index — rt.repoRoots (RT-49)", () => { // ─── 13. Disposable cache ───────────────────────────────────────────────── describe("13. disposable cache", () => { - test("a pre-migration repos.json entry for a path that no longer exists is imported, then filtered from the picker", () => { + test("a pre-migration repos.json entry for a path that no longer exists is imported, then kept visible as missing", () => { const root = mkdtempSync(join(tmpdir(), "rt-cache-root-")); const repo = markerRepo(root, "stillhere"); setRepoRoots([root]); // A leftover pre-migration file: getKnownRepos() imports it (empty - // index, file present), but a registered path that no longer exists on - // disk is filtered out the same way it always was. + // index, file present); a registered path that no longer exists on + // disk stays visible, marked missing, rather than disappearing. const p = join(rtDir(), "repos.json"); mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, JSON.stringify({ "stale-repo": "/nonexistent/path" })); const repos = getKnownRepos(); expect(byName(repos, "stillhere")?.worktrees[0]?.path).toBe(repo); - expect(byName(repos, "stale-repo")).toBeUndefined(); + expect(byName(repos, "stale-repo")?.missing).toBe(true); + expect(byName(repos, "stale-repo")?.worktrees[0]?.path).toBe("/nonexistent/path"); expect(loadRepoIndex()["stale-repo"]).toBe("/nonexistent/path"); // imported into the store regardless // repos.json is the live out-of-process compat mirror gitq reads, NOT // a retired legacy file — it must never be renamed away, only kept diff --git a/lib/pickers.ts b/lib/pickers.ts index 64bd9ade..adfc54b1 100644 --- a/lib/pickers.ts +++ b/lib/pickers.ts @@ -7,7 +7,7 @@ import { execSync } from "child_process"; import { join } from "path"; -import { getRepoIdentity, getKnownRepos, pickWorktreeFromRepo, getWorkspacePackages, repoOptions, type KnownRepo } from "./repo.ts"; +import { getRepoIdentity, getKnownRepos, pickWorktreeFromRepo, getWorkspacePackages, repoOptions, missingRepoRefusal, type KnownRepo } from "./repo.ts"; import { enrichBranches, formatBranchLabel } from "./enrich.ts"; const SWITCH_REPO = "__switch_repo__" as const; @@ -88,15 +88,23 @@ export async function pickFromAllRepos( repos: KnownRepo[], opts?: { stderr?: boolean; errorMessage?: string; includePackages?: boolean }, ): Promise { - const { filterableSelect, BackNavigation } = await import("./rt-render.tsx"); + const writer = opts?.stderr ? console.error : console.log; if (repos.length === 0) { const msg = opts?.errorMessage || "no known repos found — run rt from inside a git repo first"; - const writer = opts?.stderr ? console.error : console.log; writer(`\n ${msg}\n`); process.exit(1); } + /** Refusing before the picker loads keeps a lost-repo-only index off the ink path entirely. */ + const refuse = (repo: KnownRepo): never => { + writer(`\n ${missingRepoRefusal(repo)}\n`); + process.exit(1); + }; + if (repos.length === 1 && repos[0]!.missing) refuse(repos[0]!); + + const { filterableSelect, BackNavigation } = await import("./rt-render.tsx"); + // Loop: back from worktree/package picker restarts at repo picker while (true) { let selectedRepo: KnownRepo; @@ -112,6 +120,7 @@ export async function pickFromAllRepos( if (!picked) process.exit(1); selectedRepo = repos.find(r => r.repoName === picked)!; } + if (selectedRepo.missing) refuse(selectedRepo); // Resolve worktree path (or auto-select if only one) let worktreePath: string | null; diff --git a/lib/repo-index.ts b/lib/repo-index.ts index 5125b45b..705980dc 100644 --- a/lib/repo-index.ts +++ b/lib/repo-index.ts @@ -41,6 +41,9 @@ export interface KnownRepo { /** False for repos discovered by scanning sibling directories, never * explicitly visited by rt. Omitted (implicitly true) for indexed repos. */ registered?: boolean; + /** The indexed path no longer exists. The row is kept so `rt repos locate` + * can move it as one unit with its registry; it is never a cd target. */ + missing?: true; } // ─── Index CRUD ────────────────────────────────────────────────────────────── @@ -700,8 +703,12 @@ export function getKnownRepos(): KnownRepo[] { } const repos: KnownRepo[] = []; + const liveEntries: RepoIndexEntry[] = []; + const lostEntries: RepoIndexEntry[] = []; + for (const e of entries) (existsSync(e.path) ? liveEntries : lostEntries).push(e); + // Hidden here, not evicted — see partitionByRealpath. - const { keep } = partitionByRealpath(entries.filter((e) => existsSync(e.path))); + const { keep } = partitionByRealpath(liveEntries); for (const { repoName, path: mainPath } of keep) { const worktrees: KnownRepo["worktrees"] = []; @@ -745,15 +752,23 @@ export function getKnownRepos(): KnownRepo[] { } const known = repos.filter(r => r.worktrees.length > 0); - const knownNames = new Set(known.map(r => r.repoName)); + // A pair of rows for one gone directory is one lost repo, not two. + const lost: KnownRepo[] = partitionByRealpath(lostEntries).keep.map((e) => ({ + repoName: e.repoName, + worktrees: [{ path: e.path, branch: "", isBare: false }], + dataDir: repoDataDir(e.repoName), + missing: true as const, + })); + const knownNames = new Set([...known, ...lost].map(r => r.repoName)); // realpath'd for set-membership ONLY — a symlinked path component (macOS // /tmp → /private/tmp being the canonical case) must not let the same // directory double-emit under two spellings. `known` itself keeps its // original, user-visible spellings (KnownRepo.worktrees[].path, repos.json, - // `rt cd` targets) untouched. + // `rt cd` targets) untouched. Lost paths are deliberately absent: the scan + // must be free to surface the moved repo's NEW directory. const knownPaths = new Set(known.flatMap(r => r.worktrees.map(w => safeRealpath(w.path)))); - return [...known, ...scanUnregisteredRepos(known, knownNames, knownPaths)]; + return [...known, ...lost, ...scanUnregisteredRepos([...known, ...lost], knownNames, knownPaths)]; } interface Candidate { @@ -928,6 +943,10 @@ function scanUnregisteredRepos( it); only `label` is decoded for humans. Prefer `repoOptions` for a full list — it disambiguates repos whose identities share a last segment. */ export function repoOption(r: KnownRepo, label: string = repoLabel(r.repoName)): { value: string; label: string; hint: string; color?: string } { + if (r.missing) { + return { value: r.repoName, label, hint: "missing — rt repos locate", color: dim }; + } + const location = r.worktrees.length > 1 ? `${r.worktrees.length} worktrees` : r.worktrees[0]?.path.replace(homedir(), "~") || ""; @@ -963,6 +982,12 @@ export function repoOptions(repos: KnownRepo[]): Array --repo ${r.repoName}`; +} + // ─── Test seam ─────────────────────────────────────────────────────────────── export const __test__ = { diff --git a/lib/repo.ts b/lib/repo.ts index 6484bf4e..75c8391a 100644 --- a/lib/repo.ts +++ b/lib/repo.ts @@ -14,12 +14,12 @@ import { identityFromRemote, serializeIdentity } from "./settings/identity.ts"; // ─── Re-exports ────────────────────────────────────────────────────────────── export { getRepoRoot, getCurrentBranch, getRemoteUrl } from "./git.ts"; -export { updateRepoIndex, getKnownRepos, repoOption, repoOptions, type KnownRepo } from "./repo-index.ts"; +export { updateRepoIndex, getKnownRepos, repoOption, repoOptions, missingRepoRefusal, type KnownRepo } from "./repo-index.ts"; // ─── Internal imports ──────────────────────────────────────────────────────── import { getRepoRoot, getRemoteUrl } from "./git.ts"; -import { updateRepoIndex, getKnownRepos, repoOption, repoOptions, type KnownRepo } from "./repo-index.ts"; +import { updateRepoIndex, getKnownRepos, repoOption, repoOptions, missingRepoRefusal, type KnownRepo } from "./repo-index.ts"; // ─── Constants ─────────────────────────────────────────────────────────────── @@ -184,6 +184,13 @@ export async function requireIdentity(commandLabel?: string): Promise { const totalWorktrees = repos.reduce((n, r) => n + r.worktrees.length, 0); if (totalWorktrees === 1) { + refuseIfMissing(repos[0]!); return repos[0]!.worktrees[0]!.path; } @@ -271,6 +280,7 @@ export async function pickWorktree(prompt: string): Promise { if (!match) process.exit(0); // shouldn't happen, but don't crash selectedRepo = match; } + refuseIfMissing(selectedRepo); if (selectedRepo.worktrees.length === 1) { return selectedRepo.worktrees[0]!.path; From 52d58b1b2800b538bb2f6a6f2553b16fb1e076f3 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Tue, 25 Aug 2026 14:19:15 -0500 Subject: [PATCH 06/20] fix(repos): make missing rows opt-in via getKnownRepos({ includeMissing }) Co-Authored-By: Claude Fable 5 --- commands/__tests__/cd.test.ts | 75 ++++++++++++++++++++++++ commands/cd.ts | 21 +++++-- lib/__tests__/command-tree.test.ts | 42 ++++++++++++- lib/__tests__/repo-index-missing.test.ts | 16 +++-- lib/__tests__/repo-index.test.ts | 27 +++++++-- lib/command-tree.ts | 10 +++- lib/repo-index.ts | 21 ++++--- lib/repo.ts | 4 +- 8 files changed, 187 insertions(+), 29 deletions(-) create mode 100644 commands/__tests__/cd.test.ts diff --git a/commands/__tests__/cd.test.ts b/commands/__tests__/cd.test.ts new file mode 100644 index 00000000..06f11784 --- /dev/null +++ b/commands/__tests__/cd.test.ts @@ -0,0 +1,75 @@ +/** + * The `--repo --worktree ` combo picks a repo via its own inline + * picker (not lib/pickers.ts's pickFromAllRepos), so a `missing: true` row + * needs its own guard: refuse via missingRepoRefusal before ever falling + * into branch resolution against a dead path. + */ + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { closeStateDb, setKvValue } from "../../lib/state/index.ts"; +import { worktreePicker } from "../cd.ts"; + +// Satisfies ensureShellFunction()'s early-return check so worktreePicker +// never reaches the interactive "install rt cd?" prompt. +const UP_TO_DATE_RC = 'rt() {\n whence -p rt\n "$rt_bin" nav\n}\n'; + +describe("rt cd --repo --worktree with a missing repo", () => { + const origHome = process.env.HOME; + const origShell = process.env.SHELL; + const origCwd = process.cwd(); + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-cd-missing-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-cd-missing-repos-"))); + process.env.HOME = home; + process.env.SHELL = "/bin/zsh"; + writeFileSync(join(home, ".zshrc"), UP_TO_DATE_RC); + closeStateDb(); + // cwd must NOT be a git repo: getRepoIdentity()'s real getRepoRoot() runs + // "git rev-parse --show-toplevel" against process.cwd() with no override, + // and if it found one it would auto-register it into this isolated + // index (updateRepoIndex's side effect), giving repoChoices a second, + // live entry and forcing the interactive picker instead of the + // single-entry auto-select this test exercises. + process.chdir(scratch); + }); + + afterEach(() => { + process.chdir(origCwd); + process.env.HOME = origHome; + process.env.SHELL = origShell; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + test("refuses with missingRepoRefusal instead of falling into branch resolution", async () => { + setKvValue("repo-index", "moved", join(scratch, "gone-away")); + + const originalStdoutWrite = process.stdout.write; + const chdirSpy = spyOn(process, "chdir").mockImplementation(() => {}); + const errSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit sentinel"); + }); + + try { + await expect(worktreePicker(["--repo", "--worktree", "anybranch"])).rejects.toThrow( + "process.exit sentinel", + ); + expect(chdirSpy).not.toHaveBeenCalled(); + expect(exitSpy.mock.calls.at(-1)?.[0]).toBe(1); + expect(errSpy.mock.calls.flat().join(" ")).toContain("rt repos locate"); + } finally { + process.stdout.write = originalStdoutWrite; + chdirSpy.mockRestore(); + errSpy.mockRestore(); + exitSpy.mockRestore(); + } + }); +}); diff --git a/commands/cd.ts b/commands/cd.ts index 88299aa1..0d49fe86 100644 --- a/commands/cd.ts +++ b/commands/cd.ts @@ -22,7 +22,7 @@ import { readFileSync, writeFileSync, appendFileSync } from "fs"; import { join } from "path"; import { homedir } from "os"; import { yellow, green, reset } from "../lib/tui.ts"; -import { getRepoIdentity, getKnownRepos, getWorkspacePackages, repoOptions, type KnownRepo } from "../lib/repo.ts"; +import { getRepoIdentity, getKnownRepos, getWorkspacePackages, repoOptions, missingRepoRefusal, type KnownRepo } from "../lib/repo.ts"; import { pickWorktreeWithSwitch, pickFromAllRepos, @@ -207,14 +207,23 @@ export async function worktreePicker(args: string[]): Promise { // ── --repo flag: always go to repo picker ──────────────────────────────────── if (forceRepo) { if (wtBranch) { - // Pick repo first, then jump to the matching worktree (or show picker) + // Pick repo first, then jump to the matching worktree (or show picker). + // Scoped includeMissing fetch: a missing row must be pickable here so + // it gets the clean missingRepoRefusal below instead of resolving via + // branch name against a dead path — but only in this branch, not the + // rest of rt cd's default flows. const { filterableSelect } = await import("../lib/rt-render.tsx"); - const options = repoOptions(repos); - const pickedRepoName = repos.length === 1 - ? repos[0]!.repoName + const repoChoices = getKnownRepos({ includeMissing: true }); + const options = repoOptions(repoChoices); + const pickedRepoName = repoChoices.length === 1 + ? repoChoices[0]!.repoName : await filterableSelect({ message: "Pick a repo", options, stderr: true }); if (!pickedRepoName) process.exit(0); // Esc on repo picker - const pickedRepo = repos.find((r) => r.repoName === pickedRepoName)!; + const pickedRepo = repoChoices.find((r) => r.repoName === pickedRepoName)!; + if (pickedRepo.missing) { + console.error(`\n ${missingRepoRefusal(pickedRepo)}\n`); + process.exit(1); + } // Try to resolve the worktree in that repo; fall back to picker const lower = wtBranch.toLowerCase(); diff --git a/lib/__tests__/command-tree.test.ts b/lib/__tests__/command-tree.test.ts index ab7312cd..d59fc938 100644 --- a/lib/__tests__/command-tree.test.ts +++ b/lib/__tests__/command-tree.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, afterEach, mock } from "bun:test"; +import { describe, test, expect, afterEach, mock, spyOn } from "bun:test"; import { spawnSync } from "child_process"; import { mkdtempSync, rmSync } from "fs"; import { tmpdir } from "os"; @@ -157,6 +157,46 @@ describe("dispatch --repo flag scoping", () => { expect(capturedArgs).toEqual(["--repo", "foo", "--ticket", "bar"]); }); + + // A `missing: true` row's single worktree is a dead path (its indexed + // directory no longer exists) — resolving it by name must refuse with + // missingRepoRefusal instead of chdir-ing into that path. + test('context:"worktree" node: --repo refuses before any chdir', async () => { + const real = realRepoModule; + const missingRepo: KnownRepo = { + repoName: "moved", + worktrees: [{ path: "/nonexistent/gone", branch: "", isBare: false }], + dataDir: "/fake/moved-data", + missing: true, + }; + mock.module("../repo.ts", () => ({ + ...real, + getKnownRepos: () => [missingRepo], + pickWorktreeFromRepo: async () => null, + getRepoIdentity: () => null, + })); + + const chdirSpy = spyOn(process, "chdir").mockImplementation(() => {}); + const errSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit sentinel"); + }); + + const tree: Record = { + cmd: { description: "test", context: "worktree", handler: noop }, + }; + + try { + await expect(dispatch(tree, ["cmd", "--repo", "moved"])).rejects.toThrow("process.exit sentinel"); + expect(chdirSpy).not.toHaveBeenCalled(); + expect(exitSpy.mock.calls.at(-1)?.[0]).toBe(1); + expect(errSpy.mock.calls.flat().join(" ")).toContain("rt repos locate"); + } finally { + chdirSpy.mockRestore(); + errSpy.mockRestore(); + exitSpy.mockRestore(); + } + }); }); // ─── ANSI never reaches a pipe ─────────────────────────────────────────────── diff --git a/lib/__tests__/repo-index-missing.test.ts b/lib/__tests__/repo-index-missing.test.ts index bcabb5d4..46e4beb6 100644 --- a/lib/__tests__/repo-index-missing.test.ts +++ b/lib/__tests__/repo-index-missing.test.ts @@ -40,26 +40,32 @@ describe("missing index rows", () => { return dir; } - test("a row whose path is gone survives getKnownRepos, marked missing", () => { + test("getKnownRepos() default excludes a row whose path is gone", () => { setKvValue("repo-index", "moved", join(scratch, "gone-away")); - const row = getKnownRepos().find((r) => r.repoName === "moved"); + expect(getKnownRepos().find((r) => r.repoName === "moved")).toBeUndefined(); + }); + + test("getKnownRepos({ includeMissing: true }) surfaces that same row, marked missing", () => { + setKvValue("repo-index", "moved", join(scratch, "gone-away")); + + const row = getKnownRepos({ includeMissing: true }).find((r) => r.repoName === "moved"); expect(row?.missing).toBe(true); expect(row?.worktrees[0]?.path).toBe(join(scratch, "gone-away")); }); - test("a live row is never marked missing", () => { + test("a live row is never marked missing, even with includeMissing: true", () => { setKvValue("repo-index", "alive", realRepo("alive")); - expect(getKnownRepos().find((r) => r.repoName === "alive")?.missing).toBeUndefined(); + expect(getKnownRepos({ includeMissing: true }).find((r) => r.repoName === "alive")?.missing).toBeUndefined(); }); test("two lost rows for one directory collapse to a single missing entry", () => { setKvValue("repo-index", "legacy-name", join(scratch, "gone-away")); setKvValue("repo-index", "remote:gitlab.com%2Fg%2Fgone", join(scratch, "gone-away")); - expect(getKnownRepos().filter((r) => r.missing).length).toBe(1); + expect(getKnownRepos({ includeMissing: true }).filter((r) => r.missing).length).toBe(1); }); test("the picker row says what to run", () => { diff --git a/lib/__tests__/repo-index.test.ts b/lib/__tests__/repo-index.test.ts index 3116c94e..ebcb6bb8 100644 --- a/lib/__tests__/repo-index.test.ts +++ b/lib/__tests__/repo-index.test.ts @@ -475,22 +475,20 @@ describe("repo-index — rt.repoRoots (RT-49)", () => { // ─── 13. Disposable cache ───────────────────────────────────────────────── describe("13. disposable cache", () => { - test("a pre-migration repos.json entry for a path that no longer exists is imported, then kept visible as missing", () => { + test("a stale on-disk repos.json is ignored — the store is authoritative, nothing crashes", () => { const root = mkdtempSync(join(tmpdir(), "rt-cache-root-")); const repo = markerRepo(root, "stillhere"); setRepoRoots([root]); - // A leftover pre-migration file: getKnownRepos() imports it (empty - // index, file present); a registered path that no longer exists on - // disk stays visible, marked missing, rather than disappearing. + // A leftover pre-migration file with different, stale data must never + // resurface through getKnownRepos()'s default (missing rows excluded). const p = join(rtDir(), "repos.json"); mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, JSON.stringify({ "stale-repo": "/nonexistent/path" })); const repos = getKnownRepos(); expect(byName(repos, "stillhere")?.worktrees[0]?.path).toBe(repo); - expect(byName(repos, "stale-repo")?.missing).toBe(true); - expect(byName(repos, "stale-repo")?.worktrees[0]?.path).toBe("/nonexistent/path"); + expect(byName(repos, "stale-repo")).toBeUndefined(); expect(loadRepoIndex()["stale-repo"]).toBe("/nonexistent/path"); // imported into the store regardless // repos.json is the live out-of-process compat mirror gitq reads, NOT // a retired legacy file — it must never be renamed away, only kept @@ -501,6 +499,23 @@ describe("repo-index — rt.repoRoots (RT-49)", () => { rmSync(root, { recursive: true, force: true }); }); + test("includeMissing: true surfaces that same stale entry, marked missing, instead of dropping it", () => { + const root = mkdtempSync(join(tmpdir(), "rt-cache-root-")); + const repo = markerRepo(root, "stillhere"); + setRepoRoots([root]); + + const p = join(rtDir(), "repos.json"); + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, JSON.stringify({ "stale-repo": "/nonexistent/path" })); + + const repos = getKnownRepos({ includeMissing: true }); + expect(byName(repos, "stillhere")?.worktrees[0]?.path).toBe(repo); + expect(byName(repos, "stale-repo")?.missing).toBe(true); + expect(byName(repos, "stale-repo")?.worktrees[0]?.path).toBe("/nonexistent/path"); + + rmSync(root, { recursive: true, force: true }); + }); + test("a pre-migration repos.json is imported on first read, and stays in place (refreshed, never renamed) as the live compat mirror", () => { const p = join(rtDir(), "repos.json"); mkdirSync(dirname(p), { recursive: true }); diff --git a/lib/command-tree.ts b/lib/command-tree.ts index f316d5b3..b42d1d2c 100644 --- a/lib/command-tree.ts +++ b/lib/command-tree.ts @@ -246,8 +246,8 @@ export async function dispatch( if (repoFlag) { // --repo provided: resolve that repo and show worktree picker (skip repo picker + cwd detection) - const { getKnownRepos, pickWorktreeFromRepo, getRepoIdentity } = await import("./repo.ts"); - const repos = getKnownRepos(); + const { getKnownRepos, pickWorktreeFromRepo, getRepoIdentity, missingRepoRefusal } = await import("./repo.ts"); + const repos = getKnownRepos({ includeMissing: true }); const repo = repos.find(r => r.repoName === repoFlag); if (!repo) { const { yellow } = await import("./tui.ts"); @@ -255,6 +255,12 @@ export async function dispatch( console.error(` ${dim}known: ${repos.map(r => r.repoName).join(", ")}${reset}\n`); process.exit(1); } + // A missing row still resolves by name (that's the point — locate it), + // but its one synthetic worktree is a dead path: never chdir into it. + if (repo.missing) { + console.error(`\n ${missingRepoRefusal(repo)}\n`); + process.exit(1); + } if (repo.worktrees.length === 1) { process.chdir(repo.worktrees[0]!.path); } else { diff --git a/lib/repo-index.ts b/lib/repo-index.ts index 705980dc..10f0a4ce 100644 --- a/lib/repo-index.ts +++ b/lib/repo-index.ts @@ -689,8 +689,13 @@ function buildRootSet(known: KnownRepo[]): RootEntry[] { /** * Get all known repos from the global index, with worktree discovery. * Used when rt is run outside a git repo to offer a picker. + * + * `includeMissing` is opt-in: a caller that resolves a repo and then chdirs + * or spawns against its worktree path must ask for `missing` rows explicitly + * and refuse them (`missingRepoRefusal`) before acting, or leave the default + * off and keep today's silent-exclusion behavior. */ -export function getKnownRepos(): KnownRepo[] { +export function getKnownRepos(opts?: { includeMissing?: boolean }): KnownRepo[] { // Same degrade-don't-crash rule as getRepoIdentity()'s index write: an // unopenable state.db (root-owned after a `sudo rt …`) must not take down // the `rt cd`/`rt run` picker — it falls back to the unregistered-scan @@ -753,12 +758,14 @@ export function getKnownRepos(): KnownRepo[] { const known = repos.filter(r => r.worktrees.length > 0); // A pair of rows for one gone directory is one lost repo, not two. - const lost: KnownRepo[] = partitionByRealpath(lostEntries).keep.map((e) => ({ - repoName: e.repoName, - worktrees: [{ path: e.path, branch: "", isBare: false }], - dataDir: repoDataDir(e.repoName), - missing: true as const, - })); + const lost: KnownRepo[] = opts?.includeMissing + ? partitionByRealpath(lostEntries).keep.map((e) => ({ + repoName: e.repoName, + worktrees: [{ path: e.path, branch: "", isBare: false }], + dataDir: repoDataDir(e.repoName), + missing: true as const, + })) + : []; const knownNames = new Set([...known, ...lost].map(r => r.repoName)); // realpath'd for set-membership ONLY — a symlinked path component (macOS // /tmp → /private/tmp being the canonical case) must not let the same diff --git a/lib/repo.ts b/lib/repo.ts index 75c8391a..14681c9b 100644 --- a/lib/repo.ts +++ b/lib/repo.ts @@ -202,7 +202,7 @@ export async function requireRepoIdentity(commandLabel?: string): Promise { - const repos = getKnownRepos(); + const repos = getKnownRepos({ includeMissing: true }); if (repos.length === 0) { console.log(`\n not in a git repo and no known repos found`); From be1eb62c8262533a14ff0e710a7a1a83e8090d13 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Tue, 25 Aug 2026 14:36:04 -0500 Subject: [PATCH 07/20] =?UTF-8?q?feat(repos):=20locate=20core=20=E2=80=94?= =?UTF-8?q?=20plan=20and=20apply=20a=20moved=20repo's=20path=20rewrites=20?= =?UTF-8?q?atomically?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit planLocate matches a moved directory to its index rows by derived identity (never by name), pairing a legacy-name row through the lost path it shares with the identity row. applyLocate writes index rows, the merged registry and the endpoint claims in ONE sync state.db transaction, then runs `git worktree repair` (per-path pass then no-arg pass) and verifies every re-rooted path against `git worktree list`; a re-rooted path that exists but git does not list restores the pre-apply snapshot. Two deltas from the task brief: - An index with no lost rows refuses `nothing-lost` rather than falling through to `identity-mismatch` (the brief's own test asserts this). - findLocateCandidates() opts into missing rows via getKnownRepos({ includeMissing: true }), per ruling R9. Co-Authored-By: Claude Fable 5 --- lib/__tests__/repo-locate.test.ts | 252 ++++++++++++++++++ lib/repo-index.ts | 26 +- lib/repo-locate.ts | 410 ++++++++++++++++++++++++++++++ lib/worktree/registry.ts | 13 +- 4 files changed, 699 insertions(+), 2 deletions(-) create mode 100644 lib/__tests__/repo-locate.test.ts create mode 100644 lib/repo-locate.ts diff --git a/lib/__tests__/repo-locate.test.ts b/lib/__tests__/repo-locate.test.ts new file mode 100644 index 00000000..2a3a9f9c --- /dev/null +++ b/lib/__tests__/repo-locate.test.ts @@ -0,0 +1,252 @@ +/** + * The locate core: plan a move by identity, then apply index + registry + + * claim + git-admin rewrites as one unit. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execSync } from "child_process"; +import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { closeStateDb, listEndpointClaims, setKvValue } from "../state/index.ts"; +import { loadRepoIndex } from "../repo-index.ts"; +import { loadRegistry, saveRegistry, type TreeRecord } from "../worktree/registry.ts"; +import { saveClaims } from "../endpoint/store.ts"; +import { deriveRepoIdentity, serializeIdentity } from "../settings/identity.ts"; +import { applyLocate, findLocateCandidates, isRefusal, planLocate } from "../repo-locate.ts"; + +describe("repo locate", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-repos-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + /** A repo with an origin remote, so its identity is remote-kind and survives the move. */ + function repoWithRemote(name: string): string { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync(`git remote add origin https://gitlab.com/g/${name}.git`, { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + return realpathSync(dir); + } + + function localRepo(name: string): string { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + return realpathSync(dir); + } + + function rec(over: Partial & { path: string }): TreeRecord { + return { name: "t", kind: "unmanaged", branch: null, createdAt: "2026-01-01T00:00:00.000Z", ...over }; + } + + test("a directory that is not a git repo is refused", async () => { + const plain = join(scratch, "plain"); + mkdirSync(plain); + const out = await planLocate({ newPath: plain }); + expect(isRefusal(out) && out.refusal).toBe("not-a-git-repo"); + }); + + test("nothing lost in the index is refused", async () => { + const repo = repoWithRemote("alpha"); + const out = await planLocate({ newPath: repo }); + expect(isRefusal(out) && out.refusal).toBe("nothing-lost"); + }); + + test("a derived identity matching no lost row refuses and names both sides", async () => { + setKvValue("repo-index", "remote:gitlab.com%2Fg%2Fsomething-else", join(scratch, "gone")); + const repo = repoWithRemote("beta"); + + const out = await planLocate({ newPath: repo }); + + expect(isRefusal(out) && out.refusal).toBe("identity-mismatch"); + expect(isRefusal(out) && out.message).toContain("remote:gitlab.com%2Fg%2Fbeta"); + expect(isRefusal(out) && out.message).toContain("remote:gitlab.com%2Fg%2Fsomething-else"); + }); + + test("a remote-less repo is refused: its identity IS its path, so a move mints a new one", async () => { + setKvValue("repo-index", `path:${encodeURIComponent(join(scratch, "gone"))}`, join(scratch, "gone")); + const repo = localRepo("gamma"); + + const out = await planLocate({ newPath: repo }); + + expect(isRefusal(out) && out.refusal).toBe("identity-changed"); + expect(isRefusal(out) && out.message).toContain("rt repos register"); + }); + + test("an old path that still exists is a second clone, not a move", async () => { + const original = repoWithRemote("delta"); + const clone = join(scratch, "delta-clone"); + execSync(`git clone -q ${original} ${clone}`, { stdio: "pipe" }); + execSync(`git remote set-url origin https://gitlab.com/g/delta.git`, { cwd: clone, stdio: "pipe" }); + setKvValue("repo-index", serializeIdentity(await deriveRepoIdentity(original)), original); + + const out = await planLocate({ newPath: realpathSync(clone) }); + + expect(isRefusal(out) && out.refusal).toBe("old-path-exists"); + }); + + test("plans the index keys, registry rewrite, claim rewrite and repair paths of a moved repo", async () => { + const repo = repoWithRemote("epsilon"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue("repo-index", identity, repo); + setKvValue("repo-index", "epsilon-legacy", repo); + saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); + saveRegistry("epsilon-legacy", [rec({ name: "t1", path: treePath, kind: "ephemeral", state: "on-deck", branch: "feat" })]); + saveClaims(identity, [{ worktree: treePath, role: "web", port: 4001, ts: "2026-01-01T00:00:00.000Z" }]); + + const moved = join(scratch, "epsilon-moved"); + renameSync(repo, moved); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + + expect(plan.identity).toBe(identity); + expect(plan.oldPath).toBe(repo); + expect(plan.newPath).toBe(moved); + expect(plan.indexKeys.sort()).toEqual([identity, "epsilon-legacy"].sort()); + expect(plan.legacyKeys).toEqual(["epsilon-legacy"]); + expect(plan.gitRepairPaths).toEqual([join(moved, ".worktrees", "t1")]); + expect(plan.claimRewrites).toEqual([ + { repoKey: identity, worktree: treePath, newWorktree: join(moved, ".worktrees", "t1") }, + ]); + }); + + test("apply re-points the index, merges the pair's registries, rewrites claims and repairs git", async () => { + const repo = repoWithRemote("zeta"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue("repo-index", identity, repo); + setKvValue("repo-index", "zeta-legacy", repo); + saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); + saveRegistry("zeta-legacy", [rec({ name: "t1", path: treePath, kind: "ephemeral", state: "claimed", owner: "matt", branch: "feat" })]); + saveClaims(identity, [{ worktree: treePath, role: "web", port: 4001, ts: "2026-01-01T00:00:00.000Z" }]); + + const moved = join(scratch, "zeta-moved"); + renameSync(repo, moved); + const newTree = join(moved, ".worktrees", "t1"); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + const result = await applyLocate(plan); + + expect(result.ok).toBe(true); + expect(loadRepoIndex()[identity]).toBe(moved); + expect(loadRepoIndex()["zeta-legacy"]).toBeUndefined(); + expect(loadRegistry(identity).map((t) => t.path).sort()).toEqual([moved, newTree].sort()); + expect(loadRegistry(identity).find((t) => t.path === newTree)).toMatchObject({ state: "claimed", owner: "matt" }); + expect(listEndpointClaims(identity)[0]?.worktree).toBe(newTree); + expect( + execSync("git worktree list --porcelain", { cwd: moved, encoding: "utf8" }), + ).toContain(newTree); + expect(result.legacyRows).toEqual([{ key: "zeta-legacy", outcome: "collapsed" }]); + }); + + test("a worktree outside the moved tree keeps its own path", async () => { + const repo = repoWithRemote("iota"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const inTree = join(repo, ".worktrees", "t1"); + const external = join(scratch, "iota-external"); + execSync(`git worktree add -q -b feat ${inTree}`, { cwd: repo, stdio: "pipe" }); + execSync(`git worktree add -q -b other ${external}`, { cwd: repo, stdio: "pipe" }); + setKvValue("repo-index", identity, repo); + saveRegistry(identity, [ + rec({ name: "main", path: repo, kind: "main", branch: "main" }), + rec({ name: "t1", path: inTree, kind: "ephemeral", state: "on-deck", branch: "feat" }), + rec({ name: "ext", path: external, kind: "ephemeral", state: "claimed", owner: "matt", branch: "other" }), + ]); + + const moved = join(scratch, "iota-moved"); + renameSync(repo, moved); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + const result = await applyLocate(plan); + + expect(plan.gitRepairPaths).toEqual([join(moved, ".worktrees", "t1")]); + expect(result.ok).toBe(true); + expect(loadRegistry(identity).find((t) => t.name === "ext")).toMatchObject({ + path: external, + state: "claimed", + owner: "matt", + }); + }); + + test("a registry record whose tree is gone is reported stale, not a failure", async () => { + const repo = repoWithRemote("eta"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + setKvValue("repo-index", identity, repo); + saveRegistry(identity, [ + rec({ name: "main", path: repo, kind: "main", branch: "main" }), + rec({ name: "ghost", path: join(repo, ".worktrees", "ghost"), kind: "ephemeral", state: "on-deck" }), + ]); + + const moved = join(scratch, "eta-moved"); + renameSync(repo, moved); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + const result = await applyLocate(plan); + + expect(result.ok).toBe(true); + expect(result.stalePaths).toEqual([join(moved, ".worktrees", "ghost")]); + expect(loadRepoIndex()[identity]).toBe(moved); + }); + + test("a failed verification restores the pre-apply rows", async () => { + const repo = repoWithRemote("theta"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue("repo-index", identity, repo); + saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); + + const moved = join(scratch, "theta-moved"); + renameSync(repo, moved); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + // A directory that exists but git will never list: the exact shape a + // failed `git worktree repair` leaves behind. + const decoy = join(moved, "decoy"); + mkdirSync(decoy, { recursive: true }); + plan.registryRewrites[0]!.movedPaths.push(decoy); + + const result = await applyLocate(plan); + + expect(result.ok).toBe(false); + expect(result.restored).toBe(true); + expect(loadRepoIndex()[identity]).toBe(repo); + expect(loadRegistry(identity)[0]?.path).toBe(repo); + }); + + test("candidates pair a scanned directory with the lost row it derives", async () => { + const repo = repoWithRemote("kappa"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + setKvValue("repo-index", identity, repo); + + const moved = join(scratch, "kappa-moved"); + renameSync(repo, moved); + + expect(await findLocateCandidates()).toEqual([{ path: moved, identity }]); + }); +}); diff --git a/lib/repo-index.ts b/lib/repo-index.ts index 10f0a4ce..db63cb08 100644 --- a/lib/repo-index.ts +++ b/lib/repo-index.ts @@ -52,7 +52,7 @@ interface RepoIndex { [repoName: string]: string; // repoName → primary repo root path } -const REPO_INDEX_NS = "repo-index"; +export const REPO_INDEX_NS = "repo-index"; /** * Deprecated derived-compatibility path: state.db is authoritative, but @@ -157,6 +157,30 @@ export function updateRepoIndex(repoName: string, repoRoot: string): void { } catch { /* best effort */ } } +/** + * Raw index-row write: no git probe, no move detection. `updateRepoIndex` is + * the caller-facing path that DERIVES the main path; this is the primitive for + * a caller that has already decided what the row must say, and it is the only + * index write that is safe to run inside a state.db transaction. + */ +export function setIndexPath(key: string, mainPath: string): void { + setKvValue(REPO_INDEX_NS, key, mainPath); +} + +/** Drop one index row. */ +export function removeIndexRow(key: string): void { + deleteKvValue(REPO_INDEX_NS, key); +} + +/** Rewrite ~/.mattstack/rt/repos.json from the current rows — a FILE write, so it runs after a transaction commits, never inside one. */ +export function refreshRepoIndexMirror(): void { + try { + writeRepoIndexCompat(loadRepoIndex()); + } catch { + // best effort — see repoIndexCompatPath's doc comment + } +} + /** * Resolves a serialized identity to its indexed main-worktree path, tolerating * an index whose rows still carry legacy repo-name keys (the state every diff --git a/lib/repo-locate.ts b/lib/repo-locate.ts new file mode 100644 index 00000000..c8fab8cd --- /dev/null +++ b/lib/repo-locate.ts @@ -0,0 +1,410 @@ +/** + * Repo locate: re-point every literal path rt stores for a repo whose folder + * moved, as one unit. + * + * Ordering is the whole point. The reconciler prunes a registry row whose path + * is absent from `git worktree list`, so an index row that heals ahead of the + * registry destroys claimed/on-deck state and replenish then mints replacement + * trees. Everything that can be written atomically goes in one state.db + * transaction; `git worktree repair` and the verification run after it, and a + * verification failure puts the pre-apply rows back. + * + * Pure of the daemon and the CLI: the daemon handler and `commands/repos.ts` + * both drive these functions, and neither the caller nor the transport is + * visible from here. + */ + +import { existsSync, realpathSync } from "fs"; +import { join, resolve as resolvePath } from "path"; +import { + getKnownRepos, + loadRepoIndexEntries, + migrateRepoData, + migrationIncomplete, + refreshRepoIndexMirror, + removeIndexRow, + setIndexPath, + type RepoIndexEntry, +} from "./repo-index.ts"; +import { + deleteRegistry, + hasRegistry, + loadRegistry, + mergeRegistries, + saveRegistry, + type TreeRecord, +} from "./worktree/registry.ts"; +import { loadClaims, saveClaims, type EndpointClaim } from "./endpoint/store.ts"; +import { deriveRepoIdentity, parseIdentity, serializeIdentity } from "./settings/identity.ts"; +import { getStateDb } from "./state/index.ts"; +import { listWorktreesAsync, runGit } from "./worktree/git-async.ts"; + +export type LocateRefusalCode = + | "not-a-git-repo" + | "nothing-lost" + | "old-path-exists" + | "identity-mismatch" + | "identity-changed"; + +export interface LocateRefusal { + refusal: LocateRefusalCode; + message: string; +} + +export interface RegistryRewrite { + /** Index key this registry belongs to: the identity, or a legacy-name half of a healed pair. */ + repoKey: string; + /** The whole registry after the re-root, in its original order. */ + trees: TreeRecord[]; + /** New spellings of the records this move re-rooted — what verification checks. */ + movedPaths: string[]; +} + +export interface ClaimRewrite { + repoKey: string; + worktree: string; + newWorktree: string; +} + +export interface LocatePlan { + identity: string; + oldPath: string; + newPath: string; + indexKeys: string[]; + /** Every `indexKeys` entry that is not the identity — collapsed after a verified apply. */ + legacyKeys: string[]; + registryRewrites: RegistryRewrite[]; + claimRewrites: ClaimRewrite[]; + /** In-tree worktree paths (new spellings, main excluded) handed to `git worktree repair`. */ + gitRepairPaths: string[]; +} + +export interface LocateResult { + ok: boolean; + identity: string; + from: string; + to: string; + indexKeys: string[]; + treesRewritten: number; + claimsRewritten: number; + repaired: string[]; + /** Re-rooted registry paths with nothing on disk — a record the reconciler will prune, never a locate failure. */ + stalePaths: string[]; + legacyRows: { key: string; outcome: "collapsed" | "retained" }[]; + restored?: true; + error?: string; +} + +export interface LocateCandidate { + path: string; + identity: string; +} + +export function isRefusal(x: LocatePlan | LocateRefusal): x is LocateRefusal { + return "refusal" in x; +} + +function refuse(refusal: LocateRefusalCode, message: string): LocateRefusal { + return { refusal, message }; +} + +/** realpathSync, degrading to the literal spelling — a gone path must compare, not throw. */ +function canon(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +/** `path` re-rooted onto `newPath`, or null when it lives outside the moved tree (an external worktree keeps its own path). */ +function relocatePath(path: string, oldPath: string, newPath: string): string | null { + if (path === oldPath) return newPath; + if (path.startsWith(`${oldPath}/`)) return newPath + path.slice(oldPath.length); + return null; +} + +/** + * Resolve which index rows a move touches, matching by IDENTITY only. + * + * A legacy-name row joins the plan through the identity row it shares a lost + * directory with — never by name, which is exactly the drift identities exist + * to end. + */ +export async function planLocate(opts: { newPath: string; repo?: string }): Promise { + const newPath = canon(resolvePath(opts.newPath)); + if (!existsSync(join(newPath, ".git"))) { + return refuse("not-a-git-repo", `${newPath} is not a git repository`); + } + + const identity = serializeIdentity(await deriveRepoIdentity(newPath)); + const entries = loadRepoIndexEntries(); + const lost = entries.filter((e) => !existsSync(e.path)); + + const named: RepoIndexEntry | null = opts.repo ? entries.find((e) => e.repoName === opts.repo) ?? null : null; + if (opts.repo && !named) { + return refuse("nothing-lost", `--repo ${opts.repo} is not in the repo index`); + } + if (named && existsSync(named.path)) { + return refuse("old-path-exists", `${opts.repo} is indexed at ${named.path}, which still exists — that is a second clone, not a move`); + } + + const identityRow = entries.find((e) => e.repoName === identity) ?? null; + if (identityRow && existsSync(identityRow.path)) { + return canon(identityRow.path) === newPath + ? refuse("nothing-lost", `${identity} is already indexed at ${newPath}`) + : refuse("old-path-exists", `${identity} is already indexed at ${identityRow.path}, which still exists — that is a second clone, not a move`); + } + + if (!identityRow) { + if (lost.length === 0) { + return refuse("nothing-lost", `no indexed repo is missing from disk, so ${newPath} has nothing to be located as`); + } + if (parseIdentity(identity)?.kind === "path") { + return refuse( + "identity-changed", + `${newPath} derives ${identity}, and no index row is keyed by it. A repo with no origin remote is identified BY its main worktree's path, so moving it mints a new identity rather than keeping the old one — locate re-points paths, it never re-keys a repo. Register the new path instead: rt repos register ${newPath}`, + ); + } + return refuse( + "identity-mismatch", + `${newPath} derives ${identity}, which matches no indexed repo whose path is missing (lost rows: ${lost.map((e) => e.repoName).join(", ")})`, + ); + } + if (named && canon(named.path) !== canon(identityRow.path)) { + return refuse( + "identity-mismatch", + `${newPath} derives ${identity} (indexed at ${identityRow.path}), but --repo names ${named.repoName} at ${named.path} — locate matches by identity, never by name`, + ); + } + + const oldPath = identityRow.path; + const indexKeys = lost.filter((e) => e.path === oldPath).map((e) => e.repoName); + const legacyKeys = indexKeys.filter((key) => key !== identity); + + const registryRewrites: RegistryRewrite[] = []; + const repairPaths = new Set(); + for (const key of indexKeys) { + if (!hasRegistry(key)) continue; + const movedPaths: string[] = []; + const trees = loadRegistry(key).map((rec) => { + const moved = relocatePath(rec.path, oldPath, newPath); + if (moved === null) return rec; + movedPaths.push(moved); + if (moved !== newPath) repairPaths.add(moved); + return { ...rec, path: moved }; + }); + registryRewrites.push({ repoKey: key, trees, movedPaths }); + } + + const claimRewrites: ClaimRewrite[] = []; + for (const key of indexKeys) { + for (const claim of loadClaims(key)) { + const moved = relocatePath(claim.worktree, oldPath, newPath); + if (moved === null) continue; + claimRewrites.push({ repoKey: key, worktree: claim.worktree, newWorktree: moved }); + } + } + + return { + identity, + oldPath, + newPath, + indexKeys, + legacyKeys, + registryRewrites, + claimRewrites, + gitRepairPaths: [...repairPaths], + }; +} + +interface LocateSnapshot { + index: { key: string; path: string | null }[]; + registries: { key: string; trees: TreeRecord[]; existed: boolean }[]; + claims: { key: string; claims: EndpointClaim[] }[]; +} + +/** Every row the apply can touch, read before the first write — the identity's own rows included, since the merge writes them whether or not the plan rewrote them. */ +function captureSnapshot(plan: LocatePlan): LocateSnapshot { + const claimKeys = [...new Set(plan.claimRewrites.map((c) => c.repoKey))]; + const entries = loadRepoIndexEntries(); + return { + index: indexWriteKeys(plan).map((key) => ({ + key, + path: entries.find((e) => e.repoName === key)?.path ?? null, + })), + registries: [...new Set([...plan.registryRewrites.map((r) => r.repoKey), plan.identity])].map((key) => ({ + key, + trees: loadRegistry(key), + existed: hasRegistry(key), + })), + claims: claimKeys.map((key) => ({ key, claims: loadClaims(key) })), + }; +} + +function indexWriteKeys(plan: LocatePlan): string[] { + return [...new Set([...plan.indexKeys, plan.identity])]; +} + +function restoreSnapshot(snapshot: LocateSnapshot): void { + getStateDb().transaction(() => { + for (const row of snapshot.index) { + if (row.path === null) removeIndexRow(row.key); + else setIndexPath(row.key, row.path); + } + for (const reg of snapshot.registries) { + if (reg.existed) saveRegistry(reg.key, reg.trees); + else deleteRegistry(reg.key); + } + for (const c of snapshot.claims) saveClaims(c.key, c.claims); + })(); +} + +/** + * The registry half of the apply: the pair's registries are merged onto the + * IDENTITY key and every legacy registry row is dropped, so the reconciler + * (which iterates identity keys) sees one pool instead of two halves. + */ +function writeRegistries(plan: LocatePlan): void { + const byKey = new Map(plan.registryRewrites.map((r) => [r.repoKey, r.trees])); + let merged = byKey.get(plan.identity) ?? loadRegistry(plan.identity); + let touched = byKey.has(plan.identity); + for (const key of plan.legacyKeys) { + const legacy = byKey.get(key); + if (!legacy) continue; + merged = mergeRegistries(merged, legacy); + deleteRegistry(key); + touched = true; + } + if (touched) saveRegistry(plan.identity, merged); +} + +function writeClaims(plan: LocatePlan): void { + for (const key of new Set(plan.claimRewrites.map((c) => c.repoKey))) { + const moves = new Map( + plan.claimRewrites.filter((c) => c.repoKey === key).map((c) => [c.worktree, c.newWorktree]), + ); + saveClaims( + key, + loadClaims(key).map((c) => { + const moved = moves.get(c.worktree); + return moved === undefined ? c : { ...c, worktree: moved }; + }), + ); + } +} + +/** + * Every re-rooted tree that exists on disk must also be one git knows about; + * a re-rooted tree with nothing on disk is a stale record, which the + * reconciler prunes on its own and which must not fail an otherwise correct + * move. + */ +async function verifyLocate(plan: LocatePlan): Promise<{ error: string | null; stalePaths: string[] }> { + const listed = await listWorktreesAsync(plan.newPath); + if (listed === null) return { error: `git worktree list failed in ${plan.newPath}`, stalePaths: [] }; + const known = new Set(listed.map((w) => canon(w.path))); + if (!known.has(canon(plan.newPath))) { + return { error: `${plan.newPath} is not the main worktree git reports`, stalePaths: [] }; + } + + const stalePaths: string[] = []; + for (const rewrite of plan.registryRewrites) { + for (const path of rewrite.movedPaths) { + if (!existsSync(path)) { + stalePaths.push(path); + continue; + } + if (!known.has(canon(path))) { + return { error: `${path} exists but git does not list it as a worktree of ${plan.newPath}`, stalePaths }; + } + } + } + return { error: null, stalePaths }; +} + +/** + * Collapse the legacy half of a healed pair, on prune's rules: the row is + * dropped only once its data dir has fully moved, because eviction is what + * makes a leftover unreachable. + */ +function collapseLegacyRows(plan: LocatePlan): LocateResult["legacyRows"] { + const out: LocateResult["legacyRows"] = []; + for (const key of plan.legacyKeys) { + const data = migrateRepoData(key, plan.identity); + if (migrationIncomplete(data)) { + out.push({ key, outcome: "retained" }); + continue; + } + removeIndexRow(key); + out.push({ key, outcome: "collapsed" }); + } + return out; +} + +export async function applyLocate(plan: LocatePlan): Promise { + const snapshot = captureSnapshot(plan); + const base = { + identity: plan.identity, + from: plan.oldPath, + to: plan.newPath, + indexKeys: plan.indexKeys, + treesRewritten: plan.registryRewrites.reduce((n, r) => n + r.movedPaths.length, 0), + claimsRewritten: plan.claimRewrites.length, + }; + + // bun:sqlite transactions are sync-only: every git call lives below this + // block, never inside it. + getStateDb().transaction(() => { + for (const key of indexWriteKeys(plan)) setIndexPath(key, plan.newPath); + writeRegistries(plan); + writeClaims(plan); + })(); + refreshRepoIndexMirror(); + + // Path arguments fix each linked worktree's entry in the main repo's admin + // dir; the no-arg pass then fixes the `.git` file inside every linked + // worktree. A move breaks both directions, so both passes run. + if (plan.gitRepairPaths.length > 0) { + await runGit(plan.newPath, ["worktree", "repair", ...plan.gitRepairPaths]); + } + await runGit(plan.newPath, ["worktree", "repair"]); + + const { error, stalePaths } = await verifyLocate(plan); + if (error !== null) { + restoreSnapshot(snapshot); + refreshRepoIndexMirror(); + return { ...base, ok: false, repaired: plan.gitRepairPaths, stalePaths, legacyRows: [], restored: true, error }; + } + + const legacyRows = collapseLegacyRows(plan); + refreshRepoIndexMirror(); + return { ...base, ok: true, repaired: plan.gitRepairPaths, stalePaths, legacyRows }; +} + +/** + * Directories the repo scan surfaced whose derived identity is one of the + * index's lost rows — the candidate set `rt repos locate` offers when it is + * given no path. Never auto-picked: this only proposes. + */ +export async function findLocateCandidates(): Promise { + const repos = getKnownRepos({ includeMissing: true }); + const lostKeys = new Set(repos.filter((r) => r.missing).map((r) => r.repoName)); + if (lostKeys.size === 0) return []; + + const candidates: LocateCandidate[] = []; + for (const repo of repos) { + if (repo.registered !== false) continue; + const path = repo.worktrees[0]?.path; + if (!path) continue; + let identity: string; + try { + identity = serializeIdentity(await deriveRepoIdentity(path)); + } catch { + continue; + } + if (!lostKeys.has(identity)) continue; + candidates.push({ path, identity }); + } + return candidates; +} diff --git a/lib/worktree/registry.ts b/lib/worktree/registry.ts index 22471415..aadf0ead 100644 --- a/lib/worktree/registry.ts +++ b/lib/worktree/registry.ts @@ -1,7 +1,7 @@ import { realpathSync } from "fs"; import { join } from "path"; import { repoDataDir } from "../rt-paths.ts"; -import { getKvValue, hasKvValue, importLegacyJsonFile, setKvValue } from "../state/index.ts"; +import { deleteKvValue, getKvValue, hasKvValue, importLegacyJsonFile, setKvValue } from "../state/index.ts"; export type TreeKind = "main" | "ephemeral" | "unmanaged"; export type TreeState = "creating" | "on-deck" | "claimed" | "disposable"; @@ -145,3 +145,14 @@ export function mergeRegistries(winner: TreeRecord[], loser: TreeRecord[]): Tree } return order.map((key) => byPath.get(key)!); } + +/** Whether this repo has a registry row at all — distinct from an empty registry. */ +export function hasRegistry(repoName: string): boolean { + return hasKvValue(WORKTREE_REGISTRY_NS, repoName); +} + +/** Drop a whole registry row. Only ever the retired half of a pair, after its records have been merged onto the survivor. */ +export function deleteRegistry(repoName: string): void { + deleteKvValue(WORKTREE_REGISTRY_NS, repoName); + epochs.set(repoName, registryEpoch(repoName) + 1); +} From 75a31f950b58e42bf20598fef780f9f3e56cb5c7 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Tue, 25 Aug 2026 14:55:10 -0500 Subject: [PATCH 08/20] fix(repos): locate repairs git and verifies before it writes any rt state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 (ruling R10). applyLocate now runs both `git worktree repair` passes, checks their exit codes, and verifies the re-rooted paths BEFORE the state.db transaction. Until that transaction commits the index still names the dead path, so a reconciler pass that interleaves with the repair finds a repo whose path is gone and bails instead of pruning worktrees whose gitdir pointers are mid-repair. Nothing is written unless the move verifies, so the snapshot / restore machinery (and LocateResult.restored) is gone. Also: - verifyLocate compares the FIRST listed worktree against newPath (git lists main first); membership alone accepted a linked worktree as the new root. planLocate gates on main-ness up front with a new `not-main-worktree` refusal — a `.git` directory is main, a `.git` file is decided by comparing git-dir with git-common-dir. - registries and claims are re-read and re-rooted inside the transaction through the same helper planLocate uses, so a tree provisioned between plan and apply moves instead of being overwritten by a plan-time snapshot. registryRewrites/claimRewrites stay on the plan for dry-run display. - `repaired` reports only paths git actually repaired; a non-existent repair path (git exits 1 on those) is filtered out and left to the stale-path report. - the repair comment now states git's real mechanics; tests key on REPO_INDEX_NS instead of the literal namespace. Co-Authored-By: Claude Fable 5 --- lib/__tests__/repo-locate.test.ts | 149 ++++++++++++++++-- lib/repo-locate.ts | 249 ++++++++++++++++++------------ 2 files changed, 289 insertions(+), 109 deletions(-) diff --git a/lib/__tests__/repo-locate.test.ts b/lib/__tests__/repo-locate.test.ts index 2a3a9f9c..cd550cfd 100644 --- a/lib/__tests__/repo-locate.test.ts +++ b/lib/__tests__/repo-locate.test.ts @@ -9,7 +9,7 @@ import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { closeStateDb, listEndpointClaims, setKvValue } from "../state/index.ts"; -import { loadRepoIndex } from "../repo-index.ts"; +import { loadRepoIndex, REPO_INDEX_NS } from "../repo-index.ts"; import { loadRegistry, saveRegistry, type TreeRecord } from "../worktree/registry.ts"; import { saveClaims } from "../endpoint/store.ts"; import { deriveRepoIdentity, serializeIdentity } from "../settings/identity.ts"; @@ -70,7 +70,7 @@ describe("repo locate", () => { }); test("a derived identity matching no lost row refuses and names both sides", async () => { - setKvValue("repo-index", "remote:gitlab.com%2Fg%2Fsomething-else", join(scratch, "gone")); + setKvValue(REPO_INDEX_NS, "remote:gitlab.com%2Fg%2Fsomething-else", join(scratch, "gone")); const repo = repoWithRemote("beta"); const out = await planLocate({ newPath: repo }); @@ -81,7 +81,7 @@ describe("repo locate", () => { }); test("a remote-less repo is refused: its identity IS its path, so a move mints a new one", async () => { - setKvValue("repo-index", `path:${encodeURIComponent(join(scratch, "gone"))}`, join(scratch, "gone")); + setKvValue(REPO_INDEX_NS, `path:${encodeURIComponent(join(scratch, "gone"))}`, join(scratch, "gone")); const repo = localRepo("gamma"); const out = await planLocate({ newPath: repo }); @@ -95,7 +95,7 @@ describe("repo locate", () => { const clone = join(scratch, "delta-clone"); execSync(`git clone -q ${original} ${clone}`, { stdio: "pipe" }); execSync(`git remote set-url origin https://gitlab.com/g/delta.git`, { cwd: clone, stdio: "pipe" }); - setKvValue("repo-index", serializeIdentity(await deriveRepoIdentity(original)), original); + setKvValue(REPO_INDEX_NS, serializeIdentity(await deriveRepoIdentity(original)), original); const out = await planLocate({ newPath: realpathSync(clone) }); @@ -107,8 +107,8 @@ describe("repo locate", () => { const identity = serializeIdentity(await deriveRepoIdentity(repo)); const treePath = join(repo, ".worktrees", "t1"); execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); - setKvValue("repo-index", identity, repo); - setKvValue("repo-index", "epsilon-legacy", repo); + setKvValue(REPO_INDEX_NS, identity, repo); + setKvValue(REPO_INDEX_NS, "epsilon-legacy", repo); saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); saveRegistry("epsilon-legacy", [rec({ name: "t1", path: treePath, kind: "ephemeral", state: "on-deck", branch: "feat" })]); saveClaims(identity, [{ worktree: treePath, role: "web", port: 4001, ts: "2026-01-01T00:00:00.000Z" }]); @@ -135,8 +135,8 @@ describe("repo locate", () => { const identity = serializeIdentity(await deriveRepoIdentity(repo)); const treePath = join(repo, ".worktrees", "t1"); execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); - setKvValue("repo-index", identity, repo); - setKvValue("repo-index", "zeta-legacy", repo); + setKvValue(REPO_INDEX_NS, identity, repo); + setKvValue(REPO_INDEX_NS, "zeta-legacy", repo); saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); saveRegistry("zeta-legacy", [rec({ name: "t1", path: treePath, kind: "ephemeral", state: "claimed", owner: "matt", branch: "feat" })]); saveClaims(identity, [{ worktree: treePath, role: "web", port: 4001, ts: "2026-01-01T00:00:00.000Z" }]); @@ -168,7 +168,7 @@ describe("repo locate", () => { const external = join(scratch, "iota-external"); execSync(`git worktree add -q -b feat ${inTree}`, { cwd: repo, stdio: "pipe" }); execSync(`git worktree add -q -b other ${external}`, { cwd: repo, stdio: "pipe" }); - setKvValue("repo-index", identity, repo); + setKvValue(REPO_INDEX_NS, identity, repo); saveRegistry(identity, [ rec({ name: "main", path: repo, kind: "main", branch: "main" }), rec({ name: "t1", path: inTree, kind: "ephemeral", state: "on-deck", branch: "feat" }), @@ -194,7 +194,7 @@ describe("repo locate", () => { test("a registry record whose tree is gone is reported stale, not a failure", async () => { const repo = repoWithRemote("eta"); const identity = serializeIdentity(await deriveRepoIdentity(repo)); - setKvValue("repo-index", identity, repo); + setKvValue(REPO_INDEX_NS, identity, repo); saveRegistry(identity, [ rec({ name: "main", path: repo, kind: "main", branch: "main" }), rec({ name: "ghost", path: join(repo, ".worktrees", "ghost"), kind: "ephemeral", state: "on-deck" }), @@ -212,13 +212,14 @@ describe("repo locate", () => { expect(loadRepoIndex()[identity]).toBe(moved); }); - test("a failed verification restores the pre-apply rows", async () => { + test("a failed verification writes nothing to state.db", async () => { const repo = repoWithRemote("theta"); const identity = serializeIdentity(await deriveRepoIdentity(repo)); const treePath = join(repo, ".worktrees", "t1"); execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); - setKvValue("repo-index", identity, repo); + setKvValue(REPO_INDEX_NS, identity, repo); saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); + saveClaims(identity, [{ worktree: treePath, role: "web", port: 4001, ts: "2026-01-01T00:00:00.000Z" }]); const moved = join(scratch, "theta-moved"); renameSync(repo, moved); @@ -234,15 +235,135 @@ describe("repo locate", () => { const result = await applyLocate(plan); expect(result.ok).toBe(false); - expect(result.restored).toBe(true); + expect(result.error).toContain(decoy); + expect(loadRepoIndex()[identity]).toBe(repo); + expect(loadRegistry(identity)[0]?.path).toBe(repo); + expect(listEndpointClaims(identity)[0]?.worktree).toBe(treePath); + }); + + test("a linked worktree is refused as the new root", async () => { + const repo = repoWithRemote("lambda"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue(REPO_INDEX_NS, identity, join(scratch, "gone")); + + const out = await planLocate({ newPath: treePath }); + + expect(isRefusal(out) && out.refusal).toBe("not-main-worktree"); + }); + + test("a registry record written between plan and apply is moved, not overwritten", async () => { + const repo = repoWithRemote("pi"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue(REPO_INDEX_NS, identity, repo); + saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); + + const moved = join(scratch, "pi-moved"); + renameSync(repo, moved); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + // The daemon provisioning a tree while the operator confirms the move: it + // writes the pre-move spelling, because the index still says so. + saveRegistry(identity, [ + ...loadRegistry(identity), + rec({ name: "t1", path: treePath, kind: "ephemeral", state: "claimed", owner: "matt", branch: "feat" }), + ]); + + const result = await applyLocate(plan); + + expect(result.ok).toBe(true); + expect(loadRegistry(identity).map((t) => t.path).sort()).toEqual( + [moved, join(moved, ".worktrees", "t1")].sort(), + ); + expect(loadRegistry(identity).find((t) => t.name === "t1")).toMatchObject({ state: "claimed", owner: "matt" }); + }); + + test("a failed git repair aborts before anything is written", async () => { + const repo = repoWithRemote("omicron"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue(REPO_INDEX_NS, identity, repo); + saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); + + const moved = join(scratch, "omicron-moved"); + renameSync(repo, moved); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + // A real directory git cannot repair — `git worktree repair` exits 1 on it. + const decoy = join(moved, "decoy"); + mkdirSync(decoy, { recursive: true }); + plan.gitRepairPaths.push(decoy); + + const result = await applyLocate(plan); + + expect(result.ok).toBe(false); + expect(result.error).toContain("git worktree repair failed"); + expect(result.repaired).toEqual([]); expect(loadRepoIndex()[identity]).toBe(repo); expect(loadRegistry(identity)[0]?.path).toBe(repo); }); + test("verification rejects a plan rooted at a linked worktree, whatever built it", async () => { + const repo = repoWithRemote("xi"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue(REPO_INDEX_NS, identity, join(scratch, "gone")); + + const result = await applyLocate({ + identity, + oldPath: join(scratch, "gone"), + newPath: treePath, + indexKeys: [identity], + legacyKeys: [], + registryRewrites: [], + claimRewrites: [], + gitRepairPaths: [], + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("not the main worktree"); + expect(loadRepoIndex()[identity]).toBe(join(scratch, "gone")); + }); + + test("a claim whose worktree lies outside the moved root is left untouched", async () => { + const repo = repoWithRemote("nu"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const inTree = join(repo, ".worktrees", "t1"); + const external = join(scratch, "nu-external"); + execSync(`git worktree add -q -b feat ${inTree}`, { cwd: repo, stdio: "pipe" }); + execSync(`git worktree add -q -b other ${external}`, { cwd: repo, stdio: "pipe" }); + setKvValue(REPO_INDEX_NS, identity, repo); + saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); + saveClaims(identity, [ + { worktree: inTree, role: "web", port: 4001, ts: "2026-01-01T00:00:00.000Z" }, + { worktree: external, role: "web", port: 4002, ts: "2026-01-01T00:00:00.000Z" }, + ]); + + const moved = join(scratch, "nu-moved"); + renameSync(repo, moved); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + const result = await applyLocate(plan); + + expect(result.ok).toBe(true); + expect(result.claimsRewritten).toBe(1); + expect(listEndpointClaims(identity).map((c) => c.worktree).sort()).toEqual( + [external, join(moved, ".worktrees", "t1")].sort(), + ); + }); + test("candidates pair a scanned directory with the lost row it derives", async () => { const repo = repoWithRemote("kappa"); const identity = serializeIdentity(await deriveRepoIdentity(repo)); - setKvValue("repo-index", identity, repo); + setKvValue(REPO_INDEX_NS, identity, repo); const moved = join(scratch, "kappa-moved"); renameSync(repo, moved); diff --git a/lib/repo-locate.ts b/lib/repo-locate.ts index c8fab8cd..94e877df 100644 --- a/lib/repo-locate.ts +++ b/lib/repo-locate.ts @@ -5,16 +5,22 @@ * Ordering is the whole point. The reconciler prunes a registry row whose path * is absent from `git worktree list`, so an index row that heals ahead of the * registry destroys claimed/on-deck state and replenish then mints replacement - * trees. Everything that can be written atomically goes in one state.db - * transaction; `git worktree repair` and the verification run after it, and a - * verification failure puts the pre-apply rows back. + * trees. So the apply repairs git and verifies FIRST, while the index still + * names the dead path (a reconciler pass that interleaves there finds a repo + * whose path does not exist and bails), and only then commits index rows, + * registries and claims in one state.db transaction. Nothing is written until + * the whole move is known to be sound, which is why there is no rollback path. + * + * An in-daemon caller still runs this under the reconciler's in-flight hold: + * the transaction is atomic against a reader, but the git repair passes are + * not, and a pass that starts mid-repair can see a half-linked worktree. * * Pure of the daemon and the CLI: the daemon handler and `commands/repos.ts` * both drive these functions, and neither the caller nor the transport is * visible from here. */ -import { existsSync, realpathSync } from "fs"; +import { existsSync, realpathSync, statSync } from "fs"; import { join, resolve as resolvePath } from "path"; import { getKnownRepos, @@ -34,13 +40,14 @@ import { saveRegistry, type TreeRecord, } from "./worktree/registry.ts"; -import { loadClaims, saveClaims, type EndpointClaim } from "./endpoint/store.ts"; +import { loadClaims, saveClaims } from "./endpoint/store.ts"; import { deriveRepoIdentity, parseIdentity, serializeIdentity } from "./settings/identity.ts"; import { getStateDb } from "./state/index.ts"; import { listWorktreesAsync, runGit } from "./worktree/git-async.ts"; export type LocateRefusalCode = | "not-a-git-repo" + | "not-main-worktree" | "nothing-lost" | "old-path-exists" | "identity-mismatch" @@ -73,7 +80,9 @@ export interface LocatePlan { indexKeys: string[]; /** Every `indexKeys` entry that is not the identity — collapsed after a verified apply. */ legacyKeys: string[]; + /** Plan-time preview (dry-run display) and the path set verification checks — the apply re-reads and re-roots each registry itself. */ registryRewrites: RegistryRewrite[]; + /** Plan-time preview, same as `registryRewrites`. */ claimRewrites: ClaimRewrite[]; /** In-tree worktree paths (new spellings, main excluded) handed to `git worktree repair`. */ gitRepairPaths: string[]; @@ -91,7 +100,6 @@ export interface LocateResult { /** Re-rooted registry paths with nothing on disk — a record the reconciler will prune, never a locate failure. */ stalePaths: string[]; legacyRows: { key: string; outcome: "collapsed" | "retained" }[]; - restored?: true; error?: string; } @@ -124,6 +132,48 @@ function relocatePath(path: string, oldPath: string, newPath: string): string | return null; } +/** The one re-root both the plan and the apply run, so what lands is never a plan-time snapshot of the registry. */ +function relocateTrees( + trees: TreeRecord[], + oldPath: string, + newPath: string, +): { trees: TreeRecord[]; movedPaths: string[] } { + const movedPaths: string[] = []; + const next = trees.map((rec) => { + const moved = relocatePath(rec.path, oldPath, newPath); + if (moved === null) return rec; + movedPaths.push(moved); + return { ...rec, path: moved }; + }); + return { trees: next, movedPaths }; +} + +/** + * Whether `path` is a repo's MAIN worktree. Locating a linked worktree would + * re-root every stored path onto a base that is one directory of the repo, so + * this is a gate, not a nicety: a linked worktree derives the same identity as + * its main worktree and would otherwise plan cleanly. + * + * A `.git` directory is main by construction. A `.git` FILE is either a linked + * worktree or a `--separate-git-dir` main worktree, and only git can tell them + * apart: git-dir equals git-common-dir for main, and is + * `/worktrees/` for a linked tree. + */ +async function isMainWorktree(path: string): Promise { + try { + if (statSync(join(path, ".git")).isDirectory()) return true; + } catch { + return false; + } + const r = await runGit(path, ["rev-parse", "--git-dir", "--git-common-dir"]); + if (r.exitCode !== 0) return false; + const [gitDir, commonDir] = r.stdout.trim().split("\n"); + if (gitDir === undefined || commonDir === undefined) return false; + // git prints these relative to the worktree unless they are absolute — which + // is exactly what `resolve` handles and `join` would corrupt. + return canon(resolvePath(path, gitDir)) === canon(resolvePath(path, commonDir)); +} + /** * Resolve which index rows a move touches, matching by IDENTITY only. * @@ -136,6 +186,12 @@ export async function planLocate(opts: { newPath: string; repo?: string }): Prom if (!existsSync(join(newPath, ".git"))) { return refuse("not-a-git-repo", `${newPath} is not a git repository`); } + if (!(await isMainWorktree(newPath))) { + return refuse( + "not-main-worktree", + `${newPath} is a linked worktree, not the repo's main worktree — locate re-roots every stored path onto the path it is given, so it must be given the repo root`, + ); + } const identity = serializeIdentity(await deriveRepoIdentity(newPath)); const entries = loadRepoIndexEntries(); @@ -186,14 +242,10 @@ export async function planLocate(opts: { newPath: string; repo?: string }): Prom const repairPaths = new Set(); for (const key of indexKeys) { if (!hasRegistry(key)) continue; - const movedPaths: string[] = []; - const trees = loadRegistry(key).map((rec) => { - const moved = relocatePath(rec.path, oldPath, newPath); - if (moved === null) return rec; - movedPaths.push(moved); + const { trees, movedPaths } = relocateTrees(loadRegistry(key), oldPath, newPath); + for (const moved of movedPaths) { if (moved !== newPath) repairPaths.add(moved); - return { ...rec, path: moved }; - }); + } registryRewrites.push({ repoKey: key, trees, movedPaths }); } @@ -218,80 +270,54 @@ export async function planLocate(opts: { newPath: string; repo?: string }): Prom }; } -interface LocateSnapshot { - index: { key: string; path: string | null }[]; - registries: { key: string; trees: TreeRecord[]; existed: boolean }[]; - claims: { key: string; claims: EndpointClaim[] }[]; -} - -/** Every row the apply can touch, read before the first write — the identity's own rows included, since the merge writes them whether or not the plan rewrote them. */ -function captureSnapshot(plan: LocatePlan): LocateSnapshot { - const claimKeys = [...new Set(plan.claimRewrites.map((c) => c.repoKey))]; - const entries = loadRepoIndexEntries(); - return { - index: indexWriteKeys(plan).map((key) => ({ - key, - path: entries.find((e) => e.repoName === key)?.path ?? null, - })), - registries: [...new Set([...plan.registryRewrites.map((r) => r.repoKey), plan.identity])].map((key) => ({ - key, - trees: loadRegistry(key), - existed: hasRegistry(key), - })), - claims: claimKeys.map((key) => ({ key, claims: loadClaims(key) })), - }; -} - function indexWriteKeys(plan: LocatePlan): string[] { return [...new Set([...plan.indexKeys, plan.identity])]; } -function restoreSnapshot(snapshot: LocateSnapshot): void { - getStateDb().transaction(() => { - for (const row of snapshot.index) { - if (row.path === null) removeIndexRow(row.key); - else setIndexPath(row.key, row.path); - } - for (const reg of snapshot.registries) { - if (reg.existed) saveRegistry(reg.key, reg.trees); - else deleteRegistry(reg.key); - } - for (const c of snapshot.claims) saveClaims(c.key, c.claims); - })(); -} - /** - * The registry half of the apply: the pair's registries are merged onto the - * IDENTITY key and every legacy registry row is dropped, so the reconciler + * The registry half of the apply: every registry is re-read and re-rooted + * HERE, not carried over from the plan, so a tree provisioned between plan and + * apply is moved rather than overwritten. The pair's registries are merged onto + * the IDENTITY key and every legacy registry row is dropped, so the reconciler * (which iterates identity keys) sees one pool instead of two halves. */ -function writeRegistries(plan: LocatePlan): void { - const byKey = new Map(plan.registryRewrites.map((r) => [r.repoKey, r.trees])); - let merged = byKey.get(plan.identity) ?? loadRegistry(plan.identity); - let touched = byKey.has(plan.identity); +function writeRegistries(plan: LocatePlan): number { + const relocate = (key: string) => relocateTrees(loadRegistry(key), plan.oldPath, plan.newPath); + let moved = 0; + let touched = hasRegistry(plan.identity); + let merged: TreeRecord[] = []; + if (touched) { + const own = relocate(plan.identity); + merged = own.trees; + moved += own.movedPaths.length; + } for (const key of plan.legacyKeys) { - const legacy = byKey.get(key); - if (!legacy) continue; - merged = mergeRegistries(merged, legacy); + if (!hasRegistry(key)) continue; + const legacy = relocate(key); + merged = mergeRegistries(merged, legacy.trees); + moved += legacy.movedPaths.length; deleteRegistry(key); touched = true; } if (touched) saveRegistry(plan.identity, merged); + return moved; } -function writeClaims(plan: LocatePlan): void { - for (const key of new Set(plan.claimRewrites.map((c) => c.repoKey))) { - const moves = new Map( - plan.claimRewrites.filter((c) => c.repoKey === key).map((c) => [c.worktree, c.newWorktree]), - ); - saveClaims( - key, - loadClaims(key).map((c) => { - const moved = moves.get(c.worktree); - return moved === undefined ? c : { ...c, worktree: moved }; - }), - ); +/** Claims are re-read here for the same reason registries are: a claim taken between plan and apply must move with the repo, not be reverted to the plan's copy. */ +function writeClaims(plan: LocatePlan): number { + let moved = 0; + for (const key of indexWriteKeys(plan)) { + const claims = loadClaims(key); + if (claims.length === 0) continue; + const next = claims.map((c) => { + const relocated = relocatePath(c.worktree, plan.oldPath, plan.newPath); + if (relocated === null) return c; + moved += 1; + return { ...c, worktree: relocated }; + }); + saveClaims(key, next); } + return moved; } /** @@ -304,7 +330,9 @@ async function verifyLocate(plan: LocatePlan): Promise<{ error: string | null; s const listed = await listWorktreesAsync(plan.newPath); if (listed === null) return { error: `git worktree list failed in ${plan.newPath}`, stalePaths: [] }; const known = new Set(listed.map((w) => canon(w.path))); - if (!known.has(canon(plan.newPath))) { + // git lists the main worktree FIRST — membership alone would accept a linked + // worktree of the same repo as the new root. + if (listed[0] === undefined || canon(listed[0].path) !== canon(plan.newPath)) { return { error: `${plan.newPath} is not the main worktree git reports`, stalePaths: [] }; } @@ -342,44 +370,75 @@ function collapseLegacyRows(plan: LocatePlan): LocateResult["legacyRows"] { return out; } +/** + * Repair git's admin files for the moved trees. + * + * A path argument fixes both halves of the link for the tree it names (the + * main repo's `worktrees//gitdir` entry and that tree's own `.git` file); + * the no-arg pass then re-links the trees that did NOT move, whose `.git` + * files still point at the main worktree's old location. Both are needed + * because a folder move breaks both populations at once. + * + * `git worktree repair` exits non-zero on a path argument it cannot resolve, + * so the list is filtered to what exists — a re-rooted record with nothing on + * disk is the stale case verification reports, not a failed repair. + */ +async function repairGit(plan: LocatePlan): Promise<{ repaired: string[]; error: string | null }> { + const repaired = plan.gitRepairPaths.filter((path) => existsSync(path)); + if (repaired.length > 0) { + const r = await runGit(plan.newPath, ["worktree", "repair", ...repaired]); + if (r.exitCode !== 0) return { repaired: [], error: `git worktree repair failed: ${r.stderr.trim() || `exit ${r.exitCode}`}` }; + } + const all = await runGit(plan.newPath, ["worktree", "repair"]); + if (all.exitCode !== 0) return { repaired: [], error: `git worktree repair failed: ${all.stderr.trim() || `exit ${all.exitCode}`}` }; + return { repaired, error: null }; +} + +/** + * Git first, state.db last. Until the transaction commits, the index still + * names the dead path, so a reconciler pass that interleaves with the repair + * finds a repo whose path does not exist and bails instead of pruning trees + * whose gitdir pointers are still being fixed. Nothing is written unless the + * whole move verifies, which is why no rollback exists. + */ export async function applyLocate(plan: LocatePlan): Promise { - const snapshot = captureSnapshot(plan); const base = { identity: plan.identity, from: plan.oldPath, to: plan.newPath, indexKeys: plan.indexKeys, - treesRewritten: plan.registryRewrites.reduce((n, r) => n + r.movedPaths.length, 0), - claimsRewritten: plan.claimRewrites.length, }; + const failed = (repaired: string[], stalePaths: string[], error: string): LocateResult => ({ + ...base, + ok: false, + treesRewritten: 0, + claimsRewritten: 0, + repaired, + stalePaths, + legacyRows: [], + error, + }); + + const repair = await repairGit(plan); + if (repair.error !== null) return failed(repair.repaired, [], repair.error); + + const { error, stalePaths } = await verifyLocate(plan); + if (error !== null) return failed(repair.repaired, stalePaths, error); - // bun:sqlite transactions are sync-only: every git call lives below this + // bun:sqlite transactions are sync-only: every git call lives above this // block, never inside it. + let treesRewritten = 0; + let claimsRewritten = 0; getStateDb().transaction(() => { for (const key of indexWriteKeys(plan)) setIndexPath(key, plan.newPath); - writeRegistries(plan); - writeClaims(plan); + treesRewritten = writeRegistries(plan); + claimsRewritten = writeClaims(plan); })(); refreshRepoIndexMirror(); - // Path arguments fix each linked worktree's entry in the main repo's admin - // dir; the no-arg pass then fixes the `.git` file inside every linked - // worktree. A move breaks both directions, so both passes run. - if (plan.gitRepairPaths.length > 0) { - await runGit(plan.newPath, ["worktree", "repair", ...plan.gitRepairPaths]); - } - await runGit(plan.newPath, ["worktree", "repair"]); - - const { error, stalePaths } = await verifyLocate(plan); - if (error !== null) { - restoreSnapshot(snapshot); - refreshRepoIndexMirror(); - return { ...base, ok: false, repaired: plan.gitRepairPaths, stalePaths, legacyRows: [], restored: true, error }; - } - const legacyRows = collapseLegacyRows(plan); refreshRepoIndexMirror(); - return { ...base, ok: true, repaired: plan.gitRepairPaths, stalePaths, legacyRows }; + return { ...base, ok: true, treesRewritten, claimsRewritten, repaired: repair.repaired, stalePaths, legacyRows }; } /** From e9c79887ea49a2bf7dc1d4f13f382533072fb0f4 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Tue, 25 Aug 2026 15:05:44 -0500 Subject: [PATCH 09/20] =?UTF-8?q?feat(daemon):=20withReconcilerHeld=20?= =?UTF-8?q?=E2=80=94=20exclusive=20access=20to=20the=20worktree=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `withReconcilerHeld(fn)` to `createWorktreeReconciler`: it awaits any pass already in flight, blocks `kick()` from starting a new pass until `fn` settles, and serializes concurrent holders. A kick arriving during the hold is coalesced to one pass fired on release, so no trigger is lost. `repos:locate` needs this: a reconcile pass that sees a healed index path against un-rewritten registry paths prunes every row as "no matching worktree", taking the pool's claim state with it. Co-Authored-By: Claude Fable 5 --- lib/daemon/__tests__/reconciler-hold.test.ts | 116 +++++++++++++++++++ lib/daemon/worktree-reconciler.ts | 40 ++++++- 2 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 lib/daemon/__tests__/reconciler-hold.test.ts diff --git a/lib/daemon/__tests__/reconciler-hold.test.ts b/lib/daemon/__tests__/reconciler-hold.test.ts new file mode 100644 index 00000000..ee300dea --- /dev/null +++ b/lib/daemon/__tests__/reconciler-hold.test.ts @@ -0,0 +1,116 @@ +/** + * The hold `repos:locate` runs inside: a reconcile pass that observed a healed + * index path against un-rewritten registry paths prunes every registry row as + * "no matching worktree", taking the pool's claim state with it. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, realpathSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import type { Logger } from "pino"; +import { closeStateDb } from "../../state/index.ts"; +import { createWorktreeReconciler } from "../worktree-reconciler.ts"; + +const silentLog = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as unknown as Logger; + +/** An empty index makes each pass a no-op with real awaits — enough to observe pass boundaries without any git. */ +function harness(order: string[]) { + return createWorktreeReconciler({ + cache: { entries: {} }, + repoIndex: () => { + order.push("pass"); + return {}; + }, + emit: () => {}, + log: silentLog, + }); +} + +async function settle(reconciler: { passInFlight: () => boolean }): Promise { + for (let i = 0; i < 200 && reconciler.passInFlight(); i++) await Bun.sleep(5); +} + +describe("withReconcilerHeld", () => { + const origHome = process.env.HOME; + let home: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-hold-home-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(async () => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + }); + + test("a pass already in flight finishes before the held fn runs", async () => { + const order: string[] = []; + const reconciler = harness(order); + + reconciler.kick(); + await reconciler.withReconcilerHeld(async () => { + order.push("fn"); + }); + + expect(order).toEqual(["pass", "fn"]); + }); + + test("a kick during the hold starts no pass until the fn settles", async () => { + const order: string[] = []; + const reconciler = harness(order); + + await reconciler.withReconcilerHeld(async () => { + reconciler.kick(); + await Bun.sleep(10); + expect(order).toEqual([]); + order.push("fn-done"); + }); + + await settle(reconciler); + expect(order).toEqual(["fn-done", "pass"]); + }); + + test("two holders serialize", async () => { + const order: string[] = []; + const reconciler = harness(order); + + await Promise.all([ + reconciler.withReconcilerHeld(async () => { + order.push("a-start"); + await Bun.sleep(10); + order.push("a-end"); + }), + reconciler.withReconcilerHeld(async () => { + order.push("b-start"); + await Bun.sleep(1); + order.push("b-end"); + }), + ]); + + expect(order).toEqual(["a-start", "a-end", "b-start", "b-end"]); + }); + + test("a throwing fn releases the hold", async () => { + const order: string[] = []; + const reconciler = harness(order); + + await expect( + reconciler.withReconcilerHeld(async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + + reconciler.kick(); + await settle(reconciler); + expect(order).toEqual(["pass"]); + }); + + test("the fn's value comes back to the caller", async () => { + const reconciler = harness([]); + expect(await reconciler.withReconcilerHeld(async () => 42)).toBe(42); + }); +}); diff --git a/lib/daemon/worktree-reconciler.ts b/lib/daemon/worktree-reconciler.ts index c6c2cce3..d427c942 100644 --- a/lib/daemon/worktree-reconciler.ts +++ b/lib/daemon/worktree-reconciler.ts @@ -1044,8 +1044,19 @@ export function createWorktreeReconciler(deps: ReconcilerDeps): { * background pass survives into a later test's HOME once its own * `beforeEach` repoints that (shared, global) env var. */ passInFlight: () => boolean; + /** + * Run `fn` with the reconciler held: any pass in flight is awaited first, + * and `kick()` starts no new pass until `fn` settles (one queued kick fires + * on release). A holder rewrites registry paths that a concurrent pass would + * read as "no matching worktree" and prune, taking the pool's claim state + * with it. Holders serialize, so `fn` must not take the hold again. + */ + withReconcilerHeld: (fn: () => Promise) => Promise; } { let inFlight: Promise | null = null; + /** Non-null while a holder owns the reconciler. */ + let hold: Promise | null = null; + let kickQueued = false; const creationPromises = new Map>(); async function runOnce(): Promise { @@ -1108,6 +1119,10 @@ export function createWorktreeReconciler(deps: ReconcilerDeps): { } function kick(): void { + if (hold) { + kickQueued = true; + return; + } if (inFlight) return; const p = runOnce() .catch((err) => { @@ -1119,6 +1134,29 @@ export function createWorktreeReconciler(deps: ReconcilerDeps): { inFlight = p; } + async function withReconcilerHeld(fn: () => Promise): Promise { + // Claiming the hold must stay synchronous from the last `hold` read to the + // assignment below, or two woken waiters both see null and both run. + while (hold) await hold; + let release!: () => void; + hold = new Promise((resolve) => { + release = resolve; + }); + try { + // A pass that started before the hold was taken still reads the rows the + // holder is about to rewrite, so it has to finish first. + while (inFlight) await inFlight; + return await fn(); + } finally { + hold = null; + release(); + if (kickQueued) { + kickQueued = false; + kick(); + } + } + } + function creationInFlight(repoName: string): Promise | null { return creationPromises.get(repoName) ?? null; } @@ -1127,7 +1165,7 @@ export function createWorktreeReconciler(deps: ReconcilerDeps): { return inFlight !== null; } - return { kick, runOnce, creationInFlight, passInFlight }; + return { kick, runOnce, creationInFlight, passInFlight, withReconcilerHeld }; } export const __test__ = { From a1a12052672d02b8d66c5fa24b388ea9ff436c5e Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Tue, 25 Aug 2026 15:19:43 -0500 Subject: [PATCH 10/20] feat(daemon): repos:locate verb, applied under the reconciler hold Co-Authored-By: Claude Fable 5 --- lib/daemon.ts | 4 + lib/daemon/__tests__/repos-handlers.test.ts | 109 ++++++++++++++++++ .../__tests__/rt-client-commands.test.ts | 1 + lib/daemon/command-router.ts | 7 ++ lib/daemon/handlers/repos.ts | 50 ++++++++ 5 files changed, 171 insertions(+) create mode 100644 lib/daemon/__tests__/repos-handlers.test.ts create mode 100644 lib/daemon/handlers/repos.ts diff --git a/lib/daemon.ts b/lib/daemon.ts index 971bdbaf..e793c31c 100644 --- a/lib/daemon.ts +++ b/lib/daemon.ts @@ -443,6 +443,10 @@ export function startDaemon(): void { }, eventsBus, homeSnapshot, + repos: { + withReconcilerHeld: worktreeReconciler.withReconcilerHeld, + refreshWatchedRepos: hooksGuard.refreshWatchedRepos, + }, chatDb: getStateDb("daemon"), }); diff --git a/lib/daemon/__tests__/repos-handlers.test.ts b/lib/daemon/__tests__/repos-handlers.test.ts new file mode 100644 index 00000000..9fa52c49 --- /dev/null +++ b/lib/daemon/__tests__/repos-handlers.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execSync } from "child_process"; +import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { closeStateDb, setKvValue } from "../../state/index.ts"; +import { loadRepoIndex } from "../../repo-index.ts"; +import { saveRegistry } from "../../worktree/registry.ts"; +import { deriveRepoIdentity, serializeIdentity } from "../../settings/identity.ts"; +import { createReposHandlers } from "../handlers/repos.ts"; + +describe("repos:locate", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + let order: string[]; + let events: { topic: string; payload: unknown }[]; + let handlers: ReturnType; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-repos-handler-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-repos-handler-repos-"))); + process.env.HOME = home; + closeStateDb(); + order = []; + events = []; + handlers = createReposHandlers({ + withReconcilerHeld: async (fn) => { + order.push("hold-start"); + try { + return await fn(); + } finally { + order.push("hold-end"); + } + }, + refreshWatchedRepos: () => order.push("refresh"), + emitEvent: (topic, payload) => { + order.push(`emit:${topic}`); + events.push({ topic, payload }); + }, + }); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + async function movedRepo(name: string): Promise<{ identity: string; from: string; to: string }> { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync(`git remote add origin https://gitlab.com/g/${name}.git`, { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + const from = realpathSync(dir); + const identity = serializeIdentity(await deriveRepoIdentity(from)); + setKvValue("repo-index", identity, from); + saveRegistry(identity, [{ name: "main", path: from, kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }]); + const to = join(scratch, `${name}-moved`); + renameSync(from, to); + return { identity, from, to }; + } + + test("a missing newPath is rejected", async () => { + expect(await handlers["repos:locate"]({})).toEqual({ ok: false, error: "newPath-required" }); + }); + + test("a non-identity repo key is rejected, not name-resolved", async () => { + const res = await handlers["repos:locate"]({ newPath: scratch, repo: "repo-tools" }); + expect(res).toEqual({ ok: false, error: "repo-unknown" }); + expect(order).toEqual([]); + }); + + test("applies inside the hold, refreshes watchers, then emits repo:moved", async () => { + const { identity, from, to } = await movedRepo("alpha"); + + const res = await handlers["repos:locate"]({ newPath: to }); + + expect(res.ok).toBe(true); + expect(loadRepoIndex()[identity]).toBe(to); + expect(order).toEqual(["hold-start", "refresh", "emit:repo:moved", "hold-end"]); + expect(events[0]!.payload).toEqual({ identity, from, to }); + }); + + test("dryRun returns the plan and writes nothing", async () => { + const { identity, from, to } = await movedRepo("beta"); + + const res = await handlers["repos:locate"]({ newPath: to, dryRun: true }); + + expect(res.ok).toBe(true); + expect(res.data.dryRun).toBe(true); + expect(res.data.plan.identity).toBe(identity); + expect(loadRepoIndex()[identity]).toBe(from); + expect(events).toEqual([]); + }); + + test("a refusal comes back as a typed error, and nothing is emitted", async () => { + const plain = join(scratch, "plain"); + mkdirSync(plain); + + const res = await handlers["repos:locate"]({ newPath: plain }); + + expect(res.ok).toBe(false); + expect(res.error).toContain("not-a-git-repo"); + expect(events).toEqual([]); + }); +}); diff --git a/lib/daemon/__tests__/rt-client-commands.test.ts b/lib/daemon/__tests__/rt-client-commands.test.ts index 96aef518..be5d1b47 100644 --- a/lib/daemon/__tests__/rt-client-commands.test.ts +++ b/lib/daemon/__tests__/rt-client-commands.test.ts @@ -40,6 +40,7 @@ describe("rt-client command coverage", () => { worktree: { emit: () => {}, kick: () => {}, creationInFlight: () => null }, eventsBus: createEventsBus({ dbPath: ":memory:", log: pino({ level: "silent" }) }), homeSnapshot: { stop: () => {}, runNow: async () => ({}) as any, status: () => ({}) as any, ready: Promise.resolve() }, + repos: { withReconcilerHeld: async (fn) => fn(), refreshWatchedRepos: () => {} }, chatDb: openStateDb(":memory:"), }); for (const name of COMMAND_NAMES) { diff --git a/lib/daemon/command-router.ts b/lib/daemon/command-router.ts index 0106f7a2..c03fc0e9 100644 --- a/lib/daemon/command-router.ts +++ b/lib/daemon/command-router.ts @@ -24,6 +24,7 @@ import { createChatHandlers } from "./handlers/chat.ts"; import { createEndpointHandlers } from "./handlers/endpoint.ts"; import { createSettingsHandlers } from "./handlers/settings.ts"; import { createHomeHandlers } from "./handlers/home.ts"; +import { createReposHandlers } from "./handlers/repos.ts"; import { reconcileFreshness, getFreshnessSnapshot } from "./freshness.ts"; import type { SystemProcessScanner } from "./system-process-scanner.ts"; import type { EventsBus } from "./events-bus.ts"; @@ -44,6 +45,11 @@ export function buildRoutedHandlers(opts: { eventsBus: EventsBus; /** Home-repo snapshot daemon (H2) — inert handle when disabled/not-a-repo. */ homeSnapshot: HomeSnapshotHandle; + /** Reconciler hold + hooks-guard rewire the repos:locate verb drives. */ + repos: { + withReconcilerHeld: (fn: () => Promise) => Promise; + refreshWatchedRepos: () => void; + }; /** * state.db, for chat:* handlers (RT-48 Task 6). Passed in already-open * rather than resolved here with getStateDb(): this function is called at @@ -79,6 +85,7 @@ export function buildRoutedHandlers(opts: { ...createEndpointHandlers(ctx), ...createSettingsHandlers(), ...createHomeHandlers(opts.homeSnapshot), + ...createReposHandlers({ ...opts.repos, emitEvent }), // Applies repo-tracking edits immediately (rt daemon track // live|poll|off) instead of waiting for the next refresh-tail reconcile. diff --git a/lib/daemon/handlers/repos.ts b/lib/daemon/handlers/repos.ts new file mode 100644 index 00000000..ffae5a88 --- /dev/null +++ b/lib/daemon/handlers/repos.ts @@ -0,0 +1,50 @@ +/** + * Repo-index IPC verbs. + * + * `repos:locate` runs the whole apply inside the reconciler's hold: a + * reconcile pass that sees a healed index path against un-rewritten registry + * paths prunes every registry row as "no matching worktree", and replenish + * then mints replacement trees for a pool that never lost anything. + */ + +import { parseIdentity } from "../../settings/identity.ts"; +import { applyLocate, isRefusal, planLocate } from "../../repo-locate.ts"; +import type { HandlerMap } from "./types.ts"; + +export interface ReposHandlerOpts { + /** Exclusive access to the worktree registry for the duration of `fn`. */ + withReconcilerHeld: (fn: () => Promise) => Promise; + /** Re-point the hooks guard's per-repo git-config watchers once paths have moved. */ + refreshWatchedRepos: () => void; + /** Events-bus emit — the router's shared `emitEvent`. */ + emitEvent: (topic: string, payload: unknown) => void; +} + +// Named-key return type (not a bare HandlerMap): under +// noUncheckedIndexedAccess a plain Record makes handlers["repos:locate"] +// resolve to `Handler | undefined` for every caller, tests included. +export function createReposHandlers( + opts: ReposHandlerOpts, +): Record<"repos:locate", (payload: any) => Promise> & HandlerMap { + return { + "repos:locate": async (payload) => { + const newPath = payload?.newPath; + if (typeof newPath !== "string" || newPath.length === 0) return { ok: false, error: "newPath-required" }; + const repo = typeof payload?.repo === "string" ? payload.repo : undefined; + if (repo !== undefined && parseIdentity(repo) === null) return { ok: false, error: "repo-unknown" }; + + return opts.withReconcilerHeld(async () => { + const plan = await planLocate({ newPath, repo }); + if (isRefusal(plan)) return { ok: false, error: `${plan.refusal}: ${plan.message}` }; + if (payload?.dryRun === true) return { ok: true, data: { dryRun: true, plan } }; + + const result = await applyLocate(plan); + if (!result.ok) return { ok: false, error: result.error ?? "locate-failed" }; + + opts.refreshWatchedRepos(); + opts.emitEvent("repo:moved", { identity: result.identity, from: result.from, to: result.to }); + return { ok: true, data: result }; + }); + }, + }; +} From a8199ba4e8e2f1e561b875ac3be547d5fb783737 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Tue, 25 Aug 2026 15:36:59 -0500 Subject: [PATCH 11/20] =?UTF-8?q?feat(repos):=20rt=20repos=20locate=20?= =?UTF-8?q?=E2=80=94=20daemon-first,=20local=20when=20nothing=20answers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds lib/repo-locate-dispatch.ts (daemon-vs-local dispatch: hard stop when the daemon is up but does not answer, since a local apply there would race the worktree reconciler) and the `rt repos locate` CLI verb in commands/repos.ts. Also carries forward Task 4's missing-repo fix to the two call sites that still fed pickFromAllRepos a bare getKnownRepos(): rt cd's default picker path and the dispatcher's "switch repo" screen, so a lost repo renders dimmed and refuses on pick instead of silently vanishing. Co-Authored-By: Claude Fable 5 --- commands/__tests__/cd.test.ts | 57 ++++++++ commands/__tests__/repos-locate.test.ts | 148 +++++++++++++++++++++ commands/cd.ts | 6 +- commands/repos.ts | 126 +++++++++++++++++- lib/__tests__/repo-locate-dispatch.test.ts | 124 +++++++++++++++++ lib/command-tree-def.ts | 11 ++ lib/command-tree.ts | 5 +- lib/repo-locate-dispatch.ts | 58 ++++++++ 8 files changed, 532 insertions(+), 3 deletions(-) create mode 100644 commands/__tests__/repos-locate.test.ts create mode 100644 lib/__tests__/repo-locate-dispatch.test.ts create mode 100644 lib/repo-locate-dispatch.ts diff --git a/commands/__tests__/cd.test.ts b/commands/__tests__/cd.test.ts index 06f11784..1e707fb7 100644 --- a/commands/__tests__/cd.test.ts +++ b/commands/__tests__/cd.test.ts @@ -73,3 +73,60 @@ describe("rt cd --repo --worktree with a missing repo", () => { } }); }); + +/** + * The plain `rt cd` picker (no --repo/--worktree flags) reaches + * pickFromAllRepos through commands/cd.ts's own `getKnownRepos()` call — + * before RT-63/68's carry-forward fix, that call excluded missing rows, so a + * lost repo silently vanished from the picker instead of hitting the + * missingRepoRefusal guard pickFromAllRepos already carries. + */ +describe("rt cd default picker with a missing repo", () => { + const origHome = process.env.HOME; + const origCwd = process.cwd(); + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-cd-default-missing-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-cd-default-missing-repos-"))); + process.env.HOME = home; + process.env.SHELL = "/bin/zsh"; + writeFileSync(join(home, ".zshrc"), UP_TO_DATE_RC); + closeStateDb(); + // Not a git repo — see the comment in the describe block above for why + // this matters (getRepoIdentity() must not auto-register process.cwd()). + process.chdir(scratch); + }); + + afterEach(() => { + process.chdir(origCwd); + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + test("the only known repo being missing refuses instead of silently dropping it from the picker", async () => { + setKvValue("repo-index", "moved", join(scratch, "gone-away")); + + const originalStdoutWrite = process.stdout.write; + const chdirSpy = spyOn(process, "chdir").mockImplementation(() => {}); + const errSpy = spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit sentinel"); + }); + + try { + await expect(worktreePicker([])).rejects.toThrow("process.exit sentinel"); + expect(chdirSpy).not.toHaveBeenCalled(); + expect(exitSpy.mock.calls.at(-1)?.[0]).toBe(1); + expect(errSpy.mock.calls.flat().join(" ")).toContain("rt repos locate"); + } finally { + process.stdout.write = originalStdoutWrite; + chdirSpy.mockRestore(); + errSpy.mockRestore(); + exitSpy.mockRestore(); + } + }); +}); diff --git a/commands/__tests__/repos-locate.test.ts b/commands/__tests__/repos-locate.test.ts new file mode 100644 index 00000000..027188b6 --- /dev/null +++ b/commands/__tests__/repos-locate.test.ts @@ -0,0 +1,148 @@ +/** + * The CLI runs the local path here: under a throwaway HOME no daemon socket + * exists, so `isDaemonRunning()` is false and the apply happens in-process — + * which is exactly the "no daemon, nothing to race" branch. + */ + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { execSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { closeStateDb, setKvValue } from "../../lib/state/index.ts"; +import { loadRepoIndex } from "../../lib/repo-index.ts"; +import { saveRegistry, loadRegistry } from "../../lib/worktree/registry.ts"; +import { deriveRepoIdentity, serializeIdentity } from "../../lib/settings/identity.ts"; +import { reposLocate, type RegisterDeps } from "../repos.ts"; + +function testDeps(): RegisterDeps & { lines: string[] } { + const lines: string[] = []; + return { print: (s) => lines.push(s), lines }; +} + +async function runExpectingProcessExit(fn: () => Promise): Promise { + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit sentinel"); + }); + try { + await fn(); + return undefined; + } catch { + return exitSpy.mock.calls.at(-1)?.[0] as number | undefined; + } finally { + exitSpy.mockRestore(); + } +} + +describe("reposLocate", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-cli-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-cli-repos-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + async function movedRepo(name: string): Promise<{ identity: string; from: string; to: string }> { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync(`git remote add origin https://gitlab.com/g/${name}.git`, { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + const from = realpathSync(dir); + const identity = serializeIdentity(await deriveRepoIdentity(from)); + setKvValue("repo-index", identity, from); + saveRegistry(identity, [{ name: "main", path: from, kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }]); + const to = join(scratch, `${name}-moved`); + renameSync(from, to); + return { identity, from, to }; + } + + test("locates a moved repo and says where it went", async () => { + const { identity, from, to } = await movedRepo("alpha"); + const deps = testDeps(); + + await reposLocate([to], {}, deps); + + expect(loadRepoIndex()[identity]).toBe(to); + expect(loadRegistry(identity)[0]?.path).toBe(to); + expect(deps.lines.join("\n")).toContain(from); + expect(deps.lines.join("\n")).toContain(to); + }); + + test("--dry-run reports the plan and writes nothing", async () => { + const { identity, from, to } = await movedRepo("beta"); + const deps = testDeps(); + + await reposLocate([to, "--dry-run"], {}, deps); + + expect(loadRepoIndex()[identity]).toBe(from); + expect(deps.lines.join("\n")).toContain("would move"); + }); + + test("--json emits a contract envelope", async () => { + const { identity, to } = await movedRepo("gamma"); + const deps = testDeps(); + + await reposLocate([to, "--json"], {}, deps); + + const parsed = JSON.parse(deps.lines[0]!); + expect(parsed.contract).toBe(1); + expect(parsed.located.identity).toBe(identity); + expect(parsed.located.to).toBe(to); + }); + + test("--repo resolves to an identity and is honoured", async () => { + const { identity, to } = await movedRepo("delta"); + const deps = testDeps(); + + await reposLocate([to, "--repo", identity], {}, deps); + + expect(loadRepoIndex()[identity]).toBe(to); + }); + + test("a refusal exits 2 with the typed message", async () => { + const plain = join(scratch, "plain"); + mkdirSync(plain); + const deps = testDeps(); + + const code = await runExpectingProcessExit(() => reposLocate([plain], {}, deps)); + + expect(code).toBe(2); + expect(deps.lines.join("\n")).toContain("not a git repository"); + }); + + test("an unknown flag is a usage error", async () => { + const deps = testDeps(); + const code = await runExpectingProcessExit(() => reposLocate(["--nope"], {}, deps)); + expect(code).toBe(2); + expect(deps.lines.join("\n")).toContain("usage: rt repos locate"); + }); + + test("no path and no lost rows exits 1 saying so", async () => { + const deps = testDeps(); + const code = await runExpectingProcessExit(() => reposLocate([], {}, deps)); + expect(code).toBe(1); + expect(deps.lines.join("\n")).toContain("no indexed repo is missing"); + }); + + test("no path, a lost row and no candidate lists the lost row and exits 1", async () => { + setKvValue("repo-index", "remote:gitlab.com%2Fg%2Fghost", join(scratch, "ghost")); + const deps = testDeps(); + + const code = await runExpectingProcessExit(() => reposLocate([], {}, deps)); + + expect(code).toBe(1); + expect(deps.lines.join("\n")).toContain("remote:gitlab.com%2Fg%2Fghost"); + }); +}); diff --git a/commands/cd.ts b/commands/cd.ts index 0d49fe86..3157eb7c 100644 --- a/commands/cd.ts +++ b/commands/cd.ts @@ -196,8 +196,12 @@ export async function worktreePicker(args: string[]): Promise { // the first time — is absent from `repos`, currentRepo resolves to null, and // rt cd wrongly falls through to the global all-repos picker instead of // recognizing where you are. + // + // includeMissing: true so a lost repo still renders (dimmed, via repoOption) + // in every picker built from `repos` — pickFromAllRepos's missing guard is + // otherwise dead code, since a bare getKnownRepos() never hands it one. const identity = getRepoIdentity(); - const repos = getKnownRepos(); + const repos = getKnownRepos({ includeMissing: true }); const currentRepo = identity ? repos.find((r) => r.repoName === identity.repoName) ?? null : null; diff --git a/commands/repos.ts b/commands/repos.ts index a4c9d007..cc598376 100644 --- a/commands/repos.ts +++ b/commands/repos.ts @@ -17,11 +17,14 @@ import { realpathSync } from "fs"; import { homedir } from "os"; import { basename } from "path"; import type { CommandContext } from "../lib/command-tree.ts"; -import { pruneRepoIndex, updateRepoIndex, type PrunedEntry } from "../lib/repo-index.ts"; +import { getKnownRepos, pruneRepoIndex, updateRepoIndex, type PrunedEntry } from "../lib/repo-index.ts"; import { deriveRepoIdentity, serializeIdentity } from "../lib/settings/identity.ts"; import { CACHE_KINDS, loadMachineRepoTrackingRaw, parseCachesArg, saveRepoTrackingRaw, type CacheKind, type TrackingMode } from "../lib/repo-tracking.ts"; import { envelope } from "../lib/setup/contract.ts"; import { UserActionableError, exitUserError } from "../lib/setup/errors.ts"; +import { findLocateCandidates } from "../lib/repo-locate.ts"; +import { locateMovedRepo } from "../lib/repo-locate-dispatch.ts"; +import { resolveRepoArg } from "../lib/repo-arg.ts"; export interface RegisterDeps { print: (s: string) => void; @@ -215,3 +218,124 @@ export async function reposPrune(args: string[], _ctx: CommandContext = {}, deps deps.print(`${verb} ${r.repoName} (${r.path.replace(homedir(), "~")}) — ${why}`); } } + +// ─── locate ────────────────────────────────────────────────────────────────── + +const LOCATE_USAGE = "usage: rt repos locate [] [--repo ] [--dry-run] [--json]"; +const LOCATE_FLAGS = ["--json", "--dry-run", "--repo"]; + +/** Every non-flag token that is not `--repo`'s value. */ +function locatePositionals(args: string[]): string[] { + const out: string[] = []; + for (let i = 0; i < args.length; i++) { + const a = args[i]!; + if (a === "--repo") { + i++; + continue; + } + if (a.startsWith("--")) continue; + out.push(a); + } + return out; +} + +/** + * rt repos locate — tell rt where a repo moved to. + * + * A folder move keeps the repo identity but leaves every stored path stale. + * The daemon owns the apply whenever it answers; a local apply only happens + * when nothing is up to race. + */ +export async function reposLocate(args: string[], _ctx: CommandContext = {}, deps: RegisterDeps = realRegisterDeps()): Promise { + const json = args.includes("--json"); + const dryRun = args.includes("--dry-run"); + for (const a of args) { + if (a.startsWith("--") && !LOCATE_FLAGS.includes(a)) { + exitUserError(new UserActionableError("usage", `unknown flag "${a}" — ${LOCATE_USAGE}`), json, "repos locate", deps.print); + } + } + + const repoArg = flagValue(args, "--repo"); + const repo = repoArg + ? await resolveRepoArg(repoArg, (msg) => + exitUserError(new UserActionableError("repo-unknown", msg), json, "repos locate", deps.print)) + : undefined; + + const newPath = locatePositionals(args)[0] ?? (await pickLocateTarget(json, deps)); + + const outcome = await locateMovedRepo({ newPath, ...(repo ? { repo } : {}), dryRun }); + if (!outcome.ok) { + exitUserError(new UserActionableError("refused", outcome.error), json, "repos locate", deps.print); + } + + if (outcome.dryRun) { + const p = outcome.plan; + if (json) { + deps.print(JSON.stringify(envelope({ plan: p, dryRun: true }))); + return; + } + deps.print(`would move ${p.identity} from ${p.oldPath} to ${p.newPath}`); + deps.print(` index rows: ${p.indexKeys.join(", ")}`); + deps.print(` worktree records: ${p.registryRewrites.reduce((n, r) => n + r.movedPaths.length, 0)}`); + deps.print(` endpoint claims: ${p.claimRewrites.length}`); + deps.print(` git worktree repair: ${p.gitRepairPaths.length === 0 ? "(main worktree only)" : p.gitRepairPaths.join(", ")}`); + return; + } + + const r = outcome.result; + if (json) { + deps.print(JSON.stringify(envelope({ located: r, via: outcome.via }))); + return; + } + deps.print(`located ${r.identity}: ${r.from} → ${r.to}`); + deps.print(` ${r.treesRewritten} worktree record${r.treesRewritten === 1 ? "" : "s"}, ${r.claimsRewritten} endpoint claim${r.claimsRewritten === 1 ? "" : "s"}, ${r.repaired.length} tree${r.repaired.length === 1 ? "" : "s"} repaired`); + for (const stale of r.stalePaths) deps.print(` stale record kept for the reconciler to prune: ${stale}`); + for (const row of r.legacyRows) { + deps.print(row.outcome === "collapsed" + ? ` collapsed the legacy row ${row.key}` + : ` kept the legacy row ${row.key} — its data dir could not all move`); + } +} + +/** + * No ``: propose, never auto-pick. One candidate still asks; several + * open a picker; none is a hard stop that names what is lost. + */ +async function pickLocateTarget(json: boolean, deps: RegisterDeps): Promise { + const lost = getKnownRepos({ includeMissing: true }).filter((r) => r.missing); + if (lost.length === 0) { + deps.print(json ? JSON.stringify(envelope({ lost: [], candidates: [] })) : "no indexed repo is missing — nothing to locate"); + process.exit(1); + } + + const candidates = await findLocateCandidates(); + if (candidates.length === 0 || !process.stdin.isTTY) { + if (json) { + deps.print(JSON.stringify(envelope({ lost: lost.map((r) => ({ repo: r.repoName, path: r.worktrees[0]?.path })), candidates }))); + } else { + deps.print("missing repos:"); + for (const r of lost) deps.print(` ${r.repoName} — last seen at ${r.worktrees[0]?.path}`); + deps.print(candidates.length === 0 + ? `pass the new path: ${LOCATE_USAGE}` + : "run interactively to pick a candidate, or pass the new path"); + } + process.exit(1); + } + + if (candidates.length === 1) { + const only = candidates[0]!; + const { confirm } = await import("../lib/rt-render.tsx"); + const ok = await confirm({ message: `Locate ${only.identity} at ${only.path}?`, stderr: true }); + if (!ok) process.exit(0); + return only.path; + } + + const { filterableSelect } = await import("../lib/rt-render.tsx"); + const picked = await filterableSelect({ + message: "Which directory did it move to?", + options: candidates.map((c) => ({ value: c.path, label: c.path, hint: c.identity })), + stderr: true, + }); + if (!picked) process.exit(0); + return picked; +} diff --git a/lib/__tests__/repo-locate-dispatch.test.ts b/lib/__tests__/repo-locate-dispatch.test.ts new file mode 100644 index 00000000..f560c344 --- /dev/null +++ b/lib/__tests__/repo-locate-dispatch.test.ts @@ -0,0 +1,124 @@ +/** + * lib/repo-locate-dispatch.ts's whole reason to exist is the daemon-vs-local + * decision — a daemon that is up but unresponsive must hard-stop, never fall + * through to a local apply that would race the worktree reconciler holding + * the registry. That branch has no real daemon to exercise it against, so it + * is covered here by faking the transport. + */ + +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { locateMovedRepo } from "../repo-locate-dispatch.ts"; +import type { LocatePlan } from "../repo-locate.ts"; + +// Captured before any mock.module call — mock.module mutates the live +// namespace object in place, so restoring with the ORIGINAL bindings (not a +// re-import) is what undoes it for every other test file sharing this process. +const realDaemonClient = await import("../daemon-client.ts"); +const realIsDaemonRunning = realDaemonClient.isDaemonRunning; +const realDaemonSocketQuery = realDaemonClient.daemonSocketQuery; + +const realRepoLocate = await import("../repo-locate.ts"); +const realPlanLocate = realRepoLocate.planLocate; + +afterEach(() => { + mock.module("../daemon-client.ts", () => ({ + ...realDaemonClient, + isDaemonRunning: realIsDaemonRunning, + daemonSocketQuery: realDaemonSocketQuery, + })); + mock.module("../repo-locate.ts", () => ({ + ...realRepoLocate, + planLocate: realPlanLocate, + })); +}); + +describe("locateMovedRepo: daemon up but unresponsive is a hard stop", () => { + test("never falls through to a local apply", async () => { + let planLocateCalled = false; + mock.module("../repo-locate.ts", () => ({ + ...realRepoLocate, + planLocate: async () => { + planLocateCalled = true; + throw new Error("must not run planLocate — the daemon is holding the registry"); + }, + })); + mock.module("../daemon-client.ts", () => ({ + ...realDaemonClient, + isDaemonRunning: async () => true, + daemonSocketQuery: async () => null, // timed out / no response + })); + + const outcome = await locateMovedRepo({ newPath: "/wherever" }); + + expect(planLocateCalled).toBe(false); + expect(outcome).toEqual({ + via: "daemon", + ok: false, + error: "the rt daemon is running but did not answer repos:locate — not applying locally, which would race the worktree reconciler", + }); + }); +}); + +describe("locateMovedRepo: daemon transport", () => { + test("a daemon refusal surfaces its error verbatim", async () => { + mock.module("../daemon-client.ts", () => ({ + ...realDaemonClient, + isDaemonRunning: async () => true, + daemonSocketQuery: async () => ({ ok: false, error: "not-a-git-repo: /wherever is not a git repository" }), + })); + + const outcome = await locateMovedRepo({ newPath: "/wherever" }); + + expect(outcome).toEqual({ via: "daemon", ok: false, error: "not-a-git-repo: /wherever is not a git repository" }); + }); + + test("a dry-run success unwraps the plan from the envelope", async () => { + const plan: LocatePlan = { + identity: "path:%2Fx", + oldPath: "/old", + newPath: "/new", + indexKeys: ["path:%2Fx"], + legacyKeys: [], + registryRewrites: [], + claimRewrites: [], + gitRepairPaths: [], + }; + let sentPayload: Record | undefined; + mock.module("../daemon-client.ts", () => ({ + ...realDaemonClient, + isDaemonRunning: async () => true, + daemonSocketQuery: async (_cmd: string, payload?: Record) => { + sentPayload = payload; + return { ok: true, data: { dryRun: true, plan } }; + }, + })); + + const outcome = await locateMovedRepo({ newPath: "/new", repo: "path:%2Fx", dryRun: true }); + + expect(outcome).toEqual({ via: "daemon", ok: true, dryRun: true, plan }); + expect(sentPayload).toEqual({ newPath: "/new", repo: "path:%2Fx", dryRun: true }); + }); +}); + +describe("locateMovedRepo: no daemon running", () => { + test("takes the local path without ever calling the daemon transport", async () => { + let daemonSocketQueryCalled = false; + mock.module("../daemon-client.ts", () => ({ + ...realDaemonClient, + isDaemonRunning: async () => false, + daemonSocketQuery: async () => { + daemonSocketQueryCalled = true; + return null; + }, + })); + mock.module("../repo-locate.ts", () => ({ + ...realRepoLocate, + planLocate: async () => ({ refusal: "not-a-git-repo" as const, message: "/wherever is not a git repository" }), + })); + + const outcome = await locateMovedRepo({ newPath: "/wherever" }); + + expect(daemonSocketQueryCalled).toBe(false); + expect(outcome).toEqual({ via: "local", ok: false, error: "not-a-git-repo: /wherever is not a git repository" }); + }); +}); diff --git a/lib/command-tree-def.ts b/lib/command-tree-def.ts index e00b2bfe..1644761a 100644 --- a/lib/command-tree-def.ts +++ b/lib/command-tree-def.ts @@ -1010,6 +1010,17 @@ export const TREE: Record = { SETUP_JSON_ARG, ], }, + locate: { + description: "Tell rt where a repo moved to — re-points the index, worktree registry, endpoint claims and git's worktree admin files together", + module: "./commands/repos.ts", + fn: "reposLocate", + args: [ + { name: "New path", type: "text", placeholder: "/path/to/moved-repo", hint: "Where the repo lives now; omit to pick from candidates under rt.repoRoots" }, + { name: "Repo", flag: "--repo", type: "text", placeholder: "repo-tools", hint: "Which indexed repo moved (identity, path, or name); omit to match by the new path's own identity" }, + { name: "Dry run", flag: "--dry-run", type: "boolean", default: false, hint: "Print what would be re-pointed without writing" }, + SETUP_JSON_ARG, + ], + }, }, }, diff --git a/lib/command-tree.ts b/lib/command-tree.ts index b42d1d2c..52ab1d7d 100644 --- a/lib/command-tree.ts +++ b/lib/command-tree.ts @@ -346,7 +346,10 @@ export async function dispatch( const { pickWorktreeWithSwitch, pickFromAllRepos, isSwitchRepo } = await import("./pickers.ts"); - const repos = getKnownRepos(); + // includeMissing: true so pickFromAllRepos's missing guard (below, via + // "Switch repo") actually sees a lost row instead of a silently + // filtered list. + const repos = getKnownRepos({ includeMissing: true }); // KnownRepo.repoName holds the index key — a serialized identity, not // the display name ctx.identity.repoName carries. const currentRepo = repos.find(r => r.repoName === ctx.identity!.identity); diff --git a/lib/repo-locate-dispatch.ts b/lib/repo-locate-dispatch.ts new file mode 100644 index 00000000..9097602d --- /dev/null +++ b/lib/repo-locate-dispatch.ts @@ -0,0 +1,58 @@ +/** + * The one place that decides whether a locate runs in the daemon or in this + * process. + * + * The daemon is the single writer of the worktree registry, so a locate must + * never run locally while it answers: a reconcile pass landing between the + * index write and the registry write is exactly the prune this feature exists + * to prevent. A daemon that is up but does not answer is a hard stop, not a + * fall-through — `daemonSocketQuery` is the read-only client, so probing never + * starts a daemon or warns. + */ + +import { daemonSocketQuery, isDaemonRunning } from "./daemon-client.ts"; +import { applyLocate, isRefusal, planLocate, type LocatePlan, type LocateResult } from "./repo-locate.ts"; + +/** git worktree repair across a large pool is the slow part; the 2s default IPC timeout is a client number, not a daemon-op one. */ +export const LOCATE_TIMEOUT_MS = 2 * 60_000; + +export type LocateOutcome = + | { via: "daemon" | "local"; ok: true; dryRun: false; result: LocateResult } + | { via: "daemon" | "local"; ok: true; dryRun: true; plan: LocatePlan } + | { via: "daemon" | "local"; ok: false; error: string }; + +export async function locateMovedRepo(req: { + newPath: string; + repo?: string; + dryRun?: boolean; +}): Promise { + const dryRun = req.dryRun === true; + + if (await isDaemonRunning()) { + const res = await daemonSocketQuery( + "repos:locate", + { newPath: req.newPath, ...(req.repo ? { repo: req.repo } : {}), dryRun }, + LOCATE_TIMEOUT_MS, + ); + if (!res) { + return { + via: "daemon", + ok: false, + error: "the rt daemon is running but did not answer repos:locate — not applying locally, which would race the worktree reconciler", + }; + } + if (!res.ok) return { via: "daemon", ok: false, error: res.error ?? "repos:locate failed" }; + return dryRun + ? { via: "daemon", ok: true, dryRun: true, plan: res.data.plan as LocatePlan } + : { via: "daemon", ok: true, dryRun: false, result: res.data as LocateResult }; + } + + const plan = await planLocate({ newPath: req.newPath, repo: req.repo }); + if (isRefusal(plan)) return { via: "local", ok: false, error: `${plan.refusal}: ${plan.message}` }; + if (dryRun) return { via: "local", ok: true, dryRun: true, plan }; + + const result = await applyLocate(plan); + return result.ok + ? { via: "local", ok: true, dryRun: false, result } + : { via: "local", ok: false, error: result.error ?? "locate failed" }; +} From 47979eb9bd0ade588393a3d4d72e584ca4fb7b74 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Tue, 25 Aug 2026 15:48:58 -0500 Subject: [PATCH 12/20] fix(repos): decide daemon presence from liveness evidence, not a ping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isDaemonRunning() pings with a fixed timeout, so an event-loop-stalled daemon (alive, holding the registry, just not servicing requests) failed the ping and locateMovedRepo silently took the local apply branch — the exact reconciler race the daemon-first dispatch exists to prevent. Presence is now decided by a live pid (isDaemonProcessRunning) or the socket file existing, matching how lib/daemon/boot-reconcile.ts checks for a live daemon elsewhere; an unanswered repos:locate once presence is established stays a hard stop with an actionable message. Also drops a ticket reference from a test comment (clean-code-comments: no ticket numbers in source). Co-Authored-By: Claude Fable 5 --- commands/__tests__/cd.test.ts | 8 +- lib/__tests__/repo-locate-dispatch.test.ts | 103 +++++++++++++++++---- lib/repo-locate-dispatch.ts | 26 ++++-- 3 files changed, 110 insertions(+), 27 deletions(-) diff --git a/commands/__tests__/cd.test.ts b/commands/__tests__/cd.test.ts index 1e707fb7..afe8eced 100644 --- a/commands/__tests__/cd.test.ts +++ b/commands/__tests__/cd.test.ts @@ -76,10 +76,10 @@ describe("rt cd --repo --worktree with a missing repo", () => { /** * The plain `rt cd` picker (no --repo/--worktree flags) reaches - * pickFromAllRepos through commands/cd.ts's own `getKnownRepos()` call — - * before RT-63/68's carry-forward fix, that call excluded missing rows, so a - * lost repo silently vanished from the picker instead of hitting the - * missingRepoRefusal guard pickFromAllRepos already carries. + * pickFromAllRepos through commands/cd.ts's own `getKnownRepos()` call — a + * bare call there excludes missing rows, so a lost repo would silently vanish + * from the picker instead of hitting the missingRepoRefusal guard + * pickFromAllRepos already carries. */ describe("rt cd default picker with a missing repo", () => { const origHome = process.env.HOME; diff --git a/lib/__tests__/repo-locate-dispatch.test.ts b/lib/__tests__/repo-locate-dispatch.test.ts index f560c344..5a8eaf0f 100644 --- a/lib/__tests__/repo-locate-dispatch.test.ts +++ b/lib/__tests__/repo-locate-dispatch.test.ts @@ -1,12 +1,19 @@ /** * lib/repo-locate-dispatch.ts's whole reason to exist is the daemon-vs-local - * decision — a daemon that is up but unresponsive must hard-stop, never fall - * through to a local apply that would race the worktree reconciler holding - * the registry. That branch has no real daemon to exercise it against, so it - * is covered here by faking the transport. + * decision — a present daemon must hard-stop rather than fall through to a + * local apply that would race the worktree reconciler holding the registry, + * and "present" must come from liveness evidence (a live pid, or the socket + * file existing), not a ping: an event-loop-stalled daemon fails a ping the + * same way a dead one does, and treating that as "absent" would race the very + * daemon still holding the registry. Both branches — and the true-absent + * local branch — have no real daemon process to exercise them against, so + * they are covered here by faking the transport and the presence checks. */ import { afterEach, describe, expect, mock, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; import { locateMovedRepo } from "../repo-locate-dispatch.ts"; import type { LocatePlan } from "../repo-locate.ts"; @@ -14,26 +21,36 @@ import type { LocatePlan } from "../repo-locate.ts"; // namespace object in place, so restoring with the ORIGINAL bindings (not a // re-import) is what undoes it for every other test file sharing this process. const realDaemonClient = await import("../daemon-client.ts"); -const realIsDaemonRunning = realDaemonClient.isDaemonRunning; const realDaemonSocketQuery = realDaemonClient.daemonSocketQuery; +const realDaemonConfig = await import("../daemon-config.ts"); +const realIsDaemonProcessRunning = realDaemonConfig.isDaemonProcessRunning; +const realSockPath = realDaemonConfig.DAEMON_SOCK_PATH; + const realRepoLocate = await import("../repo-locate.ts"); const realPlanLocate = realRepoLocate.planLocate; +/** A path guaranteed not to exist, for the "no socket file" half of presence. */ +const NO_SOCKET_PATH = join(tmpdir(), "rt-locate-dispatch-test-no-such-socket"); + afterEach(() => { mock.module("../daemon-client.ts", () => ({ ...realDaemonClient, - isDaemonRunning: realIsDaemonRunning, daemonSocketQuery: realDaemonSocketQuery, })); + mock.module("../daemon-config.ts", () => ({ + ...realDaemonConfig, + isDaemonProcessRunning: realIsDaemonProcessRunning, + DAEMON_SOCK_PATH: realSockPath, + })); mock.module("../repo-locate.ts", () => ({ ...realRepoLocate, planLocate: realPlanLocate, })); }); -describe("locateMovedRepo: daemon up but unresponsive is a hard stop", () => { - test("never falls through to a local apply", async () => { +describe("locateMovedRepo: presence by pid, no answer", () => { + test("a live pid with an unresponsive daemon hard-stops — planLocate never runs", async () => { let planLocateCalled = false; mock.module("../repo-locate.ts", () => ({ ...realRepoLocate, @@ -42,10 +59,14 @@ describe("locateMovedRepo: daemon up but unresponsive is a hard stop", () => { throw new Error("must not run planLocate — the daemon is holding the registry"); }, })); + mock.module("../daemon-config.ts", () => ({ + ...realDaemonConfig, + isDaemonProcessRunning: () => true, + DAEMON_SOCK_PATH: NO_SOCKET_PATH, + })); mock.module("../daemon-client.ts", () => ({ ...realDaemonClient, - isDaemonRunning: async () => true, - daemonSocketQuery: async () => null, // timed out / no response + daemonSocketQuery: async () => null, // event-loop stalled, or otherwise not answering })); const outcome = await locateMovedRepo({ newPath: "/wherever" }); @@ -54,16 +75,58 @@ describe("locateMovedRepo: daemon up but unresponsive is a hard stop", () => { expect(outcome).toEqual({ via: "daemon", ok: false, - error: "the rt daemon is running but did not answer repos:locate — not applying locally, which would race the worktree reconciler", + error: "the rt daemon is present but did not answer repos:locate; not applying locally (would race the worktree reconciler) — check `rt daemon status` and retry", }); }); }); -describe("locateMovedRepo: daemon transport", () => { +describe("locateMovedRepo: presence by socket file, no answer", () => { + test("a socket file with no live pid still hard-stops", async () => { + const scratch = mkdtempSync(join(tmpdir(), "rt-locate-dispatch-sock-")); + const sockPath = join(scratch, "rt.sock"); + writeFileSync(sockPath, ""); // presence is decided by existence, not connectability + let planLocateCalled = false; + try { + mock.module("../repo-locate.ts", () => ({ + ...realRepoLocate, + planLocate: async () => { + planLocateCalled = true; + throw new Error("must not run planLocate — the daemon is holding the registry"); + }, + })); + mock.module("../daemon-config.ts", () => ({ + ...realDaemonConfig, + isDaemonProcessRunning: () => false, // pid file stale/absent + DAEMON_SOCK_PATH: sockPath, + })); + mock.module("../daemon-client.ts", () => ({ + ...realDaemonClient, + daemonSocketQuery: async () => null, + })); + + const outcome = await locateMovedRepo({ newPath: "/wherever" }); + + expect(planLocateCalled).toBe(false); + expect(outcome).toEqual({ + via: "daemon", + ok: false, + error: "the rt daemon is present but did not answer repos:locate; not applying locally (would race the worktree reconciler) — check `rt daemon status` and retry", + }); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + }); +}); + +describe("locateMovedRepo: daemon transport, present and answering", () => { test("a daemon refusal surfaces its error verbatim", async () => { + mock.module("../daemon-config.ts", () => ({ + ...realDaemonConfig, + isDaemonProcessRunning: () => true, + DAEMON_SOCK_PATH: NO_SOCKET_PATH, + })); mock.module("../daemon-client.ts", () => ({ ...realDaemonClient, - isDaemonRunning: async () => true, daemonSocketQuery: async () => ({ ok: false, error: "not-a-git-repo: /wherever is not a git repository" }), })); @@ -84,9 +147,13 @@ describe("locateMovedRepo: daemon transport", () => { gitRepairPaths: [], }; let sentPayload: Record | undefined; + mock.module("../daemon-config.ts", () => ({ + ...realDaemonConfig, + isDaemonProcessRunning: () => true, + DAEMON_SOCK_PATH: NO_SOCKET_PATH, + })); mock.module("../daemon-client.ts", () => ({ ...realDaemonClient, - isDaemonRunning: async () => true, daemonSocketQuery: async (_cmd: string, payload?: Record) => { sentPayload = payload; return { ok: true, data: { dryRun: true, plan } }; @@ -100,12 +167,16 @@ describe("locateMovedRepo: daemon transport", () => { }); }); -describe("locateMovedRepo: no daemon running", () => { +describe("locateMovedRepo: daemon absent (no live pid, no socket file)", () => { test("takes the local path without ever calling the daemon transport", async () => { let daemonSocketQueryCalled = false; + mock.module("../daemon-config.ts", () => ({ + ...realDaemonConfig, + isDaemonProcessRunning: () => false, + DAEMON_SOCK_PATH: NO_SOCKET_PATH, + })); mock.module("../daemon-client.ts", () => ({ ...realDaemonClient, - isDaemonRunning: async () => false, daemonSocketQuery: async () => { daemonSocketQueryCalled = true; return null; diff --git a/lib/repo-locate-dispatch.ts b/lib/repo-locate-dispatch.ts index 9097602d..e4cedaf4 100644 --- a/lib/repo-locate-dispatch.ts +++ b/lib/repo-locate-dispatch.ts @@ -3,14 +3,21 @@ * process. * * The daemon is the single writer of the worktree registry, so a locate must - * never run locally while it answers: a reconcile pass landing between the + * never run locally while it is present: a reconcile pass landing between the * index write and the registry write is exactly the prune this feature exists - * to prevent. A daemon that is up but does not answer is a hard stop, not a - * fall-through — `daemonSocketQuery` is the read-only client, so probing never - * starts a daemon or warns. + * to prevent. Presence is decided from liveness EVIDENCE (a live pid, or the + * socket file existing) rather than a ping: an event-loop-stalled daemon — + * alive, holding the registry, just not servicing requests — fails a ping + * exactly like a dead one does, and treating that as "absent" would take the + * local branch anyway and race the very daemon still holding the registry. So + * once presence is established, an unanswered `repos:locate` is a hard stop, + * never a fall-through — `daemonSocketQuery` is the read-only client, so + * probing never starts a daemon or warns. */ -import { daemonSocketQuery, isDaemonRunning } from "./daemon-client.ts"; +import { existsSync } from "fs"; +import { daemonSocketQuery } from "./daemon-client.ts"; +import { DAEMON_SOCK_PATH, isDaemonProcessRunning } from "./daemon-config.ts"; import { applyLocate, isRefusal, planLocate, type LocatePlan, type LocateResult } from "./repo-locate.ts"; /** git worktree repair across a large pool is the slow part; the 2s default IPC timeout is a client number, not a daemon-op one. */ @@ -21,6 +28,11 @@ export type LocateOutcome = | { via: "daemon" | "local"; ok: true; dryRun: true; plan: LocatePlan } | { via: "daemon" | "local"; ok: false; error: string }; +/** A live pid file OR a socket file on disk — either is evidence the daemon holds the registry, whether or not it is currently answering requests. */ +function daemonPresent(): boolean { + return isDaemonProcessRunning() || existsSync(DAEMON_SOCK_PATH); +} + export async function locateMovedRepo(req: { newPath: string; repo?: string; @@ -28,7 +40,7 @@ export async function locateMovedRepo(req: { }): Promise { const dryRun = req.dryRun === true; - if (await isDaemonRunning()) { + if (daemonPresent()) { const res = await daemonSocketQuery( "repos:locate", { newPath: req.newPath, ...(req.repo ? { repo: req.repo } : {}), dryRun }, @@ -38,7 +50,7 @@ export async function locateMovedRepo(req: { return { via: "daemon", ok: false, - error: "the rt daemon is running but did not answer repos:locate — not applying locally, which would race the worktree reconciler", + error: "the rt daemon is present but did not answer repos:locate; not applying locally (would race the worktree reconciler) — check `rt daemon status` and retry", }; } if (!res.ok) return { via: "daemon", ok: false, error: res.error ?? "repos:locate failed" }; From e2d7e7b1fb1c83835bf71507518ff7a28e90230f Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Tue, 25 Aug 2026 16:00:30 -0500 Subject: [PATCH 13/20] feat(repos): implicit index heal moves a repo instead of re-pointing one row updateRepoIndex declines to overwrite a stored path that has stopped existing: re-pointing the index ahead of the worktree registry is the ordering that makes the reconciler prune every claimed tree, and the repair it owes is async git the sync seam cannot run. updateRepoIndexAsync routes that case through locateMovedRepo so index, registries and claims move as one unit; rt repos register adopts it. Co-Authored-By: Claude Fable 5 --- commands/repos.ts | 4 +- lib/__tests__/repo-locate-heal.test.ts | 115 +++++++++++++++++++++++++ lib/repo-index.ts | 75 ++++++++++++++-- 3 files changed, 183 insertions(+), 11 deletions(-) create mode 100644 lib/__tests__/repo-locate-heal.test.ts diff --git a/commands/repos.ts b/commands/repos.ts index cc598376..b8ebd924 100644 --- a/commands/repos.ts +++ b/commands/repos.ts @@ -17,7 +17,7 @@ import { realpathSync } from "fs"; import { homedir } from "os"; import { basename } from "path"; import type { CommandContext } from "../lib/command-tree.ts"; -import { getKnownRepos, pruneRepoIndex, updateRepoIndex, type PrunedEntry } from "../lib/repo-index.ts"; +import { getKnownRepos, pruneRepoIndex, updateRepoIndexAsync, type PrunedEntry } from "../lib/repo-index.ts"; import { deriveRepoIdentity, serializeIdentity } from "../lib/settings/identity.ts"; import { CACHE_KINDS, loadMachineRepoTrackingRaw, parseCachesArg, saveRepoTrackingRaw, type CacheKind, type TrackingMode } from "../lib/repo-tracking.ts"; import { envelope } from "../lib/setup/contract.ts"; @@ -126,7 +126,7 @@ export async function reposRegister(args: string[], _ctx: CommandContext = {}, d const rawTracking = track ? loadMachineRepoTrackingRaw() : null; for (const { name, real, identity } of resolved) { - updateRepoIndex(identity, real); + await updateRepoIndexAsync(identity, real); let tracking: Registered["tracking"] = null; if (track && caches && rawTracking) { diff --git a/lib/__tests__/repo-locate-heal.test.ts b/lib/__tests__/repo-locate-heal.test.ts new file mode 100644 index 00000000..558494ab --- /dev/null +++ b/lib/__tests__/repo-locate-heal.test.ts @@ -0,0 +1,115 @@ +/** + * The implicit heal must move a repo, not re-point one row of it: the sync + * seam is reachable from the daemon thread (no sync git there) and cannot + * await `git worktree repair`, so it declines the write and the async seam + * performs the whole locate. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execSync } from "child_process"; +import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { closeStateDb, setKvValue } from "../state/index.ts"; +import { loadRepoIndex, updateRepoIndex, updateRepoIndexAsync } from "../repo-index.ts"; +import { loadRegistry, saveRegistry } from "../worktree/registry.ts"; +import { deriveRepoIdentity, serializeIdentity } from "../settings/identity.ts"; + +describe("move-aware index heal", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-heal-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-heal-repos-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + async function movedRepo(name: string): Promise<{ identity: string; from: string; to: string; tree: string }> { + const dir = join(scratch, name); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync(`git remote add origin https://gitlab.com/g/${name}.git`, { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + const from = realpathSync(dir); + const tree = join(from, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${tree}`, { cwd: from, stdio: "pipe" }); + const identity = serializeIdentity(await deriveRepoIdentity(from)); + setKvValue("repo-index", identity, from); + saveRegistry(identity, [ + { name: "main", path: from, kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }, + { name: "t1", path: tree, kind: "ephemeral", state: "on-deck", branch: "feat", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + const to = join(scratch, `${name}-moved`); + renameSync(from, to); + return { identity, from, to, tree }; + } + + test("the sync seam refuses to re-point a row whose stored path is gone", async () => { + const { identity, from, to } = await movedRepo("alpha"); + + updateRepoIndex(identity, to); + + expect(loadRepoIndex()[identity]).toBe(from); + }); + + test("the sync seam still writes a live path and a brand-new row", async () => { + const dir = join(scratch, "beta"); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + const live = realpathSync(dir); + + updateRepoIndex("beta-key", live); + expect(loadRepoIndex()["beta-key"]).toBe(live); + + updateRepoIndex("beta-key", live); + expect(loadRepoIndex()["beta-key"]).toBe(live); + }); + + test("the sync seam overwrites a stored path that still exists — a second clone, not a move", () => { + const first = join(scratch, "eps-a"); + const second = join(scratch, "eps-b"); + for (const dir of [first, second]) { + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + } + + updateRepoIndex("eps-key", realpathSync(first)); + updateRepoIndex("eps-key", realpathSync(second)); + + expect(loadRepoIndex()["eps-key"]).toBe(realpathSync(second)); + }); + + test("the async seam heals the move as one unit", async () => { + const { identity, to } = await movedRepo("gamma"); + + await updateRepoIndexAsync(identity, to); + + expect(loadRepoIndex()[identity]).toBe(to); + expect(loadRegistry(identity).map((t) => t.path).sort()).toEqual( + [to, join(to, ".worktrees", "t1")].sort(), + ); + expect(loadRegistry(identity).find((t) => t.path === join(to, ".worktrees", "t1"))?.state).toBe("on-deck"); + expect(execSync("git worktree list --porcelain", { cwd: to, encoding: "utf8" })).toContain(join(to, ".worktrees", "t1")); + }); + + test("the async seam is a plain write when nothing moved", async () => { + const dir = join(scratch, "delta"); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + const live = realpathSync(dir); + + await updateRepoIndexAsync("delta-key", live); + + expect(loadRepoIndex()["delta-key"]).toBe(live); + }); +}); diff --git a/lib/repo-index.ts b/lib/repo-index.ts index db63cb08..79769b30 100644 --- a/lib/repo-index.ts +++ b/lib/repo-index.ts @@ -5,7 +5,9 @@ * The index is a DISPOSABLE CACHE (RT-49, collapsed into state.db by RT-50): * it self-populates as rt visits repos (`updateRepoIndex`, called from * lib/repo.ts's `getRepoIdentity`). Losing it loses nothing durable — every - * entry regenerates the next time rt runs inside that repo, and meanwhile the + * entry regenerates the next time rt runs inside that repo — except a row + * whose repo MOVED, which only `updateRepoIndexAsync` or `rt repos locate` + * can re-point (see `writeIndexRow`) — and meanwhile the * picker still surfaces every repo reachable under the `rt.repoRoots` * settings key (below) as an unregistered candidate. It is not part of any * backup/restore story and never will be. @@ -135,28 +137,83 @@ export function loadRepoIndex(): RepoIndex { return Object.keys(imported).length > 0 ? imported : existing; } -export function updateRepoIndex(repoName: string, repoRoot: string): void { - let mainPath: string; +/** The repo's MAIN worktree path as git reports it, degrading to `repoRoot`. */ +function observedMainPath(repoRoot: string): string { try { - const mainWorktree = execSync("git worktree list --porcelain", { + const listed = execSync("git worktree list --porcelain", { cwd: repoRoot, encoding: "utf8", stdio: "pipe", }); - mainPath = mainWorktree.split("\n")[0]?.replace("worktree ", "").trim() || repoRoot; + return listed.split("\n")[0]?.replace("worktree ", "").trim() || repoRoot; } catch { - mainPath = repoRoot; + return repoRoot; } +} + +/** + * The row's current path, read straight from the namespace rather than through + * `loadRepoIndex()`: that function's legacy-repos.json import is a migration + * side effect (it writes rows AND rewrites the mirror), and firing it from + * inside the write path would reorder it ahead of the write it guards. + */ +function storedIndexPath(repoName: string): string | undefined { + return getKvValue(REPO_INDEX_NS, repoName, undefined); +} + +/** True when the stored row names a directory that is gone and the repo is now somewhere else — a MOVE, not a second clone. */ +function storedPathMoved(stored: string | undefined, mainPath: string): stored is string { + return stored !== undefined && stored !== mainPath && !existsSync(stored); +} + +function writeIndexRow(repoName: string, mainPath: string): void { try { - // loadRepoIndex() can throw (an unopenable state.db — e.g. root-owned - // after a sudo invocation) — inside the try along with the write it - // depends on, so getRepoIdentity() (which every in-repo command calls) + // The read and loadRepoIndex() can throw (an unopenable state.db — e.g. + // root-owned after a sudo invocation) — inside the try along with the write + // they bracket, so getRepoIdentity() (which every in-repo command calls) // degrades to skipping the index update rather than crashing the command. + // + // A moved repo is NOT written here: re-pointing the index row ahead of the + // worktree registry is what makes the reconciler prune every claimed tree, + // and the repair that ordering owes is async git — forbidden on the daemon + // thread, which reaches this function through resolveIndexPathForIdentity. + // The row stays lost (visible as `missing`) until `updateRepoIndexAsync` + // or `rt repos locate` moves it as one unit. + if (storedPathMoved(storedIndexPath(repoName), mainPath)) return; setKvValue(REPO_INDEX_NS, repoName, mainPath); writeRepoIndexCompat(loadRepoIndex()); } catch { /* best effort */ } } +export function updateRepoIndex(repoName: string, repoRoot: string): void { + writeIndexRow(repoName, observedMainPath(repoRoot)); +} + +/** + * `updateRepoIndex` for callers that can await: the same write, plus the move + * heal the sync seam cannot perform. The locate runs in the daemon whenever + * one is present — imported lazily, both to keep the daemon client off every + * rt command's startup path and because repo-locate.ts imports this module. + */ +export async function updateRepoIndexAsync(repoName: string, repoRoot: string): Promise { + const mainPath = observedMainPath(repoRoot); + let stored: string | undefined; + try { + stored = storedIndexPath(repoName); + } catch { + stored = undefined; + } + if (!storedPathMoved(stored, mainPath)) { + writeIndexRow(repoName, mainPath); + return; + } + const { locateMovedRepo } = await import("./repo-locate-dispatch.ts"); + const outcome = await locateMovedRepo({ newPath: mainPath, repo: repoName }); + if (!outcome.ok) { + console.warn(`rt: ${repoName} moved to ${mainPath} but could not be located (${outcome.error})`); + } +} + /** * Raw index-row write: no git probe, no move detection. `updateRepoIndex` is * the caller-facing path that DERIVES the main path; this is the primitive for From 6d6c74deb2309fb874e1a81b73e79f20d5fb898c Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Tue, 25 Aug 2026 16:16:28 -0500 Subject: [PATCH 14/20] fix(repos): a refused move is reported by its caller, never swallowed updateRepoIndexAsync returns { ok, healed } instead of warning and returning void: a refusal leaves the index row naming the gone path, so a caller that reports success is claiming a repo is indexed when nothing points there. rt repos register exits through exitUserError (non-zero, --json error envelope) and repos.clone counts the identity failed with a log line rather than tallying it cloned/present. Co-Authored-By: Claude Fable 5 --- commands/__tests__/repos.test.ts | 46 +++++++++++++++++++++++++- commands/repos.ts | 16 ++++++++- lib/__tests__/repo-locate-heal.test.ts | 38 +++++++++++++++++++-- lib/repo-index.ts | 21 ++++++++---- lib/setup/__tests__/steps-a.test.ts | 20 ++++++++++- lib/setup/steps/repos.ts | 30 ++++++++++++----- 6 files changed, 150 insertions(+), 21 deletions(-) diff --git a/commands/__tests__/repos.test.ts b/commands/__tests__/repos.test.ts index 828c2512..a280e3cf 100644 --- a/commands/__tests__/repos.test.ts +++ b/commands/__tests__/repos.test.ts @@ -5,8 +5,9 @@ import { homedir, tmpdir } from "node:os"; import { basename, join } from "node:path"; import { getKnownRepos, loadRepoIndex, loadRepoIndexEntries, updateRepoIndex } from "../../lib/repo-index.ts"; import { loadRepoTracking } from "../../lib/repo-tracking.ts"; +import { saveRegistry } from "../../lib/worktree/registry.ts"; import { deriveRepoIdentity, serializeIdentity } from "../../lib/settings/identity.ts"; -import { closeStateDb } from "../../lib/state/index.ts"; +import { closeStateDb, setKvValue } from "../../lib/state/index.ts"; import { reposPrune, reposRegister, type RegisterDeps } from "../repos.ts"; function testDeps(): RegisterDeps & { lines: string[] } { @@ -119,6 +120,49 @@ describe("reposRegister", () => { expect(body).toEqual({ contract: 1, registered: [{ name, path: repoPath, tracking: null }] }); }); + test("a repo whose move cannot be applied exits 2 instead of reporting it registered", async () => { + const repoPath = makeTempRepo(); + execSync("git remote add origin git@gitlab.com:group/moved.git", { cwd: repoPath, stdio: "pipe" }); + const identity = serializeIdentity(await deriveRepoIdentity(repoPath)); + const gone = join(home, "gone-away"); + setKvValue("repo-index", identity, gone); + // A registry record whose re-rooted spelling is occupied by something git + // does not list as a worktree — the apply refuses at verification, so + // nothing is written and the row keeps naming the gone path. + saveRegistry(identity, [ + { name: "main", path: gone, kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }, + { name: "t1", path: join(gone, ".worktrees", "t1"), kind: "ephemeral", state: "on-deck", branch: "feat", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + mkdirSync(join(repoPath, ".worktrees", "t1"), { recursive: true }); + const deps = testDeps(); + + const code = await runExpectingProcessExit(() => reposRegister([repoPath], {}, deps)); + + expect(code).toBe(2); + expect(deps.lines.some((l) => l.includes("registered"))).toBe(false); + expect(loadRepoIndex()[identity]).toBe(gone); + }); + + test("--json reports a failed move as an error envelope, never a registered one", async () => { + const repoPath = makeTempRepo(); + execSync("git remote add origin git@gitlab.com:group/moved-json.git", { cwd: repoPath, stdio: "pipe" }); + const identity = serializeIdentity(await deriveRepoIdentity(repoPath)); + const gone = join(home, "gone-away-json"); + setKvValue("repo-index", identity, gone); + saveRegistry(identity, [ + { name: "t1", path: join(gone, ".worktrees", "t1"), kind: "ephemeral", state: "on-deck", branch: "feat", createdAt: "2026-01-01T00:00:00.000Z" }, + ]); + mkdirSync(join(repoPath, ".worktrees", "t1"), { recursive: true }); + const deps = testDeps(); + + await runExpectingProcessExit(() => reposRegister([repoPath, "--json"], {}, deps)); + + expect(deps.lines).toHaveLength(1); + const body = JSON.parse(deps.lines[0]!); + expect(body.error?.code).toBe("locate-failed"); + expect(body.registered).toBeUndefined(); + }); + test("no paths exits 2 with a usage error", async () => { const deps = testDeps(); const code = await runExpectingProcessExit(() => reposRegister([], {}, deps)); diff --git a/commands/repos.ts b/commands/repos.ts index b8ebd924..11e4565b 100644 --- a/commands/repos.ts +++ b/commands/repos.ts @@ -126,7 +126,21 @@ export async function reposRegister(args: string[], _ctx: CommandContext = {}, d const rawTracking = track ? loadMachineRepoTrackingRaw() : null; for (const { name, real, identity } of resolved) { - await updateRepoIndexAsync(identity, real); + // A refused move leaves the row naming the gone path, so printing + // "registered" (or a JSON ok envelope) here would tell a script the repo + // is indexed at `real` when nothing points there. + const indexed = await updateRepoIndexAsync(identity, real); + if (!indexed.ok) { + exitUserError( + new UserActionableError( + "locate-failed", + `"${name}" is indexed at a path that no longer exists, and moving it to ${real} failed — ${indexed.error}`, + ), + json, + "repos register", + deps.print, + ); + } let tracking: Registered["tracking"] = null; if (track && caches && rawTracking) { diff --git a/lib/__tests__/repo-locate-heal.test.ts b/lib/__tests__/repo-locate-heal.test.ts index 558494ab..2a1a0b71 100644 --- a/lib/__tests__/repo-locate-heal.test.ts +++ b/lib/__tests__/repo-locate-heal.test.ts @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { execSync } from "child_process"; -import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync } from "fs"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { closeStateDb, setKvValue } from "../state/index.ts"; @@ -92,7 +92,7 @@ describe("move-aware index heal", () => { test("the async seam heals the move as one unit", async () => { const { identity, to } = await movedRepo("gamma"); - await updateRepoIndexAsync(identity, to); + expect(await updateRepoIndexAsync(identity, to)).toEqual({ ok: true, healed: true }); expect(loadRepoIndex()[identity]).toBe(to); expect(loadRegistry(identity).map((t) => t.path).sort()).toEqual( @@ -108,8 +108,40 @@ describe("move-aware index heal", () => { execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); const live = realpathSync(dir); - await updateRepoIndexAsync("delta-key", live); + expect(await updateRepoIndexAsync("delta-key", live)).toEqual({ ok: true, healed: false }); expect(loadRepoIndex()["delta-key"]).toBe(live); }); + + test("a refused locate is returned, not swallowed, and leaves the row naming the gone path", async () => { + const dir = join(scratch, "zeta"); + mkdirSync(dir, { recursive: true }); + // No origin remote: the repo is identified BY its main worktree path, so + // moving it mints a new identity and locate refuses rather than re-keying. + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + const from = realpathSync(dir); + const identity = serializeIdentity(await deriveRepoIdentity(from)); + setKvValue("repo-index", identity, from); + const to = join(scratch, "zeta-moved"); + renameSync(from, to); + + const outcome = await updateRepoIndexAsync(identity, to); + + expect(outcome.ok).toBe(false); + expect(outcome.ok === false && outcome.error).toContain("identity-changed"); + expect(loadRepoIndex()[identity]).toBe(from); + }); + + test("repo-index.ts never statically imports the locate dispatcher or the daemon client", () => { + // The dynamic import is what breaks the repo-locate -> repo-index cycle + // AND keeps daemon-client off every rt command's startup path; a static + // one reintroduces both at once. + const source = readFileSync(join(import.meta.dir, "..", "repo-index.ts"), "utf8"); + const offenders = source + .split("\n") + .filter((line) => /\bfrom\s*["'][^"']*(repo-locate-dispatch|daemon-client)/.test(line)); + + expect(offenders).toEqual([]); + }); }); diff --git a/lib/repo-index.ts b/lib/repo-index.ts index 79769b30..b91a95ab 100644 --- a/lib/repo-index.ts +++ b/lib/repo-index.ts @@ -162,7 +162,7 @@ function storedIndexPath(repoName: string): string | undefined { } /** True when the stored row names a directory that is gone and the repo is now somewhere else — a MOVE, not a second clone. */ -function storedPathMoved(stored: string | undefined, mainPath: string): stored is string { +function storedPathMoved(stored: string | undefined, mainPath: string): boolean { return stored !== undefined && stored !== mainPath && !existsSync(stored); } @@ -189,13 +189,24 @@ export function updateRepoIndex(repoName: string, repoRoot: string): void { writeIndexRow(repoName, observedMainPath(repoRoot)); } +/** + * `healed` distinguishes a plain index write from a whole move; `ok: false` is + * ONLY ever a refused/failed locate — the plain write keeps the sync seam's + * best-effort contract and never reports failure. + */ +export type IndexHealResult = { ok: true; healed: boolean } | { ok: false; error: string }; + /** * `updateRepoIndex` for callers that can await: the same write, plus the move * heal the sync seam cannot perform. The locate runs in the daemon whenever * one is present — imported lazily, both to keep the daemon client off every * rt command's startup path and because repo-locate.ts imports this module. + * + * A refused move is RETURNED, never thrown and never warned about here: the + * row is left naming the gone path, so a caller that reports success without + * checking is claiming a repo is indexed when it is not. */ -export async function updateRepoIndexAsync(repoName: string, repoRoot: string): Promise { +export async function updateRepoIndexAsync(repoName: string, repoRoot: string): Promise { const mainPath = observedMainPath(repoRoot); let stored: string | undefined; try { @@ -205,13 +216,11 @@ export async function updateRepoIndexAsync(repoName: string, repoRoot: string): } if (!storedPathMoved(stored, mainPath)) { writeIndexRow(repoName, mainPath); - return; + return { ok: true, healed: false }; } const { locateMovedRepo } = await import("./repo-locate-dispatch.ts"); const outcome = await locateMovedRepo({ newPath: mainPath, repo: repoName }); - if (!outcome.ok) { - console.warn(`rt: ${repoName} moved to ${mainPath} but could not be located (${outcome.error})`); - } + return outcome.ok ? { ok: true, healed: true } : { ok: false, error: outcome.error }; } /** diff --git a/lib/setup/__tests__/steps-a.test.ts b/lib/setup/__tests__/steps-a.test.ts index d49d7862..7e573612 100644 --- a/lib/setup/__tests__/steps-a.test.ts +++ b/lib/setup/__tests__/steps-a.test.ts @@ -8,7 +8,8 @@ import { HELPERS_DIR, RT_BUNDLE_PATH, __test__ as bundleLayoutTest } from "../.. import { rtDir, teamSettingsPath } from "../../rt-paths.ts"; import { getSetting } from "../../settings/resolve.ts"; import { setSetting } from "../../settings/write.ts"; -import { closeStateDb } from "../../state/index.ts"; +import { closeStateDb, setKvValue } from "../../state/index.ts"; +import { serializeIdentity } from "../../settings/identity.ts"; import { linkPath } from "../../deps/links.ts"; import type { ExecResult, Probes } from "../probes.ts"; import type { SecretsExecResult, SecretsExecSeam, SecretsSeams } from "../../secrets/store.ts"; @@ -827,6 +828,23 @@ describe("path.link / settings.seed / repos.clone / intercepts.install (real HOM expect(logs.some((l) => l.line.includes("isn't a clone of"))).toBe(true); }); + test("repos.clone: an identity whose index row moved and cannot be located is counted failed, never cloned", async () => { + setSetting("rt.repoRoots", [join(home, "code")], "machine"); + mkdirSync(join(home, "code"), { recursive: true }); + const dest = join(home, "code", "acme-dev"); + // The row names a directory that is gone, so indexing `dest` is a MOVE, + // not a write — and the clone here is faked, so there is nothing at + // `dest` for the locate to move onto and it refuses. + setKvValue("repo-index", serializeIdentity({ kind: "remote", id: "gitlab.com/acme/acme-dev" }), join(home, "gone-away")); + + const p = fakeProbes({ home, exec: async () => ok() }); + const { ctx, logs } = makeCtx(p, { snapshot: { slug: "acme", integrations: {}, trackingIdentities: ["gitlab.com/acme/acme-dev"], marketplaces: [], plugins: [], remote: null } }); + + const outcome = await reposCloneStep.run(ctx); + expect(outcome).toEqual({ state: "done", detail: "cloned 0, present 0, failed 1" }); + expect(logs.some((l) => l.line.includes("could not be moved"))).toBe(true); + }); + test("repos.clone: zero identities to clone -> skipped, never a hard failure", async () => { const p = fakeProbes({ home }); // no rt.repoRoots configured either — must not matter const { ctx } = makeCtx(p); diff --git a/lib/setup/steps/repos.ts b/lib/setup/steps/repos.ts index d5f75086..8c507701 100644 --- a/lib/setup/steps/repos.ts +++ b/lib/setup/steps/repos.ts @@ -8,7 +8,7 @@ import { join } from "path"; import { getSetting } from "../../settings/resolve.ts"; -import { updateRepoIndex } from "../../repo-index.ts"; +import { updateRepoIndexAsync } from "../../repo-index.ts"; import { serializeIdentity } from "../../settings/identity.ts"; import { withoutUrls } from "../../team/redact.ts"; import type { ApplyContext } from "../apply.ts"; @@ -34,6 +34,20 @@ function skippedIdentities(env: Record): Set ); } +/** + * Index `dest` under the tracked identity, reporting a refused move rather + * than counting the repo present/cloned: the row would still name the path + * the repo moved away from, so the tally would claim a repo rt cannot reach. + */ +async function indexDest(ctx: ApplyContext, identity: string, base: string, dest: string): Promise { + // The index keys on the serialized identity; `identity` here is already the + // raw host/path the tracked-repos setting carries. + const indexed = await updateRepoIndexAsync(serializeIdentity({ kind: "remote", id: identity }), dest); + if (indexed.ok) return true; + ctx.log("repos.clone", `${base}: ${dest} is in place, but ${identity} is indexed at a path that no longer exists and could not be moved — ${indexed.error}; run: rt repos locate ${dest}`); + return false; +} + /** A real clone of `identity`, not just any directory that happens to share its basename — two tracked identities can collide on basename (`gitlab.com/a/api`, `github.com/b/api`), and an unrelated folder can already occupy the path. */ function isCloneOf(p: ApplyContext["p"], dest: string, identity: string): boolean { const config = p.readFile(join(dest, ".git", "config")); @@ -83,13 +97,13 @@ async function reposCloneRunUnsafe(ctx: ApplyContext): Promise { const dest = join(root, base); if (p.exists(dest)) { - if (isCloneOf(p, dest, identity)) { - present++; - updateRepoIndex(serializeIdentity({ kind: "remote", id: identity }), dest); - } else { + if (!isCloneOf(p, dest, identity)) { failed++; ctx.log("repos.clone", `${base}: ${dest} exists but isn't a clone of ${identity} (basename collision or unrelated folder) — resolve by hand`); + continue; } + if (await indexDest(ctx, identity, base, dest)) present++; + else failed++; continue; } @@ -100,10 +114,8 @@ async function reposCloneRunUnsafe(ctx: ApplyContext): Promise { continue; } - cloned++; - // The index keys on the serialized identity; `identity` here is already - // the raw host/path the tracked-repos setting carries. - updateRepoIndex(serializeIdentity({ kind: "remote", id: identity }), dest); + if (await indexDest(ctx, identity, base, dest)) cloned++; + else failed++; } return { state: "done", detail: `cloned ${cloned}, present ${present}, failed ${failed}` }; From 5da9fb9bd927bcbc757aefb165e4d86f0928457c Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Tue, 25 Aug 2026 16:29:18 -0500 Subject: [PATCH 15/20] test(repos): end-to-end locate against real git state and a live reconcile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proves the repair-then-verify-then-commit ordering against a real temp git repo with a linked worktree: after a move and a local applyLocate, the index/registry/claim rows land on the new path, git worktree list shows only the new path, pruneRepoIndex finds nothing prunable, and a subsequent reconcileRepoRegistry pass keeps the on-deck record instead of pruning or re-adopting it. Full gate: bunx tsc --noEmit (0 errors) and bun run test = bun test lib commands packages scripts — 3944 pass, 3 skip (pre-existing, unrelated env skips), 0 fail, 9850 expect() calls across 3947 tests in 273 files. Co-Authored-By: Claude Fable 5 --- lib/__tests__/repo-locate-e2e.test.ts | 122 ++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 lib/__tests__/repo-locate-e2e.test.ts diff --git a/lib/__tests__/repo-locate-e2e.test.ts b/lib/__tests__/repo-locate-e2e.test.ts new file mode 100644 index 00000000..22e2de26 --- /dev/null +++ b/lib/__tests__/repo-locate-e2e.test.ts @@ -0,0 +1,122 @@ +/** + * The whole story against real state: a repo with a linked worktree under + * `.worktrees/`, an ephemeral on-deck record, and a live endpoint claim, moved + * on disk and then located. The assertion that matters most is the last one — + * a reconcile pass over the located repo must prune nothing. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execSync } from "child_process"; +import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import type { Logger } from "pino"; +import { closeStateDb, listEndpointClaims, setKvValue } from "../state/index.ts"; +import { loadRepoIndex, pruneRepoIndex } from "../repo-index.ts"; +import { loadRegistry, saveRegistry } from "../worktree/registry.ts"; +import { saveClaims } from "../endpoint/store.ts"; +import { deriveRepoIdentity, serializeIdentity } from "../settings/identity.ts"; +import { applyLocate, isRefusal, planLocate } from "../repo-locate.ts"; +import { reconcileRepoRegistry } from "../daemon/worktree-reconciler.ts"; + +const silentLog = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as unknown as Logger; + +describe("repo locate — real state", () => { + const origHome = process.env.HOME; + let home: string; + let scratch: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-e2e-home-"))); + scratch = realpathSync(mkdtempSync(join(tmpdir(), "rt-locate-e2e-repos-"))); + process.env.HOME = home; + closeStateDb(); + }); + + afterEach(() => { + process.env.HOME = origHome; + closeStateDb(); + rmSync(home, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + }); + + test("a moved repo with a claimed pool survives locate intact", async () => { + // ── a throwaway repo with a linked worktree under .worktrees/ + const dir = join(scratch, "acme-dev"); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync("git remote add origin https://gitlab.com/acme/acme-dev.git", { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + const from = realpathSync(dir); + const oldTree = join(from, ".worktrees", "tree-1"); + execSync(`git worktree add -q -b on-deck/tree-1 ${oldTree}`, { cwd: from, stdio: "pipe" }); + + // ── registered in an isolated HOME's state.db: index row, registry with an + // ephemeral on-deck record, and an endpoint_claims row + const identity = serializeIdentity(await deriveRepoIdentity(from)); + setKvValue("repo-index", identity, from); + saveRegistry(identity, [ + { name: "main", path: from, kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }, + { + name: "tree-1", + path: oldTree, + kind: "ephemeral", + state: "on-deck", + branch: "on-deck/tree-1", + createdAt: "2026-01-02T00:00:00.000Z", + readyAt: "2026-01-02T01:00:00.000Z", + readyStamp: "abc123", + }, + ]); + saveClaims(identity, [{ worktree: oldTree, role: "web", port: 4010, pid: 4242, ts: "2026-01-02T02:00:00.000Z" }]); + + // ── mv the repo + const to = join(scratch, "moved", "acme-dev"); + mkdirSync(join(scratch, "moved"), { recursive: true }); + renameSync(from, to); + const newTree = join(to, ".worktrees", "tree-1"); + + // ── locate it, locally + const plan = await planLocate({ newPath: to }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + const result = await applyLocate(plan); + expect(result.ok).toBe(true); + expect(result.error).toBeUndefined(); + + // index path updated + expect(loadRepoIndex()[identity]).toBe(to); + + // registry record path updated, state intact + const trees = loadRegistry(identity); + expect(trees.map((t) => t.path).sort()).toEqual([to, newTree].sort()); + expect(trees.find((t) => t.path === newTree)).toMatchObject({ + kind: "ephemeral", + state: "on-deck", + branch: "on-deck/tree-1", + readyStamp: "abc123", + }); + + // claim row updated + expect(listEndpointClaims(identity)).toEqual([ + { worktree: newTree, role: "web", port: 4010, pid: 4242, ts: "2026-01-02T02:00:00.000Z" }, + ]); + + // git worktree list shows the new path (and not the old one) + const listed = execSync("git worktree list --porcelain", { cwd: to, encoding: "utf8" }); + expect(listed).toContain(newTree); + expect(listed).not.toContain(oldTree); + + // no prunable entries + expect(pruneRepoIndex({ dryRun: true })).toEqual([]); + + // and the reconciler prunes nothing: the ordering this whole feature exists for + const reconciled = await reconcileRepoRegistry({ + repoName: identity, + repoPath: to, + emit: () => {}, + log: silentLog, + }); + expect(reconciled.map((t) => t.path).sort()).toEqual([to, newTree].sort()); + expect(reconciled.find((t) => t.path === newTree)).toMatchObject({ state: "on-deck", readyStamp: "abc123" }); + }); +}); From 7b4910811ec338f23c5969c837b41455bcbfb744 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Tue, 25 Aug 2026 17:04:57 -0500 Subject: [PATCH 16/20] locate: unshadow the moved directory, keep claims and legacy rows sound A lost legacy-name row is named after the folder that moved, so counting it as "known" hid that folder's new location from the repo scan and left zero candidates in exactly the case locate exists for. Lost names now stay out of the scan's name set, and an identity key beats a legacy name outright in the duplicate partition so prune can never migrate identity-keyed data back onto a name. The apply also merges the pair's endpoint_claims onto the identity (identity wins a (worktree, role) collision) and empties the legacy key, instead of re-rooting rows under a key the collapse then drops. When the collapse is refused because both data dirs hold the same filename, the retained legacy row is written back to the old, dead path and the reason is reported: a legacy row naming a LIVE path with no registry makes the reconciler adopt every tree as unmanaged under it and replenish a duplicate pool. Co-Authored-By: Claude Fable 5 --- lib/__tests__/repo-index-missing.test.ts | 14 ++++ lib/__tests__/repo-index-rename.test.ts | 10 +++ lib/__tests__/repo-locate-e2e.test.ts | 74 +++++++++++++++++++ lib/__tests__/repo-locate.test.ts | 91 +++++++++++++++++++++++- lib/repo-index.ts | 29 ++++++-- lib/repo-locate.ts | 70 ++++++++++++++---- 6 files changed, 265 insertions(+), 23 deletions(-) diff --git a/lib/__tests__/repo-index-missing.test.ts b/lib/__tests__/repo-index-missing.test.ts index 46e4beb6..6f3bf630 100644 --- a/lib/__tests__/repo-index-missing.test.ts +++ b/lib/__tests__/repo-index-missing.test.ts @@ -68,6 +68,20 @@ describe("missing index rows", () => { expect(getKnownRepos({ includeMissing: true }).filter((r) => r.missing).length).toBe(1); }); + test("a lost legacy-named row does not shadow a scanned directory of the same basename", () => { + // The live anchor is what makes `scratch` an inferred scan root; the lost + // row is the pre-cutover legacy name, which is the moved folder's basename. + setKvValue("repo-index", "remote:gitlab.com%2Fg%2Fanchor", realRepo("anchor")); + setKvValue("repo-index", "mu", join(scratch, "nest", "mu")); + const moved = realRepo("mu"); + + const scanned = getKnownRepos({ includeMissing: true }) + .filter((r) => r.registered === false) + .map((r) => r.worktrees[0]?.path); + + expect(scanned).toContain(moved); + }); + test("the picker row says what to run", () => { const opt = repoOption({ repoName: "moved", worktrees: [{ path: "/x/gone", branch: "", isBare: false }], dataDir: "/d", missing: true }); expect(opt.hint).toBe("missing — rt repos locate"); diff --git a/lib/__tests__/repo-index-rename.test.ts b/lib/__tests__/repo-index-rename.test.ts index 86e1044b..1d9c120e 100644 --- a/lib/__tests__/repo-index-rename.test.ts +++ b/lib/__tests__/repo-index-rename.test.ts @@ -141,6 +141,16 @@ describe("repo-index — rename drift (RT-60)", () => { expect(duplicates).toEqual([{ entry: entry("old", scratch, 1_000), keptAs: "new" }]); }); + test("an identity key beats a legacy name even when the name was written last", () => { + const identity = "remote:gitlab.com%2Fg%2Fdeck"; + const { keep, duplicates } = partitionByRealpath([ + entry(identity, scratch, 1_000), + entry("deck", scratch, 9_000), + ]); + expect(keep.map((e) => e.repoName)).toEqual([identity]); + expect(duplicates.map((d) => d.keptAs)).toEqual([identity]); + }); + test("an equal timestamp — every row of one legacy import — breaks by name, not insertion order", () => { const a = partitionByRealpath([entry("zeta", scratch, 1_000), entry("alpha", scratch, 1_000)]); const b = partitionByRealpath([entry("alpha", scratch, 1_000), entry("zeta", scratch, 1_000)]); diff --git a/lib/__tests__/repo-locate-e2e.test.ts b/lib/__tests__/repo-locate-e2e.test.ts index 22e2de26..985c3883 100644 --- a/lib/__tests__/repo-locate-e2e.test.ts +++ b/lib/__tests__/repo-locate-e2e.test.ts @@ -119,4 +119,78 @@ describe("repo locate — real state", () => { expect(reconciled.map((t) => t.path).sort()).toEqual([to, newTree].sort()); expect(reconciled.find((t) => t.path === newTree)).toMatchObject({ state: "on-deck", readyStamp: "abc123" }); }); + + test("a split pair's two half-pools survive the move as one, and an external tree stays where it is", async () => { + // ── the shape the identity cutover left: a name row and an identity row + // for one repo, each registry owning half of one on-deck pool + const dir = join(scratch, "split-dev"); + mkdirSync(dir, { recursive: true }); + execSync("git init -q -b main", { cwd: dir, stdio: "pipe" }); + execSync("git remote add origin https://gitlab.com/acme/split-dev.git", { cwd: dir, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: dir, stdio: "pipe" }); + const from = realpathSync(dir); + const treeA = join(from, ".worktrees", "tree-a"); + const treeB = join(from, ".worktrees", "tree-b"); + const external = join(scratch, "external-tree"); + execSync(`git worktree add -q -b on-deck/tree-a ${treeA}`, { cwd: from, stdio: "pipe" }); + execSync(`git worktree add -q -b on-deck/tree-b ${treeB}`, { cwd: from, stdio: "pipe" }); + execSync(`git worktree add -q -b side ${external}`, { cwd: from, stdio: "pipe" }); + + const identity = serializeIdentity(await deriveRepoIdentity(from)); + setKvValue("repo-index", identity, from); + setKvValue("repo-index", "split-dev", from); + saveRegistry(identity, [ + { name: "main", path: from, kind: "main", branch: "main", createdAt: "2026-01-01T00:00:00.000Z" }, + { + name: "tree-a", + path: treeA, + kind: "ephemeral", + state: "on-deck", + branch: "on-deck/tree-a", + createdAt: "2026-01-02T00:00:00.000Z", + readyStamp: "aaa", + }, + ]); + saveRegistry("split-dev", [ + { + name: "tree-b", + path: treeB, + kind: "ephemeral", + state: "on-deck", + branch: "on-deck/tree-b", + createdAt: "2026-01-03T00:00:00.000Z", + readyStamp: "bbb", + }, + { name: "side", path: external, kind: "unmanaged", branch: "side", createdAt: "2026-01-04T00:00:00.000Z" }, + ]); + + const to = join(scratch, "moved", "split-dev"); + mkdirSync(join(scratch, "moved"), { recursive: true }); + renameSync(from, to); + const newA = join(to, ".worktrees", "tree-a"); + const newB = join(to, ".worktrees", "tree-b"); + + const plan = await planLocate({ newPath: to }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + const result = await applyLocate(plan); + expect(result.ok).toBe(true); + expect(result.legacyRows).toEqual([{ key: "split-dev", outcome: "collapsed" }]); + + expect(loadRepoIndex()[identity]).toBe(to); + expect(loadRepoIndex()["split-dev"]).toBeUndefined(); + + // git still lists the tree that did NOT move, under its own path + expect(execSync("git worktree list --porcelain", { cwd: to, encoding: "utf8" })).toContain(external); + + const reconciled = await reconcileRepoRegistry({ + repoName: identity, + repoPath: to, + emit: () => {}, + log: silentLog, + }); + expect(reconciled.map((t) => t.path).sort()).toEqual([to, newA, newB, external].sort()); + expect(reconciled.find((t) => t.path === newA)).toMatchObject({ state: "on-deck", readyStamp: "aaa" }); + expect(reconciled.find((t) => t.path === newB)).toMatchObject({ state: "on-deck", readyStamp: "bbb" }); + expect(reconciled.find((t) => t.path === external)).toMatchObject({ name: "side", kind: "unmanaged" }); + }); }); diff --git a/lib/__tests__/repo-locate.test.ts b/lib/__tests__/repo-locate.test.ts index cd550cfd..d565b4ed 100644 --- a/lib/__tests__/repo-locate.test.ts +++ b/lib/__tests__/repo-locate.test.ts @@ -5,9 +5,10 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { execSync } from "child_process"; -import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync } from "fs"; +import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; +import { repoDataDir } from "../rt-paths.ts"; import { closeStateDb, listEndpointClaims, setKvValue } from "../state/index.ts"; import { loadRepoIndex, REPO_INDEX_NS } from "../repo-index.ts"; import { loadRegistry, saveRegistry, type TreeRecord } from "../worktree/registry.ts"; @@ -360,6 +361,71 @@ describe("repo locate", () => { ); }); + test("a legacy key's claims land on the identity, re-rooted, and are left nowhere else", async () => { + const repo = repoWithRemote("rho"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue(REPO_INDEX_NS, identity, repo); + setKvValue(REPO_INDEX_NS, "rho-legacy", repo); + saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); + saveClaims(identity, [{ worktree: treePath, role: "web", port: 4001, ts: "2026-01-01T00:00:00.000Z" }]); + // The `web` row collides with the identity's on (worktree, role); `api` does not. + saveClaims("rho-legacy", [ + { worktree: treePath, role: "web", port: 4999, ts: "2026-01-01T00:00:00.000Z" }, + { worktree: treePath, role: "api", port: 4002, ts: "2026-01-01T00:00:00.000Z" }, + ]); + + const moved = join(scratch, "rho-moved"); + renameSync(repo, moved); + const newTree = join(moved, ".worktrees", "t1"); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + const result = await applyLocate(plan); + + expect(result.ok).toBe(true); + expect(listEndpointClaims("rho-legacy")).toEqual([]); + expect(listEndpointClaims(identity).map((c) => [c.worktree, c.role, c.port])).toEqual([ + [newTree, "api", 4002], + [newTree, "web", 4001], + ]); + }); + + test("a legacy row whose data dir cannot all move keeps naming the OLD path", async () => { + const repo = repoWithRemote("sigma"); + const identity = serializeIdentity(await deriveRepoIdentity(repo)); + const treePath = join(repo, ".worktrees", "t1"); + execSync(`git worktree add -q -b feat ${treePath}`, { cwd: repo, stdio: "pipe" }); + setKvValue(REPO_INDEX_NS, identity, repo); + setKvValue(REPO_INDEX_NS, "sigma-legacy", repo); + saveRegistry(identity, [rec({ name: "main", path: repo, kind: "main", branch: "main" })]); + saveRegistry("sigma-legacy", [rec({ name: "t1", path: treePath, kind: "ephemeral", state: "on-deck", branch: "feat" })]); + // The one collision migrateRepoData refuses to guess at: the same filename + // under both names. + for (const key of [identity, "sigma-legacy"]) { + mkdirSync(repoDataDir(key), { recursive: true }); + writeFileSync(join(repoDataDir(key), "notes.json"), "{}"); + } + + const moved = join(scratch, "sigma-moved"); + renameSync(repo, moved); + const newTree = join(moved, ".worktrees", "t1"); + + const plan = await planLocate({ newPath: moved }); + if (isRefusal(plan)) throw new Error(`unexpected refusal: ${plan.message}`); + const result = await applyLocate(plan); + + expect(result.ok).toBe(true); + expect(result.legacyRows).toEqual([ + { key: "sigma-legacy", outcome: "retained", reason: "both names hold notes.json" }, + ]); + expect(loadRepoIndex()["sigma-legacy"]).toBe(repo); + expect(loadRepoIndex()[identity]).toBe(moved); + expect(loadRegistry(identity).map((t) => t.path).sort()).toEqual([moved, newTree].sort()); + expect(loadRegistry("sigma-legacy")).toEqual([]); + }); + test("candidates pair a scanned directory with the lost row it derives", async () => { const repo = repoWithRemote("kappa"); const identity = serializeIdentity(await deriveRepoIdentity(repo)); @@ -370,4 +436,27 @@ describe("repo locate", () => { expect(await findLocateCandidates()).toEqual([{ path: moved, identity }]); }); + + test("a lost legacy row named after the moved folder does not hide it from the candidates", async () => { + // The live anchor is what makes `scratch` a scan root once the pair's own + // parent stops naming one. + const anchor = repoWithRemote("anchor"); + setKvValue(REPO_INDEX_NS, serializeIdentity(await deriveRepoIdentity(anchor)), anchor); + + const nest = join(scratch, "nest"); + mkdirSync(nest, { recursive: true }); + const repo = join(nest, "mu"); + mkdirSync(repo, { recursive: true }); + execSync("git init -q -b main", { cwd: repo, stdio: "pipe" }); + execSync("git remote add origin https://gitlab.com/g/mu.git", { cwd: repo, stdio: "pipe" }); + execSync("git -c user.email=t@t -c user.name=t commit --allow-empty -q -m init", { cwd: repo, stdio: "pipe" }); + const identity = serializeIdentity(await deriveRepoIdentity(realpathSync(repo))); + setKvValue(REPO_INDEX_NS, identity, realpathSync(repo)); + setKvValue(REPO_INDEX_NS, "mu", realpathSync(repo)); + + const moved = join(scratch, "mu"); + renameSync(repo, moved); + + expect(await findLocateCandidates()).toEqual([{ path: moved, identity }]); + }); }); diff --git a/lib/repo-index.ts b/lib/repo-index.ts index b91a95ab..3209f014 100644 --- a/lib/repo-index.ts +++ b/lib/repo-index.ts @@ -323,6 +323,10 @@ export interface IndexPartition { duplicates: DuplicateEntry[]; } +function identityRank(entry: RepoIndexEntry): number { + return parseIdentity(entry.repoName) === null ? 0 : 1; +} + /** * Splits index rows that point at the SAME directory under two names. * @@ -332,11 +336,15 @@ export interface IndexPartition { * resolving — `existsSync` follows symlinks, so the dead row passes the * liveness filter and the picker shows the tree twice. * - * The most recently written row wins, because `updateRepoIndex` restamps a - * name every time rt runs inside that repo: the live identity keeps moving - * forward while the retired one stays frozen at whenever it was last used. - * Name order breaks ties so a legacy import (every row stamped within the - * same millisecond) is at least deterministic. + * An identity key beats a legacy name outright, whatever the stamps say: the + * loser is what prune migrates ONTO the winner, and carrying identity-keyed + * data back onto a name would re-mint the split the cutover ended. + * + * Among rows of the same kind the most recently written wins, because + * `updateRepoIndex` restamps a name every time rt runs inside that repo: the + * live identity keeps moving forward while the retired one stays frozen at + * whenever it was last used. Name order breaks ties so a legacy import (every + * row stamped within the same millisecond) is at least deterministic. * * Losers are only hidden, never dropped, by the caller in `getKnownRepos`. * Lookups by name elsewhere (`loadRepoIndex()[name]`, and the per-repo data @@ -356,7 +364,10 @@ export function partitionByRealpath(entries: RepoIndexEntry[]): IndexPartition { const duplicates: DuplicateEntry[] = []; for (const group of groups.values()) { const sorted = [...group].sort( - (a, b) => b.updatedAt - a.updatedAt || a.repoName.localeCompare(b.repoName), + (a, b) => + identityRank(b) - identityRank(a) || + b.updatedAt - a.updatedAt || + a.repoName.localeCompare(b.repoName), ); const winner = sorted[0]!; keep.push(winner); @@ -856,7 +867,11 @@ export function getKnownRepos(opts?: { includeMissing?: boolean }): KnownRepo[] missing: true as const, })) : []; - const knownNames = new Set([...known, ...lost].map(r => r.repoName)); + // Lost names are excluded for the same reason lost paths are (below): a lost + // legacy-name row is named after the directory that moved, so counting it as + // known would shadow that directory's NEW location out of the scan — the one + // candidate `rt repos locate` exists to surface. + const knownNames = new Set(known.map(r => r.repoName)); // realpath'd for set-membership ONLY — a symlinked path component (macOS // /tmp → /private/tmp being the canonical case) must not let the same // directory double-emit under two spellings. `known` itself keeps its diff --git a/lib/repo-locate.ts b/lib/repo-locate.ts index 94e877df..612bfee2 100644 --- a/lib/repo-locate.ts +++ b/lib/repo-locate.ts @@ -30,6 +30,7 @@ import { refreshRepoIndexMirror, removeIndexRow, setIndexPath, + type DataMigration, type RepoIndexEntry, } from "./repo-index.ts"; import { @@ -40,7 +41,7 @@ import { saveRegistry, type TreeRecord, } from "./worktree/registry.ts"; -import { loadClaims, saveClaims } from "./endpoint/store.ts"; +import { loadClaims, saveClaims, type EndpointClaim } from "./endpoint/store.ts"; import { deriveRepoIdentity, parseIdentity, serializeIdentity } from "./settings/identity.ts"; import { getStateDb } from "./state/index.ts"; import { listWorktreesAsync, runGit } from "./worktree/git-async.ts"; @@ -99,7 +100,8 @@ export interface LocateResult { repaired: string[]; /** Re-rooted registry paths with nothing on disk — a record the reconciler will prune, never a locate failure. */ stalePaths: string[]; - legacyRows: { key: string; outcome: "collapsed" | "retained" }[]; + /** `retained` carries the reason its data dir could not all move; the row is left naming `from`, never `to`. */ + legacyRows: { key: string; outcome: "collapsed" | "retained"; reason?: string }[]; error?: string; } @@ -303,21 +305,42 @@ function writeRegistries(plan: LocatePlan): number { return moved; } -/** Claims are re-read here for the same reason registries are: a claim taken between plan and apply must move with the repo, not be reverted to the plan's copy. */ +/** + * The claims half of the apply, on the same rules as `writeRegistries`: every + * claim is re-read HERE (a claim taken between plan and apply must move with + * the repo, not be reverted to the plan's copy), and the pair's claims are + * merged onto the IDENTITY key with the legacy keys emptied — a legacy row + * whose index row `collapseLegacyRows` then drops would otherwise keep claim + * rows under a key nothing looks up again. + * + * `endpoint_claims` is keyed `(repo, worktree, role)`, so a pair that claimed + * the same tree in the same role collides on the merge; the identity's own row + * wins, which is why it is absorbed last. + */ function writeClaims(plan: LocatePlan): number { - let moved = 0; - for (const key of indexWriteKeys(plan)) { + const merged = new Map(); + const absorb = (key: string): number => { const claims = loadClaims(key); - if (claims.length === 0) continue; - const next = claims.map((c) => { - const relocated = relocatePath(c.worktree, plan.oldPath, plan.newPath); - if (relocated === null) return c; - moved += 1; - return { ...c, worktree: relocated }; - }); - saveClaims(key, next); + for (const c of claims) { + const moved = relocatePath(c.worktree, plan.oldPath, plan.newPath); + const claim = moved === null ? c : { ...c, worktree: moved }; + merged.set(JSON.stringify([claim.worktree, claim.role]), { claim, relocated: moved !== null }); + } + return claims.length; + }; + + let touched = false; + for (const key of plan.legacyKeys) { + if (absorb(key) === 0) continue; + saveClaims(key, []); + touched = true; } - return moved; + if (absorb(plan.identity) > 0) touched = true; + if (!touched) return 0; + + const entries = [...merged.values()]; + saveClaims(plan.identity, entries.map((e) => e.claim)); + return entries.filter((e) => e.relocated).length; } /** @@ -351,17 +374,34 @@ async function verifyLocate(plan: LocatePlan): Promise<{ error: string | null; s return { error: null, stalePaths }; } +/** Why a legacy row outlived the collapse, in the terms the operator has to act on. */ +function retentionReason(data: DataMigration): string { + const parts: string[] = []; + if (data.refused.length > 0) parts.push(`both names hold ${data.refused.join(", ")}`); + if (data.registry === "refused") parts.push("its worktree registry could not be written"); + return parts.join("; "); +} + /** * Collapse the legacy half of a healed pair, on prune's rules: the row is * dropped only once its data dir has fully moved, because eviction is what * makes a leftover unreachable. + * + * INVARIANT: a legacy index row must never name a LIVE path without owning a + * worktree registry. Its registry merged onto the identity inside the + * transaction, so a retained row is written back to the (now dead) `oldPath`: + * a reconcile pass keyed on that row then bails on the missing path, where a + * live path would make it derive the repo's worktree settings, adopt every + * tree as unmanaged under the legacy key, and replenish a second pool beside + * the real one. */ function collapseLegacyRows(plan: LocatePlan): LocateResult["legacyRows"] { const out: LocateResult["legacyRows"] = []; for (const key of plan.legacyKeys) { const data = migrateRepoData(key, plan.identity); if (migrationIncomplete(data)) { - out.push({ key, outcome: "retained" }); + setIndexPath(key, plan.oldPath); + out.push({ key, outcome: "retained", reason: retentionReason(data) }); continue; } removeIndexRow(key); From 6ff226a06c7da31ae981bae48cec2b94e57fc21c Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Tue, 25 Aug 2026 17:05:02 -0500 Subject: [PATCH 17/20] locate: usage guards, stderr refusals, and the doc's legacy-world section `--repo` with no value and a second positional are usage errors rather than a silently ignored argument; the retained-legacy line names the row's path and the reason it stayed. rt cd drops a duplicate getKnownRepos fetch (the outer one already includes missing rows), refuseIfMissing prints on stderr like every other refusal, and the reconciler-hold comment no longer claims exclusive registry access. docs/repo-identity.md now describes what prune merges and what locate rewrites, and the test header states the real reason the CLI suite sees no daemon. Co-Authored-By: Claude Fable 5 --- commands/__tests__/repos-locate.test.ts | 23 ++++++++++++-- commands/cd.ts | 17 +++++------ commands/repos.ts | 17 +++++++++-- docs/repo-identity.md | 40 +++++++++++++++++++++++-- lib/daemon/handlers/repos.ts | 2 +- lib/repo.ts | 2 +- 6 files changed, 82 insertions(+), 19 deletions(-) diff --git a/commands/__tests__/repos-locate.test.ts b/commands/__tests__/repos-locate.test.ts index 027188b6..5c963d4a 100644 --- a/commands/__tests__/repos-locate.test.ts +++ b/commands/__tests__/repos-locate.test.ts @@ -1,7 +1,10 @@ /** - * The CLI runs the local path here: under a throwaway HOME no daemon socket - * exists, so `isDaemonRunning()` is false and the apply happens in-process — - * which is exactly the "no daemon, nothing to race" branch. + * The CLI takes its local branch here. `daemonPresent()` reads + * `DAEMON_PID_PATH`/`DAEMON_SOCK_PATH`, which are module-load constants bound + * to the throwaway HOME the bunfig preload (test-setup.ts) sets before any + * module loads — not to the per-test HOME below. That tree holds neither a pid + * file nor a socket, so the apply happens in-process, which is exactly the + * "no daemon, nothing to race" branch. */ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; @@ -129,6 +132,20 @@ describe("reposLocate", () => { expect(deps.lines.join("\n")).toContain("usage: rt repos locate"); }); + test("--repo without a value is a usage error", async () => { + const deps = testDeps(); + const code = await runExpectingProcessExit(() => reposLocate(["--repo"], {}, deps)); + expect(code).toBe(2); + expect(deps.lines.join("\n")).toContain("--repo needs a value"); + }); + + test("a second positional is a usage error, not a silently ignored path", async () => { + const deps = testDeps(); + const code = await runExpectingProcessExit(() => reposLocate([scratch, join(scratch, "other")], {}, deps)); + expect(code).toBe(2); + expect(deps.lines.join("\n")).toContain("locate takes one path"); + }); + test("no path and no lost rows exits 1 saying so", async () => { const deps = testDeps(); const code = await runExpectingProcessExit(() => reposLocate([], {}, deps)); diff --git a/commands/cd.ts b/commands/cd.ts index 3157eb7c..f2093b2f 100644 --- a/commands/cd.ts +++ b/commands/cd.ts @@ -212,18 +212,15 @@ export async function worktreePicker(args: string[]): Promise { if (forceRepo) { if (wtBranch) { // Pick repo first, then jump to the matching worktree (or show picker). - // Scoped includeMissing fetch: a missing row must be pickable here so - // it gets the clean missingRepoRefusal below instead of resolving via - // branch name against a dead path — but only in this branch, not the - // rest of rt cd's default flows. + // A missing row must be pickable here so it gets the clean + // missingRepoRefusal below instead of resolving via branch name against + // a dead path. const { filterableSelect } = await import("../lib/rt-render.tsx"); - const repoChoices = getKnownRepos({ includeMissing: true }); - const options = repoOptions(repoChoices); - const pickedRepoName = repoChoices.length === 1 - ? repoChoices[0]!.repoName - : await filterableSelect({ message: "Pick a repo", options, stderr: true }); + const pickedRepoName = repos.length === 1 + ? repos[0]!.repoName + : await filterableSelect({ message: "Pick a repo", options: repoOptions(repos), stderr: true }); if (!pickedRepoName) process.exit(0); // Esc on repo picker - const pickedRepo = repoChoices.find((r) => r.repoName === pickedRepoName)!; + const pickedRepo = repos.find((r) => r.repoName === pickedRepoName)!; if (pickedRepo.missing) { console.error(`\n ${missingRepoRefusal(pickedRepo)}\n`); process.exit(1); diff --git a/commands/repos.ts b/commands/repos.ts index 11e4565b..f57c54e4 100644 --- a/commands/repos.ts +++ b/commands/repos.ts @@ -270,12 +270,25 @@ export async function reposLocate(args: string[], _ctx: CommandContext = {}, dep } const repoArg = flagValue(args, "--repo"); + if (args.includes("--repo") && (repoArg === undefined || repoArg.startsWith("--"))) { + exitUserError(new UserActionableError("usage", `--repo needs a value — ${LOCATE_USAGE}`), json, "repos locate", deps.print); + } const repo = repoArg ? await resolveRepoArg(repoArg, (msg) => exitUserError(new UserActionableError("repo-unknown", msg), json, "repos locate", deps.print)) : undefined; - const newPath = locatePositionals(args)[0] ?? (await pickLocateTarget(json, deps)); + const positionals = locatePositionals(args); + if (positionals.length > 1) { + exitUserError( + new UserActionableError("usage", `locate takes one path, got ${positionals.length} (${positionals.join(", ")}) — ${LOCATE_USAGE}`), + json, + "repos locate", + deps.print, + ); + } + + const newPath = positionals[0] ?? (await pickLocateTarget(json, deps)); const outcome = await locateMovedRepo({ newPath, ...(repo ? { repo } : {}), dryRun }); if (!outcome.ok) { @@ -307,7 +320,7 @@ export async function reposLocate(args: string[], _ctx: CommandContext = {}, dep for (const row of r.legacyRows) { deps.print(row.outcome === "collapsed" ? ` collapsed the legacy row ${row.key}` - : ` kept the legacy row ${row.key} — its data dir could not all move`); + : ` kept the legacy row ${row.key}, still naming ${r.from} — ${row.reason || "its data dir could not all move"}`); } } diff --git a/docs/repo-identity.md b/docs/repo-identity.md index 7d5ae3f5..f82abf98 100644 --- a/docs/repo-identity.md +++ b/docs/repo-identity.md @@ -132,7 +132,7 @@ not a cosmetic one. Build handles from the label, slugified. ## The legacy world -Machines carry pre-cutover, name-keyed state until it is healed. Three +Machines carry pre-cutover, name-keyed state until it is healed. Four mechanisms, none of which callers should reimplement: - **Daemon boot migration** (`lib/daemon/boot-migrate.ts`): one-shot at every @@ -151,7 +151,43 @@ mechanisms, none of which callers should reimplement: row must survive for prune to collapse the pair. - **`rt repos prune`**: collapses the name/identity pairs the heal leaves behind. Until it runs, both rows point at the same directory, and - name-matching code that counts rows will see doubles. + name-matching code that counts rows will see doubles. The identity row + always wins the pair, whatever the timestamps say — the retired name's data + dir is carried onto it, and its worktree registry too: when BOTH names own + a registry they are **merged** (union by path; the managed record wins a + collision) rather than refused, and only then is the retired row evicted. A + row whose data could not all move is kept and reported, because eviction is + what makes a leftover unreachable — and so is a `missing` row that still + owns a registry, reported `retained` with the hint to run `rt repos locate` + instead. +- **`rt repos locate `**: the verb for a repo whose folder MOVED. + The identity survives a move, so this re-points paths and never re-keys: + every index row of the pair, both registries (re-rooted onto the new root, + external worktrees keeping their own paths, then merged onto the identity) + and the pair's `endpoint_claims` rows (merged onto the identity too, the + legacy key emptied) — one `state.db` transaction, matched by identity and + never by name, with the `repos.json` mirror rewritten as it commits. + - **Repair before commit.** `git worktree repair` and the + `git worktree list` verification run FIRST, while the index still names + the dead path: a reconcile pass that interleaves there finds a repo whose + path does not exist and bails, where a healed index over un-rewritten + registry paths would prune every claimed tree and replenish a fresh pool. + Nothing is written unless the whole move verifies, which is why there is + no rollback. A legacy row retained by a data-dir conflict is written back + to the OLD path for the same reason — a legacy row must never name a live + path without owning a registry. + - **The daemon is never stopped for a move.** It owns the registry, so the + CLI hands the work to the `repos:locate` verb whenever a daemon is + present — presence being a live pid file OR a socket on disk, not a ping, + since a stalled daemon still holds the registry. Present but unanswering + is a hard stop, never a local apply. The handler runs the whole apply + inside the reconciler's in-flight hold. + - **The sync seam cannot do this.** `updateRepoIndex` refuses to re-point a + row whose stored path is gone (the repair it owes is async git, forbidden + on the daemon thread): the row stays `missing` until a locate moves it as + one unit. `updateRepoIndexAsync` — what `rt repos register` calls — routes + that case through the same locate and surfaces a refusal instead of + reporting success over an unhealed index. What this means for a caller: an empty result for a repo that exists usually means its row hasn't been touched since the upgrade. Resolve through diff --git a/lib/daemon/handlers/repos.ts b/lib/daemon/handlers/repos.ts index ffae5a88..e846d7db 100644 --- a/lib/daemon/handlers/repos.ts +++ b/lib/daemon/handlers/repos.ts @@ -12,7 +12,7 @@ import { applyLocate, isRefusal, planLocate } from "../../repo-locate.ts"; import type { HandlerMap } from "./types.ts"; export interface ReposHandlerOpts { - /** Exclusive access to the worktree registry for the duration of `fn`. */ + /** Excludes reconciler passes — not other registry writers — for the duration of `fn`. */ withReconcilerHeld: (fn: () => Promise) => Promise; /** Re-point the hooks guard's per-repo git-config watchers once paths have moved. */ refreshWatchedRepos: () => void; diff --git a/lib/repo.ts b/lib/repo.ts index 14681c9b..c9fb1772 100644 --- a/lib/repo.ts +++ b/lib/repo.ts @@ -187,7 +187,7 @@ export async function requireIdentity(commandLabel?: string): Promise Date: Tue, 25 Aug 2026 18:22:33 -0500 Subject: [PATCH 18/20] docs: scrub employer terms from the repo-locate spec Co-Authored-By: Claude Fable 5 --- docs/superpowers/specs/2026-08-25-repo-locate-design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-08-25-repo-locate-design.md b/docs/superpowers/specs/2026-08-25-repo-locate-design.md index 4d7bbeab..ca23672f 100644 --- a/docs/superpowers/specs/2026-08-25-repo-locate-design.md +++ b/docs/superpowers/specs/2026-08-25-repo-locate-design.md @@ -72,7 +72,7 @@ state go through the daemon, never around it. ## Out of scope -gitq's commonDir-hash store, claimview `uow.json`, `board.cwds`, +gitq's commonDir-hash store, the work-pipeline `uow.json`, `board.cwds`, `rt.workspacePrefs.workspaces` keys — those owners react to `repo:moved` (follow-up tickets). The RT-65 `rt worktree list` no-heal bug itself. @@ -91,5 +91,5 @@ gitq's commonDir-hash store, claimview `uow.json`, `board.cwds`, ## Verification `bunx tsc --noEmit`; `bun test lib commands packages`; a real-state dry run: -`rt repos locate --dry-run ~/Documents/GitHub/assured-dev` against a copy of +`rt repos locate --dry-run ~/Documents/GitHub/acme-dev` against a copy of `~/.mattstack` (isolated `HOME`) after `mv`-ing a throwaway clone. From 5ea690c593f0582817476631a0fb41ea900c7e0c Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Tue, 25 Aug 2026 19:52:22 -0500 Subject: [PATCH 19/20] docs: regenerate the command reference for repos locate Co-Authored-By: Claude Fable 5 --- website/docs/reference/repos/index.mdx | 1 + website/docs/reference/repos/locate.mdx | 29 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 website/docs/reference/repos/locate.mdx diff --git a/website/docs/reference/repos/index.mdx b/website/docs/reference/repos/index.mdx index 00705e55..a9bb3b39 100644 --- a/website/docs/reference/repos/index.mdx +++ b/website/docs/reference/repos/index.mdx @@ -21,5 +21,6 @@ rt repos | --- | --- | | [`register`](register) | Add repo paths to the rt index, optionally granting background tracking | | [`prune`](prune) | Drop index entries whose path is gone, and duplicate names left behind by a repo rename | +| [`locate`](locate) | Tell rt where a repo moved to — re-points the index, worktree registry, endpoint claims and git's worktree admin files together | {/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/repos/locate.mdx b/website/docs/reference/repos/locate.mdx new file mode 100644 index 00000000..c6e3bd9c --- /dev/null +++ b/website/docs/reference/repos/locate.mdx @@ -0,0 +1,29 @@ +--- +title: rt repos locate +sidebar_label: locate +--- + +# rt repos locate + +`rt › repos › locate` + +Tell rt where a repo moved to — re-points the index, worktree registry, endpoint claims and git's worktree admin files together + +## Usage + +```bash +rt repos locate [flags] +``` + +## Arguments & flags + +| Flag / Arg | Type | Default | Description | +| --- | --- | --- | --- | +| `` | text | | Where the repo lives now; omit to pick from candidates under rt.repoRoots | +| [`--repo`](/guides/common-flags) | text | | Which indexed repo moved (identity, path, or name); omit to match by the new path's own identity | +| [`--dry-run`](/guides/common-flags) | boolean | `false` | Print what would be re-pointed without writing | +| [`--json`](/guides/common-flags) | boolean | `false` | Machine-readable result | + +_See code: [commands/repos.ts › reposLocate](https://github.com/m4ttstack/rt/blob/main/commands/repos.ts)_ + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file From 897188641ede4a805ce4a64338dfa9d932fc82b0 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Tue, 25 Aug 2026 20:06:35 -0500 Subject: [PATCH 20/20] locate: unique picker values, live-only fast path, strict repo selector CodeRabbit triage on PR #99. Picker options are now unique within a list (a lost legacy row and the scanned directory it moved to share a name), the single-live-repo fast path counts only live rows so a stale missing row does not cost headless callers their auto-resolve, and repos:locate rejects any supplied-but-invalid repo selector instead of silently going unscoped. Spec items 2, 4 and 6 restated to match what shipped. Co-Authored-By: Claude Fable 5 --- commands/__tests__/cd.test.ts | 2 + commands/cd.ts | 4 +- .../specs/2026-08-25-repo-locate-design.md | 54 ++++++++++++------- lib/__tests__/repo-index-missing.test.ts | 46 +++++++++++++++- lib/daemon/__tests__/repos-handlers.test.ts | 6 +++ lib/daemon/handlers/repos.ts | 8 ++- lib/pickers.ts | 4 +- lib/repo-index.ts | 35 ++++++++++-- lib/repo.ts | 40 +++++++++----- 9 files changed, 155 insertions(+), 44 deletions(-) diff --git a/commands/__tests__/cd.test.ts b/commands/__tests__/cd.test.ts index afe8eced..67496d96 100644 --- a/commands/__tests__/cd.test.ts +++ b/commands/__tests__/cd.test.ts @@ -83,6 +83,7 @@ describe("rt cd --repo --worktree with a missing repo", () => { */ describe("rt cd default picker with a missing repo", () => { const origHome = process.env.HOME; + const origShell = process.env.SHELL; const origCwd = process.cwd(); let home: string; let scratch: string; @@ -102,6 +103,7 @@ describe("rt cd default picker with a missing repo", () => { afterEach(() => { process.chdir(origCwd); process.env.HOME = origHome; + process.env.SHELL = origShell; closeStateDb(); rmSync(home, { recursive: true, force: true }); rmSync(scratch, { recursive: true, force: true }); diff --git a/commands/cd.ts b/commands/cd.ts index f2093b2f..0cab0fe8 100644 --- a/commands/cd.ts +++ b/commands/cd.ts @@ -22,7 +22,7 @@ import { readFileSync, writeFileSync, appendFileSync } from "fs"; import { join } from "path"; import { homedir } from "os"; import { yellow, green, reset } from "../lib/tui.ts"; -import { getRepoIdentity, getKnownRepos, getWorkspacePackages, repoOptions, missingRepoRefusal, type KnownRepo } from "../lib/repo.ts"; +import { getRepoIdentity, getKnownRepos, getWorkspacePackages, repoOptions, repoFromOptionValue, missingRepoRefusal, type KnownRepo } from "../lib/repo.ts"; import { pickWorktreeWithSwitch, pickFromAllRepos, @@ -220,7 +220,7 @@ export async function worktreePicker(args: string[]): Promise { ? repos[0]!.repoName : await filterableSelect({ message: "Pick a repo", options: repoOptions(repos), stderr: true }); if (!pickedRepoName) process.exit(0); // Esc on repo picker - const pickedRepo = repos.find((r) => r.repoName === pickedRepoName)!; + const pickedRepo = repoFromOptionValue(repos, pickedRepoName)!; if (pickedRepo.missing) { console.error(`\n ${missingRepoRefusal(pickedRepo)}\n`); process.exit(1); diff --git a/docs/superpowers/specs/2026-08-25-repo-locate-design.md b/docs/superpowers/specs/2026-08-25-repo-locate-design.md index ca23672f..4884bf9c 100644 --- a/docs/superpowers/specs/2026-08-25-repo-locate-design.md +++ b/docs/superpowers/specs/2026-08-25-repo-locate-design.md @@ -1,6 +1,9 @@ # Repo locate + registry merge (RT-63, RT-68) — design Status: ratified 2026-08-25 (Matt: "get it done"). Binding constraints in **bold**. +Items 2, 4 and 6 were amended after implementation to state what shipped; the +rulings behind each divergence are recorded in +`.superpowers/sdd/2026-08-25-repo-locate/progress.md`. ## Why @@ -23,24 +26,34 @@ state go through the daemon, never around it. wins (`main`/`ephemeral` beat `unmanaged`; two managed → later `createdAt`; tie → winner side). Pure, unit-tested. 2. **Prune uses the merge** (`migrateWorktreeRegistry` in `lib/repo-index.ts`): - when both names own a registry, merge into the live name, verify persisted, - delete the retired registry — `registry: "merged"` outcome replaces - `"refused"` for that case. `rt repos prune` output names the merge. + when both keys of a name/identity pair own a registry, merge onto the + surviving key — the identity key always wins the pair (`partitionByRealpath` + ranks identity over a legacy name, whatever the timestamps say) — verify + persisted, delete the retired legacy key's registry; `registry: "merged"` + outcome replaces `"refused"` for that case. `rt repos prune` output names + the merge. 3. **Prune guard**: a `missing` row that owns a worktree registry is retained (reported `retained`, reason `missing`, hint `rt repos locate`) — never evicted while it owns data. 4. **Locate core** (`lib/repo-locate.ts`, pure of daemon/CLI): `planLocate({ newPath, repoArg? })` → `{ identity, oldPath, indexKeys, registryRewrites, claimRewrites, gitRepairPaths }` or a typed refusal; - `applyLocate(plan)` performs, in one `state.db` transaction: index rows - (identity + legacy pair) → `newPath`; every registry of the pair: - prefix-replace `path` for records under `oldPath` (external trees - untouched), then merge the pair's registries via (1) and delete the legacy - registry + legacy index row; `endpoint_claims.worktree` prefix; `repos.json` - mirror. After commit: `git worktree repair ` from - `newPath`, then a no-arg pass. Verify every registry path exists on disk and - appears in `git worktree list --porcelain`; on failure restore the pre-apply - snapshot of the touched rows (captured before the transaction) and report. + `applyLocate(plan)` **repairs git before it writes anything**: `git worktree + repair ` from `newPath`, then a no-arg pass, then + verification that every re-rooted path exists on disk and appears in `git + worktree list --porcelain` (a re-rooted path missing on disk is reported as + a stale path, the reconciler's job; present-but-unlisted is a hard failure). + Until that verifies, the index still names the dead path, so a reconcile + pass that interleaves finds a gone repo and bails — and nothing is written, + which is why there is no snapshot/rollback. Only then, in one `state.db` + transaction: index rows (identity + legacy pair) → `newPath`; every registry + of the pair: prefix-replace `path` for records under `oldPath` (external + trees untouched), then merge the pair's registries via (1); the pair's + `endpoint_claims.worktree` prefix, merged onto the identity key with the + legacy key emptied. Post-commit, because file ops cannot join the + transaction: the `repos.json` mirror and the legacy index row's collapse (a + legacy row whose data could not all move is retained, written back to + `oldPath`). - **Match by identity, never by name**: `serializeIdentity(await deriveRepoIdentity(newPath))` must equal a lost index key, or the derived identity of a lost legacy-name row's pair. Mismatch → refusal printing @@ -54,13 +67,16 @@ state go through the daemon, never around it. settles), then `hooksGuard.refreshWatchedRepos()`, then emits `repo:moved { identity, from, to }` on the events bus. Registered in `lib/daemon/command-router.ts` like every other handler. -6. **CLI** `rt repos locate [--repo ] [--dry-run] [--json]` - (`commands/repos.ts`): resolves `--repo` via `resolveRepoArg`; calls the - daemon verb when the socket answers, otherwise runs `applyLocate` locally - (no daemon → nothing to race). No ``: scan `rt.repoRoots` - (existing scanner in `lib/repo-index.ts`) for candidates whose derived - identity matches a lost row; one match → confirm; several → picker; none → - list lost rows and exit 1. Never auto-pick. +6. **CLI** `rt repos locate [] [--repo ] [--dry-run] [--json]` + (`commands/repos.ts`): resolves `--repo` via `resolveRepoArg`; hands the + work to the daemon verb whenever a daemon is PRESENT — a live pid file or a + socket on disk, not a ping, since a stalled daemon still owns the registry. + Present but not answering is a **hard stop** naming `rt daemon status`, + never a local apply (it would race the reconciler); only with no presence + evidence at all does the CLI run `applyLocate` locally. No ``: scan + `rt.repoRoots` (existing scanner in `lib/repo-index.ts`) for candidates + whose derived identity matches a lost row; one match → confirm; several → + picker; none → list lost rows and exit 1. Never auto-pick. 7. **Lost rows visible**: `getKnownRepos` keeps missing-path rows and marks them `missing: true`; `rt cd` / pickers render `name (missing — rt repos locate)` and refuse to cd into them. diff --git a/lib/__tests__/repo-index-missing.test.ts b/lib/__tests__/repo-index-missing.test.ts index 6f3bf630..b5dc6112 100644 --- a/lib/__tests__/repo-index-missing.test.ts +++ b/lib/__tests__/repo-index-missing.test.ts @@ -10,8 +10,9 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { closeStateDb, setKvValue } from "../state/index.ts"; -import { getKnownRepos, missingRepoRefusal, repoOption } from "../repo-index.ts"; +import { getKnownRepos, missingRepoRefusal, repoFromOptionValue, repoOption, repoOptions, type KnownRepo } from "../repo-index.ts"; import { pickFromAllRepos } from "../pickers.ts"; +import { pickWorktree } from "../repo.ts"; describe("missing index rows", () => { const origHome = process.env.HOME; @@ -88,6 +89,49 @@ describe("missing index rows", () => { expect(opt.color).toBeDefined(); }); + test("a lost row and the scanned directory sharing its name get distinct picker values", () => { + const lost: KnownRepo = { repoName: "mu", worktrees: [{ path: "/x/gone/mu", branch: "", isBare: false }], dataDir: "/d", missing: true }; + const scanned: KnownRepo = { repoName: "mu", worktrees: [{ path: "/x/live/mu", branch: "", isBare: false }], dataDir: "/d", registered: false }; + const repos = [lost, scanned]; + + const [lostOpt, scannedOpt] = repoOptions(repos); + + expect(lostOpt!.value).not.toBe(scannedOpt!.value); + expect(repoFromOptionValue(repos, lostOpt!.value)).toBe(lost); + expect(repoFromOptionValue(repos, scannedOpt!.value)).toBe(scanned); + }); + + test("an uncontested name keeps the raw index key as its picker value", () => { + const row: KnownRepo = { repoName: "remote:gitlab.com%2Fg%2Fsolo", worktrees: [{ path: "/x/solo", branch: "", isBare: false }], dataDir: "/d" }; + + expect(repoOptions([row])[0]!.value).toBe(row.repoName); + expect(repoFromOptionValue([row], row.repoName)).toBe(row); + }); + + test("a stale missing row does not cost a single live repo its headless auto-resolve", async () => { + const live = realRepo("solo"); + setKvValue("repo-index", "remote:gitlab.com%2Fg%2Fsolo", live); + setKvValue("repo-index", "gone", join(scratch, "gone-away")); + + expect(await pickWorktree("Pick a repo")).toBe(live); + }); + + test("pickWorktree still refuses when the only row is a missing one", async () => { + setKvValue("repo-index", "gone", join(scratch, "gone-away")); + const exitSpy = spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit sentinel"); + }); + const errSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + await expect(pickWorktree("Pick a repo")).rejects.toThrow("process.exit sentinel"); + expect(exitSpy.mock.calls.at(-1)?.[0]).toBe(1); + expect(errSpy.mock.calls.flat().join(" ")).toContain("rt repos locate"); + } finally { + errSpy.mockRestore(); + exitSpy.mockRestore(); + } + }); + test("the refusal names the repo, the gone path, and the fix", () => { const msg = missingRepoRefusal({ repoName: "moved", worktrees: [{ path: "/x/gone", branch: "", isBare: false }], dataDir: "/d", missing: true }); expect(msg).toContain("/x/gone"); diff --git a/lib/daemon/__tests__/repos-handlers.test.ts b/lib/daemon/__tests__/repos-handlers.test.ts index 9fa52c49..6edd6010 100644 --- a/lib/daemon/__tests__/repos-handlers.test.ts +++ b/lib/daemon/__tests__/repos-handlers.test.ts @@ -73,6 +73,12 @@ describe("repos:locate", () => { expect(order).toEqual([]); }); + test("a non-string repo key is rejected, not dropped to an unscoped locate", async () => { + const res = await handlers["repos:locate"]({ newPath: scratch, repo: 42 }); + expect(res).toEqual({ ok: false, error: "repo-unknown" }); + expect(order).toEqual([]); + }); + test("applies inside the hold, refreshes watchers, then emits repo:moved", async () => { const { identity, from, to } = await movedRepo("alpha"); diff --git a/lib/daemon/handlers/repos.ts b/lib/daemon/handlers/repos.ts index e846d7db..1b5e13ae 100644 --- a/lib/daemon/handlers/repos.ts +++ b/lib/daemon/handlers/repos.ts @@ -30,8 +30,12 @@ export function createReposHandlers( "repos:locate": async (payload) => { const newPath = payload?.newPath; if (typeof newPath !== "string" || newPath.length === 0) return { ok: false, error: "newPath-required" }; - const repo = typeof payload?.repo === "string" ? payload.repo : undefined; - if (repo !== undefined && parseIdentity(repo) === null) return { ok: false, error: "repo-unknown" }; + // A supplied-but-unusable `repo` must not degrade to "unscoped": planLocate + // would then relocate whichever lost row matches newPath. + const repo = payload?.repo; + if (repo !== undefined && (typeof repo !== "string" || parseIdentity(repo) === null)) { + return { ok: false, error: "repo-unknown" }; + } return opts.withReconcilerHeld(async () => { const plan = await planLocate({ newPath, repo }); diff --git a/lib/pickers.ts b/lib/pickers.ts index adfc54b1..e35521ed 100644 --- a/lib/pickers.ts +++ b/lib/pickers.ts @@ -7,7 +7,7 @@ import { execSync } from "child_process"; import { join } from "path"; -import { getRepoIdentity, getKnownRepos, pickWorktreeFromRepo, getWorkspacePackages, repoOptions, missingRepoRefusal, type KnownRepo } from "./repo.ts"; +import { getRepoIdentity, getKnownRepos, pickWorktreeFromRepo, getWorkspacePackages, repoOptions, repoFromOptionValue, missingRepoRefusal, type KnownRepo } from "./repo.ts"; import { enrichBranches, formatBranchLabel } from "./enrich.ts"; const SWITCH_REPO = "__switch_repo__" as const; @@ -118,7 +118,7 @@ export async function pickFromAllRepos( ...(opts?.stderr ? { stderr: true } : {}), }); if (!picked) process.exit(1); - selectedRepo = repos.find(r => r.repoName === picked)!; + selectedRepo = repoFromOptionValue(repos, picked)!; } if (selectedRepo.missing) refuse(selectedRepo); diff --git a/lib/repo-index.ts b/lib/repo-index.ts index 3209f014..418f79c8 100644 --- a/lib/repo-index.ts +++ b/lib/repo-index.ts @@ -1073,10 +1073,27 @@ export function repoOption(r: KnownRepo, label: string = repoLabel(r.repoName)): }; } +function duplicateRepoNames(repos: KnownRepo[]): Set { + const counts = new Map(); + for (const r of repos) counts.set(r.repoName, (counts.get(r.repoName) ?? 0) + 1); + return new Set([...counts].filter(([, n]) => n > 1).map(([name]) => name)); +} + +/** + * One list, one value per row. A lost legacy-name row and the scanned + * directory that name moved to carry the SAME `repoName`, so an unqualified + * value resolves the live directory to the dead row. The qualifier trails the + * name because fzf matches on this field (`--nth=1`). + */ +function repoOptionValue(r: KnownRepo, i: number, duplicated: Set): string { + return duplicated.has(r.repoName) ? `${r.repoName}#${i}` : r.repoName; +} + /** Picker options for a repo list: short labels, upgraded to owner/name where two repos would otherwise render identically, and to the full decoded id when even owner/name collides (same owner/name on two hosts; two path - repos sharing a basename). */ + repos sharing a basename). Resolve what the picker returns with + `repoFromOptionValue` — the values are list-scoped, not bare index keys. */ export function repoOptions(repos: KnownRepo[]): Array> { const shortCounts = new Map(); const qualifiedCounts = new Map(); @@ -1086,14 +1103,24 @@ export function repoOptions(repos: KnownRepo[]): Array { + const duplicated = duplicateRepoNames(repos); + return repos.map((r, i) => { const short = repoLabel(r.repoName); - if ((shortCounts.get(short) ?? 0) <= 1) return repoOption(r, short); const qualified = repoLabelQualified(r.repoName); - return repoOption(r, (qualifiedCounts.get(qualified) ?? 0) > 1 ? repoLabelFull(r.repoName) : qualified); + const label = (shortCounts.get(short) ?? 0) <= 1 + ? short + : (qualifiedCounts.get(qualified) ?? 0) > 1 ? repoLabelFull(r.repoName) : qualified; + return { ...repoOption(r, label), value: repoOptionValue(r, i, duplicated) }; }); } +/** The row a `repoOptions` value came from. The list must be the one the + options were built from — values are positional when names collide. */ +export function repoFromOptionValue(repos: KnownRepo[], value: string): KnownRepo | undefined { + const duplicated = duplicateRepoNames(repos); + return repos.find((r, i) => repoOptionValue(r, i, duplicated) === value); +} + /** The one-line refusal every picker prints instead of cd-ing into a repo whose indexed path is gone. */ export function missingRepoRefusal(r: KnownRepo): string { const gone = r.worktrees[0]?.path ?? "its indexed path"; diff --git a/lib/repo.ts b/lib/repo.ts index c9fb1772..6fff086e 100644 --- a/lib/repo.ts +++ b/lib/repo.ts @@ -14,12 +14,12 @@ import { identityFromRemote, serializeIdentity } from "./settings/identity.ts"; // ─── Re-exports ────────────────────────────────────────────────────────────── export { getRepoRoot, getCurrentBranch, getRemoteUrl } from "./git.ts"; -export { updateRepoIndex, getKnownRepos, repoOption, repoOptions, missingRepoRefusal, type KnownRepo } from "./repo-index.ts"; +export { updateRepoIndex, getKnownRepos, repoOption, repoOptions, repoFromOptionValue, missingRepoRefusal, type KnownRepo } from "./repo-index.ts"; // ─── Internal imports ──────────────────────────────────────────────────────── import { getRepoRoot, getRemoteUrl } from "./git.ts"; -import { updateRepoIndex, getKnownRepos, repoOption, repoOptions, missingRepoRefusal, type KnownRepo } from "./repo-index.ts"; +import { updateRepoIndex, getKnownRepos, repoOption, repoOptions, repoFromOptionValue, missingRepoRefusal, type KnownRepo } from "./repo-index.ts"; // ─── Constants ─────────────────────────────────────────────────────────────── @@ -191,6 +191,16 @@ function refuseIfMissing(repo: KnownRepo): void { process.exit(1); } +/** + * A stale `missing` row must not cost a single live repo its auto-resolve — + * headless callers have no picker to fall through to. With no live repo the + * list stands as-is, so the sole missing row still reaches `refuseIfMissing`. + */ +function pickableRepos(repos: KnownRepo[]): KnownRepo[] { + const live = repos.filter(r => !r.missing); + return live.length === 1 ? live : repos; +} + /** * Get repo identity at the repo level (no worktree picker step). * Falls back to a repo-only picker if not currently inside a git repo. @@ -210,9 +220,10 @@ export async function requireRepoIdentity(commandLabel?: string): Promise 1) { + if (choices.length > 1) { if (!process.stdin.isTTY) { console.log(`\n not in a git repo — run interactively to pick one\n`); process.exit(1); @@ -221,10 +232,10 @@ export async function requireRepoIdentity(commandLabel?: string): Promise r.repoName === picked); + const match = repoFromOptionValue(choices, picked); if (!match) process.exit(0); selectedRepo = match; } @@ -255,10 +266,11 @@ export async function pickWorktree(prompt: string): Promise { process.exit(1); } - const totalWorktrees = repos.reduce((n, r) => n + r.worktrees.length, 0); + const choices = pickableRepos(repos); + const totalWorktrees = choices.reduce((n, r) => n + r.worktrees.length, 0); if (totalWorktrees === 1) { - refuseIfMissing(repos[0]!); - return repos[0]!.worktrees[0]!.path; + refuseIfMissing(choices[0]!); + return choices[0]!.worktrees[0]!.path; } if (!process.stdin.isTTY) { @@ -268,15 +280,15 @@ export async function pickWorktree(prompt: string): Promise { let selectedRepo: KnownRepo; - if (repos.length === 1) { - selectedRepo = repos[0]!; + if (choices.length === 1) { + selectedRepo = choices[0]!; } else { const { filterableSelect } = await import("./rt-render.tsx"); - const options = repoOptions(repos); + const options = repoOptions(choices); const picked = await filterableSelect({ message: "Select a repo", options }); if (!picked) process.exit(0); // user escaped — clean exit, no error - const match = repos.find(r => r.repoName === picked); + const match = repoFromOptionValue(choices, picked); if (!match) process.exit(0); // shouldn't happen, but don't crash selectedRepo = match; } @@ -411,7 +423,7 @@ async function pickFromAllRepos(repos: KnownRepo[]): Promise { const pickedRepo = await filterableSelect({ message: "Pick a repo", options }); if (!pickedRepo) process.exit(0); // Esc on all-repos picker - const repo = repos.find((r) => r.repoName === pickedRepo); + const repo = repoFromOptionValue(repos, pickedRepo); if (!repo) process.exit(0); if (repo.worktrees.length === 1) {