From b81d16d88682db18edc3087d19366a9553b27cfb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:46:07 -0700 Subject: [PATCH] fix: preserve scratch worktree visibility boundaries --- .../sidebar/WorktreeVisibilityDialog.tsx | 14 +---- src/shared/agent-scratch-worktrees.test.ts | 62 +++++++++++++++---- src/shared/agent-scratch-worktrees.ts | 37 ++++++----- src/shared/worktree-ownership.test.ts | 34 ++++++++++ src/shared/worktree-ownership.ts | 8 +-- 5 files changed, 109 insertions(+), 46 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx index fb91fab5670..d1c2f0b5e5c 100644 --- a/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx @@ -9,6 +9,7 @@ import { DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' +import { getHiddenExternalWorktrees } from '../../../../shared/external-worktree-inbox' import { isGitRepoKind } from '../../../../shared/repo-kind' import { effectiveExternalWorktreeVisibility, @@ -32,21 +33,12 @@ export default function WorktreeVisibilityDialog(): React.JSX.Element | null { ? effectiveExternalWorktreeVisibility(repo, isLegacyRepoForExternalWorktreeVisibility(repo)) === 'show' : false - // Why: agent scratch is never shown or importable, so it must not inflate - // either count in this dialog. - const hiddenCount = - detected?.authoritative === true - ? detected.worktrees.filter( - (worktree) => !worktree.visible && worktree.ownership !== 'agent-scratch' - ).length - : 0 + const hiddenCount = getHiddenExternalWorktrees(detected).length const otherCount = detected?.authoritative === true ? detected.worktrees.filter( (worktree) => - !worktree.selectedCheckout && - worktree.ownership !== 'orca-managed' && - worktree.ownership !== 'agent-scratch' + worktree.visible && !worktree.selectedCheckout && worktree.ownership !== 'orca-managed' ).length : 0 const hiddenWorktreeLabel = `${hiddenCount} ${hiddenCount === 1 ? 'worktree' : 'worktrees'}` diff --git a/src/shared/agent-scratch-worktrees.test.ts b/src/shared/agent-scratch-worktrees.test.ts index 8ecf109c178..8da1d00dfc0 100644 --- a/src/shared/agent-scratch-worktrees.test.ts +++ b/src/shared/agent-scratch-worktrees.test.ts @@ -2,43 +2,83 @@ import { describe, expect, it } from 'vitest' import { isAgentScratchWorktreePath } from './agent-scratch-worktrees' describe('isAgentScratchWorktreePath', () => { + const repoPath = '/Users/dev/app' + it('matches Claude Code sub-agent worktrees', () => { expect( - isAgentScratchWorktreePath('/Users/dev/app/.claude/worktrees/agent-a04ccaaa55ddadb91') + isAgentScratchWorktreePath( + repoPath, + '/Users/dev/app/.claude/worktrees/agent-a04ccaaa55ddadb91' + ) ).toBe(true) }) it('matches gsd parallel-agent workspaces', () => { - expect(isAgentScratchWorktreePath('/Users/dev/app/.gsd-workspaces/phase-1-subagent-2')).toBe( - true - ) + expect( + isAgentScratchWorktreePath(repoPath, '/Users/dev/app/.gsd-workspaces/phase-1-subagent-2') + ).toBe(true) }) it('matches Windows path separators and casing', () => { expect( - isAgentScratchWorktreePath('C:\\Users\\dev\\app\\.Claude\\Worktrees\\agent-a04ccaaa') + isAgentScratchWorktreePath( + 'C:\\Users\\dev\\app', + 'c:\\USERS\\dev\\app\\.Claude\\Worktrees\\agent-a04ccaaa' + ) ).toBe(true) }) it('matches WSL UNC paths', () => { expect( isAgentScratchWorktreePath( + '//wsl$/Ubuntu/home/dev/app', '//wsl.localhost/Ubuntu/home/dev/app/.claude/worktrees/agent-a04ccaaa' ) ).toBe(true) }) - it('requires the segments to be consecutive', () => { - expect(isAgentScratchWorktreePath('/Users/dev/app/.claude/other/worktrees/agent-1')).toBe(false) + it('preserves case-sensitive POSIX and WSL tool segments', () => { + expect( + isAgentScratchWorktreePath(repoPath, '/Users/dev/app/.Claude/Worktrees/agent-a04ccaaa') + ).toBe(false) + expect( + isAgentScratchWorktreePath( + '//wsl.localhost/Ubuntu/home/dev/app', + '//wsl.localhost/ubuntu/home/dev/app/.Claude/Worktrees/agent-a04ccaaa' + ) + ).toBe(false) + }) + + it('requires the tool directory at the repo root', () => { + expect( + isAgentScratchWorktreePath(repoPath, '/Users/dev/app/.claude/other/worktrees/agent-1') + ).toBe(false) + expect( + isAgentScratchWorktreePath(repoPath, '/Users/dev/app/packages/demo/.claude/worktrees/agent-1') + ).toBe(false) + expect(isAgentScratchWorktreePath(repoPath, '/Users/dev/app/.gsd-workspaces')).toBe(false) }) it('does not match undotted claude directories', () => { - expect(isAgentScratchWorktreePath('/Users/dev/app/claude/worktrees/agent-1')).toBe(false) + expect(isAgentScratchWorktreePath(repoPath, '/Users/dev/app/claude/worktrees/agent-1')).toBe( + false + ) + }) + + it('does not inherit a scratch classification from the repo parent path', () => { + expect( + isAgentScratchWorktreePath( + '/Users/dev/.claude/worktrees/app', + '/Users/dev/.claude/worktrees/app/manual/feature-x' + ) + ).toBe(false) }) it('does not match user worktree conventions', () => { - expect(isAgentScratchWorktreePath('/Users/dev/app/.worktrees/feature-x')).toBe(false) - expect(isAgentScratchWorktreePath('/Users/dev/.superset/worktrees/app/fix-notes')).toBe(false) - expect(isAgentScratchWorktreePath('/orca/workspaces/app/feature')).toBe(false) + expect(isAgentScratchWorktreePath(repoPath, '/Users/dev/app/.worktrees/feature-x')).toBe(false) + expect( + isAgentScratchWorktreePath('/Users/dev/app', '/Users/dev/.superset/worktrees/app/fix-notes') + ).toBe(false) + expect(isAgentScratchWorktreePath('/Users/dev/app', '/orca/workspaces/app/feature')).toBe(false) }) }) diff --git a/src/shared/agent-scratch-worktrees.ts b/src/shared/agent-scratch-worktrees.ts index 6d23d1386c8..7f52bbe4b74 100644 --- a/src/shared/agent-scratch-worktrees.ts +++ b/src/shared/agent-scratch-worktrees.ts @@ -1,27 +1,26 @@ -import { normalizeRuntimePathSeparators } from './cross-platform-path' +import { isWindowsAbsolutePathLike, relativePathInsideRoot } from './cross-platform-path' +import { isWslUncPath } from './wsl-paths' -/** Why: coding-agent CLIs create throwaway sub-agent worktrees at fixed - * tool-internal paths (#9388); these are session scratch, never user - * workspaces. Curated exact segments — a generic dot-directory rule would - * swallow legitimate layouts like `/.worktrees/`. */ -const AGENT_SCRATCH_PATH_SEGMENT_RUNS: readonly (readonly string[])[] = [ +/** Why: agent CLIs reserve these repo-root paths for scratch; broader matches + * can hide legitimate user worktrees (#9388). */ +const AGENT_SCRATCH_PATH_PREFIXES: readonly (readonly string[])[] = [ ['.claude', 'worktrees'], ['.gsd-workspaces'] ] -export function isAgentScratchWorktreePath(worktreePath: string): boolean { - const segments = normalizeRuntimePathSeparators(worktreePath) +export function isAgentScratchWorktreePath(repoPath: string, worktreePath: string): boolean { + const relativePath = relativePathInsideRoot(repoPath, worktreePath) + if (!relativePath) { + return false + } + const caseInsensitive = isWindowsAbsolutePathLike(worktreePath) && !isWslUncPath(worktreePath) + const segments = relativePath .split('/') .filter(Boolean) - .map((segment) => segment.toLowerCase()) - return AGENT_SCRATCH_PATH_SEGMENT_RUNS.some((run) => hasSegmentRun(segments, run)) -} - -function hasSegmentRun(segments: readonly string[], run: readonly string[]): boolean { - for (let start = 0; start + run.length <= segments.length; start++) { - if (run.every((segment, offset) => segments[start + offset] === segment)) { - return true - } - } - return false + .map((segment) => (caseInsensitive ? segment.toLowerCase() : segment)) + return AGENT_SCRATCH_PATH_PREFIXES.some( + (prefix) => + segments.length > prefix.length && + prefix.every((segment, index) => segments[index] === segment) + ) } diff --git a/src/shared/worktree-ownership.test.ts b/src/shared/worktree-ownership.test.ts index 7b16b1e5d31..4cf17c05aed 100644 --- a/src/shared/worktree-ownership.test.ts +++ b/src/shared/worktree-ownership.test.ts @@ -524,4 +524,38 @@ describe('agent scratch worktrees', () => { ownership: 'unknown-legacy' }) }) + + it('preserves an explicit scratch import in the metadata fallback', () => { + const repo = makeRepo({ + externalWorktreeVisibility: 'hide', + importedExternalWorktreePaths: [scratchPath] + }) + const settings = makeSettings() + const scratch = toDetectedWorktree({ + repo, + settings, + worktree: makeWorktree({ path: scratchPath, isMainWorktree: false }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(scratch).toMatchObject({ ownership: 'agent-scratch', visible: true }) + expect(applyMetadataFallbackVisibility(scratch)).toBe(scratch) + }) + + it('does not classify worktrees from a repo stored below a scratch-looking parent', () => { + const repo = makeRepo({ path: '/repos/.claude/worktrees/app' }) + const settings = makeSettings() + + expect( + classifyWorktreeOwnership({ + repo, + settings, + worktree: makeWorktree({ + path: '/repos/.claude/worktrees/app/manual/feature-x', + isMainWorktree: false + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + ).not.toBe('agent-scratch') + }) }) diff --git a/src/shared/worktree-ownership.ts b/src/shared/worktree-ownership.ts index 7aaa3aa6b42..9865ef90872 100644 --- a/src/shared/worktree-ownership.ts +++ b/src/shared/worktree-ownership.ts @@ -162,7 +162,7 @@ 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.worktree.path)) { + if (isAgentScratchWorktreePath(args.repo.path, args.worktree.path)) { return 'agent-scratch' } @@ -240,12 +240,10 @@ export function shouldShowWorktree(args: { return effectiveExternalWorktreeVisibility(args.repo, args.isLegacyRepoForVisibility) === 'show' } -/** Fail-open override for listings built without an authoritative git scan. - * Why: the fallback reveals every known worktree so none get stranded, but - * agent scratch must stay hidden even there (#9388). */ export function applyMetadataFallbackVisibility(detected: DetectedWorktree): DetectedWorktree { if (detected.ownership === 'agent-scratch') { - return { ...detected, visible: false } + // Why: retain scratch policy, including explicit imports, while ordinary fallback fails open. + return detected } return { ...detected,