Files
orca/src/shared/worktree
Lesley Murfin de6fe8b7ea fix(worktrees): resolve id: worktree selectors by path equivalence (#16243) (#16494)
* test(worktrees): cover id: selector path-spelling parity with path: (#16243)

The renderer can only address a workspace by id (toRuntimeWorktreeSelector always
emits id:<repoId>::<path>), and the runtime matches that id byte for byte while a
path: selector has always compared through normalizeRuntimePathForComparison. A
stored id that spells its path differently from `git worktree list` therefore
resolves for the CLI and answers selector_not_found for the UI, which reads that
as a stale local mirror, calls forgetLocal, reports success, and lets the row
return on the next catalog refresh: a silent delete.

These tests fail on both resolution sites -- the fleet `id:` branch of
resolveWorktreeSelector and the scoped resolveScopedWorktreeIdRow a
host-qualified removal takes -- and pin what must stay closed: an exact repo id
(STA-4343), host qualification, dot segments neither selector canonicalizes, and
a refusal rather than a guess when two rows spell one path.

13 failing, 35 passing.

* fix(worktrees): resolve id: worktree selectors by path equivalence (#16243)

worktreeIdComparisonKey names one repo, one filesystem location, and one
folder-workspace instance, folding exactly the path spellings
normalizeRuntimePathForComparison already folds for a path: selector -- and
nothing more, so dot segments stay unresolved for both shapes. Both id:
resolution sites consult it only after an exact match finds nothing: the fleet
branch of resolveWorktreeSelector and resolveScopedWorktreeIdRow, which a
host-qualified removal takes. runtimeWorktreeIdsEqual now derives from the same
key so the runtime has one normalizer rather than a parallel one.

Not a pure refactor at that last site: runtimeWorktreeIdsEqual used to
normalize-compare ids that parse but carry an empty repoId or an empty path
('::/p', or 'repo::' against 'repo::/'), and worktreeIdComparisonKey returns
null for those, so across its call sites (PTY identity, refresh, mutation
queue) such ids now compare byte-exact instead. That narrows matching rather
than widening it, no real worktree carries such an id, and it is the behavior
#15616 guarantees for malformed ids -- but it is a behavior delta, not just a
tidy-up.

Perf (#14399): the exact match is still tried first and still wins outright, so
a resolvable id costs exactly what it did before. Neither site adds a scan --
the fleet branch re-filters the array it had already listed, the scoped lookup
re-filters the single owning repo's projected rows -- so an explicit id still
never scans every repo.

Fail-closed behavior is unchanged: the repo id compares exactly (STA-4343), host
qualification is untouched, the folder-workspace instance suffix stays part of
the path, and a scoped lookup with two equivalent rows refuses instead of
guessing. The bare unprefixed selector branch keeps byte-exact id matching,
since only the id: shape reaches a renderer caller.

Shares src/shared/worktree/id.ts with the open #15616, which introduces
worktreeIdComparisonKey for the same divergence in lineage pruning and
authoritative-scan purging; this adopts that helper rather than adding a second
one. Complementary to the open #16295, which makes the miss visible; this
removes the miss.

* chore(worktrees): satisfy oxfmt and oxlint on #16243 tests

oxfmt --check flagged both new test files and oxlint's
unicorn/no-useless-fallback-in-spread flagged the store mock; the full
lint and format gates now match the pre-change baseline.

* test(worktrees): pin Windows spellings and malformed-id exactness (#16243)

Review found two axes the first pass left unproven at the two id: resolution
sites. Both are the invariants the open #15616 guarantees for the shared
worktreeIdComparisonKey it introduces for #15598, so violating either here would
break a contract a sibling PR depends on.

Windows: #15598's whole defect is that one checkout is recorded under both
`D:\Agentic\game2` and `D:/Agentic/game2`. The fleet branch, the scoped removal
lookup, and the key itself now each resolve the backslash spelling against the
forward-slash spelling git reports, and fold drive-letter case -- while a
backslash inside a POSIX path stays a filename character and a POSIX root stays
case-sensitive, exactly as normalizeRuntimePathForComparison already decides for
a path: selector.

Malformed ids keep exact matching at both sites: an id with no repo boundary or
an empty path still refuses, and the scoped lookup still refuses it without
scanning.

Four of these fail without the production change (three fleet/removal Windows
cases and the scoped one); the malformed-id and POSIX-backslash cases are
invariant guards that hold either way.

Verified: 58 passed in the three files; 18 fail with the production hunks
reverted; orca-runtime.test.ts and worktree-teardown-unstopped-pty.test.ts green
(1270 passed | 1 skipped); pnpm tc:node clean.

* test(worktrees): pin Windows id: spelling folds and fleet ambiguity refusal (#16243)

The Windows backslash spelling now rides the ID_SPELLINGS rows, so it is driven
through both id: sites -- resolveWorktreeSelector and the scoped removal target --
and compared against what the same workspace's path: selector resolves, rather
than only through worktreeIdComparisonKey. That is the spelling #15598/#15616
found in the wild and the one the owner's Windows client produces.

The fleet path's ambiguity refusal had no test: two same-repo rows spelling one
directory, an id: matching neither exactly, must reject selector_ambiguous. It is
the fail-closed guard on a delete-capable resolver, and the property a later
refactor is most likely to turn into a silent pick.

Also records two limits at the source instead of leaving them to be rediscovered:
a UNC or WSL root never folds into a drive-letter location (while Windows' two
WSL UNC aliases do name one location), and a folder-workspace id keeps a trailing
slash placed before the ::workspace:<uuid> suffix, so that spelling stays
exact-match-only. Neither behavior changes here.

The file docblock overclaimed parity. path: collapses duplicate same-host
registrations to the first row while a folded id: refuses them; the contract this
file pins is path-spelling parity, not dedup parity, and the divergence is
deliberate because this resolver also serves delete.

Non-vacuity, verified by temporarily reverting the production hunks: neutralizing
both id: fallbacks turns 10 of these tests red, including both new Windows rows
and the ambiguity refusal (it degrades to selector_not_found). Making the fleet
fallback pick the first folded match instead of collecting all of them turns the
ambiguity test red on its own. The remaining cases -- malformed ids, dot
segments, the POSIX backslash, the folder-workspace slash -- pass against the
pre-fix code too: they guard against future widening rather than proving this
fix.

Drops the two Windows cases the ID_SPELLINGS row subsumes.

The drive-letter case test asserted only the Windows half its name promised; it
now also pins that a POSIX root does NOT fold case, since an unconditional
lowercase would merge /data/Foo with /data/foo on the platform CI runs on.

Fixture paths use the upstream-attested /srv/projects prefix (and a neutral
plugin-host leaf) instead of a local install root; the spelling variations the
tests exist to pin -- doubled separator, dot segment, trailing slash, uppercase
POSIX, cafe NFC/NFD, and the Windows D: rows -- are unchanged in form.

* docs(worktrees): trim the id: selector test header and document the comparison key (#16243)
2026-08-26 23:35:18 -07:00
..