mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
fix: preserve scratch worktree visibility boundaries
This commit is contained in:
@@ -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'}`
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user