Files
orca/src/main/runtime/runtime-nested-repo-import.ts
T
Neil 90c713af1f fix(wsl): refuse Windows drive folders on a WSL host, offer Add on this computer (#34530)
* fix(wsl): refuse Windows drive folders on a WSL host and offer to add them on this computer

A /mnt/<drive> project on a WSL host shows false line-ending changes, misses
edits made from Windows, and searches slowly. Add Project refuses typed,
picked and dropped Windows drive folders for a WSL host and shows the refusal
inline with Add on this computer, which switches the open dialog to this
computer. The WSL-host orcad re-checks by /proc/self/mountinfo (after
resolving symlinks) in repo.add, repo.create, repo.clone, explicit
folderWorkspace.create and the nested scan/import, answering with the
existing runtime_error code plus optional data, so older clients still show a
readable message. Everything is behind the WSL hosts gate; SSH is untouched.

* test(wsl): hold the e2e's this-computer add instead of racing its failure

On the Linux CI host the switched local add of a Windows path fails its nested
scan and hands off to the Open as Folder modal, closing Add Project before the
test looked. The refusal itself already came before any scan. The e2e now
records the local add at its scan step and asserts it went to this computer
with the Windows path; a unit test pins that a refused pick never reaches the
host path step.

* test(wsl): type the e2e's held-scan window hooks instead of Reflect
2026-10-27 16:29:54 -07:00

162 lines
6.5 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { isAbsolute } from 'node:path'
import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import type {
NestedRepoScanResult,
ProjectGroupImportMode,
ProjectGroupImportResult
} from '../../shared/project-group-types'
import type { Repo } from '../../shared/repo-types'
import { getRepoDialableSshTargetId } from '../../shared/execution-host'
import { awaitWindowsHostGitEnvironmentReady } from '../git/runner'
import { getRepoName, isGitRepoAsync } from '../git/repo'
import { scanNestedRepos } from '../project-groups/nested-repo-discovery'
import {
createNestedProjectGroupResolver,
resolveNestedRepoSelection
} from '../project-groups/nested-repo-import'
import { createNestedRepoImportTargetResolver } from '../project-groups/nested-repo-import-target'
import type { RuntimeStore } from './runtime-store-contract'
import { refuseWslHostWindowsDrivePath } from './wsl-host-windows-drive-guard'
type RuntimeNestedRepoImportDependencies = {
getStore: () => RuntimeStore | null
invalidateResolvedWorktrees: () => void
invalidateWorktreeScan: (repoId: string) => void
notifyReposChanged: () => void
}
function sanitizeImportError(fallback: string, error: unknown): string {
console.warn(`[project-groups] ${fallback}`, error)
return 'Repository could not be imported'
}
export class RuntimeNestedRepoImport {
constructor(private readonly deps: RuntimeNestedRepoImportDependencies) {}
async scan(path: string): Promise<NestedRepoScanResult> {
if (!isAbsolute(path)) {
throw new Error('Project path must be an absolute path')
}
await refuseWslHostWindowsDrivePath(path)
await awaitWindowsHostGitEnvironmentReady({ cwd: path })
return scanNestedRepos({ path, options: { timeoutMs: 15_000 } })
}
async import(args: {
parentPath: string
groupName: string
projectPaths: string[]
mode: ProjectGroupImportMode
}): Promise<ProjectGroupImportResult> {
await refuseWslHostWindowsDrivePath(args.parentPath)
await awaitWindowsHostGitEnvironmentReady({ cwd: args.parentPath })
const store = this.deps.getStore()
if (!store?.createProjectGroup || !store.moveProjectToGroup) {
throw new Error('runtime_unavailable')
}
if (!isAbsolute(args.parentPath)) {
throw new Error('Project path must be an absolute path')
}
const scan = await scanNestedRepos({ path: args.parentPath, options: { timeoutMs: 15_000 } })
const selection = resolveNestedRepoSelection({ scan, projectPaths: args.projectPaths })
const groupResolver = createNestedProjectGroupResolver({
parentPath: args.parentPath,
groupName: args.groupName,
mode: args.mode,
connectionId: null,
repoPaths: selection.selectedPaths,
createGroup: (input) => store.createProjectGroup!(input)
})
const results: ProjectGroupImportResult['projects'] = selection.rejectedPaths.map(
(repoPath) => ({
path: repoPath,
status: 'failed',
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 {
await awaitWindowsHostGitEnvironmentReady({ cwd: repoPath })
if (!(await isGitRepoAsync(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
}
// Import native rows only; SSH may share the path string. Paired hosts stamp native rows runtime:<env>, which still match.
const existing = store
.getRepos()
.find(
(repo) =>
getRepoDialableSshTargetId(repo) === null &&
normalizeRuntimePathForComparison(repo.path) === normalizedImportRepoPath
)
const group = groupResolver.getGroupForRepo(repoPath)
if (existing) {
if (group) {
store.moveProjectToGroup(existing, 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: importRepoPath,
displayName: getRepoName(importRepoPath),
badgeColor: DEFAULT_REPO_BADGE_COLOR,
addedAt: Date.now(),
kind: 'git',
externalWorktreeVisibilityLegacy: false,
...(group ? { projectGroupId: group.id, projectGroupOrder } : {})
}
store.addRepo(repo)
importedProjectIdsByRepoPath.set(normalizedImportRepoPath, repo.id)
results.push({ path: repoPath, projectId: repo.id, status: 'imported' })
} catch (error) {
results.push({
path: repoPath,
status: 'failed',
error: sanitizeImportError('Failed to import nested repository in runtime', error)
})
}
}
const importedCount = results.filter((entry) => entry.status === 'imported').length
const alreadyKnownCount = results.filter((entry) => entry.status === 'already-known').length
const failedCount = results.filter((entry) => entry.status === 'failed').length
if (importedCount + alreadyKnownCount === 0) {
for (const group of groupResolver.getCreatedGroups().toReversed()) {
store.deleteProjectGroup?.(group.id)
}
}
this.deps.invalidateResolvedWorktrees()
for (const project of results) {
if (project.projectId) {
this.deps.invalidateWorktreeScan(project.projectId)
}
}
this.deps.notifyReposChanged()
const rootGroup = groupResolver.getRootGroup()
return {
...(rootGroup && importedCount + alreadyKnownCount > 0 ? { group: rootGroup } : {}),
projects: results,
importedCount,
alreadyKnownCount,
failedCount
}
}
}