mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
* 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
162 lines
6.5 KiB
TypeScript
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
|
|
}
|
|
}
|
|
}
|