diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 201a85c35aa..a859035f159 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -2315,6 +2315,48 @@ describe('registerWorktreeHandlers', () => { }) }) + it('hides agent scratch created inside a linked checkout from desktop listings', async () => { + const linkedCheckoutPath = '/workspace/feature-x' + const scratchPath = `${linkedCheckoutPath}/.claude/worktrees/agent-a04ccaaa` + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: linkedCheckoutPath, + head: 'feature-head', + branch: 'refs/heads/feature-x', + isBare: false, + isMainWorktree: false + }, + { + path: scratchPath, + head: 'scratch-head', + branch: 'refs/heads/worktree-agent-a04ccaaa', + isBare: false, + isMainWorktree: false + } + ]) + + const detected = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: (Worktree & { ownership: string; visible: boolean })[] } + const visible = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as Worktree[] + + expect(detected.worktrees.find((worktree) => worktree.path === scratchPath)).toMatchObject({ + ownership: 'agent-scratch', + visible: false + }) + expect(visible.map((worktree) => worktree.path)).toEqual([ + '/workspace/repo', + linkedCheckoutPath + ]) + }) + it('does not reuse host detected worktree scans for a selected WSL runtime', async () => { listWorktreesMock .mockResolvedValueOnce([ diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 4cbf411765e..8ea14fe0b20 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -38,6 +38,7 @@ import { isLegacyRepoForExternalWorktreeVisibility, toDetectedWorktree } from '../../shared/worktree-ownership' +import { createAgentScratchWorktreePathMatcher } from '../../shared/agent-scratch-worktrees' import { assertWorktreeCleanForRemoval, forceDeleteLocalBranch, @@ -754,8 +755,14 @@ function buildDetectedGitWorktrees( const isLegacyRepoForVisibility = isLegacyRepoForExternalWorktreeVisibility(repo) // Why: a prunable registration has no working directory (issue #8389); only // this listing omits it — removal/cleanup flows list worktrees separately. - const liveWorktrees = gitWorktrees.filter((gitWorktree) => !gitWorktree.prunable) - return dedupeWorktreesByPath(liveWorktrees).map((gitWorktree) => { + const liveWorktrees = dedupeWorktreesByPath( + gitWorktrees.filter((gitWorktree) => !gitWorktree.prunable) + ) + const agentScratchWorktreePathMatcher = createAgentScratchWorktreePathMatcher([ + repo.path, + ...liveWorktrees.map((worktree) => worktree.path) + ]) + return liveWorktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` let meta = store.getWorktreeMeta(worktreeId) const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) @@ -765,7 +772,8 @@ function buildDetectedGitWorktrees( meta, settings, knownOrcaLayouts, - isLegacyRepoForVisibility + isLegacyRepoForVisibility, + agentScratchWorktreePathMatcher }) if (!detected.visible) { return detected @@ -778,7 +786,8 @@ function buildDetectedGitWorktrees( meta, settings, knownOrcaLayouts, - isLegacyRepoForVisibility + isLegacyRepoForVisibility, + agentScratchWorktreePathMatcher }) }) } @@ -970,6 +979,10 @@ function buildDisconnectedDetectedWorktrees( worktrees: Worktree[] ): DetectedWorktree[] { const settings = store.getSettings() + const agentScratchWorktreePathMatcher = createAgentScratchWorktreePathMatcher([ + repo.path, + ...worktrees.map((worktree) => worktree.path) + ]) return worktrees.map((worktree) => { const meta = store.getWorktreeMeta(worktree.id) const detected = toDetectedWorktree({ @@ -978,7 +991,8 @@ function buildDisconnectedDetectedWorktrees( meta, settings, knownOrcaLayouts: [], - isLegacyRepoForVisibility: true + isLegacyRepoForVisibility: true, + agentScratchWorktreePathMatcher }) return applyMetadataFallbackVisibility(detected) }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 9e194212d35..284e5971ef2 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -26388,6 +26388,31 @@ describe('OrcaRuntimeService', () => { expect(removeWorktreeLineage).not.toHaveBeenCalled() }) + it('hides agent scratch created inside a linked checkout from runtime listings', async () => { + const linkedCheckoutPath = '/tmp/worktree-a' + const scratchPath = `${linkedCheckoutPath}/.claude/worktrees/agent-a04ccaaa` + vi.mocked(listWorktrees).mockClear() + vi.mocked(listWorktrees).mockResolvedValue([ + makeWorktreeInfo(TEST_REPO_PATH), + makeWorktreeInfo(linkedCheckoutPath), + makeWorktreeInfo(scratchPath) + ]) + const runtime = createRuntime() + + const detected = await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) + const listed = await runtime.listManagedWorktrees(`id:${TEST_REPO_ID}`) + + expect(detected.worktrees.find((worktree) => worktree.path === scratchPath)).toMatchObject({ + ownership: 'agent-scratch', + visible: false + }) + expect(listed.worktrees.map((worktree) => worktree.path)).toEqual([ + TEST_REPO_PATH, + linkedCheckoutPath + ]) + expect(listWorktrees).toHaveBeenCalledTimes(1) + }) + it('bounds repeated detected worktree scans across the reported 15-repo shape', async () => { vi.mocked(listWorktrees).mockReset() const repos = Array.from({ length: 15 }, (_, index) => ({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 94f304399fb..60a83defe4b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -263,6 +263,10 @@ import { isLegacyRepoForExternalWorktreeVisibility, toDetectedWorktree } from '../../shared/worktree-ownership' +import { + createAgentScratchWorktreePathMatcher, + type AgentScratchWorktreePathMatcher +} from '../../shared/agent-scratch-worktrees' import { BROWSER_HEADLESS_RUNTIME_CAPABILITY, BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY, @@ -15207,11 +15211,29 @@ export class OrcaRuntimeService { } const resolved = await this.listResolvedWorktrees() const repoId = repoSelector ? (await this.resolveRepoSelector(repoSelector)).id : null + const checkoutPathsByRepoId = new Map() + for (const worktree of resolved) { + const checkoutPaths = checkoutPathsByRepoId.get(worktree.repoId) ?? [] + checkoutPaths.push(worktree.path) + checkoutPathsByRepoId.set(worktree.repoId, checkoutPaths) + } + const agentScratchMatchersByRepoId = new Map( + (this.store?.getRepos() ?? []).map((repo) => [ + repo.id, + createAgentScratchWorktreePathMatcher([ + repo.path, + ...(checkoutPathsByRepoId.get(repo.id) ?? []) + ]) + ]) + ) const worktrees = resolved.filter((worktree) => { if (repoId && worktree.repoId !== repoId) { return false } - return this.isRuntimeWorktreeVisible(worktree) + return this.isRuntimeWorktreeVisible( + worktree, + agentScratchMatchersByRepoId.get(worktree.repoId) + ) }) return { worktrees: worktrees.slice(0, limit), @@ -15240,11 +15262,19 @@ export class OrcaRuntimeService { if (scan.ok) { this.pruneLineageForMissingRepoWorktrees(repo, scan.worktrees) } + const agentScratchWorktreePathMatcher = createAgentScratchWorktreePathMatcher([ + repo.path, + ...scan.worktrees.map((worktree) => worktree.path) + ]) const detected = scan.worktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` const meta = this.store?.getWorktreeMeta(worktreeId) const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) - const detectedWorktree = this.toRuntimeDetectedWorktree(repo, worktree) + const detectedWorktree = this.toRuntimeDetectedWorktree( + repo, + worktree, + agentScratchWorktreePathMatcher + ) if (scan.ok) { return detectedWorktree } @@ -15258,15 +15288,22 @@ export class OrcaRuntimeService { } } - private isRuntimeWorktreeVisible(worktree: Worktree): boolean { + private isRuntimeWorktreeVisible( + worktree: Worktree, + agentScratchWorktreePathMatcher?: AgentScratchWorktreePathMatcher + ): boolean { const repo = this.store?.getRepo(worktree.repoId) if (!repo || !this.store) { return true } - return this.toRuntimeDetectedWorktree(repo, worktree).visible + return this.toRuntimeDetectedWorktree(repo, worktree, agentScratchWorktreePathMatcher).visible } - private toRuntimeDetectedWorktree(repo: Repo, worktree: Worktree): DetectedWorktree { + private toRuntimeDetectedWorktree( + repo: Repo, + worktree: Worktree, + agentScratchWorktreePathMatcher?: AgentScratchWorktreePathMatcher + ): DetectedWorktree { const settings = this.store?.getSettings() if (!settings) { return { @@ -15282,7 +15319,8 @@ export class OrcaRuntimeService { meta: this.store?.getWorktreeMeta(worktree.id), settings, knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo), - isLegacyRepoForVisibility: isLegacyRepoForExternalWorktreeVisibility(repo) + isLegacyRepoForVisibility: isLegacyRepoForExternalWorktreeVisibility(repo), + agentScratchWorktreePathMatcher }) } diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx index d1c2f0b5e5c..5cad24b5ec7 100644 --- a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx @@ -9,7 +9,10 @@ import { DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' -import { getHiddenExternalWorktrees } from '../../../../shared/external-worktree-inbox' +import { + getHiddenExternalWorktrees, + getVisibleExternalWorktrees +} from '../../../../shared/external-worktree-inbox' import { isGitRepoKind } from '../../../../shared/repo-kind' import { effectiveExternalWorktreeVisibility, @@ -34,13 +37,7 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null { 'show' : false const hiddenCount = getHiddenExternalWorktrees(detected).length - const otherCount = - detected?.authoritative === true - ? detected.worktrees.filter( - (worktree) => - worktree.visible && !worktree.selectedCheckout && worktree.ownership !== 'orca-managed' - ).length - : 0 + const otherCount = getVisibleExternalWorktrees(detected).length const hiddenWorktreeLabel = `${hiddenCount} ${hiddenCount === 1 ? 'worktree' : 'worktrees'}` const shownWorktreeLabel = `${otherCount} ${otherCount === 1 ? 'worktree' : 'worktrees'}` diff --git a/src/shared/agent-scratch-worktrees.test.ts b/src/shared/agent-scratch-worktrees.test.ts index 8da1d00dfc0..b2714bd6464 100644 --- a/src/shared/agent-scratch-worktrees.test.ts +++ b/src/shared/agent-scratch-worktrees.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { isAgentScratchWorktreePath } from './agent-scratch-worktrees' +import { + createAgentScratchWorktreePathMatcher, + isAgentScratchWorktreePath +} from './agent-scratch-worktrees' describe('isAgentScratchWorktreePath', () => { const repoPath = '/Users/dev/app' @@ -19,6 +22,22 @@ describe('isAgentScratchWorktreePath', () => { ).toBe(true) }) + it('matches scratch worktrees created from a linked checkout', () => { + const matchesAgentScratch = createAgentScratchWorktreePathMatcher([ + repoPath, + '/Users/dev/orca/workspaces/app/feature-x' + ]) + + expect( + matchesAgentScratch( + '/Users/dev/orca/workspaces/app/feature-x/.claude/worktrees/agent-a04ccaaa' + ) + ).toBe(true) + expect(matchesAgentScratch('/Users/dev/other/feature-x/.claude/worktrees/agent-a04ccaaa')).toBe( + false + ) + }) + it('matches Windows path separators and casing', () => { expect( isAgentScratchWorktreePath( diff --git a/src/shared/agent-scratch-worktrees.ts b/src/shared/agent-scratch-worktrees.ts index 7f52bbe4b74..aa17df57ac9 100644 --- a/src/shared/agent-scratch-worktrees.ts +++ b/src/shared/agent-scratch-worktrees.ts @@ -1,5 +1,4 @@ -import { isWindowsAbsolutePathLike, relativePathInsideRoot } from './cross-platform-path' -import { isWslUncPath } from './wsl-paths' +import { normalizeRuntimePathForComparison } from './cross-platform-path' /** Why: agent CLIs reserve these repo-root paths for scratch; broader matches * can hide legitimate user worktrees (#9388). */ @@ -8,19 +7,32 @@ const AGENT_SCRATCH_PATH_PREFIXES: readonly (readonly string[])[] = [ ['.gsd-workspaces'] ] -export function isAgentScratchWorktreePath(repoPath: string, worktreePath: string): boolean { - const relativePath = relativePathInsideRoot(repoPath, worktreePath) - if (!relativePath) { +export type AgentScratchWorktreePathMatcher = (worktreePath: string) => boolean + +export function createAgentScratchWorktreePathMatcher( + checkoutPaths: readonly string[] +): AgentScratchWorktreePathMatcher { + const checkoutPathKeys = new Set(checkoutPaths.map(normalizeRuntimePathForComparison)) + return (worktreePath) => { + const segments = normalizeRuntimePathForComparison(worktreePath).split('/') + for (const prefix of AGENT_SCRATCH_PATH_PREFIXES) { + for (let index = 0; index + prefix.length < segments.length; index += 1) { + if (!prefix.every((segment, offset) => segments[index + offset] === segment)) { + continue + } + const checkoutPath = segments.slice(0, index).join('/') + const checkoutPathKey = /^[a-z]:$/i.test(checkoutPath) + ? `${checkoutPath}/` + : checkoutPath || '/' + if (checkoutPathKeys.has(checkoutPathKey)) { + return true + } + } + } return false } - const caseInsensitive = isWindowsAbsolutePathLike(worktreePath) && !isWslUncPath(worktreePath) - const segments = relativePath - .split('/') - .filter(Boolean) - .map((segment) => (caseInsensitive ? segment.toLowerCase() : segment)) - return AGENT_SCRATCH_PATH_PREFIXES.some( - (prefix) => - segments.length > prefix.length && - prefix.every((segment, index) => segments[index] === segment) - ) +} + +export function isAgentScratchWorktreePath(repoPath: string, worktreePath: string): boolean { + return createAgentScratchWorktreePathMatcher([repoPath])(worktreePath) } diff --git a/src/shared/external-worktree-inbox.test.ts b/src/shared/external-worktree-inbox.test.ts index 1e16c6d5134..34bfadd7f66 100644 --- a/src/shared/external-worktree-inbox.test.ts +++ b/src/shared/external-worktree-inbox.test.ts @@ -10,6 +10,7 @@ import type { import { getHiddenExternalWorktrees, getNewExternalWorktreeInboxWorktrees, + getVisibleExternalWorktrees, mergeExternalWorktreeInboxPaths, shouldOfferNewExternalWorktreeInbox } from './external-worktree-inbox' @@ -162,6 +163,17 @@ describe('external worktree inbox', () => { ).toEqual([hidden]) }) + it('excludes explicitly visible agent scratch from visibility-control counts', () => { + const visible = detectedWorktree({ id: 'visible', visible: true }) + const scratch = detectedWorktree({ + id: 'agent-scratch', + ownership: 'agent-scratch', + visible: true + }) + + expect(getVisibleExternalWorktrees(detectedResult([visible, scratch]))).toEqual([visible]) + }) + it('offers metadata-free nested Orca workspace worktrees through the inbox', () => { const settings = makeSettings() const manual = toDetectedWorktree({ diff --git a/src/shared/external-worktree-inbox.ts b/src/shared/external-worktree-inbox.ts index c5275799b99..6b6fdaa74aa 100644 --- a/src/shared/external-worktree-inbox.ts +++ b/src/shared/external-worktree-inbox.ts @@ -39,13 +39,28 @@ export function getHiddenExternalWorktrees( return [] } return detected.worktrees.filter( - (worktree) => - !worktree.visible && - !worktree.selectedCheckout && - worktree.ownership !== 'orca-managed' && - // Why: agent scratch is never importable, so it must not feed the - // discovery card or the new-worktrees inbox. - worktree.ownership !== 'agent-scratch' + (worktree) => !worktree.visible && isUserFacingExternalWorktree(worktree) + ) +} + +export function getVisibleExternalWorktrees( + detected: DetectedWorktreeListResult | undefined +): DetectedWorktree[] { + if (detected?.authoritative !== true) { + return [] + } + return detected.worktrees.filter( + (worktree) => worktree.visible && isUserFacingExternalWorktree(worktree) + ) +} + +function isUserFacingExternalWorktree(worktree: DetectedWorktree): boolean { + // Why: an explicit scratch import may be visible, but agent plumbing must + // stay outside repo-wide discovery and visibility controls (#9388). + return ( + !worktree.selectedCheckout && + worktree.ownership !== 'orca-managed' && + worktree.ownership !== 'agent-scratch' ) } diff --git a/src/shared/worktree-ownership.test.ts b/src/shared/worktree-ownership.test.ts index 4cf17c05aed..1697b654d5a 100644 --- a/src/shared/worktree-ownership.test.ts +++ b/src/shared/worktree-ownership.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import type { GlobalSettings, Repo, Worktree, WorktreeMeta } from './types' +import { createAgentScratchWorktreePathMatcher } from './agent-scratch-worktrees' import { applyMetadataFallbackVisibility, buildKnownOrcaWorkspaceLayouts, @@ -441,6 +442,27 @@ describe('agent scratch worktrees', () => { ).toBe('agent-scratch') }) + it('classifies scratch worktrees created inside another linked checkout', () => { + const repo = makeRepo() + const settings = makeSettings() + const linkedCheckoutPath = '/orca/workspaces/app/feature-x' + expect( + classifyWorktreeOwnership({ + repo, + settings, + worktree: makeWorktree({ + path: `${linkedCheckoutPath}/.claude/worktrees/agent-a04ccaaa`, + isMainWorktree: false + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo), + agentScratchWorktreePathMatcher: createAgentScratchWorktreePathMatcher([ + repo.path, + linkedCheckoutPath + ]) + }) + ).toBe('agent-scratch') + }) + it('keeps strong Orca metadata authoritative over the scratch path match', () => { const repo = makeRepo() const settings = makeSettings() diff --git a/src/shared/worktree-ownership.ts b/src/shared/worktree-ownership.ts index 9865ef90872..1b04a05282f 100644 --- a/src/shared/worktree-ownership.ts +++ b/src/shared/worktree-ownership.ts @@ -7,7 +7,10 @@ import { resolveRuntimePath } from './cross-platform-path' import { parseWslUncPath } from './wsl-paths' -import { isAgentScratchWorktreePath } from './agent-scratch-worktrees' +import { + isAgentScratchWorktreePath, + type AgentScratchWorktreePathMatcher +} from './agent-scratch-worktrees' import { isExplicitlyImportedExternalWorktreePath } from './external-worktree-inbox' import type { DetectedWorktree, @@ -155,6 +158,7 @@ export function classifyWorktreeOwnership(args: { meta?: WorktreeMeta settings: Pick knownOrcaLayouts: OrcaWorkspaceLayout[] + agentScratchWorktreePathMatcher?: AgentScratchWorktreePathMatcher }): WorktreeOwnership { if (hasStrongOrcaMetadata(args.meta)) { return 'orca-managed' @@ -162,7 +166,10 @@ export function classifyWorktreeOwnership(args: { // Why: sub-agent scratch worktrees (e.g. .claude/worktrees) are tool // plumbing, not workspaces; classify before layout heuristics (#9388). - if (isAgentScratchWorktreePath(args.repo.path, args.worktree.path)) { + if ( + args.agentScratchWorktreePathMatcher?.(args.worktree.path) ?? + isAgentScratchWorktreePath(args.repo.path, args.worktree.path) + ) { return 'agent-scratch' } @@ -186,6 +193,7 @@ export function toDetectedWorktree(args: { settings: Pick knownOrcaLayouts: OrcaWorkspaceLayout[] isLegacyRepoForVisibility?: boolean + agentScratchWorktreePathMatcher?: AgentScratchWorktreePathMatcher }): DetectedWorktree { const ownership = classifyWorktreeOwnership(args) const selectedCheckout = areRuntimePathsEqual(args.worktree.path, args.repo.path)