diff --git a/commands/daemon.ts b/commands/daemon.ts index da5f55b2c..8179992d7 100644 --- a/commands/daemon.ts +++ b/commands/daemon.ts @@ -19,6 +19,7 @@ */ import { execSync, spawn, spawnSync } from "child_process"; +import { repoLabelQualified } from "../lib/repo-label.ts"; import { basename, join } from "path"; import { reverseLookupByName } from "../lib/repo-arg.ts"; import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs"; @@ -63,6 +64,26 @@ export interface FlavorTuple { daemon: { flavor: string; pid: number | null } | null; } + +/** + * Human events line: repo keys arrive as wire identities and MUST render as + * labels (no-wire-in-ui.test.ts pins this seam). lastSyncedAt only advances + * on a non-empty batch or a state transition, so a quiet-but-healthy repo + * can go the whole session without one; "never" would misread as broken, + * not idle. + */ +export function formatFreshnessParts( + freshness: Record, + now: number, +): string[] { + return Object.entries(freshness).map(([repo, f]) => { + const age = f.lastSyncedAt + ? `${Math.round((now - Date.parse(f.lastSyncedAt)) / 1000)}s ago` + : "no events yet"; + return `${repoLabelQualified(repo)} ${f.state} (${age})`; + }); +} + export async function describeTuple(): Promise { const holder = await probeSocketHolder(); return { intended: resolveIntendedMode(), cliFlavor: currentMode(), daemon: holder }; @@ -506,16 +527,7 @@ export function statusLines(verdict: DaemonStatusVerdict, now: number): string[] | Record | undefined; if (freshness && Object.keys(freshness).length > 0) { - const parts = Object.entries(freshness).map(([repo, f]) => { - // lastSyncedAt only advances on a non-empty batch or a state - // transition, so a quiet-but-healthy repo can go the whole session - // without one; "never" would misread as broken, not idle. - const age = f.lastSyncedAt - ? `${Math.round((now - Date.parse(f.lastSyncedAt)) / 1000)}s ago` - : "no events yet"; - return `${repo} ${f.state} (${age})`; - }); - lines.push(` ${dim}events: ${parts.join(" · ")}${reset}`); + lines.push(` ${dim}events: ${formatFreshnessParts(freshness, now).join(" · ")}${reset}`); } const health = verdict.data.health as { level: string; reasons: string[] } | undefined; diff --git a/docs/repo-identity.md b/docs/repo-identity.md index fb4b1bd54..ab1a123a5 100644 --- a/docs/repo-identity.md +++ b/docs/repo-identity.md @@ -37,13 +37,22 @@ construction, so it always fits in one URL path segment and is a legal directory name. `encodeURIComponent` it again when it rides in a URL (`/api/runs/${encodeURIComponent(identity)}/${runId}`). -Legal directory name is NOT PATH-safe: the delimiter colon splits any PATH -entry the directory ends up inside (a worktree's `node_modules/.bin` during -installs, RT-95). Any identity-keyed directory whose subtree can land in -PATH must use the pool segment form instead: the wire with its first colon -as `%3A` (`worktreePoolRoot` in `lib/rt-paths.ts` does this; id-embedded -`%3A` is untouched, so the mapping stays unambiguous). State.db keys, kv -namespaces, payloads, and URLs keep the raw wire. +Legal directory name is NOT PATH-safe, and PATH-safe is NOT URL-safe: the +delimiter colon splits any PATH entry the directory ends up inside (a +worktree's `node_modules/.bin` during installs, RT-95), and ANY +percent-encoding breaks every consumer that parses a bare path as a URL +(node's ESM loader rejects `%2F`; `import.meta.url` re-encodes `%` to +`%25`). A pool segment must therefore contain no colon and no percent at +all... an escape scheme cannot fix this class, only a percent-free slug +can, and `lib/__tests__/rt-paths.test.ts` pins that invariant. Any identity-keyed directory whose subtree can land in +PATH uses the friendly pool segment instead: `gh--` (host alias, +else the dashed hostname), `local-` for path-kind +(`worktreePoolRoot` in `lib/rt-paths.ts`). The segment is a derived +directory name, never parsed back and never a key; its dash join is +ambiguous only if two registered repos collide on it, which the host +prefix confines to a single host. State.db keys, kv namespaces, payloads, +and URLs keep the raw wire, and anything HUMAN-RENDERED goes through +`lib/repo-label.ts` (`lib/__tests__/no-wire-in-ui.test.ts` is the ratchet). Never swap the forms: settings lookups miss on the wire form, and daemon verbs refuse the raw one (silently — see below). A `path`-kind repo has no @@ -126,7 +135,7 @@ what crosses the socket is always the identity. The wire form is a key, never copy. Anything a human reads — picker rows, list output, log lines, chat handles — goes through a label decode: -`repoLabel()` in `lib/repo-arg.ts` (last path segment for remote-kind, +`repoLabel()` in `lib/repo-label.ts` (last path segment for remote-kind, basename for path-kind); consumers do the same via `parseIdentity`, whose returned `id` is already decoded — decoding it again corrupts ids that contain a literal `%`. Keys go diff --git a/lib/__tests__/no-wire-in-ui.test.ts b/lib/__tests__/no-wire-in-ui.test.ts new file mode 100644 index 000000000..26e7f07b3 --- /dev/null +++ b/lib/__tests__/no-wire-in-ui.test.ts @@ -0,0 +1,41 @@ +/** + * The wire identity form (remote:host%2Forg%2Frepo) is a KEY and must never + * reach human-rendered output; anything a person reads goes through + * lib/repo-label.ts. This file is the ratchet: every formatter that turns + * repo identities into display text gets a case here, asserted wire-free. + * Adding a new human surface that touches identities? Add it below. + * (Payloads deliberately carry the wire form; this test covers RENDERED + * text only.) + */ +import { describe, expect, test } from "bun:test"; +import { repoLabel, repoLabelQualified } from "../repo-label.ts"; +import { formatFreshnessParts } from "../../commands/daemon.ts"; + +const WIRES = [ + "remote:github.com%2Fm4ttstack%2Frt", + "remote:gitlab.com%2Facme%2Facme-dev", + "remote:gitlab.example.com%3A8443%2Fteam%2Fsub%2Frepo", + "path:%2FUsers%2Fdev%2Fscratch", +]; + +function expectWireFree(rendered: string): void { + expect(rendered).not.toMatch(/%2F|%3A|remote:|path:%/); +} + +describe("repo labels are wire-free for every identity kind", () => { + for (const wire of WIRES) { + test(`repoLabel + repoLabelQualified: ${wire.slice(0, 24)}...`, () => { + expectWireFree(repoLabel(wire)); + expectWireFree(repoLabelQualified(wire)); + }); + } +}); + +describe("daemon status events line", () => { + test("renders labels, never wire identities", () => { + const freshness = Object.fromEntries( + WIRES.map((w, i) => [w, { state: "live", lastSyncedAt: i % 2 ? null : new Date().toISOString() }]), + ); + for (const part of formatFreshnessParts(freshness, Date.now())) expectWireFree(part); + }); +}); diff --git a/lib/__tests__/rt-paths.test.ts b/lib/__tests__/rt-paths.test.ts index 5361545c6..de2d298a6 100644 --- a/lib/__tests__/rt-paths.test.ts +++ b/lib/__tests__/rt-paths.test.ts @@ -27,7 +27,7 @@ const realHostname = osReal.hostname; import { basename, dirname, join } from "path"; import { rtDir, reposDir, repoDataDir, logsDir, - worktreePoolRoot, legacyWorktreePoolRoot, + worktreePoolRoot, legacyWorktreePoolRoots, migrateLegacyRtDir, legacyDirsPresent, TRAY_APP_NAME, DEV_TRAY_APP_NAME, TRAY_APP_BUNDLE, DEV_TRAY_APP_BUNDLE, trayAppPath, devTrayAppPath, legacyTrayAppPaths, installedTrayAppPath, machineSettingsPath, @@ -548,27 +548,57 @@ describe("rt-paths", () => { }); }); -describe("worktreePoolRoot (RT-95: PATH-safe identity segment)", () => { - test("remote wire's delimiter colon becomes %3A in the dir segment", () => { - const root = worktreePoolRoot("remote:github.com%2Facme%2Frepo"); - expect(basename(root)).toBe("remote%3Agithub.com%2Facme%2Frepo"); +describe("worktreePoolRoot (friendly PATH-safe identity segment)", () => { + test("github remote gets the gh alias: gh--", () => { + expect(basename(worktreePoolRoot("remote:github.com%2Fm4ttstack%2Frt"))).toBe("gh-m4ttstack-rt"); }); - test("path wire gets the same treatment", () => { - expect(basename(worktreePoolRoot("path:%2FUsers%2Fdev%2Fscratch"))).toBe("path%3A%2FUsers%2Fdev%2Fscratch"); + test("gitlab remote gets the gl alias", () => { + expect(basename(worktreePoolRoot("remote:gitlab.com%2Facme%2Facme-dev"))).toBe("gl-acme-acme-dev"); }); - test("id-embedded %3A survives untouched; only the raw delimiter changes", () => { - const root = worktreePoolRoot("remote:gitlab.example.com%3A8443%2Fteam%2Frepo"); - expect(basename(root)).toBe("remote%3Agitlab.example.com%3A8443%2Fteam%2Frepo"); + test("unknown host falls back to the dash-safe hostname", () => { + expect(basename(worktreePoolRoot("remote:bitbucket.org%2Fteam%2Frepo"))).toBe("bitbucket-org-team-repo"); }); - test("no segment of the returned path contains a raw colon", () => { - const root = worktreePoolRoot("remote:github.com%2Facme%2Frepo"); + test("nested groups and host ports flatten to dashes, no raw colon survives", () => { + const root = worktreePoolRoot("remote:gitlab.example.com%3A8443%2Fteam%2Fsub%2Frepo"); + expect(basename(root)).toBe("gitlab-example-com-8443-team-sub-repo"); expect(root.includes(":")).toBe(false); }); - test("legacyWorktreePoolRoot keeps the raw wire form for heal targeting", () => { - expect(basename(legacyWorktreePoolRoot("remote:github.com%2Facme%2Frepo"))).toBe("remote:github.com%2Facme%2Frepo"); + test("segments are percent-free and colon-free: colons split PATH, %2F breaks node's ESM loader, %25 breaks import.meta.url pathname", () => { + for (const wire of [ + "remote:github.com%2Facme%2Frepo", + "remote:gitlab.example.com%3A8443%2Fteam%2Fsub%2Frepo", + "path:%2FUsers%2Fdev%2Fscratch", + "not-a-wire-with-%25-junk", + ]) { + const root = worktreePoolRoot(wire); + expect(root.includes(":")).toBe(false); + expect(root.includes("%")).toBe(false); + } + }); + + test("path-kind identity becomes local--, distinct paths never collide", () => { + const a = basename(worktreePoolRoot("path:%2FUsers%2Fdev%2Fscratch")); + const b = basename(worktreePoolRoot("path:%2Ftmp%2Fscratch")); + expect(a).toMatch(/^local-scratch-[0-9a-f]{6}$/); + expect(b).toMatch(/^local-scratch-[0-9a-f]{6}$/); + expect(a).not.toBe(b); + }); + + test("an unparseable wire degrades to a sanitized copy, never throws", () => { + const root = worktreePoolRoot("not-a-wire"); + expect(basename(root)).toBe("not-a-wire"); + expect(root.includes(":")).toBe(false); + }); + + test("legacyWorktreePoolRoots lists both prior forms, colon then %3A", () => { + const id = "remote:github.com%2Facme%2Frepo"; + expect(legacyWorktreePoolRoots(id).map((p) => basename(p))).toEqual([ + "remote:github.com%2Facme%2Frepo", + "remote%3Agithub.com%2Facme%2Frepo", + ]); }); }); diff --git a/lib/daemon/reconciler/__tests__/reconcile.test.ts b/lib/daemon/reconciler/__tests__/reconcile.test.ts index 6732cc026..88d52dcbe 100644 --- a/lib/daemon/reconciler/__tests__/reconcile.test.ts +++ b/lib/daemon/reconciler/__tests__/reconcile.test.ts @@ -9,7 +9,7 @@ import { loadRegistry, saveRegistry, type TreeRecord } from "../../../worktree/r import { healLegacyPoolRoots, releaseStrandedClaims, reconcileRepo, MISSING_PRUNE_PASSES } from "../reconcile.ts"; import { tryLockTree } from "../../../worktree/locks.ts"; import { markHandoffDelivered } from "../../../worktree/patch.ts"; -import { legacyWorktreePoolRoot, worktreePoolRoot } from "../../../rt-paths.ts"; +import { legacyWorktreePoolRoots, worktreePoolRoot } from "../../../rt-paths.ts"; function fakeLog(): Logger { return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as unknown as Logger; @@ -104,12 +104,13 @@ describe("reconcile.ts: healLegacyPoolRoots (RT-95)", () => { } test("flips only on-deck trees under the legacy colon root to disposable", () => { - const legacy = legacyWorktreePoolRoot(identity); + const [colonRoot, pctRoot] = legacyWorktreePoolRoots(identity); const current = worktreePoolRoot(identity); const events: Array<{ type: string; data: any }> = []; saveRegistry(identity, [ - seed("on-deck", legacy, "fred"), - seed("claimed", legacy, "snape"), + seed("on-deck", colonRoot!, "fred"), + seed("on-deck", pctRoot!, "bill"), + seed("claimed", colonRoot!, "snape"), seed("on-deck", current, "tonks"), ]); @@ -119,14 +120,15 @@ describe("reconcile.ts: healLegacyPoolRoots (RT-95)", () => { const byName = Object.fromEntries(trees.map((t) => [t.name, t])); expect(byName.fred!.state).toBe("disposable"); expect(byName.fred!.disposableReason).toContain("legacy pool root"); + expect(byName.bill!.state).toBe("disposable"); expect(byName.snape!.state).toBe("claimed"); expect(byName.tonks!.state).toBe("on-deck"); - expect(events.filter((e) => e.type === "worktree:disposable").length).toBe(1); + expect(events.filter((e) => e.type === "worktree:disposable").length).toBe(2); }); test("second run is a no-op", () => { - const legacy = legacyWorktreePoolRoot(identity); - saveRegistry(identity, [seed("on-deck", legacy, "fred")]); + const [colonRoot] = legacyWorktreePoolRoots(identity); + saveRegistry(identity, [seed("on-deck", colonRoot!, "fred")]); healLegacyPoolRoots({ repoName: identity, emit: () => {}, log: fakeLog() }); const events: unknown[] = []; healLegacyPoolRoots({ repoName: identity, emit: (t) => events.push(t), log: fakeLog() }); diff --git a/lib/daemon/reconciler/reconcile.ts b/lib/daemon/reconciler/reconcile.ts index bc3e380a9..79ed0e266 100644 --- a/lib/daemon/reconciler/reconcile.ts +++ b/lib/daemon/reconciler/reconcile.ts @@ -19,7 +19,7 @@ import { currentBranchAsync, listWorktreesAsync, runGit, type WorktreeEntry } fr import { isTreeLocked } from "../../worktree/locks.ts"; import { scrapTree, type CreateDeps } from "../../worktree/create.ts"; import { loadWorktreeAppConfig } from "../../worktree/config.ts"; -import { legacyWorktreePoolRoot, worktreePoolRoot } from "../../rt-paths.ts"; +import { legacyWorktreePoolRoots, worktreePoolRoot } from "../../rt-paths.ts"; import { patchTree } from "../../worktree/patch.ts"; export interface ReconcileDeps { @@ -67,11 +67,13 @@ export const MISSING_PRUNE_PASSES = 3; * guard needed. */ export function healLegacyPoolRoots(deps: Pick): void { - const legacyRoot = legacyWorktreePoolRoot(deps.repoName); - if (legacyRoot === worktreePoolRoot(deps.repoName)) return; - const legacyPrefix = legacyRoot + sep; + const current = worktreePoolRoot(deps.repoName); + const legacyPrefixes = legacyWorktreePoolRoots(deps.repoName) + .filter((root) => root !== current) + .map((root) => root + sep); + if (legacyPrefixes.length === 0) return; for (const rec of loadRegistry(deps.repoName)) { - if (rec.state !== "on-deck" || !rec.path.startsWith(legacyPrefix)) continue; + if (rec.state !== "on-deck" || !legacyPrefixes.some((p) => rec.path.startsWith(p))) continue; const flipped = patchTree(deps.repoName, rec.path, (r) => { r.state = "disposable"; r.disposableReason = "legacy pool root (colon path breaks installs)"; diff --git a/lib/daemon/worktree-reconciler.ts b/lib/daemon/worktree-reconciler.ts index b32b6d0fe..3da39449d 100644 --- a/lib/daemon/worktree-reconciler.ts +++ b/lib/daemon/worktree-reconciler.ts @@ -10,6 +10,7 @@ import { isAbsolute, join, relative, resolve } from "path"; import type { Logger } from "pino"; +import { legacyWorktreePoolRoots } from "../rt-paths.ts"; import { loadRegistry } from "../worktree/registry.ts"; import { recoverPendingReady } from "../worktree/ready-async.ts"; import { MR_TERMINAL_STATES } from "../enrich.ts"; @@ -120,7 +121,7 @@ function isRootAnAncestorOfRepo(repoPath: string, root: string): boolean { async function reapRepoTrash(deps: { repoName: string; repoPath: string; log: Logger }): Promise { const { repoName, repoPath, log } = deps; const cfg = await loadWorktreeRepoConfig(repoName, repoPath); - const roots = [join(repoPath, ".worktrees")]; + const roots = [join(repoPath, ".worktrees"), ...legacyWorktreePoolRoots(repoName)]; if (isRootAnAncestorOfRepo(repoPath, cfg.root)) { log.warn({ repo: repoName, root: cfg.root, repoPath }, "worktree trash sweep refused a configured root that is an ancestor of the repo"); } else { diff --git a/lib/rt-paths.ts b/lib/rt-paths.ts index 5d9a59b5d..ac02ca367 100644 --- a/lib/rt-paths.ts +++ b/lib/rt-paths.ts @@ -25,6 +25,7 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync } from "fs"; import { homedir, hostname } from "os"; import { basename, join } from "path"; +import { parseIdentity } from "../packages/rt-client/src/settings/identity-codec.ts"; import { getSetting } from "./settings/resolve.ts"; function home(): string { @@ -97,28 +98,71 @@ export function worktreesDir(): string { return join(rtDir(), "worktrees"); } +/** Hosts common enough in this estate to earn a short prefix. */ +const POOL_HOST_ALIASES: Record = { + "github.com": "gh", + "gitlab.com": "gl", +}; + +/** Anything outside [A-Za-z0-9._] becomes a dash; runs collapse; ends trim. */ +function dashSafe(part: string): string { + return part.replace(/[^A-Za-z0-9._]+/g, "-").replace(/^-+|-+$/g, ""); +} + +/** Stable 6-hex tag of an identity id; djb2-xor, no crypto import needed. */ +function shortHash(id: string): string { + let h = 5381; + for (let i = 0; i < id.length; i++) h = ((h * 33) ^ id.charCodeAt(i)) >>> 0; + return h.toString(16).padStart(6, "0").slice(-6); +} + +/** Hostname fallback: dots flatten too, so a host reads as one dashed word. */ +function hostSafe(host: string): string { + return dashSafe(host.replace(/\./g, "-")); +} + /** - * The wire form's delimiter colon is PATH-hostile: a pool tree's - * node_modules/.bin lands in PATH during installs, and PATH splits on `:`, - * vanishing every workspace bin (RT-95). Only the first colon is the - * delimiter; id colons are already %3A via encodeURIComponent, so replacing - * it is unambiguous. + * Friendly, PATH-safe pool segment: `gh--` (host alias, else the + * dash-safe hostname), `local-` for path-kind. A colon in a pool + * path splits PATH when the tree's node_modules/.bin is prepended during + * installs (RT-95), so no raw colon may survive. The segment is a derived + * DIRECTORY NAME, never parsed back and never a key; the dash join is + * ambiguous (`a-b`/`c` vs `a`/`b-c`) only if two registered repos collide + * on it, which the alias prefix makes cross-host-safe and the estate does + * not hit within one host. */ function worktreePoolSegment(serializedIdentity: string): string { - return serializedIdentity.replace(":", "%3A"); + const parsed = parseIdentity(serializedIdentity); + if (!parsed) return dashSafe(serializedIdentity); + if (parsed.kind === "path") { + // Distinct paths sharing a basename are realistic on one machine (two + // "scratch" checkouts), so path-kind carries a short identity hash. + // Remote-kind stays hashless by ruling: the dash-join ambiguity needs + // two registered repos on ONE host colliding, which the estate accepts. + return `local-${dashSafe(basename(parsed.id))}-${shortHash(parsed.id)}`; + } + const slash = parsed.id.indexOf("/"); + const host = slash === -1 ? parsed.id : parsed.id.slice(0, slash); + const rest = slash === -1 ? "" : parsed.id.slice(slash + 1); + const prefix = POOL_HOST_ALIASES[host] ?? hostSafe(host); + return rest ? `${prefix}-${dashSafe(rest)}` : prefix; } -/** worktrees/ (one repo's pool root). */ +/** worktrees/ (one repo's pool root). */ export function worktreePoolRoot(serializedIdentity: string): string { return join(worktreesDir(), worktreePoolSegment(serializedIdentity)); } /** - * The pre-RT-95 pool root with the raw wire colon. Heal targeting only: - * never create anything under it. + * Prior pool-root spellings, oldest first: the raw wire (colon, pre-RT-95) + * and the %3A form (RT-95's hotfix). Heal and trash-read targeting only: + * never create anything under them. */ -export function legacyWorktreePoolRoot(serializedIdentity: string): string { - return join(worktreesDir(), serializedIdentity); +export function legacyWorktreePoolRoots(serializedIdentity: string): string[] { + return [ + join(worktreesDir(), serializedIdentity), + join(worktreesDir(), serializedIdentity.replace(":", "%3A")), + ]; } // ─── Settings stores (RT-47, re-rooted under the home repo's user/ zone) ────── diff --git a/lib/worktree/__tests__/pool-root.test.ts b/lib/worktree/__tests__/pool-root.test.ts index 3ac8ad738..17ea95f1d 100644 --- a/lib/worktree/__tests__/pool-root.test.ts +++ b/lib/worktree/__tests__/pool-root.test.ts @@ -4,10 +4,10 @@ import { worktreesDir, worktreePoolRoot, rtDir } from "../../rt-paths.ts"; import { loadWorktreeRepoConfig } from "../config.ts"; import { serializeIdentity, deriveRepoIdentity } from "../../settings/identity.ts"; -test("worktreePoolRoot lives under rtDir/worktrees keyed by the PATH-safe identity segment", () => { +test("worktreePoolRoot lives under rtDir/worktrees keyed by the friendly PATH-safe segment", () => { const id = "remote:gitlab.com%2Facme%2Facme-dev"; expect(worktreesDir()).toBe(join(rtDir(), "worktrees")); - expect(worktreePoolRoot(id)).toBe(join(rtDir(), "worktrees", "remote%3Agitlab.com%2Facme%2Facme-dev")); + expect(worktreePoolRoot(id)).toBe(join(rtDir(), "worktrees", "gl-acme-acme-dev")); }); test("default worktrees.root is the out-of-repo pool root", async () => { diff --git a/lib/worktree/__tests__/restore.test.ts b/lib/worktree/__tests__/restore.test.ts index 49695d31a..54a058320 100644 --- a/lib/worktree/__tests__/restore.test.ts +++ b/lib/worktree/__tests__/restore.test.ts @@ -13,6 +13,7 @@ import { retainedTrashRoot } from "../trash.ts"; import { loadWorktreeRepoConfig } from "../config.ts"; import { disposeTree, type DisposeDeps } from "../dispose.ts"; import { listRestorableEntries, restoreTree, type RestoreDeps } from "../restore.ts"; +import { legacyWorktreePoolRoots } from "../../rt-paths.ts"; import { branchExistsLocalAsync } from "../git-async.ts"; const GIT_ID = "-c user.email=t@t -c user.name=t"; @@ -254,3 +255,25 @@ describe("restoreTree", () => { expect(Date.parse(restorable[0]!.keptUntil)).toBeGreaterThan(Date.now()); }); }); + +describe("legacy pool-root retention stores stay readable", () => { + test("an entry retired under a prior pool-root spelling is still listed", async () => { + const repoName = "remote:example.com%2Facme%2Flegacy-trash"; + const repo = makeRepo(); + const legacyPct = legacyWorktreePoolRoots(repoName)[1]!; + const entry = join(legacyPct, ".trash", "oldtree-1700000000000"); + mkdirSync(entry, { recursive: true }); + writeFileSync(join(entry, "manifest.json"), JSON.stringify({ + name: "oldtree", + originalPath: join(legacyPct, "oldtree"), + branch: "feat/old", + headSha: null, + reason: "test", + disposedAt: new Date().toISOString(), + keptUntil: new Date(Date.now() + 86400000).toISOString(), + })); + + const restorable = await listRestorableEntries(repoName, repo); + expect(restorable.some((e) => e.name === "oldtree")).toBe(true); + }); +}); diff --git a/lib/worktree/restore.ts b/lib/worktree/restore.ts index f6274564e..b0dbb17a3 100644 --- a/lib/worktree/restore.ts +++ b/lib/worktree/restore.ts @@ -10,6 +10,7 @@ */ import { cpSync, existsSync } from "fs"; +import { legacyWorktreePoolRoots } from "../rt-paths.ts"; import { readdir } from "fs/promises"; import { join } from "path"; import { loadRegistry, saveRegistry, type TreeRecord } from "./registry.ts"; @@ -62,8 +63,12 @@ export interface RestorableEntry { * dispose time (trash.ts retireTree), so both must be searched... there is * no migration step that moves an old entry when the config changes. */ -function retentionRootsFor(repoPath: string, cfg: WorktreeRepoConfig): string[] { - return [retainedTrashRoot(join(repoPath, ".worktrees")), retainedTrashRoot(cfg.root)]; +function retentionRootsFor(repoName: string, repoPath: string, cfg: WorktreeRepoConfig): string[] { + return [ + retainedTrashRoot(join(repoPath, ".worktrees")), + retainedTrashRoot(cfg.root), + ...legacyWorktreePoolRoots(repoName).map((root) => retainedTrashRoot(root)), + ]; } interface FoundEntry { @@ -104,7 +109,7 @@ async function findRetainedEntry(roots: string[], treeName: string): Promise { const cfg = await loadWorktreeRepoConfig(repoName, repoPath); - const roots = retentionRootsFor(repoPath, cfg); + const roots = retentionRootsFor(repoName, repoPath, cfg); const out: RestorableEntry[] = []; for (const root of new Set(roots)) { let entries: string[]; @@ -211,7 +216,7 @@ async function copyRetainedContent(entryPath: string, destPath: string): Promise export async function restoreTree(deps: RestoreDeps, treeName: string): Promise { const { repoName, repoPath, emit, log } = deps; const cfg = await loadWorktreeRepoConfig(repoName, repoPath); - const roots = retentionRootsFor(repoPath, cfg); + const roots = retentionRootsFor(repoName, repoPath, cfg); const found = await findRetainedEntry(roots, treeName); if (!found) return { ok: false, reason: "not-found" };