fix: preserve scratch worktree visibility boundaries

This commit is contained in:
Brennan Benson
2026-07-20 00:46:07 -07:00
parent cbf7bab543
commit b81d16d886
5 changed files with 109 additions and 46 deletions
@@ -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'}`
+51 -11
View File
@@ -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)
})
})
+18 -19
View File
@@ -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 `<repo>/.worktrees/<branch>`. */
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)
)
}
+34
View File
@@ -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')
})
})
+3 -5
View File
@@ -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,