fix: hide scratch worktrees from linked checkouts

This commit is contained in:
Brennan Benson
2026-07-20 11:06:12 -07:00
parent b81d16d886
commit 9cabb36b1a
11 changed files with 248 additions and 44 deletions
+42
View File
@@ -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([
+19 -5
View File
@@ -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)
})
+25
View File
@@ -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) => ({
+44 -6
View File
@@ -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<string, string[]>()
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
})
}
@@ -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'}`
+20 -1
View File
@@ -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(
+27 -15
View File
@@ -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)
}
@@ -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({
+22 -7
View File
@@ -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'
)
}
+22
View File
@@ -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()
+10 -2
View File
@@ -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<GlobalSettings, 'workspaceDir' | 'nestWorkspaces' | 'workspaceDirHistory'>
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<GlobalSettings, 'workspaceDir' | 'nestWorkspaces' | 'workspaceDirHistory'>
knownOrcaLayouts: OrcaWorkspaceLayout[]
isLegacyRepoForVisibility?: boolean
agentScratchWorktreePathMatcher?: AgentScratchWorktreePathMatcher
}): DetectedWorktree {
const ownership = classifyWorktreeOwnership(args)
const selectedCheckout = areRuntimePathsEqual(args.worktree.path, args.repo.path)