Import linked worktrees under one project (#6441)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
996ab4a224
commit
4a98b11e77
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -457,6 +457,41 @@ async function readWorktreeList(
|
|||
return parseWorktreeList(stdout)
|
||||
}
|
||||
|
||||
async function readTranslatedWorktreeGraph(
|
||||
repoPath: string,
|
||||
options: GitWorktreeExecOptions = {}
|
||||
): Promise<GitWorktreeInfo[]> {
|
||||
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<GitWorktreeInfo[]> {
|
||||
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<GitWorktreeInfo[]> {
|
||||
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') {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<string, string>()
|
||||
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`.
|
||||
|
|
|
|||
|
|
@ -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>): 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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<GitWorktreeInfo[]>
|
||||
}
|
||||
|
||||
export type NestedRepoImportTargetResolver = {
|
||||
resolveLocal: (repoPath: string) => Promise<string>
|
||||
resolveSsh: (repoPath: string, gitProvider: WorktreeLister) => Promise<string>
|
||||
}
|
||||
|
||||
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<string, string>,
|
||||
readWorktreeGraph: (path: string) => Promise<GitWorktreeInfo[]>
|
||||
): Promise<string> {
|
||||
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<string, string>()
|
||||
const sshCaches = new WeakMap<WorktreeLister, Map<string, string>>()
|
||||
|
||||
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<string> {
|
||||
return createNestedRepoImportTargetResolver().resolveLocal(repoPath)
|
||||
}
|
||||
|
||||
export async function resolveSshNestedRepoImportTargetPath(
|
||||
repoPath: string,
|
||||
gitProvider: WorktreeLister
|
||||
): Promise<string> {
|
||||
return createNestedRepoImportTargetResolver().resolveSsh(repoPath, gitProvider)
|
||||
}
|
||||
|
|
@ -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<string, string>()
|
||||
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({
|
||||
|
|
|
|||
Loading…
Reference in New Issue