mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 00:02:10 +00:00
`detectRepoIcon`, `detectRepoIconAndUpstream`, `detectGitHubAvatarIcon`, `detectRepoFileIcon` and `probeGitRemoteIdentity` took a `connectionId`-shaped parameter threaded down from their callers. That shape spells "runtime host", "unresolved" and "genuinely local" all as one falsy value, and because it is a *parameter* each caller decided independently what to pass — a wrong answer was invisible at the boundary. Replace it with a required `ExecutionHostId` and route through #18296's `resolveGitRouteForHost` / `resolveFilesystemRouteForHost`. The parameter is removed rather than added beside, so every caller became a compile error. No new resolver, no wire change: nothing these modules return carries a host id. Fixed at the call sites: - `repo-git-remote-identity-enrichment` read `repo.connectionId` raw, so a row minted with only `executionHostId: ssh:<t>` ran `git remote -v` against this machine's copy of the path (#11163), and a `runtime:` row handed its *nested* SSH target to this client's dispatch table — a same-named box of ours. - Its location key had the same collapse, so two rows at one path on different hosts shared a probe, an abort controller and a backoff deadline. - `runtime-repository-fork-backfill` guarded on `repo.connectionId`, so an `executionHostId`-only SSH row had its upstream read off the client. `runtime:` is refused inside the modules (this process does not execute another environment's git or filesystem), but store-backed callers ask `getSshTargetIdForExecutionHost` — "what may this client dial" — so a `runtime:` row keeps the probe this process has always run for it. Registering and cloning stay `local` on purpose: those controllers do the filesystem work here, whatever host id is stamped on the row (see `assertCloneHostIsSupported`).
212 lines
7.5 KiB
TypeScript
212 lines
7.5 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
import { mkdir, readdir, rm, stat } from 'node:fs/promises'
|
|
import { isAbsolute, join } from 'node:path'
|
|
import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants'
|
|
import {
|
|
LOCAL_EXECUTION_HOST_ID,
|
|
parseExecutionHostId,
|
|
type ExecutionHostId
|
|
} from '../../shared/execution-host'
|
|
import type { Repo } from '../../shared/repo-types'
|
|
import { gitExecFileAsync, awaitWindowsHostGitEnvironmentReady } from '../git/runner'
|
|
import { getRepoName, isGitRepo } from '../git/repo'
|
|
import { invalidateAuthorizedRootsCache, isENOENT } from '../ipc/filesystem-auth'
|
|
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
|
|
import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation'
|
|
import type { RuntimeStore } from './runtime-store-contract'
|
|
import { runtimePathsEqual } from './runtime-worktree-path-identity'
|
|
import { runtimeRepoMatchesExecutionHost } from './runtime-worktree-selection'
|
|
|
|
type RuntimeRepositoryRegistrationDependencies = {
|
|
getStore: () => RuntimeStore | null
|
|
invalidateResolvedWorktrees: () => void
|
|
invalidateWorktreeScan: (repoId: string) => void
|
|
notifyReposChanged: () => void
|
|
}
|
|
|
|
export class RuntimeRepositoryRegistrationController {
|
|
constructor(private readonly deps: RuntimeRepositoryRegistrationDependencies) {}
|
|
|
|
async add(
|
|
path: string,
|
|
kind: 'git' | 'folder' = 'git',
|
|
executionHostId?: ExecutionHostId | null,
|
|
displayName?: string
|
|
): Promise<Repo> {
|
|
const store = this.requireStore()
|
|
if (!isAbsolute(path)) {
|
|
throw new Error('Project path must be an absolute path')
|
|
}
|
|
if (kind === 'git') {
|
|
await awaitWindowsHostGitEnvironmentReady({ cwd: path })
|
|
}
|
|
if (kind === 'git' && !isGitRepo(path)) {
|
|
throw new Error(`Not a valid git repository: ${path}`)
|
|
}
|
|
const existing = store.getRepos().find((repo) => {
|
|
return (
|
|
runtimePathsEqual(repo.path, path) && runtimeRepoMatchesExecutionHost(repo, executionHostId)
|
|
)
|
|
})
|
|
if (existing) {
|
|
if (
|
|
existing.executionHostId == null &&
|
|
parseExecutionHostId(executionHostId)?.kind === 'runtime'
|
|
) {
|
|
const adopted =
|
|
store.updateRepo(existing.id, { executionHostId }) ??
|
|
({ ...existing, executionHostId } as Repo)
|
|
this.invalidate(existing.id)
|
|
return adopted
|
|
}
|
|
return existing
|
|
}
|
|
// Local on purpose, whatever `executionHostId` stamps on the row: this controller already
|
|
// validated and will read `path` in this process. A `runtime:` stamp is how a paired client
|
|
// addresses the row, not a second machine holding the files.
|
|
const detected = await detectRepoIconAndUpstream({
|
|
repoPath: path,
|
|
kind,
|
|
executionHostId: LOCAL_EXECUTION_HOST_ID
|
|
})
|
|
const repo: Repo = {
|
|
id: randomUUID(),
|
|
path,
|
|
displayName: displayName?.trim() || getRepoName(path),
|
|
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
|
...(executionHostId != null ? { executionHostId } : {}),
|
|
...detected,
|
|
addedAt: Date.now(),
|
|
kind,
|
|
...(kind === 'git' ? { externalWorktreeVisibilityLegacy: false } : {})
|
|
}
|
|
store.addRepo(repo)
|
|
await prepareLocalWorktreeRootForRepo(store, repo)
|
|
this.invalidate(repo.id)
|
|
return store.getRepo(repo.id) ?? repo
|
|
}
|
|
|
|
async create(
|
|
parentPath: string,
|
|
name: string,
|
|
kind: 'git' | 'folder' = 'git'
|
|
): Promise<{ repo: Repo } | { error: string }> {
|
|
const store = this.requireStore()
|
|
const trimmedName = name.trim()
|
|
const trimmedParentPath = parentPath.trim()
|
|
const repoKind: 'git' | 'folder' = kind === 'folder' ? 'folder' : 'git'
|
|
if (!trimmedName) {
|
|
return { error: 'Name cannot be empty' }
|
|
}
|
|
if (/[\\/]/.test(trimmedName) || trimmedName === '.' || trimmedName === '..') {
|
|
return { error: 'Name cannot contain slashes or be "." / ".."' }
|
|
}
|
|
if (!trimmedParentPath) {
|
|
return { error: 'Parent directory is required' }
|
|
}
|
|
if (!isAbsolute(trimmedParentPath)) {
|
|
return { error: 'Parent directory must be an absolute path' }
|
|
}
|
|
const targetPath = join(trimmedParentPath, trimmedName)
|
|
const existing = store.getRepos().find((repo) => runtimePathsEqual(repo.path, targetPath))
|
|
if (existing) {
|
|
return { repo: existing }
|
|
}
|
|
|
|
let createdDir = false
|
|
try {
|
|
await mkdir(trimmedParentPath, { recursive: true })
|
|
const existingStat = await stat(targetPath).catch((error: unknown) => {
|
|
if (isENOENT(error)) {
|
|
return null
|
|
}
|
|
throw error
|
|
})
|
|
if (existingStat) {
|
|
if (!existingStat.isDirectory()) {
|
|
return { error: `"${trimmedName}" already exists at this location and is not a folder.` }
|
|
}
|
|
if ((await readdir(targetPath)).length > 0) {
|
|
return { error: `"${trimmedName}" already exists at this location and is not empty.` }
|
|
}
|
|
} else {
|
|
await mkdir(targetPath, { recursive: false })
|
|
createdDir = true
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
return { error: `Failed to prepare directory: ${message}` }
|
|
}
|
|
|
|
if (repoKind === 'git') {
|
|
const error = await this.initializeGitRepo(targetPath, createdDir)
|
|
if (error) {
|
|
return { error }
|
|
}
|
|
}
|
|
const raceWinner = store.getRepos().find((repo) => runtimePathsEqual(repo.path, targetPath))
|
|
if (raceWinner) {
|
|
return { repo: raceWinner }
|
|
}
|
|
const detected = await detectRepoIconAndUpstream({
|
|
repoPath: targetPath,
|
|
kind: repoKind,
|
|
executionHostId: LOCAL_EXECUTION_HOST_ID
|
|
})
|
|
const repo: Repo = {
|
|
id: randomUUID(),
|
|
path: targetPath,
|
|
displayName: trimmedName,
|
|
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
|
...detected,
|
|
addedAt: Date.now(),
|
|
kind: repoKind,
|
|
...(repoKind === 'git' ? { externalWorktreeVisibilityLegacy: false } : {})
|
|
}
|
|
store.addRepo(repo)
|
|
await prepareLocalWorktreeRootForRepo(store, repo)
|
|
invalidateAuthorizedRootsCache()
|
|
this.invalidate(repo.id)
|
|
return { repo: store.getRepo(repo.id) ?? repo }
|
|
}
|
|
|
|
private async initializeGitRepo(targetPath: string, createdDir: boolean): Promise<string | null> {
|
|
let step: 'init' | 'commit' = 'init'
|
|
try {
|
|
await gitExecFileAsync(['init'], { cwd: targetPath })
|
|
step = 'commit'
|
|
await gitExecFileAsync(['commit', '--allow-empty', '-m', 'Initial commit'], {
|
|
cwd: targetPath
|
|
})
|
|
return null
|
|
} catch (error) {
|
|
if (createdDir) {
|
|
await rm(targetPath, { recursive: true, force: true }).catch(() => {})
|
|
} else if (step === 'commit') {
|
|
await rm(join(targetPath, '.git'), { recursive: true, force: true }).catch(() => {})
|
|
}
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
if (step === 'commit' && /Please tell me who you are|user\.name|user\.email/i.test(message)) {
|
|
return 'Git author identity is not configured. Run `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"`, then try again.'
|
|
}
|
|
const label =
|
|
step === 'init' ? 'Failed to initialize git repository' : 'Failed to create initial commit'
|
|
return `${label}: ${message}`
|
|
}
|
|
}
|
|
|
|
private requireStore(): RuntimeStore {
|
|
const store = this.deps.getStore()
|
|
if (!store) {
|
|
throw new Error('runtime_unavailable')
|
|
}
|
|
return store
|
|
}
|
|
|
|
private invalidate(repoId: string): void {
|
|
this.deps.invalidateResolvedWorktrees()
|
|
this.deps.invalidateWorktreeScan(repoId)
|
|
this.deps.notifyReposChanged()
|
|
}
|
|
}
|