Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 22 additions & 10 deletions commands/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, { state: string; lastSyncedAt: string | null }>,
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<FlavorTuple> {
const holder = await probeSocketHolder();
return { intended: resolveIntendedMode(), cliFlavor: currentMode(), daemon: holder };
Expand Down Expand Up @@ -506,16 +527,7 @@ export function statusLines(verdict: DaemonStatusVerdict, now: number): string[]
| Record<string, { state: string; lastSyncedAt: string | null }>
| 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;
Expand Down
25 changes: 17 additions & 8 deletions docs/repo-identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<org>-<repo>` (host alias,
else the dashed hostname), `local-<basename>` 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).
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
Expand Down Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions lib/__tests__/no-wire-in-ui.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
58 changes: 44 additions & 14 deletions lib/__tests__/rt-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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%3Ahub.lumenfield.work%2Facme%2Frepo");
describe("worktreePoolRoot (friendly PATH-safe identity segment)", () => {
test("github remote gets the gh alias: gh-<org>-<repo>", () => {
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-<basename>-<hash>, 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%3Ahub.lumenfield.work%2Facme%2Frepo",
]);
});
});
16 changes: 9 additions & 7 deletions lib/daemon/reconciler/__tests__/reconcile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"),
]);

Expand All @@ -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() });
Expand Down
12 changes: 7 additions & 5 deletions lib/daemon/reconciler/reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -67,11 +67,13 @@ export const MISSING_PRUNE_PASSES = 3;
* guard needed.
*/
export function healLegacyPoolRoots(deps: Pick<ReconcileDeps, "repoName" | "emit" | "log">): 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)";
Expand Down
3 changes: 2 additions & 1 deletion lib/daemon/worktree-reconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -120,7 +121,7 @@ function isRootAnAncestorOfRepo(repoPath: string, root: string): boolean {
async function reapRepoTrash(deps: { repoName: string; repoPath: string; log: Logger }): Promise<void> {
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 {
Expand Down
66 changes: 55 additions & 11 deletions lib/rt-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, string> = {
"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-<org>-<repo>` (host alias, else the
* dash-safe hostname), `local-<basename>` 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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/** worktrees/<PATH-safe identity segment> (one repo's pool root). */
/** worktrees/<friendly PATH-safe segment> (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) ──────
Expand Down
Loading
Loading