fix(worktree): collapse path-equal rows on the detected list too

`worktree.detectedList` served the same cached scan without the collapse, so
remote/web clients kept seeing the duplicate row `worktree.list` no longer
emits. The terminal sweep opts out: a spelling the collapse drops still names
a live directory, and killing its PTYs is irreversible.

Also makes `pruneLineageForMissingRepoWorktrees`'s `platform` required and
threads `resolveRepoWorktreePathPlatform(repo)` through all four call sites,
so an SSH repo is never evaluated under the desktop's macOS firmlink rules.
This commit is contained in:
Neil
2026-08-21 23:57:58 -07:00
parent 083aa5de61
commit 3e25f72743
5 changed files with 127 additions and 25 deletions
+19 -3
View File
@@ -4,6 +4,7 @@ import { readFile, stat } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import type { Store } from '../persistence'
import { pruneLineageForMissingRepoWorktrees } from '../worktree-lineage-pruning'
import { resolveRepoWorktreePathPlatform } from '../runtime/path-equal-worktree-row-collapse'
import { isFolderRepo } from '../../shared/repo-kind'
import { readBranchRenameFailureOutputForDisplay } from '../agent-hooks/branch-rename-failure-output'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
@@ -1303,7 +1304,12 @@ async function listDetectedWorktreesForCapturedRepo(
}
if (freshScan) {
rememberLocalWorktreeRoots(store, repo, gitWorktrees)
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
pruneLineageForMissingRepoWorktrees(
store,
repo,
gitWorktrees,
resolveRepoWorktreePathPlatform(repo)
)
}
loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`)
return {
@@ -1921,7 +1927,12 @@ export function registerWorktreeHandlers(
}
if (freshScan) {
rememberLocalWorktreeRoots(store, repo, gitWorktrees)
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
pruneLineageForMissingRepoWorktrees(
store,
repo,
gitWorktrees,
resolveRepoWorktreePathPlatform(repo)
)
}
loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`)
return buildDetectedGitWorktrees(store, repo, gitWorktrees)
@@ -1993,7 +2004,12 @@ export function registerWorktreeHandlers(
}
if (freshScan) {
rememberLocalWorktreeRoots(store, repo, gitWorktrees)
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
pruneLineageForMissingRepoWorktrees(
store,
repo,
gitWorktrees,
resolveRepoWorktreePathPlatform(repo)
)
}
loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`)
return buildDetectedGitWorktrees(store, repo, gitWorktrees)
+62
View File
@@ -41217,6 +41217,68 @@ describe('OrcaRuntimeService', () => {
expect(removeWorktreeLineage).not.toHaveBeenCalled()
})
it('collapses path-equal rows in the detected worktree list', async () => {
vi.mocked(listWorktrees).mockResolvedValue([
{
path: TEST_WORKTREE_PATH,
head: 'abc',
branch: '',
isBare: false,
isMainWorktree: false
},
{
path: '/tmp/./worktree-a',
head: 'abc',
branch: 'feature/foo',
isBare: false,
isMainWorktree: false
}
])
const runtime = new OrcaRuntimeService(store as never)
const result = await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`)
expect(result.worktrees.map((worktree) => worktree.id)).toEqual([TEST_WORKTREE_ID])
expect(result.worktrees[0]?.branch).toBe('feature/foo')
})
it('keeps terminals for a path-equal spelling the detected-list collapse drops', async () => {
const dotSpellingId = `${TEST_REPO_ID}::/tmp/./worktree-a`
const localProvider = {
listProcesses: vi.fn(async () => [
{ id: `${dotSpellingId}@@session`, cwd: '/tmp/./worktree-a', title: 'shell' }
]),
shutdown: vi.fn(async () => {})
}
vi.mocked(listWorktrees).mockResolvedValue([
{
path: TEST_WORKTREE_PATH,
head: 'abc',
branch: 'feature/foo',
isBare: false,
isMainWorktree: false
},
{
path: '/tmp/./worktree-a',
head: 'abc',
branch: 'feature/foo',
isBare: false,
isMainWorktree: false
}
])
const runtime = new OrcaRuntimeService(store as never, undefined, {
getLocalProvider: () => localProvider as never
})
const result = await runtime.teardownMissingManagedWorktreeTerminals(`id:${TEST_REPO_ID}`, [
TEST_WORKTREE_ID,
dotSpellingId
])
expect(result).toEqual({ stoppedWorktreeIds: [] })
expect(localProvider.shutdown).not.toHaveBeenCalled()
})
it('revalidates missing worktrees before stopping their local provider sessions', async () => {
const deletedId = `${TEST_REPO_ID}::/tmp/deleted`
const survivingId = `${TEST_REPO_ID}::/tmp/surviving`
+21 -4
View File
@@ -1051,6 +1051,10 @@ import {
getDefaultVoiceSettings
} from '../../shared/constants'
import { pruneLineageForMissingRepoWorktrees } from '../worktree-lineage-pruning'
import {
collapsePathEqualWorktreeRows,
resolveRepoWorktreePathPlatform
} from './path-equal-worktree-row-collapse'
import {
createWorktreeCopiedPaths,
createWorktreeLinkedPaths,
@@ -23320,7 +23324,10 @@ export class OrcaRuntimeService {
private async listDetectedWorktreesForResolvedRepo(
repo: Repo,
sourceDefaultsSupported = true
sourceDefaultsSupported = true,
// Why opt-out: the terminal sweep judges "missing" against every spelling git reported, because
// a spelling the collapse drops still names a live directory and killing its PTYs is final.
collapsePathEqualRows = true
): Promise<DetectedWorktreeListResult> {
const store = this.requireStore()
const settings = store.getSettings()
@@ -23361,8 +23368,11 @@ export class OrcaRuntimeService {
} catch {
scan = { ok: false, worktrees: [] }
}
const pathPlatform = resolveRepoWorktreePathPlatform(repo)
if (scan.ok) {
pruneLineageForMissingRepoWorktrees(store, repo, scan.worktrees)
// Why raw rows: a spelling the collapse drops still names a live directory, so pruning
// against the collapsed set would read it as a deleted worktree.
pruneLineageForMissingRepoWorktrees(store, repo, scan.worktrees, pathPlatform)
}
const worktreeVisibilitySourceMatcher = createWorktreeVisibilitySourceMatcher(
[repo.path, ...scan.worktrees.map((worktree) => worktree.path)],
@@ -23372,7 +23382,14 @@ export class OrcaRuntimeService {
const expectedHostId = getRepoExecutionHostId(repo)
const repoOwnerCount = store.getRepos().filter((candidate) => candidate.id === repo.id).length
const metaById = store.getAllWorktreeMeta()
const detected = scan.worktrees.map((gitWorktree) => {
const rows = collapsePathEqualRows
? collapsePathEqualWorktreeRows(scan.worktrees, {
repoPath: repo.path,
hasStoredMeta: (worktreePath) => metaById[`${repo.id}::${worktreePath}`] !== undefined,
platform: pathPlatform
})
: scan.worktrees
const detected = rows.map((gitWorktree) => {
const worktreeId = `${repo.id}::${gitWorktree.path}`
const meta = getRepoOwnedWorktreeMeta(repo, worktreeId, metaById, repoOwnerCount)
const worktree = {
@@ -23412,7 +23429,7 @@ export class OrcaRuntimeService {
// Why: rescanning by `id:` would re-resolve the already-resolved repo, and a
// duplicate id across hosts makes that second lookup throw selector_ambiguous
// even though the caller's selector was unique — losing the sweep entirely.
const detected = await this.listDetectedWorktreesForResolvedRepo(repo)
const detected = await this.listDetectedWorktreesForResolvedRepo(repo, true, false)
if (!detected.authoritative) {
return { stoppedWorktreeIds: [] }
}
+22 -17
View File
@@ -98,7 +98,7 @@ describe('pruneLineageForMissingRepoWorktrees', () => {
}
const store = createStore(worktreeLineageById, workspaceLineageByChildKey, metaById)
pruneLineageForMissingRepoWorktrees(store as never, repo, [])
pruneLineageForMissingRepoWorktrees(store as never, repo, [], 'linux')
expect(store.removeWorktreeLineage).not.toHaveBeenCalled()
expect(store.removeWorkspaceLineage).not.toHaveBeenCalled()
@@ -128,22 +128,27 @@ describe('pruneLineageForMissingRepoWorktrees', () => {
}
const store = createStore(worktreeLineageById, workspaceLineageByChildKey, metaById)
pruneLineageForMissingRepoWorktrees(store as never, repo, [
{
path: '/repo/live-parent',
head: 'a',
branch: 'main',
isBare: false,
isMainWorktree: false
},
{
path: '/repo/live-child',
head: 'b',
branch: 'child',
isBare: false,
isMainWorktree: false
}
])
pruneLineageForMissingRepoWorktrees(
store as never,
repo,
[
{
path: '/repo/live-parent',
head: 'a',
branch: 'main',
isBare: false,
isMainWorktree: false
},
{
path: '/repo/live-child',
head: 'b',
branch: 'child',
isBare: false,
isMainWorktree: false
}
],
'linux'
)
expect(store.removeWorktreeLineage).toHaveBeenCalledWith(missingChildId)
expect(store.removeWorktreeLineage).not.toHaveBeenCalledWith(liveChildId)
+3 -1
View File
@@ -40,7 +40,9 @@ export function pruneLineageForMissingRepoWorktrees(
store: Store,
repo: Repo,
gitWorktrees: GitWorktreeInfo[],
platform: NodeJS.Platform = process.platform
// Why required: rows can come from an SSH or WSL host, and silently defaulting to the desktop's
// rules would apply macOS `/private/tmp` remapping to a remote host's paths.
platform: NodeJS.Platform
): void {
if (
typeof store.getAllWorktreeLineage !== 'function' ||