Files
orca/src/main/project-groups/nested-repo-discovery.test.ts
T
mirror 34fa2cd5ca
Skill update round trip / roundtrip (false, macos-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, macos-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, ubuntu-latest, symlink, latest) (push) Canceled after 0s
Skill update round trip / roundtrip (false, windows-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (false, windows-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, macos-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, macos-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, ubuntu-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, ubuntu-latest, symlink, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, windows-latest, copy, 1.5.17) (push) Canceled after 0s
Skill update round trip / roundtrip (true, windows-latest, symlink, 1.5.17) (push) Canceled after 0s
source snapshot 2026-09-02
2026-09-02 02:03:16 +08:00

400 lines
13 KiB
TypeScript

import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { scanNestedRepos } from './nested-repo-discovery'
let tempDirs: string[] = []
async function tempRoot(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'orca-nested-repos-'))
tempDirs.push(dir)
return dir
}
async function makeGitRepo(path: string): Promise<void> {
await mkdir(join(path, '.git'), { recursive: true })
}
async function makeBareGitRepo(path: string): Promise<void> {
await mkdir(join(path, 'objects'), { recursive: true })
await mkdir(join(path, 'refs'), { recursive: true })
await writeFile(join(path, 'HEAD'), 'ref: refs/heads/main\n')
}
function posixTestFilesystem(args: {
directories: Map<string, string[]>
gitRepos: Set<string>
files?: Map<string, string>
}) {
return {
readDirectory: async (dirPath: string) =>
(args.directories.get(dirPath) ?? []).map((name) => ({
name,
isDirectory: !args.files?.has(`${dirPath}/${name}`)
})),
readTextFile: async (path: string) => {
const content = args.files?.get(path)
if (content === undefined) {
throw new Error('not found')
}
return content
},
joinPath: (parentPath: string, childName: string) => `${parentPath}/${childName}`,
basename: (path: string) => path.split('/').at(-1) ?? path,
hasGitMarker: (path: string) => args.gitRepos.has(path),
isSelectedPathGitRepo: (path: string) => args.gitRepos.has(path)
}
}
afterEach(async () => {
vi.restoreAllMocks()
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
tempDirs = []
})
describe('scanNestedRepos', () => {
it('returns child repos for a non-git parent', async () => {
const root = await tempRoot()
await mkdir(join(root, 'auth-service'), { recursive: true })
await mkdir(join(root, 'billing-service'), { recursive: true })
await makeGitRepo(join(root, 'auth-service'))
await makeGitRepo(join(root, 'billing-service'))
const result = await scanNestedRepos({ path: root })
expect(result.selectedPathKind).toBe('non_git_folder')
expect(result.timeoutMs).toBeNull()
expect(result.timedOut).toBe(false)
expect(result.repos.map((repo) => repo.displayName)).toEqual([
'auth-service',
'billing-service'
])
})
it('returns repositories found before a stopped scan', async () => {
const directories = new Map([['/workspace', ['api', 'web']]])
const gitRepos = new Set(['/workspace/api', '/workspace/web'])
const controller = new AbortController()
const result = await scanNestedRepos({
path: '/workspace',
signal: controller.signal,
onProgress: (scan) => {
if (scan.repos.length === 1) {
controller.abort()
}
},
filesystem: posixTestFilesystem({ directories, gitRepos })
})
expect(result).toMatchObject({
selectedPathKind: 'non_git_folder',
stopped: true,
timedOut: false,
truncated: false
})
expect(result.repos.map((repo) => repo.path)).toEqual(['/workspace/api'])
})
it('returns stopped with no repos when cancelled before traversal', async () => {
const controller = new AbortController()
controller.abort()
const result = await scanNestedRepos({
path: '/workspace',
signal: controller.signal,
filesystem: posixTestFilesystem({
directories: new Map([['/workspace', ['api']]]),
gitRepos: new Set(['/workspace/api'])
})
})
expect(result).toMatchObject({
selectedPathKind: 'non_git_folder',
repos: [],
stopped: true,
timedOut: false,
truncated: false
})
})
it('emits immutable progress snapshots as repositories are discovered', async () => {
const progress: { repos: { path: string }[] }[] = []
const result = await scanNestedRepos({
path: '/workspace',
onProgress: (scan) => progress.push(scan),
filesystem: posixTestFilesystem({
directories: new Map([['/workspace', ['api', 'web']]]),
gitRepos: new Set(['/workspace/api', '/workspace/web'])
})
})
expect(result.repos.map((repo) => repo.path)).toEqual(['/workspace/api', '/workspace/web'])
expect(progress.map((scan) => scan.repos.map((repo) => repo.path))).toEqual([
['/workspace/api'],
['/workspace/api', '/workspace/web']
])
expect(progress[0].repos).toHaveLength(1)
})
it('does not time out by default even when elapsed time grows', async () => {
vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValue(60_000)
const result = await scanNestedRepos({
path: '/workspace',
filesystem: posixTestFilesystem({
directories: new Map([['/workspace', ['api']]]),
gitRepos: new Set(['/workspace/api'])
})
})
expect(result).toMatchObject({
timedOut: false,
timeoutMs: null,
repos: [{ path: '/workspace/api' }]
})
})
it('still honors an explicit timeout option for callers that request one', async () => {
vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValue(1_000)
const result = await scanNestedRepos({
path: '/workspace',
options: { timeoutMs: 500 },
filesystem: posixTestFilesystem({
directories: new Map([['/workspace', ['api']]]),
gitRepos: new Set(['/workspace/api'])
})
})
expect(result).toMatchObject({
repos: [],
stopped: false,
timedOut: true,
timeoutMs: 500
})
})
it('does not scan inside an already discovered repo', async () => {
const root = await tempRoot()
await mkdir(join(root, 'service', 'nested'), { recursive: true })
await makeGitRepo(join(root, 'service'))
await makeGitRepo(join(root, 'service', 'nested'))
const result = await scanNestedRepos({ path: root })
expect(result.repos.map((repo) => repo.displayName)).toEqual(['service'])
})
it('skips symlinked directories reported by remote filesystems', async () => {
const result = await scanNestedRepos({
path: '/workspace',
filesystem: {
readDirectory: async (dirPath) =>
dirPath === '/workspace'
? [
{ name: 'linked-outside', isDirectory: true, isSymlink: true },
{ name: 'api', isDirectory: true, isSymlink: false }
]
: [],
joinPath: (parentPath, childName) => `${parentPath}/${childName}`,
basename: (path) => path.split('/').at(-1) ?? path,
hasGitMarker: (path) => path === '/workspace/api',
isSelectedPathGitRepo: () => false
}
})
expect(result.repos.map((repo) => repo.path)).toEqual(['/workspace/api'])
})
it('prefers shallow sibling repos before descending into non-repo folders', async () => {
const directories = new Map([
['/workspace', ['archive', 'z-web-client']],
[
'/workspace/archive',
Array.from(
{ length: 101 },
(_, index) => `archived-service-${String(index + 1).padStart(3, '0')}`
)
]
])
const gitRepos = new Set([
'/workspace/z-web-client',
...Array.from(
{ length: 101 },
(_, index) => `/workspace/archive/archived-service-${String(index + 1).padStart(3, '0')}`
)
])
const result = await scanNestedRepos({
path: '/workspace',
options: { maxRepos: 100 },
filesystem: posixTestFilesystem({ directories, gitRepos })
})
expect(result.repos).toHaveLength(100)
expect(result.repos[0].path).toBe('/workspace/z-web-client')
expect(result.repos.map((repo) => repo.path)).toContain('/workspace/z-web-client')
expect(result.truncated).toBe(true)
})
it('orders discovered repos by BFS parent queue and alphabetical children per directory', async () => {
const directories = new Map([
['/workspace', ['omega-root', 'gamma-folder', 'beta-root', 'alpha-folder']],
['/workspace/alpha-folder', ['z-alpha-child', 'm-alpha-child', 'alpha-nested']],
['/workspace/gamma-folder', ['a-gamma-child']],
['/workspace/alpha-folder/alpha-nested', ['a-alpha-grandchild']]
])
const gitRepos = new Set([
'/workspace/beta-root',
'/workspace/omega-root',
'/workspace/alpha-folder/m-alpha-child',
'/workspace/alpha-folder/z-alpha-child',
'/workspace/gamma-folder/a-gamma-child',
'/workspace/alpha-folder/alpha-nested/a-alpha-grandchild'
])
const readOrder: string[] = []
const result = await scanNestedRepos({
path: '/workspace',
filesystem: {
...posixTestFilesystem({ directories, gitRepos }),
readDirectory: async (dirPath) => {
readOrder.push(dirPath)
return (directories.get(dirPath) ?? []).map((name) => ({ name, isDirectory: true }))
}
}
})
expect(readOrder).toEqual([
'/workspace',
'/workspace/alpha-folder',
'/workspace/gamma-folder',
'/workspace/alpha-folder/alpha-nested'
])
expect(result.repos.map((repo) => repo.path)).toEqual([
'/workspace/beta-root',
'/workspace/omega-root',
'/workspace/alpha-folder/m-alpha-child',
'/workspace/alpha-folder/z-alpha-child',
'/workspace/gamma-folder/a-gamma-child',
'/workspace/alpha-folder/alpha-nested/a-alpha-grandchild'
])
expect(result.repos.map((repo) => repo.depth)).toEqual([1, 1, 2, 2, 2, 3])
})
it('uses gitignore rules to avoid scanning ignored directories', async () => {
const directories = new Map([
['/workspace', ['.gitignore', 'active', 'ignored']],
['/workspace/active', ['repo']],
['/workspace/ignored', ['repo']]
])
const files = new Map([['/workspace/.gitignore', 'ignored/\n']])
const gitRepos = new Set(['/workspace/active/repo', '/workspace/ignored/repo'])
const result = await scanNestedRepos({
path: '/workspace',
filesystem: posixTestFilesystem({ directories, gitRepos, files })
})
expect(result.repos.map((repo) => repo.path)).toEqual(['/workspace/active/repo'])
})
it('keeps root-anchored gitignore rules scoped to their base directory', async () => {
const directories = new Map([
['/workspace', ['.gitignore', 'active', 'ignored']],
['/workspace/active', ['ignored']],
['/workspace/active/ignored', ['repo']],
['/workspace/ignored', ['repo']]
])
const files = new Map([['/workspace/.gitignore', '/ignored\n']])
const gitRepos = new Set(['/workspace/active/ignored/repo', '/workspace/ignored/repo'])
const result = await scanNestedRepos({
path: '/workspace',
filesystem: posixTestFilesystem({ directories, gitRepos, files })
})
expect(result.repos.map((repo) => repo.path)).toEqual(['/workspace/active/ignored/repo'])
})
it('detects bare child repositories without scanning inside them', async () => {
const root = await tempRoot()
await makeBareGitRepo(join(root, 'mirror.git'))
await mkdir(join(root, 'mirror.git', 'refs', 'nested-repo'), { recursive: true })
await makeGitRepo(join(root, 'mirror.git', 'refs', 'nested-repo'))
const result = await scanNestedRepos({ path: root })
expect(result.repos.map((repo) => repo.displayName)).toEqual(['mirror.git'])
})
it('does not use selected-path git checks while traversing children', async () => {
const directories = new Map([
['/workspace', ['repo']],
['/workspace/repo', []]
])
const gitRepos = new Set(['/workspace/repo'])
const selectedPathChecks: string[] = []
const result = await scanNestedRepos({
path: '/workspace',
filesystem: {
...posixTestFilesystem({ directories, gitRepos }),
isSelectedPathGitRepo: (path) => {
selectedPathChecks.push(path)
return false
}
}
})
expect(result.repos.map((repo) => repo.path)).toEqual(['/workspace/repo'])
expect(selectedPathChecks).toEqual(['/workspace'])
})
it('skips heavy directories and respects result caps', async () => {
const root = await tempRoot()
await mkdir(join(root, 'node_modules', 'ignored'), { recursive: true })
await mkdir(join(root, 'one'), { recursive: true })
await mkdir(join(root, 'two'), { recursive: true })
await makeGitRepo(join(root, 'node_modules', 'ignored'))
await makeGitRepo(join(root, 'one'))
await makeGitRepo(join(root, 'two'))
const result = await scanNestedRepos({ path: root, options: { maxRepos: 1 } })
expect(result.repos[0].displayName).toBe('one')
expect(result.truncated).toBe(true)
})
it('treats a selected git repo as the existing repo path', async () => {
const root = await tempRoot()
await makeGitRepo(root)
await mkdir(join(root, 'child'), { recursive: true })
await makeGitRepo(join(root, 'child'))
await writeFile(join(root, 'README.md'), '')
const result = await scanNestedRepos({ path: root })
expect(result.selectedPathKind).toBe('git_repo')
expect(result.repos).toEqual([])
})
it.skipIf(process.platform === 'win32')(
'does not follow symlinked directories outside the selected folder',
async () => {
const root = await tempRoot()
const external = await tempRoot()
await mkdir(join(external, 'outside-repo'), { recursive: true })
await makeGitRepo(join(external, 'outside-repo'))
await symlink(external, join(root, 'linked'), 'dir')
const result = await scanNestedRepos({ path: root })
expect(result.repos).toEqual([])
}
)
})