diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 4b4d009024f..6076edc5b0f 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -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) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index fa9041d0ae5..3928a5d9f1a 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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` diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index acba1a26aca..086c00b85b0 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 { 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: [] } } diff --git a/src/main/worktree-lineage-pruning.test.ts b/src/main/worktree-lineage-pruning.test.ts index 55b3d966085..69a01bc9921 100644 --- a/src/main/worktree-lineage-pruning.test.ts +++ b/src/main/worktree-lineage-pruning.test.ts @@ -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) diff --git a/src/main/worktree-lineage-pruning.ts b/src/main/worktree-lineage-pruning.ts index c8c3f95b44d..6cd658ec90a 100644 --- a/src/main/worktree-lineage-pruning.ts +++ b/src/main/worktree-lineage-pruning.ts @@ -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' ||