Files
orca/src/main/runtime/runtime-repository-clone-controller.ts
T
Neil 2d41a26a38 fix(runtime): new local projects never dedupe onto an SSH project at the same path (#31055)
* fix(runtime): project registration dedupes only against rows on this machine

Codex review 6 #7: create() matched paths across hosts, so creating a local
project at a path an SSH project used returned the SSH row and made no
directory. add() with no host had the same gap. One predicate,
runtimeRepoIsRegistrationCandidate, now backs add, create and clone.

* test(runtime): cover nested-SSH rows and clone reuse in host-qualified dedupe

Review follow-up (Opus P3s): runtime:<env> rows carrying a nested SSH
connectionId, add() for a runtime host, the clone controller, and a stricter
create() assertion.

* test(runtime): clone dedupe fixture commits without signing
2026-10-20 01:59:18 -07:00

212 lines
7.6 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { mkdir } from 'node:fs/promises'
import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
import type { Repo } from '../../shared/repo-types'
import { isFolderRepo } from '../../shared/repo-kind'
import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message'
import {
cleanupClaimedCloneTarget,
claimCloneTarget,
deriveValidatedClonePath,
getClonePathComparisonKey
} from '../git/repo-clone-path'
import { gitSpawnAfterWindowsEnvironmentReady, promptGuardGitEnv } from '../git/runner'
import { reuseSavedCloneTarget } from '../git/saved-clone-target'
import { runWithGitReadCacheInvalidation } from '../git/status'
import { invalidateAuthorizedRootsCache } from '../ipc/registered-worktree-roots-cache'
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
import { getRepoName } from '../git/repo'
import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation'
import type { RuntimeStore } from './runtime-store-contract'
import { runtimeRepoIsRegistrationCandidate } from './runtime-worktree-selection'
import { resolveClonedFolderConversion } from '../folder-repo-git-conversion'
import { findExtraFolderWorkspaceIds } from '../folder-workspace-meta-presence'
type RuntimeRepositoryCloneDependencies = {
getStore: () => RuntimeStore | null
invalidateResolvedWorktrees: () => void
invalidateWorktreeScan: (repoId: string) => void
notifyReposChanged: () => void
}
export class RuntimeRepositoryCloneController {
private readonly inFlightByPath = new Map<string, Promise<void>>()
constructor(private readonly deps: RuntimeRepositoryCloneDependencies) {}
async clone(
url: string,
destination: string,
executionHostId?: ExecutionHostId | null
): Promise<Repo> {
if (!this.deps.getStore()) {
throw new Error('runtime_unavailable')
}
const trimmedUrl = url.trim()
const trimmedDestination = destination.trim()
if (!trimmedDestination) {
throw new Error('Clone destination is required')
}
const clonePath = deriveValidatedClonePath({ url: trimmedUrl, destination: trimmedDestination })
const clonePathKey = getClonePathComparisonKey(clonePath)
const previous = this.inFlightByPath.get(clonePathKey) ?? Promise.resolve()
let release!: () => void
const current = new Promise<void>((resolve) => {
release = resolve
})
const tail = previous.then(
() => current,
() => current
)
this.inFlightByPath.set(clonePathKey, tail)
try {
await previous
return await runWithGitReadCacheInvalidation(() =>
this.cloneAfterPathLock(
trimmedUrl,
trimmedDestination,
clonePath,
clonePathKey,
executionHostId
)
)
} finally {
release()
if (this.inFlightByPath.get(clonePathKey) === tail) {
this.inFlightByPath.delete(clonePathKey)
}
}
}
private async cloneAfterPathLock(
trimmedUrl: string,
trimmedDestination: string,
clonePath: string,
clonePathKey: string,
executionHostId?: ExecutionHostId | null
): Promise<Repo> {
const store = this.deps.getStore()
if (!store) {
throw new Error('runtime_unavailable')
}
// Why: git runs in this process, so an SSH project at the same path string is another machine's.
const findSaved = (): Repo | undefined =>
store.getRepos().find((repo) => {
return (
getClonePathComparisonKey(repo.path) === clonePathKey &&
runtimeRepoIsRegistrationCandidate(repo, executionHostId)
)
})
const reused = await reuseSavedCloneTarget(findSaved, trimmedUrl, LOCAL_EXECUTION_HOST_ID)
if (reused) {
return reused
}
await mkdir(trimmedDestination, { recursive: true })
const claimedTarget = await claimCloneTarget(clonePath)
let proc: Awaited<ReturnType<typeof gitSpawnAfterWindowsEnvironmentReady>>
try {
proc = await gitSpawnAfterWindowsEnvironmentReady(
['clone', '--progress', '--', trimmedUrl, clonePath],
{
cwd: trimmedDestination,
admissionTier: 'interactive',
env: promptGuardGitEnv(),
stdio: ['ignore', 'ignore', 'pipe']
}
)
} catch (error) {
await cleanupClaimedCloneTarget(clonePath, claimedTarget)
const message = error instanceof Error ? error.message : String(error)
throw new Error(`Clone failed: ${message}`)
}
await new Promise<void>((resolve, reject) => {
let stderrTail = ''
let settled = false
proc.stderr?.on('data', (chunk: Buffer) => {
stderrTail = (stderrTail + chunk.toString()).slice(-4096)
})
const finish = async (code: number | null, signal: NodeJS.Signals | null, error?: Error) => {
if (settled) {
return
}
settled = true
if (error || code !== 0 || signal) {
await cleanupClaimedCloneTarget(clonePath, claimedTarget)
}
if (error) {
reject(new Error(`Clone failed: ${error.message}`))
} else if (signal === 'SIGTERM') {
reject(new Error('Clone aborted'))
} else if (code === 0) {
resolve()
} else {
reject(new Error(`Clone failed: ${getGitCloneFailureMessage(stderrTail, { clonePath })}`))
}
}
proc.on('error', (error) => void finish(null, null, error))
proc.on('close', (code, signal) => void finish(code, signal))
})
const existing = findSaved()
if (existing) {
const conversion = await resolveClonedFolderConversion(store, existing)
const current = findSaved()
if (
current &&
conversion &&
findExtraFolderWorkspaceIds(current, store.getAllWorktreeMeta()).length === 0 &&
current.path === existing.path &&
isFolderRepo(current) &&
runtimeRepoIsRegistrationCandidate(current, executionHostId)
) {
const updated = store.updateRepo(current, { kind: 'git', ...conversion })
if (updated) {
await prepareLocalWorktreeRootForRepo(store, updated)
invalidateAuthorizedRootsCache()
this.invalidate(updated.id)
return updated
}
}
// Why: git re-created this project's folder, so its worktree root is gone with it and the
// authorized-roots cache still holds the answers from before the folder came back.
if (current) {
await prepareLocalWorktreeRootForRepo(store, current)
invalidateAuthorizedRootsCache()
this.invalidate(current.id)
return current
}
}
// `cloneRepo` ran `git clone` in this process (see `assertCloneHostIsSupported`), so the
// checkout is here regardless of the host id stamped on the row.
const detected = await detectRepoIconAndUpstream({
repoPath: clonePath,
kind: 'git',
executionHostId: LOCAL_EXECUTION_HOST_ID
})
const repo: Repo = {
id: randomUUID(),
path: clonePath,
displayName: getRepoName(clonePath),
badgeColor: DEFAULT_REPO_BADGE_COLOR,
...(executionHostId != null ? { executionHostId } : {}),
...detected,
addedAt: Date.now(),
kind: 'git',
externalWorktreeVisibilityLegacy: false
}
store.addRepo(repo)
await prepareLocalWorktreeRootForRepo(store, repo)
invalidateAuthorizedRootsCache()
this.invalidate(repo.id)
return store.getRepo(repo.id) ?? repo
}
private invalidate(repoId: string): void {
this.deps.invalidateResolvedWorktrees()
this.deps.invalidateWorktreeScan(repoId)
this.deps.notifyReposChanged()
}
}