From 4a98b11e770a81dcc30cd4454bbce89c7d2f452d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:26:58 -0700 Subject: [PATCH] Import linked worktrees under one project (#6441) Co-authored-by: Orca --- src/main/git/worktree.test.ts | 44 ++++++ src/main/git/worktree.ts | 40 ++++- src/main/ipc/repos-remote.test.ts | 142 ++++++++++++++++++ src/main/ipc/repos.ts | 34 ++++- .../nested-repo-import-target.test.ts | 116 ++++++++++++++ .../nested-repo-import-target.ts | 87 +++++++++++ src/main/runtime/orca-runtime.ts | 22 ++- 7 files changed, 471 insertions(+), 14 deletions(-) create mode 100644 src/main/project-groups/nested-repo-import-target.test.ts create mode 100644 src/main/project-groups/nested-repo-import-target.ts diff --git a/src/main/git/worktree.test.ts b/src/main/git/worktree.test.ts index 464d9447b..44679c14e 100644 --- a/src/main/git/worktree.test.ts +++ b/src/main/git/worktree.test.ts @@ -21,6 +21,7 @@ vi.mock('./runner', () => ({ import { addSparseWorktree, addWorktree, + listWorktreeGraph, moveWorktree, parseWorktreeList, removeWorktree @@ -235,6 +236,49 @@ bare }) }) +describe('listWorktreeGraph', () => { + it('returns the worktree graph without sparse-checkout annotation probes', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }) + + await expect(listWorktreeGraph('/repo')).resolves.toEqual([ + { + path: '/repo', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/repo-feature', + head: 'def456', + branch: 'refs/heads/feature/test', + isBare: false, + isMainWorktree: false + } + ]) + + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], { + cwd: '/repo' + }) + }) + + it('returns an empty graph for paths Git reports as non-repositories', async () => { + gitExecFileAsyncMock.mockRejectedValueOnce(new Error('fatal: not a git repository')) + + await expect(listWorktreeGraph('/not-a-repo')).resolves.toEqual([]) + }) +}) + describe('addWorktree', () => { const resolveRemoteBase = () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit} diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 714d31898..af1546396 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -457,6 +457,41 @@ async function readWorktreeList( return parseWorktreeList(stdout) } +async function readTranslatedWorktreeGraph( + repoPath: string, + options: GitWorktreeExecOptions = {} +): Promise { + return (await readWorktreeList(repoPath, options)).map((worktree) => { + const translatedPath = translateWorktreePath(worktree.path, repoPath, options) + return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath } + }) +} + +export async function listWorktreeGraph( + repoPath: string, + options: GitWorktreeExecOptions = {} +): Promise { + try { + return await readTranslatedWorktreeGraph(repoPath, options) + } catch (err) { + if (getErrorCode(err) === 'ENOENT') { + try { + await stat(repoPath) + } catch (statErr) { + if (getErrorCode(statErr) === 'ENOENT') { + console.warn(`[git/worktree] repo path missing; skipping worktree list: ${repoPath}`) + return [] + } + } + } + if (isNotGitRepositoryError(err)) { + return [] + } + console.warn(`[git/worktree] listWorktreeGraph failed for ${repoPath}:`, err) + return [] + } +} + /** * List all worktrees for a git repo at the given path. */ @@ -465,10 +500,7 @@ export async function listWorktrees( options: GitWorktreeExecOptions = {} ): Promise { try { - const worktrees = (await readWorktreeList(repoPath, options)).map((worktree) => { - const translatedPath = translateWorktreePath(worktree.path, repoPath, options) - return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath } - }) + const worktrees = await readTranslatedWorktreeGraph(repoPath, options) return annotateSparseCheckoutStatus(worktrees) } catch (err) { if (getErrorCode(err) === 'ENOENT') { diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 8ee9a0ea8..b7aa15bbc 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -20,6 +20,7 @@ const { mockFilesystemProvider, mockMultiplexer, gitSpawnMock, + listWorktreeGraphMock, invalidateAuthorizedRootsCacheMock, prepareLocalWorktreeRootForRepoMock } = vi.hoisted(() => ({ @@ -45,6 +46,7 @@ const { isGitRepoAsync: vi.fn().mockResolvedValue({ isRepo: true, rootPath: null }), exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), clone: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), + listWorktrees: vi.fn().mockResolvedValue([]), getHostPlatform: vi.fn().mockReturnValue({ relayPlatform: 'linux-x64', os: 'linux', @@ -68,6 +70,7 @@ const { notify: vi.fn() }, gitSpawnMock: vi.fn(), + listWorktreeGraphMock: vi.fn(), invalidateAuthorizedRootsCacheMock: vi.fn(), prepareLocalWorktreeRootForRepoMock: vi.fn() })) @@ -103,6 +106,10 @@ vi.mock('../git/runner', () => ({ gitSpawn: gitSpawnMock })) +vi.mock('../git/worktree', () => ({ + listWorktreeGraph: listWorktreeGraphMock +})) + vi.mock('./filesystem-auth', () => ({ invalidateAuthorizedRootsCache: invalidateAuthorizedRootsCacheMock })) @@ -158,6 +165,7 @@ describe('projectGroups IPC validation', () => { mockStore.updateProjectGroup.mockReset() mockStore.deleteProjectGroup.mockReset() mockStore.moveProjectToGroup.mockReset() + mockStore.addRepo.mockReset() mockStore.getProjects.mockReset().mockReturnValue([]) mockStore.getProjectHostSetups.mockReset().mockReturnValue([]) mockStore.updateProjectHostSetup.mockReset() @@ -171,6 +179,10 @@ describe('projectGroups IPC validation', () => { mockFilesystemProvider.stat.mockRejectedValue(new Error('not found')) mockGitProvider.isGitRepoAsync.mockReset() mockGitProvider.isGitRepoAsync.mockResolvedValue({ isRepo: true, rootPath: null }) + mockGitProvider.listWorktrees.mockReset() + mockGitProvider.listWorktrees.mockResolvedValue([]) + listWorktreeGraphMock.mockReset() + listWorktreeGraphMock.mockResolvedValue([]) vi.mocked(isGitRepo).mockReset() vi.mocked(isGitRepo).mockReturnValue(true) mockMultiplexer.notify.mockReset() @@ -643,6 +655,75 @@ describe('projectGroups IPC validation', () => { }) }) + it('resolves SSH linked worktree imports through the SSH provider worktree graph', async () => { + const selectedPath = '/srv/platform/demo/brash-binder' + const secondSelectedPath = '/srv/platform/demo/quick-howler' + const mainPath = '/srv/source/demo-project' + mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({ + isRepo: path === selectedPath || path === secondSelectedPath, + rootPath: '/srv/provider/root' + })) + mockGitProvider.listWorktrees.mockResolvedValue([ + { + path: mainPath, + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: selectedPath, + head: 'feature-head', + branch: 'refs/heads/brash-binder', + isBare: false, + isMainWorktree: false + }, + { + path: secondSelectedPath, + head: 'feature-head', + branch: 'refs/heads/quick-howler', + isBare: false, + isMainWorktree: false + } + ]) + mockFilesystemProvider.stat.mockImplementation(async (path: string) => { + if (path === `${selectedPath}/.git` || path === `${secondSelectedPath}/.git`) { + return { type: 'directory', size: 0, mtime: 0 } + } + throw new Error('not found') + }) + mockFilesystemProvider.readDir.mockImplementation(async (dirPath: string) => + dirPath === '/srv/platform/demo' + ? [ + { name: 'brash-binder', isDirectory: true, isSymlink: false }, + { name: 'quick-howler', isDirectory: true, isSymlink: false } + ] + : [] + ) + + const result = await handlers.get('projectGroups:importNested')!(null, { + parentPath: '/srv/platform/demo', + groupName: '', + projectPaths: [selectedPath, secondSelectedPath], + connectionId: 'conn-1', + mode: 'separate' + }) + + expect(result).toMatchObject({ importedCount: 1, alreadyKnownCount: 1, failedCount: 0 }) + expect(mockGitProvider.listWorktrees).toHaveBeenCalledWith(selectedPath) + expect(mockGitProvider.listWorktrees).toHaveBeenCalledTimes(1) + expect(listWorktreeGraphMock).not.toHaveBeenCalled() + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ + path: mainPath, + connectionId: 'conn-1' + }) + ) + expect(mockMultiplexer.notify).toHaveBeenCalledWith('session.registerRoot', { + rootPath: mainPath + }) + }) + it('imports a small selection from a large nested SSH scan', async () => { const group = { id: 'group-1', @@ -709,6 +790,67 @@ describe('projectGroups IPC validation', () => { ) }) + it('imports selected local linked worktrees as one project rooted at the main worktree', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'orca-nested-linked-worktrees-')) + try { + const parentPath = join(tempRoot, 'paseo-worktrees', 'demo-project') + const mainPath = join(tempRoot, 'source', 'demo-project') + const firstWorktreePath = join(parentPath, 'brash-binder') + const secondWorktreePath = join(parentPath, 'quick-howler') + await mkdir(join(firstWorktreePath, '.git'), { recursive: true }) + await mkdir(join(secondWorktreePath, '.git'), { recursive: true }) + await mkdir(mainPath, { recursive: true }) + vi.mocked(isGitRepo).mockReturnValue(false) + vi.mocked(isGitRepo).mockImplementation((path: string) => + [firstWorktreePath, secondWorktreePath, mainPath].includes(path) + ) + listWorktreeGraphMock.mockResolvedValue([ + { + path: mainPath, + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: firstWorktreePath, + head: 'feature-head', + branch: 'refs/heads/brash-binder', + isBare: false, + isMainWorktree: false + }, + { + path: secondWorktreePath, + head: 'feature-head', + branch: 'refs/heads/quick-howler', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await handlers.get('projectGroups:importNested')!(null, { + parentPath, + groupName: '', + projectPaths: [firstWorktreePath, secondWorktreePath], + mode: 'separate' + }) + + expect(result).toMatchObject({ + importedCount: 1, + alreadyKnownCount: 1, + failedCount: 0 + }) + expect(mockStore.addRepo).toHaveBeenCalledTimes(1) + expect(mockStore.addRepo).toHaveBeenCalledWith(expect.objectContaining({ path: mainPath })) + expect(listWorktreeGraphMock).toHaveBeenCalledTimes(1) + expect((result as { projects: { projectId?: string }[] }).projects[0].projectId).toBe( + (result as { projects: { projectId?: string }[] }).projects[1].projectId + ) + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) + it('sanitizes unexpected nested import errors before returning results', async () => { const group = { id: 'group-1', diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index ffd2bb049..e3959bb48 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -57,6 +57,7 @@ import { createNestedProjectGroupResolver, resolveNestedRepoSelection } from '../project-groups/nested-repo-import' +import { createNestedRepoImportTargetResolver } from '../project-groups/nested-repo-import-target' import { isGitRepo, getGitUsername, @@ -1498,12 +1499,16 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v error: 'Repository was not found in the nested repo scan result' }) ) + const importedProjectIdsByRepoPath = new Map() + const importTargetResolver = createNestedRepoImportTargetResolver() for (const [projectGroupOrder, repoPath] of selection.selectedPaths.entries()) { try { + let importRepoPath = repoPath if (args.connectionId) { const gitProvider = getSshGitProvider(args.connectionId) - if (!gitProvider || !(await gitProvider.isGitRepoAsync(repoPath)).isRepo) { + const check = gitProvider ? await gitProvider.isGitRepoAsync(repoPath) : null + if (!gitProvider || !check?.isRepo) { results.push({ path: repoPath, status: 'failed', @@ -1511,35 +1516,49 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v }) continue } + importRepoPath = await importTargetResolver.resolveSsh(repoPath, gitProvider) } else if (!isGitRepo(repoPath)) { results.push({ path: repoPath, status: 'failed', error: 'Not a valid git repository' }) continue + } else { + importRepoPath = await importTargetResolver.resolveLocal(repoPath) + } + const normalizedImportRepoPath = normalizeRuntimePathForComparison(importRepoPath) + const alreadyImportedProjectId = + importedProjectIdsByRepoPath.get(normalizedImportRepoPath) + if (alreadyImportedProjectId) { + results.push({ + path: repoPath, + projectId: alreadyImportedProjectId, + status: 'already-known' + }) + continue } const existing = store .getRepos() .find( (repo) => (repo.connectionId ?? null) === (args.connectionId ?? null) && - normalizeRuntimePathForComparison(repo.path) === - normalizeRuntimePathForComparison(repoPath) + normalizeRuntimePathForComparison(repo.path) === normalizedImportRepoPath ) const group = groupResolver.getGroupForRepo(repoPath) if (existing) { if (group) { store.moveProjectToGroup(existing.id, group.id, projectGroupOrder) } + importedProjectIdsByRepoPath.set(normalizedImportRepoPath, existing.id) results.push({ path: repoPath, projectId: existing.id, status: 'already-known' }) continue } const detected = await detectRepoIconAndUpstream({ - repoPath, + repoPath: importRepoPath, kind: 'git', connectionId: args.connectionId }) const repo: Repo = { id: randomUUID(), - path: repoPath, - displayName: getRepoName(repoPath), + path: importRepoPath, + displayName: getRepoName(importRepoPath), badgeColor: DEFAULT_REPO_BADGE_COLOR, ...detected, addedAt: Date.now(), @@ -1559,9 +1578,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v await prepareLocalWorktreeRootForRepo(store, repo) if (args.connectionId) { getActiveMultiplexer(args.connectionId)?.notify('session.registerRoot', { - rootPath: repoPath + rootPath: importRepoPath }) } + importedProjectIdsByRepoPath.set(normalizedImportRepoPath, repo.id) results.push({ path: repoPath, projectId: repo.id, status: 'imported' }) // Why: nested-repo import only reaches here after the isGitRepo / // isGitRepoAsync guard above confirmed a git repo, so always `true`. diff --git a/src/main/project-groups/nested-repo-import-target.test.ts b/src/main/project-groups/nested-repo-import-target.test.ts new file mode 100644 index 000000000..7efb23e83 --- /dev/null +++ b/src/main/project-groups/nested-repo-import-target.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { join } from 'path' +import type { GitWorktreeInfo } from '../../shared/types' +import { listWorktreeGraph } from '../git/worktree' +import { + createNestedRepoImportTargetResolver, + resolveLocalNestedRepoImportTargetPath, + resolveSshNestedRepoImportTargetPath +} from './nested-repo-import-target' + +vi.mock('../git/worktree', () => ({ + listWorktreeGraph: vi.fn() +})) + +function worktree(overrides: Partial): GitWorktreeInfo { + return { + path: join('/workspace', 'repo'), + head: 'abc', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: false, + ...overrides + } +} + +describe('nested repo import target resolution', () => { + beforeEach(() => { + vi.mocked(listWorktreeGraph).mockReset() + }) + + it('canonicalizes a selected linked worktree to its non-bare main worktree', async () => { + const mainPath = join('/workspace', 'source', 'demo') + const selectedPath = `${join('/workspace', 'paseo', 'demo', 'brash-binder')}/` + const selectedPathInGraph = join('/workspace', 'paseo', 'demo', 'brash-binder') + vi.mocked(listWorktreeGraph).mockResolvedValue([ + worktree({ path: mainPath, isMainWorktree: true }), + worktree({ path: selectedPathInGraph, branch: 'refs/heads/brash-binder' }) + ]) + + await expect(resolveLocalNestedRepoImportTargetPath(selectedPath)).resolves.toBe(mainPath) + expect(listWorktreeGraph).toHaveBeenCalledWith(selectedPath) + }) + + it('warms the local resolver cache for sibling worktrees in the same graph', async () => { + const mainPath = join('/workspace', 'source', 'demo') + const firstPath = join('/workspace', 'paseo', 'demo', 'brash-binder') + const secondPath = join('/workspace', 'paseo', 'demo', 'quick-howler') + vi.mocked(listWorktreeGraph).mockResolvedValue([ + worktree({ path: mainPath, isMainWorktree: true }), + worktree({ path: firstPath, branch: 'refs/heads/brash-binder' }), + worktree({ path: secondPath, branch: 'refs/heads/quick-howler' }) + ]) + const resolver = createNestedRepoImportTargetResolver() + + await expect(resolver.resolveLocal(firstPath)).resolves.toBe(mainPath) + await expect(resolver.resolveLocal(secondPath)).resolves.toBe(mainPath) + expect(listWorktreeGraph).toHaveBeenCalledTimes(1) + }) + + it('falls back when the worktree list is empty', async () => { + const selectedPath = join('/workspace', 'paseo', 'demo', 'brash-binder') + vi.mocked(listWorktreeGraph).mockResolvedValue([]) + + await expect(resolveLocalNestedRepoImportTargetPath(selectedPath)).resolves.toBe(selectedPath) + }) + + it('falls back when the worktree lister throws', async () => { + const selectedPath = join('/workspace', 'paseo', 'demo', 'brash-binder') + vi.mocked(listWorktreeGraph).mockRejectedValue(new Error('git failed')) + + await expect(resolveLocalNestedRepoImportTargetPath(selectedPath)).resolves.toBe(selectedPath) + }) + + it('falls back when Git returns a stale graph that omits the selected path', async () => { + const selectedPath = join('/workspace', 'paseo', 'demo', 'brash-binder') + vi.mocked(listWorktreeGraph).mockResolvedValue([ + worktree({ path: join('/other', 'source', 'demo'), isMainWorktree: true }), + worktree({ path: join('/other', 'linked', 'quick-howler') }) + ]) + + await expect(resolveLocalNestedRepoImportTargetPath(selectedPath)).resolves.toBe(selectedPath) + }) + + it('falls back when the only main worktree is bare', async () => { + const selectedPath = join('/workspace', 'paseo', 'demo', 'brash-binder') + vi.mocked(listWorktreeGraph).mockResolvedValue([ + worktree({ + path: join('/workspace', 'source', 'demo.git'), + isBare: true, + isMainWorktree: true + }), + worktree({ path: selectedPath, branch: 'refs/heads/brash-binder' }) + ]) + + await expect(resolveLocalNestedRepoImportTargetPath(selectedPath)).resolves.toBe(selectedPath) + }) + + it('uses the SSH provider worktree list for remote import targets', async () => { + const mainPath = join('/srv', 'source', 'demo') + const selectedPath = join('/srv', 'paseo', 'demo', 'brash-binder') + const gitProvider = { + listWorktrees: vi + .fn() + .mockResolvedValue([ + worktree({ path: mainPath, isMainWorktree: true }), + worktree({ path: selectedPath, branch: 'refs/heads/brash-binder' }) + ]) + } + + await expect(resolveSshNestedRepoImportTargetPath(selectedPath, gitProvider)).resolves.toBe( + mainPath + ) + expect(gitProvider.listWorktrees).toHaveBeenCalledWith(selectedPath) + expect(listWorktreeGraph).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/project-groups/nested-repo-import-target.ts b/src/main/project-groups/nested-repo-import-target.ts new file mode 100644 index 000000000..bde2abb0b --- /dev/null +++ b/src/main/project-groups/nested-repo-import-target.ts @@ -0,0 +1,87 @@ +import type { GitWorktreeInfo } from '../../shared/types' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { listWorktreeGraph } from '../git/worktree' + +type WorktreeLister = { + listWorktrees: (repoPath: string) => Promise +} + +export type NestedRepoImportTargetResolver = { + resolveLocal: (repoPath: string) => Promise + resolveSsh: (repoPath: string, gitProvider: WorktreeLister) => Promise +} + +function findImportTarget( + selectedPath: string, + worktrees: readonly GitWorktreeInfo[] +): { targetPath: string; graphPaths: string[] } | null { + const selectedPathKey = normalizeRuntimePathForComparison(selectedPath) + const graphContainsSelectedPath = worktrees.some( + (worktree) => normalizeRuntimePathForComparison(worktree.path) === selectedPathKey + ) + if (!graphContainsSelectedPath) { + return null + } + + // Why: a linked worktree may only collapse to its owner when Git proves the + // selected path belongs to that same non-bare worktree graph. + const mainWorktree = worktrees.find((worktree) => worktree.isMainWorktree && !worktree.isBare) + return mainWorktree + ? { targetPath: mainWorktree.path, graphPaths: worktrees.map((worktree) => worktree.path) } + : null +} + +async function resolveWithCache( + repoPath: string, + cache: Map, + readWorktreeGraph: (path: string) => Promise +): Promise { + const repoPathKey = normalizeRuntimePathForComparison(repoPath) + const cachedPath = cache.get(repoPathKey) + if (cachedPath) { + return cachedPath + } + + try { + const target = findImportTarget(repoPath, await readWorktreeGraph(repoPath)) + if (target) { + for (const graphPath of target.graphPaths) { + cache.set(normalizeRuntimePathForComparison(graphPath), target.targetPath) + } + return target.targetPath + } + } catch { + // Fall through to selected-path compatibility behavior. + } + cache.set(repoPathKey, repoPath) + return repoPath +} + +export function createNestedRepoImportTargetResolver(): NestedRepoImportTargetResolver { + const localCache = new Map() + const sshCaches = new WeakMap>() + + return { + resolveLocal: (repoPath) => + resolveWithCache(repoPath, localCache, (path) => listWorktreeGraph(path)), + resolveSsh: (repoPath, gitProvider) => { + let cache = sshCaches.get(gitProvider) + if (!cache) { + cache = new Map() + sshCaches.set(gitProvider, cache) + } + return resolveWithCache(repoPath, cache, (path) => gitProvider.listWorktrees(path)) + } + } +} + +export async function resolveLocalNestedRepoImportTargetPath(repoPath: string): Promise { + return createNestedRepoImportTargetResolver().resolveLocal(repoPath) +} + +export async function resolveSshNestedRepoImportTargetPath( + repoPath: string, + gitProvider: WorktreeLister +): Promise { + return createNestedRepoImportTargetResolver().resolveSsh(repoPath, gitProvider) +} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 105f33460..e2dfaf591 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -663,6 +663,7 @@ import { createNestedProjectGroupResolver, resolveNestedRepoSelection } from '../project-groups/nested-repo-import' +import { createNestedRepoImportTargetResolver } from '../project-groups/nested-repo-import-target' function sanitizeNestedRepoRuntimeImportError(context: string, error: unknown): string { console.warn(`[project-groups] ${context}`, error) @@ -9288,27 +9289,41 @@ export class OrcaRuntimeService { error: 'Repository was not found in the nested repo scan result' }) ) + const importedProjectIdsByRepoPath = new Map() + const importTargetResolver = createNestedRepoImportTargetResolver() for (const [projectGroupOrder, repoPath] of selection.selectedPaths.entries()) { try { if (!isGitRepo(repoPath)) { results.push({ path: repoPath, status: 'failed', error: 'Not a valid git repository' }) continue } + const importRepoPath = await importTargetResolver.resolveLocal(repoPath) + const normalizedImportRepoPath = normalizeRuntimePathForComparison(importRepoPath) + const alreadyImportedProjectId = importedProjectIdsByRepoPath.get(normalizedImportRepoPath) + if (alreadyImportedProjectId) { + results.push({ + path: repoPath, + projectId: alreadyImportedProjectId, + status: 'already-known' + }) + continue + } const existing = this.store .getRepos() - .find((repo) => runtimePathsEqual(repo.path, repoPath)) + .find((repo) => normalizeRuntimePathForComparison(repo.path) === normalizedImportRepoPath) const group = groupResolver.getGroupForRepo(repoPath) if (existing) { if (group) { this.store.moveProjectToGroup(existing.id, group.id, projectGroupOrder) } + importedProjectIdsByRepoPath.set(normalizedImportRepoPath, existing.id) results.push({ path: repoPath, projectId: existing.id, status: 'already-known' }) continue } const repo: Repo = { id: randomUUID(), - path: repoPath, - displayName: getRepoName(repoPath), + path: importRepoPath, + displayName: getRepoName(importRepoPath), badgeColor: DEFAULT_REPO_BADGE_COLOR, addedAt: Date.now(), kind: 'git', @@ -9322,6 +9337,7 @@ export class OrcaRuntimeService { : {}) } this.store.addRepo(repo) + importedProjectIdsByRepoPath.set(normalizedImportRepoPath, repo.id) results.push({ path: repoPath, projectId: repo.id, status: 'imported' }) } catch (error) { results.push({