From 4c03924618dfc444bfcf6ff7824604ea7bbfe271 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:24:07 -0700 Subject: [PATCH] Show Git-created worktrees in external discovery (#7078) --- src/main/ipc/worktrees.ts | 7 +- src/main/runtime/orca-runtime.ts | 7 +- src/main/worktree-removal-authority.test.ts | 49 +++++----- src/main/worktree-removal-safety.test.ts | 2 - src/main/worktree-removal-safety.ts | 13 +-- src/shared/external-worktree-inbox.test.ts | 82 +++++++++++++++- ...ktree-ownership-worktree-base-path.test.ts | 4 +- src/shared/worktree-ownership.test.ts | 96 ++++++++++++++++++- src/shared/worktree-ownership.ts | 49 +--------- 9 files changed, 209 insertions(+), 100 deletions(-) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 890449f51..c7ff8f45c 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -1402,13 +1402,9 @@ export function registerWorktreeHandlers( if (!registeredWorktree) { const fsProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : null let canCleanOrphanedDirectory = false - const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(store.getSettings(), repo) if ( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: removedMeta, - worktreePath, - repo, - knownOrcaLayouts + meta: removedMeta }) ) { if (repo.connectionId) { @@ -1481,7 +1477,6 @@ export function registerWorktreeHandlers( runtimeWorktreePath, repo, runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - knownOrcaLayouts, registeredWorktrees, statPath: access.statPath, isGitRepository: (path) => isLocalGitRepository(path, localWorktreeGitOptions) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 819fb34ea..69dbea1eb 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -14713,13 +14713,9 @@ export class OrcaRuntimeService { ) if (!registeredWorktree) { let canCleanOrphanedDirectory = false - const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(store.getSettings(), repo) if ( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: removedMeta, - worktreePath: removalTarget.path, - repo, - knownOrcaLayouts + meta: removedMeta }) ) { if (repo.connectionId) { @@ -14792,7 +14788,6 @@ export class OrcaRuntimeService { runtimeWorktreePath, repo, runtimeRepoPath: toLocalWorktreeRuntimePath(repo.path, localWorktreeGitOptions), - knownOrcaLayouts, registeredWorktrees, statPath: access.statPath, isGitRepository: (path) => isLocalRuntimeGitRepository(path, localWorktreeGitOptions) diff --git a/src/main/worktree-removal-authority.test.ts b/src/main/worktree-removal-authority.test.ts index acb8d2f3f..cbc9e2ed2 100644 --- a/src/main/worktree-removal-authority.test.ts +++ b/src/main/worktree-removal-authority.test.ts @@ -4,6 +4,7 @@ import { isWorktreePathMissing, stripOrcaProvenanceMetaUpdates } from './worktree-removal-safety' +import type { WorktreeMeta } from '../shared/types' describe('isWorktreePathMissing', () => { it('recognizes missing-path errors from local and remote stat providers', async () => { @@ -33,10 +34,7 @@ describe('canCleanupUnregisteredOrcaWorktreeDirectory', () => { it('does not treat orcaCreatedAt alone as cleanup authority', () => { expect( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: { orcaCreatedAt: Date.now() }, - worktreePath: '/outside/orphan', - repo: { path: '/repo' }, - knownOrcaLayouts: [] + meta: { orcaCreatedAt: Date.now() } }) ).toBe(false) expect( @@ -44,10 +42,7 @@ describe('canCleanupUnregisteredOrcaWorktreeDirectory', () => { meta: { orcaCreatedAt: Date.now(), orcaCreationSource: 'runtime' - }, - worktreePath: '/outside/orphan', - repo: { path: '/repo' }, - knownOrcaLayouts: [] + } }) ).toBe(true) }) @@ -55,32 +50,40 @@ describe('canCleanupUnregisteredOrcaWorktreeDirectory', () => { it('accepts legacy Orca-created metadata before explicit provenance existed', () => { expect( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: { createdAt: Date.now() }, - worktreePath: '/outside/orphan', - repo: { path: '/repo' }, - knownOrcaLayouts: [] + meta: { createdAt: Date.now() } }) ).toBe(true) }) - it('accepts legacy repo-nested Orca workspace paths without metadata provenance', () => { + it('does not treat creation layout metadata alone as cleanup authority', () => { + const layoutOnlyMeta: WorktreeMeta = { + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + workspaceStatus: 'todo', + orcaCreationWorkspaceLayout: { path: '/orca/workspaces', nestWorkspaces: true } + } + expect( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: undefined, - worktreePath: '/orca/workspaces/app/legacy-orphan', - repo: { path: '/repos/app' }, - knownOrcaLayouts: [{ path: '/orca/workspaces', nestWorkspaces: true }] + meta: layoutOnlyMeta }) - ).toBe(true) + ).toBe(false) }) - it('does not trust flat workspace-root paths without legacy metadata', () => { + it('does not trust paths without provenance or legacy metadata', () => { expect( canCleanupUnregisteredOrcaWorktreeDirectory({ - meta: undefined, - worktreePath: '/orca/workspaces/legacy-orphan', - repo: { path: '/repos/app' }, - knownOrcaLayouts: [{ path: '/orca/workspaces', nestWorkspaces: false }] + meta: undefined }) ).toBe(false) }) diff --git a/src/main/worktree-removal-safety.test.ts b/src/main/worktree-removal-safety.test.ts index d008b9520..5a64b8c90 100644 --- a/src/main/worktree-removal-safety.test.ts +++ b/src/main/worktree-removal-safety.test.ts @@ -325,7 +325,6 @@ describe('canCleanupUnregisteredOrcaLeftoverDirectory', () => { runtimeWorktreePath: '/workspaces/orca-owned', repo, runtimeRepoPath: repo.path, - knownOrcaLayouts: [], registeredWorktrees: [makeGitWorktree(repo.path, true)] } @@ -376,7 +375,6 @@ describe('canCleanupUnregisteredOrcaLeftoverDirectory', () => { canCleanupUnregisteredOrcaLeftoverDirectory({ ...baseArgs, meta: undefined, - knownOrcaLayouts: [{ path: '/workspaces', nestWorkspaces: false }], statPath: makeStatPath([], ['/workspaces/orca-owned']), isGitRepository }) diff --git a/src/main/worktree-removal-safety.ts b/src/main/worktree-removal-safety.ts index a8236ea49..a589bce9e 100644 --- a/src/main/worktree-removal-safety.ts +++ b/src/main/worktree-removal-safety.ts @@ -2,8 +2,7 @@ import { lstat, readFile } from 'node:fs/promises' import { homedir } from 'node:os' import { posix, win32 } from 'node:path' import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' -import type { GitWorktreeInfo, OrcaWorkspaceLayout, Repo, WorktreeMeta } from '../shared/types' -import { matchesStrongOrcaCreatePath } from '../shared/worktree-ownership' +import type { GitWorktreeInfo, Repo, WorktreeMeta } from '../shared/types' import { areWorktreePathsEqual } from './ipc/worktree-logic' import { gitFileProvesOrphanedWorktreeDirectory, @@ -172,9 +171,6 @@ export async function canSafelyRemoveOrphanedWorktreeDirectory( export function canCleanupUnregisteredOrcaWorktreeDirectory(args: { meta: UnregisteredOrcaCleanupMeta | null | undefined - worktreePath: string - repo: Pick - knownOrcaLayouts: readonly OrcaWorkspaceLayout[] }): boolean { if (hasCurrentOrcaCreationProvenance(args.meta)) { return true @@ -184,9 +180,9 @@ export function canCleanupUnregisteredOrcaWorktreeDirectory(args: { return true } - // Why: profiles created before explicit provenance can still contain Orca - // workspaces at the repo-specific workspaceDir// path shape. - return matchesStrongOrcaCreatePath(args.worktreePath, args.knownOrcaLayouts, args.repo) + // Why: path shape alone is not authority; users can create plain Git + // worktrees inside Orca's workspace directory too. + return false } export async function canCleanupUnregisteredOrcaLeftoverDirectory(args: { @@ -195,7 +191,6 @@ export async function canCleanupUnregisteredOrcaLeftoverDirectory(args: { runtimeWorktreePath: string repo: Pick runtimeRepoPath: string - knownOrcaLayouts: readonly OrcaWorkspaceLayout[] registeredWorktrees: readonly GitWorktreeInfo[] statPath: StatPath isGitRepository: (runtimeWorktreePath: string) => Promise diff --git a/src/shared/external-worktree-inbox.test.ts b/src/shared/external-worktree-inbox.test.ts index 029e04c39..1e16c6d51 100644 --- a/src/shared/external-worktree-inbox.test.ts +++ b/src/shared/external-worktree-inbox.test.ts @@ -1,13 +1,23 @@ import { describe, expect, it } from 'vitest' -import type { DetectedWorktree, DetectedWorktreeListResult, Repo } from './types' +import type { + DetectedWorktree, + DetectedWorktreeListResult, + GlobalSettings, + Repo, + Worktree +} from './types' import { getHiddenExternalWorktrees, getNewExternalWorktreeInboxWorktrees, mergeExternalWorktreeInboxPaths, shouldOfferNewExternalWorktreeInbox } from './external-worktree-inbox' -import { EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT } from './worktree-ownership' +import { + buildKnownOrcaWorkspaceLayouts, + EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT, + toDetectedWorktree +} from './worktree-ownership' const repo: Repo = { id: 'repo-1', @@ -54,6 +64,55 @@ function detectedResult(worktrees: DetectedWorktree[]): DetectedWorktreeListResu } } +function makeSettings(): GlobalSettings { + return { + workspaceDir: '/orca/workspaces', + nestWorkspaces: true, + workspaceDirHistory: [], + refreshLocalBaseRefOnWorktreeCreate: false, + localBaseRefSuggestionDismissed: false, + branchPrefix: 'none', + branchPrefixCustom: '', + enableGitHubAttribution: false, + theme: 'system', + appFontFamily: 'Geist', + editorAutoSave: false, + editorAutoSaveDelayMs: 1000, + editorMinimapEnabled: false, + markdownReviewToolsEnabled: true, + terminalFontSize: 14, + terminalFontFamily: 'monospace', + terminalFontWeight: 400, + terminalLineHeight: 1.2 + } as unknown as GlobalSettings +} + +function makeGitWorktree(overrides: Partial = {}): Worktree { + return { + id: `repo-1::${overrides.path ?? '/repo'}`, + repoId: repo.id, + path: '/repo', + displayName: 'repo', + branch: 'refs/heads/main', + head: 'abc123', + isBare: false, + isMainWorktree: true, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + workspaceStatus: 'todo', + ...overrides + } +} + describe('external worktree inbox', () => { it('merges inbox paths without duplicates when paths match after normalization', () => { expect(mergeExternalWorktreeInboxPaths(['/repo/one/'], ['/repo/one', '/repo/two'])).toEqual([ @@ -103,6 +162,25 @@ describe('external worktree inbox', () => { ).toEqual([hidden]) }) + it('offers metadata-free nested Orca workspace worktrees through the inbox', () => { + const settings = makeSettings() + const manual = toDetectedWorktree({ + repo, + settings, + worktree: makeGitWorktree({ + path: '/orca/workspaces/orca/manual-from-git', + displayName: 'manual-from-git', + branch: 'refs/heads/manual-from-git', + isMainWorktree: false + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(manual.ownership).toBe('external') + expect(manual.visible).toBe(false) + expect(getNewExternalWorktreeInboxWorktrees(detectedResult([manual]), repo)).toEqual([manual]) + }) + it('suppresses non-authoritative detected results', () => { expect( getNewExternalWorktreeInboxWorktrees(detectedResult([detectedWorktree()]), { diff --git a/src/shared/worktree-ownership-worktree-base-path.test.ts b/src/shared/worktree-ownership-worktree-base-path.test.ts index 968b1f3da..d6af4dab7 100644 --- a/src/shared/worktree-ownership-worktree-base-path.test.ts +++ b/src/shared/worktree-ownership-worktree-base-path.test.ts @@ -48,7 +48,7 @@ describe('repo-specific worktree ownership layouts', () => { worktree: makeWorktree('/projects/a/worktrees/repo/feature'), knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repoA) }) - ).toBe('orca-managed') + ).toBe('external') expect( classifyWorktreeOwnership({ repo: repoB, @@ -73,7 +73,7 @@ describe('repo-specific worktree ownership layouts', () => { worktree: makeWorktree('C:\\projects\\App\\worktrees\\repo\\Feature'), knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) }) - ).toBe('orca-managed') + ).toBe('external') }) it('includes relative global layouts for SSH repos without applying absolute desktop paths', () => { diff --git a/src/shared/worktree-ownership.test.ts b/src/shared/worktree-ownership.test.ts index c7c77ea59..0b41859d2 100644 --- a/src/shared/worktree-ownership.test.ts +++ b/src/shared/worktree-ownership.test.ts @@ -108,7 +108,7 @@ describe('worktree ownership classification', () => { ).toBe('orca-managed') }) - it('requires the nested repo-specific path shape for path-only ownership', () => { + it('treats nested Orca workspace paths without metadata as external', () => { const repo = makeRepo() const settings = makeSettings() const layouts = buildKnownOrcaWorkspaceLayouts(settings, repo) @@ -119,7 +119,7 @@ describe('worktree ownership classification', () => { worktree: makeWorktree({ path: '/orca/workspaces/app/feature' }), knownOrcaLayouts: layouts }) - ).toBe('orca-managed') + ).toBe('external') expect( classifyWorktreeOwnership({ repo, @@ -130,6 +130,94 @@ describe('worktree ownership classification', () => { ).toBe('external') }) + it('treats explicit Orca creation layout metadata as managed', () => { + const repo = makeRepo() + const settings = makeSettings() + expect( + classifyWorktreeOwnership({ + repo, + settings, + worktree: makeWorktree({ path: '/orca/workspaces/app/feature' }), + meta: makeMeta({ + orcaCreationWorkspaceLayout: { path: '/orca/workspaces', nestWorkspaces: true } + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + ).toBe('orca-managed') + }) + + it('does not treat metadata-free nested workspace paths as Orca-managed for new repos', () => { + const repo = makeRepo({ externalWorktreeVisibility: 'hide' }) + const settings = makeSettings() + const detected = toDetectedWorktree({ + repo, + settings, + worktree: makeWorktree({ + path: '/orca/workspaces/app/manual-git-worktree', + isMainWorktree: false + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(detected.ownership).toBe('external') + expect(detected.visible).toBe(false) + }) + + it('does not treat generic discovery metadata on nested workspace paths as Orca-managed', () => { + const repo = makeRepo({ externalWorktreeVisibility: 'hide' }) + const settings = makeSettings() + const detected = toDetectedWorktree({ + repo, + settings, + worktree: makeWorktree({ + path: '/orca/workspaces/app/manual-git-worktree', + isMainWorktree: false + }), + meta: makeMeta({ displayName: 'manual-git-worktree' }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(detected.ownership).toBe('external') + expect(detected.visible).toBe(false) + }) + + it('keeps nested workspace paths visible for legacy repos without explicit visibility', () => { + const repo = makeRepo() + const settings = makeSettings() + const detected = toDetectedWorktree({ + repo, + settings, + worktree: makeWorktree({ + path: '/orca/workspaces/app/manual-git-worktree', + isMainWorktree: false + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(detected.ownership).toBe('external') + expect(detected.visible).toBe(true) + }) + + it('hides metadata-free nested workspace paths for legacy repos that hide external worktrees', () => { + const repo = makeRepo({ + externalWorktreeVisibility: 'hide', + externalWorktreeVisibilityLegacy: true + }) + const settings = makeSettings() + const detected = toDetectedWorktree({ + repo, + settings, + worktree: makeWorktree({ + path: '/orca/workspaces/app/manual-git-worktree', + isMainWorktree: false + }), + knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) + }) + + expect(detected.ownership).toBe('external') + expect(detected.visible).toBe(false) + }) + it('treats flat workspace-root descendants as unknown legacy without strong metadata', () => { const repo = makeRepo() const settings = makeSettings({ nestWorkspaces: false }) @@ -172,7 +260,7 @@ describe('worktree ownership classification', () => { worktree: makeWorktree({ path: '/old/workspaces/app/feature' }), knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) }) - ).toBe('orca-managed') + ).toBe('external') }) it('builds known layouts from large workspace history lists', () => { @@ -214,7 +302,7 @@ describe('worktree ownership classification', () => { }), knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo) }) - ).toBe('orca-managed') + ).toBe('external') }) it('keeps selected linked checkouts visible without trusting Git main-worktree', () => { diff --git a/src/shared/worktree-ownership.ts b/src/shared/worktree-ownership.ts index 9140f4179..94ba1d908 100644 --- a/src/shared/worktree-ownership.ts +++ b/src/shared/worktree-ownership.ts @@ -1,5 +1,4 @@ import { - getRuntimePathBasename, isRuntimePathAbsolute, isWindowsAbsolutePathLike, normalizeRuntimePathForComparison, @@ -160,15 +159,13 @@ export function classifyWorktreeOwnership(args: { return 'orca-managed' } - if (matchesStrongOrcaCreatePath(args.worktree.path, args.knownOrcaLayouts, args.repo)) { - return 'orca-managed' - } - if (isUnderFlatOrUntrustedOrcaRoot(args.worktree.path, args.knownOrcaLayouts)) { return 'unknown-legacy' } if (canClassifyAsExternal(args.worktree.path, args.knownOrcaLayouts)) { + // Why: a plain `git worktree add` can target Orca's nested workspace + // folder. Only metadata proves Orca created it. return 'external' } @@ -240,6 +237,7 @@ export function areRuntimePathsEqual(leftPath: string, rightPath: string): boole function hasStrongOrcaMetadata(meta: WorktreeMeta | undefined): boolean { return Boolean( meta?.orcaCreatedAt || + meta?.orcaCreationWorkspaceLayout || meta?.createdAt || meta?.createdWithAgent || meta?.pushTarget || @@ -249,38 +247,6 @@ function hasStrongOrcaMetadata(meta: WorktreeMeta | undefined): boolean { ) } -export function matchesStrongOrcaCreatePath( - worktreePath: string, - knownOrcaLayouts: readonly OrcaWorkspaceLayout[], - repo: Pick -): boolean { - const repoName = getRuntimePathBasename(repo.path).replace(/\.git$/i, '') - if (!repoName) { - return false - } - for (const layout of knownOrcaLayouts) { - if (!layout.nestWorkspaces) { - continue - } - const relative = relativePathInsideRoot(layout.path, worktreePath) - if (relative === null) { - continue - } - const segments = splitNormalizedPath(relative) - const caseInsensitive = - isWindowsAbsolutePathLike(layout.path) || isWindowsAbsolutePathLike(worktreePath) - if ( - segments.length === 2 && - normalizePathSegment(segments[0], caseInsensitive) === - normalizePathSegment(repoName, caseInsensitive) && - segments[1].length > 0 - ) { - return true - } - } - return false -} - function isUnderFlatOrUntrustedOrcaRoot( worktreePath: string, knownOrcaLayouts: OrcaWorkspaceLayout[] @@ -313,12 +279,3 @@ function canClassifyAsExternal( } return true } - -function splitNormalizedPath(value: string): string[] { - return normalizeRuntimePathSeparators(value).split('/').filter(Boolean) -} - -function normalizePathSegment(value: string, caseInsensitive: boolean): string { - const normalized = normalizeRuntimePathSeparators(value) - return caseInsensitive ? normalized.toLowerCase() : normalized -}