diff --git a/src/main/git/saved-clone-probe-budget.test.ts b/src/main/git/saved-clone-probe-budget.test.ts new file mode 100644 index 00000000000..a33727345c1 --- /dev/null +++ b/src/main/git/saved-clone-probe-budget.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import { runGitProbeOnHost } from '../repo-git-remote-identity' +import { reuseSavedCloneTarget } from './saved-clone-target' + +vi.mock('../repo-git-remote-identity', () => ({ runGitProbeOnHost: vi.fn() })) + +const url = 'https://gitlab.example.com/team/orca.git' +const project: Repo = { + id: 'saved', + path: '/repos/orca', + displayName: 'orca', + badgeColor: '#fff', + addedAt: 1, + kind: 'git' +} +const missingHead = Object.assign(new Error('Git returned no matching revision'), { + code: 1, + stderr: '' +}) +const probe = vi.mocked(runGitProbeOnHost) +const decide = (signal?: AbortSignal) => + reuseSavedCloneTarget(() => project, url, 'ssh:fixture', signal) + +beforeEach(() => { + probe.mockReset() +}) +afterEach(() => vi.useRealTimers()) + +describe('saved clone probe budget', () => { + it('does no Git reads for a new clone or a folder workspace', async () => { + await expect(reuseSavedCloneTarget(() => undefined, url, 'local')).resolves.toBeNull() + await expect( + reuseSavedCloneTarget(() => ({ ...project, kind: 'folder' }), url, 'local') + ).resolves.toBeNull() + expect(probe).not.toHaveBeenCalled() + }) + + it('uses two fixed-size reads for a finished checkout, independent of repository file count', async () => { + probe + .mockResolvedValueOnce({ stdout: '\nabc123\n' }) + .mockResolvedValueOnce({ stdout: `${url}\n` }) + await expect(decide()).resolves.toBe(project) + expect(probe.mock.calls.map(([args]) => args)).toEqual([ + ['rev-parse', '--show-cdup', '--verify', '--quiet', 'HEAD'], + ['config', '--get-all', 'remote.origin.url'] + ]) + }) + + it('uses the unborn-branch fallback only after Git reports no HEAD revision', async () => { + probe + .mockRejectedValueOnce(missingHead) + .mockResolvedValueOnce({ stdout: '\n' }) + .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) + .mockResolvedValueOnce({ + stdout: `remote.origin.url ${url}\nbranch.main.remote origin\nbranch.main.merge refs/heads/main\n` + }) + await expect(decide()).resolves.toBe(project) + expect(probe).toHaveBeenCalledTimes(4) + }) + + it('accepts a quiet missing HEAD through an older SSH error without relying on English text', async () => { + probe + .mockRejectedValueOnce(Object.assign(new Error('La commande a échoué'), { code: 1 })) + .mockResolvedValueOnce({ stdout: '\n' }) + .mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) + .mockResolvedValueOnce({ + stdout: `remote.origin.url ${url}\nbranch.main.remote origin\nbranch.main.merge refs/heads/main\n` + }) + await expect(decide()).resolves.toBe(project) + expect(probe).toHaveBeenCalledTimes(4) + }) + + it('refuses the invalid HEAD left by a clone interrupted during fetch', async () => { + probe + .mockRejectedValueOnce(missingHead) + .mockResolvedValueOnce({ stdout: '\n' }) + .mockRejectedValueOnce(Object.assign(new Error('fatal: No such ref: HEAD'), { code: 128 })) + await expect(decide()).rejects.toThrow('already an Orca project') + expect(probe).toHaveBeenCalledTimes(3) + }) + + it.each([ + ['transport loss', new Error('SSH channel closed')], + ['missing cwd', Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' })], + ['permission failure', Object.assign(new Error('fatal: permission denied'), { code: 128 })], + ['unstructured failure', new Error('fatal: Needed a single revision')], + [ + 'wrapper failure', + Object.assign(new Error('connection lost'), { code: 1, stderr: 'connection lost' }) + ] + ])('does not retry the host after %s', async (_label, error) => { + probe.mockRejectedValue(error) + await expect(decide()).rejects.toThrow('already an Orca project') + expect(probe).toHaveBeenCalledTimes(1) + }) + + it('does not retry when there is no route to the host', async () => { + probe.mockResolvedValue(null) + await expect(decide()).rejects.toThrow('already an Orca project') + expect(probe).toHaveBeenCalledTimes(1) + }) + + it('spends one SSH timeout budget, rather than starting a second timed-out read', async () => { + vi.useFakeTimers() + const started = Date.now() + probe.mockImplementation( + () => + new Promise((_resolve, reject) => { + setTimeout( + () => reject(Object.assign(new Error('request timed out'), { code: 'ETIMEDOUT' })), + 20_000 + ) + }) + ) + const assertion = expect(decide()).rejects.toThrow('already an Orca project') + await vi.runAllTimersAsync() + await assertion + expect(Date.now() - started).toBe(20_000) + expect(probe).toHaveBeenCalledTimes(1) + }) + + it('issues no later read after cancellation during the first read', async () => { + const controller = new AbortController() + probe.mockImplementation(async () => { + controller.abort() + throw new Error('cancelled') + }) + await expect(decide(controller.signal)).rejects.toThrow('Clone aborted') + expect(probe).toHaveBeenCalledTimes(1) + }) + + it('issues no read when the caller already cancelled', async () => { + const controller = new AbortController() + controller.abort() + await expect(decide(controller.signal)).rejects.toThrow('Clone aborted') + expect(probe).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/git/saved-clone-target.test.ts b/src/main/git/saved-clone-target.test.ts new file mode 100644 index 00000000000..6525b6a0f48 --- /dev/null +++ b/src/main/git/saved-clone-target.test.ts @@ -0,0 +1,186 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { Repo } from '../../shared/repo-types' +import { reuseSavedCloneTarget } from './saved-clone-target' + +const url = 'https://github.com/stablyai/orca.git' +const roots: string[] = [] + +function git(cwd: string, ...args: string[]): void { + execFileSync('git', ['-C', cwd, '-c', 'user.name=t', '-c', 'user.email=t@t.invalid', ...args]) +} + +// What `git clone url` leaves at /orca. `finished: false` is a clone killed before checkout; +// `commits: false` is a finished clone of an empty repository, whose HEAD stays unborn. +function checkout(originUrl: string, { finished = true, commits = true } = {}): string { + const root = mkdtempSync(join(tmpdir(), 'orca-saved-clone-target-')) + roots.push(root) + const path = join(root, 'orca') + if (finished && !commits) { + const source = join(root, 'empty-source') + execFileSync('git', ['init', '-q', source]) + execFileSync('git', ['clone', '-q', source, path], { stdio: 'ignore' }) + git(path, 'remote', 'set-url', 'origin', originUrl) + } else { + execFileSync('git', ['init', '-q', path]) + git(path, 'remote', 'add', 'origin', originUrl) + } + if (!finished) { + writeFileSync(join(path, '.git', 'HEAD'), 'ref: refs/heads/.invalid\n') + } else if (commits) { + git(path, 'commit', '-q', '--allow-empty', '-m', 'init') + } + return path +} + +function saved(path: string, extra: Partial = {}): Repo { + return { + id: 'saved', + path, + displayName: 'orca', + badgeColor: '#fff', + addedAt: 1, + kind: 'git', + ...extra + } +} + +const decide = (project: Repo, requested = url) => + reuseSavedCloneTarget(() => project, requested, LOCAL_EXECUTION_HOST_ID) + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('reuseSavedCloneTarget', () => { + it('reuses a saved project whose folder is a finished clone of the URL', async () => { + const project = saved(checkout(url)) + await expect(decide(project)).resolves.toBe(project) + }) + + it('reuses a finished clone of the same repo spelled differently', async () => { + const project = saved(checkout('git@github.com:stablyai/orca.git')) + await expect(decide(project, 'https://github.com/stablyai/orca')).resolves.toBe(project) + }) + + // A brand-new repository has no commits, so a repeat clone must not read as a failed one. + it('reuses a finished clone of an empty repository, whose HEAD is unborn', async () => { + const project = saved(checkout(url, { commits: false })) + await expect(decide(project)).resolves.toBe(project) + }) + + it('refuses an unborn clone without tracking metadata, as Git 2.25 leaves during fetch', async () => { + const path = checkout(url, { finished: false }) + git(path, 'symbolic-ref', 'HEAD', 'refs/heads/master') + await expect(decide(saved(path))).rejects.toThrow('already an Orca project') + }) + + it('matches the exact unborn branch when its name contains regex metacharacters', async () => { + const path = checkout(url, { commits: false }) + const branch = 'release/v1.2+probe' + git(path, 'symbolic-ref', 'HEAD', `refs/heads/${branch}`) + git(path, 'config', `branch.${branch}.remote`, 'origin') + git(path, 'config', `branch.${branch}.merge`, `refs/heads/${branch}`) + await expect(decide(saved(path))).resolves.toMatchObject({ path }) + git(path, 'config', '--unset', `branch.${branch}.remote`) + git(path, 'config', 'branch.release/v1x222probe.remote', 'origin') + await expect(decide(saved(path))).rejects.toThrow('already an Orca project') + }) + + it('refuses an empty clone with duplicate origin URLs or incomplete tracking metadata', async () => { + const path = checkout(url, { commits: false }) + git(path, 'remote', 'set-url', '--add', 'origin', url) + await expect(decide(saved(path))).rejects.toThrow('already an Orca project') + git(path, 'config', '--replace-all', 'remote.origin.url', url) + const head = execFileSync('git', ['-C', path, 'symbolic-ref', '--short', 'HEAD'], { + encoding: 'utf8' + }).trim() + git(path, 'config', '--unset', `branch.${head}.merge`) + await expect(decide(saved(path))).rejects.toThrow('already an Orca project') + }) + + it('reuses a finished clone left on a detached HEAD', async () => { + const path = checkout(url) + git(path, 'update-ref', '--no-deref', 'HEAD', 'HEAD') + const project = saved(path) + await expect(decide(project)).resolves.toBe(project) + }) + + it('does not reuse a folder nested inside a clone of an empty repository', async () => { + const nested = join(checkout(url, { commits: false }), 'orca') + mkdirSync(nested) + await expect(decide(saved(nested))).rejects.toThrow('already an Orca project') + }) + + it('refuses a finished clone of a different URL with the same folder name', async () => { + const project = saved(checkout('https://github.com/me/orca.git')) + await expect(decide(project)).rejects.toThrow('"orca" is already an Orca project') + }) + + // The two reasons a clone can be refused need different things from the user, so say which it is. + it('says the recorded repository differs without disclosing its credential-bearing URL', async () => { + const project = saved(join(tmpdir(), 'orca-saved-clone-target-absent'), { + gitRemoteIdentity: { + canonicalKey: 'github.com/someone/orca', + remoteName: 'origin', + remoteUrl: 'https://synthetic-user:synthetic-secret@github.com/someone/orca.git' + } + }) + const result = decide(project) + await expect(result).rejects.toThrow('recorded as a different repository') + await expect(result).rejects.not.toThrow('synthetic-secret') + await expect(result).rejects.not.toThrow(project.gitRemoteIdentity?.remoteUrl ?? '') + }) + + it('says it has no record of the repository when the project never stored an origin', async () => { + const project = saved(join(tmpdir(), 'orca-saved-clone-target-absent')) + await expect(decide(project)).rejects.toThrow('no record of which repository') + }) + + it('refuses a finished clone of a different local path, which does not normalize', async () => { + const project = saved(checkout('/srv/other/orca')) + await expect(decide(project, '/srv/mine/orca')).rejects.toThrow('already an Orca project') + }) + + it('does not reuse a clone that was killed before it finished', async () => { + const identity = { + canonicalKey: 'github.com/stablyai/orca', + remoteName: 'origin', + remoteUrl: url + } + const path = checkout(url, { finished: false }) + await expect(decide(saved(path))).rejects.toThrow('already an Orca project') + // Same repo by its saved identity: let git clone, which refuses the non-empty folder. + await expect(decide(saved(path, { gitRemoteIdentity: identity }))).resolves.toBeNull() + }) + + it('does not reuse a clone whose origin also lists another URL', async () => { + const path = checkout('https://github.com/me/orca.git') + git(path, 'remote', 'set-url', '--add', 'origin', url) + await expect(decide(saved(path))).rejects.toThrow('already an Orca project') + }) + + it('refuses to re-clone into a fork project whose stored identity is its upstream', async () => { + const path = join(tmpdir(), 'orca-saved-clone-target-deleted-fork') + const upstream = { + canonicalKey: 'github.com/stablyai/orca', + remoteName: 'upstream', + remoteUrl: url + } + await expect(decide(saved(path, { gitRemoteIdentity: upstream }))).rejects.toThrow( + 'already an Orca project' + ) + }) + + it('does not reuse a folder that only sits inside a clone of the URL', async () => { + const nested = join(checkout(url), 'orca') + mkdirSync(nested) + await expect(decide(saved(nested))).rejects.toThrow('already an Orca project') + }) +}) diff --git a/src/main/git/saved-clone-target.ts b/src/main/git/saved-clone-target.ts new file mode 100644 index 00000000000..d9534c8dd61 --- /dev/null +++ b/src/main/git/saved-clone-target.ts @@ -0,0 +1,152 @@ +import type { ExecutionHostId } from '../../shared/execution-host' +import { normalizeGitRemoteUrl } from '../../shared/git-remote-identity' +import { isShowRefNoMatchError } from '../../shared/git-show-ref-no-match' +import { isFolderRepo } from '../../shared/repo-kind' +import type { Repo } from '../../shared/repo-types' +import { runGitProbeOnHost } from '../repo-git-remote-identity' + +type GitProbe = (args: string[]) => Promise + +/** Read failures stay distinct from Git proving an unborn HEAD. */ +function probeIn(repoPath: string, hostId: ExecutionHostId, signal?: AbortSignal): GitProbe { + return async (args) => { + if (signal?.aborted) { + throw new Error('Clone aborted') + } + const result = await runGitProbeOnHost(args, repoPath, hostId, { signal }) + if (!result) { + throw new Error('Clone target host is unavailable') + } + return result.stdout + } +} + +/** Only an empty first line means the folder is the checkout's own top level: a subfolder prints + * `../`, and a bare repo prints nothing so whatever follows lands on the first line instead. */ +function isCheckoutTopLevel(cdupStdout: string | null): boolean { + return cdupStdout !== null && cdupStdout.split(/\r?\n/)[0] === '' +} + +/** True when git shows `repoPath` is a checkout's own top level whose HEAD git finished writing. */ +async function isFinishedCheckout( + probe: GitProbe +): Promise<{ unbornBranch: string | null } | null> { + try { + const settled = await probe(['rev-parse', '--show-cdup', '--verify', '--quiet', 'HEAD']) + return isCheckoutTopLevel(settled) ? { unbornBranch: null } : null + } catch (error) { + // The same quiet missing-ref exit applies to rev-parse; transport faults must stop here. + if (!isShowRefNoMatchError(error)) { + throw error + } + } + if (!isCheckoutTopLevel(await probe(['rev-parse', '--show-cdup']))) { + return null + } + const head = (await probe(['symbolic-ref', 'HEAD'])).trim() + return head.startsWith('refs/heads/') ? { unbornBranch: head.slice('refs/heads/'.length) } : null +} + +async function readCloneOrigin( + probe: GitProbe, + unbornBranch: string | null +): Promise { + if (unbornBranch === null) { + return (await probe(['config', '--get-all', 'remote.origin.url'])).trim() + } + const branchKey = `branch.${unbornBranch}` + const escapedKey = branchKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const config = await probe([ + 'config', + '--get-regexp', + `^(remote\\.origin\\.url|${escapedKey}\\.(remote|merge))$` + ]) + const values = new Map() + for (const line of config.trim().split('\n')) { + const separator = line.indexOf(' ') + if (separator === -1) { + return null + } + const key = line.slice(0, separator) + if (values.has(key)) { + return null + } + values.set(key, line.slice(separator + 1)) + } + // Git 2.25 writes this tracking pair only after an empty clone is known to have finished. + if ( + values.get(`${branchKey}.remote`) !== 'origin' || + values.get(`${branchKey}.merge`) !== `refs/heads/${unbornBranch}` + ) { + return null + } + return values.get('remote.origin.url') ?? null +} + +/** True only when git on `hostId` shows `repoPath` is a finished checkout whose single origin URL + * names the same repo as `url` — what a finished `git clone url` leaves. */ +async function isFinishedCloneOf( + repoPath: string, + url: string, + hostId: ExecutionHostId, + signal?: AbortSignal +): Promise { + const probe = probeIn(repoPath, hostId, signal) + try { + const checkout = await isFinishedCheckout(probe) + if (!checkout) { + return false + } + const originUrl = await readCloneOrigin(probe, checkout.unbornBranch) + if (!originUrl || originUrl.includes('\n')) { + return false + } + const requestedKey = normalizeGitRemoteUrl(url) + return requestedKey ? normalizeGitRemoteUrl(originUrl) === requestedKey : originUrl === url + } catch { + return false + } +} + +/** + * Decides what a clone does about a saved project already at its path. Returns the project when its + * folder is already a clone of `url`, null when git should clone (nothing saved, or the saved + * project was this repo and lost its folder), and throws when the saved project is something else. + * `findSaved` must match path and host, so re-reading it after the probe also checks the host. + */ +export async function reuseSavedCloneTarget( + findSaved: () => Repo | undefined, + url: string, + hostId: ExecutionHostId, + signal?: AbortSignal +): Promise { + const saved = findSaved() + if (!saved || isFolderRepo(saved)) { + return null + } + const isClone = await isFinishedCloneOf(saved.path, url, hostId, signal) + if (signal?.aborted) { + throw new Error('Clone aborted') + } + if (isClone) { + const current = findSaved() + // Why: removed or replaced while git answered; git clone then refuses the non-empty folder. + return current?.id === saved.id ? current : null + } + const requestedKey = normalizeGitRemoteUrl(url) + // Why: `origin` is the only stored remote that names the project's own repo. `upstream` names the + // repo a fork came from, and so do `Repo.upstream` and the GitHub avatar `repoIcon` derived from it. + const storedOrigin = + saved.gitRemoteIdentity?.remoteName === 'origin' ? saved.gitRemoteIdentity : null + // Why: the project was this repo, so its settings still belong once git re-creates the folder. + if (requestedKey && storedOrigin?.canonicalKey === requestedKey) { + return null + } + throw new Error( + `"${saved.displayName}" is already an Orca project at ${saved.path}, and Orca couldn't confirm that folder is a clone of this URL: ${ + storedOrigin + ? 'Orca has that project recorded as a different repository' + : 'Orca has no record of which repository that project holds, so it cannot tell whether the folder is missing or holds something else' + }. Remove the project from Orca or choose another folder.` + ) +} diff --git a/src/main/ipc/repos-local-clone-lifecycle.test.ts b/src/main/ipc/repos-local-clone-lifecycle.test.ts index 4bd7e4f45a3..00284d42699 100644 --- a/src/main/ipc/repos-local-clone-lifecycle.test.ts +++ b/src/main/ipc/repos-local-clone-lifecycle.test.ts @@ -54,6 +54,33 @@ const { prepareLocalWorktreeRootForRepoMock } = reposMocks +type GitExecOptions = { cwd?: unknown; signal?: unknown } + +// The shared spy is typed for exec's (argv, cwd); gitExecFileAsync gets (argv, options). +function readGitExecOptions(call: unknown[]): GitExecOptions { + const options = call[1] + if (typeof options !== 'object' || options === null) { + return {} + } + return { + cwd: 'cwd' in options ? options.cwd : undefined, + signal: 'signal' in options ? options.signal : undefined + } +} + +// Answers the proof the way git does inside a finished clone of `originUrl` at `path`. +function answerAsCloneOf(path: string, originUrl: string): void { + gitExecFileAsyncMock.mockImplementation((...call: unknown[]) => { + const args = call[0] + if (!Array.isArray(args) || readGitExecOptions(call).cwd !== path) { + return Promise.reject(new Error('fatal: not a git repository')) + } + const stdout = + args[0] === 'rev-parse' ? '\n0123abcd\n' : args[0] === 'config' ? `${originUrl}\n` : '' + return Promise.resolve({ stdout, stderr: '' }) + }) +} + beforeEach(() => { clearGitCapabilityStateForTests() resetSshProviderAuthorities() @@ -495,6 +522,8 @@ describe('repos:add + repos:clone', () => { it('dedupes retry when abort races with a successful clone close', async () => { const destination = await createTempRoot() const clonePath = join(destination, 'orca') + // The queued request finds the first clone's project and must see a finished clone there. + answerAsCloneOf(clonePath, 'https://example.com/orca.git') const repos: unknown[] = [] mockStore.getRepos.mockImplementation(() => repos) mockStore.addRepo.mockImplementation((repo: unknown) => { @@ -529,6 +558,8 @@ describe('repos:add + repos:clone', () => { it('serializes concurrent clones for the same target', async () => { const destination = await createTempRoot() const clonePath = join(destination, 'orca') + // The queued request finds the first clone's project and must see a finished clone there. + answerAsCloneOf(clonePath, 'https://example.com/orca.git') const repos: unknown[] = [] mockStore.getRepos.mockImplementation(() => repos) mockStore.addRepo.mockImplementation((repo: unknown) => { @@ -614,4 +645,102 @@ describe('repos:add + repos:clone', () => { expect(existsSync(replacementFile)).toBe(true) }) + + describe('a saved project already at the clone path', () => { + const url = 'https://example.com/orca.git' + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + const savedProject = (path: string, extra: Record = {}) => ({ + id: 'saved-project', + path, + displayName: 'orca', + badgeColor: '#fff', + addedAt: 1, + kind: 'git', + ...extra + }) + const identity = (canonicalKey: string) => ({ + canonicalKey, + remoteName: 'origin', + remoteUrl: `https://${canonicalKey}.git` + }) + it('clones again when the saved project was this repo but its folder is gone', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + const saved = savedProject(clonePath, { gitRemoteIdentity: identity('example.com/orca') }) + mockStore.getRepos.mockReturnValue([saved]) + gitExecFileAsyncMock.mockRejectedValue(new Error('spawn ENOENT')) + + await expect(handlers.get('repos:clone')!(null, { url, destination })).resolves.toBe(saved) + + expect(gitSpawnMock).toHaveBeenCalledTimes(1) + expect(mockStore.addRepo).not.toHaveBeenCalled() + expect(mockWindow.webContents.send).toHaveBeenCalledWith('repos:changed') + // The folder git just re-created has no worktree root, and the cached roots predate it. + expect(prepareLocalWorktreeRootForRepoMock).toHaveBeenCalledWith(mockStore, saved) + expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled() + }) + + it('refuses, naming the project, when the saved project was a different repo', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + const saved = savedProject(clonePath, { + gitRemoteIdentity: identity('github.com/stablyai/orca') + }) + mockStore.getRepos.mockReturnValue([saved]) + answerAsCloneOf(clonePath, 'https://github.com/stablyai/orca.git') + + await expect( + handlers.get('repos:clone')!(null, { url: 'https://github.com/me/orca.git', destination }) + ).rejects.toThrow('"orca" is already an Orca project') + + expect(gitSpawnMock).not.toHaveBeenCalled() + }) + + it.each([ + { connectionId: 'conn-1' }, + { executionHostId: 'runtime:env-1' }, + { executionHostId: 'runtime:env-1', kind: 'folder' } + ])( + 'does not treat a saved remote project at the same path as the local clone: %j', + async (host) => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + const remoteProject = savedProject(clonePath, host) + mockStore.getRepos.mockReturnValue([remoteProject]) + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + const result = await handlers.get('repos:clone')!(null, { url, destination }) + + expect(gitSpawnMock).toHaveBeenCalledTimes(1) + expect(result).not.toBe(remoteProject) + expect(mockStore.addRepo).toHaveBeenCalledWith(expect.objectContaining({ path: clonePath })) + } + ) + + it('stops without cloning when cancelled while git checks the saved folder', async () => { + const destination = await createTempRoot() + const clonePath = join(destination, 'orca') + mockStore.getRepos.mockReturnValue([ + savedProject(clonePath, { gitRemoteIdentity: identity('example.com/orca') }) + ]) + gitExecFileAsyncMock.mockImplementation( + (...call: unknown[]) => + new Promise((_resolve, reject) => { + const signal = readGitExecOptions(call).signal + if (signal instanceof AbortSignal) { + signal.addEventListener('abort', () => reject(new Error('aborted'))) + } + }) + ) + + const clonePromise = handlers.get('repos:clone')!(null, { url, destination }) + await waitForAssertion(() => expect(gitExecFileAsyncMock).toHaveBeenCalled()) + await handlers.get('repos:cloneAbort')!(null, undefined) + + await expect(clonePromise).rejects.toThrow('Clone aborted') + expect(gitSpawnMock).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 3e18624828b..95388193985 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -207,7 +207,68 @@ describe('repos:addRemote', () => { }) }) - it('returns an existing SSH repo instead of cloning the same target again', async () => { + it.each([ + ['connectionId', { connectionId: 'conn-1' }], + ['executionHostId only', { executionHostId: 'ssh:conn-1' }] + ])( + 'returns an existing SSH repo (%s) instead of cloning the same target again', + async (_label, owner) => { + const existing = { + id: 'existing-id', + path: '/home/user/orca', + ...owner, + displayName: 'orca', + badgeColor: '#fff', + addedAt: 1000, + kind: 'git' + } + mockStore.getRepos.mockReturnValue([existing]) + // What git on the SSH host answers inside a finished clone of the URL. + mockGitProvider.exec.mockImplementation((argv: string[], cwd?: string) => + cwd === '/home/user/orca' && argv[0] === 'rev-parse' + ? Promise.resolve({ stdout: '\n0123abcd\n', stderr: '' }) + : cwd === '/home/user/orca' && argv[0] === 'config' + ? Promise.resolve({ stdout: 'https://github.com/stablyai/orca.git\n', stderr: '' }) + : Promise.reject(new Error('fatal: not a git repository')) + ) + + const result = await handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/user' + }) + + expect(result).toBe(existing) + expect(mockGitProvider.clone).not.toHaveBeenCalled() + expect(mockStore.addRepo).not.toHaveBeenCalled() + } + ) + + it('refuses a saved SSH project at the destination whose folder is not this clone', async () => { + mockStore.getRepos.mockReturnValue([ + { + id: 'existing-id', + path: '/home/user/orca', + connectionId: 'conn-1', + displayName: 'orca', + badgeColor: '#fff', + addedAt: 1000, + kind: 'git' + } + ]) + mockGitProvider.exec.mockRejectedValue(new Error('fatal: not a git repository')) + + await expect( + handlers.get('repos:cloneRemote')!(null, { + connectionId: 'conn-1', + url: 'https://github.com/stablyai/orca.git', + destination: '/home/user' + }) + ).rejects.toThrow('"orca" is already an Orca project') + expect(mockGitProvider.clone).not.toHaveBeenCalled() + }) + + it('re-registers the root after re-cloning into a saved SSH project whose folder was gone', async () => { const existing = { id: 'existing-id', path: '/home/user/orca', @@ -215,9 +276,15 @@ describe('repos:addRemote', () => { displayName: 'orca', badgeColor: '#fff', addedAt: 1000, - kind: 'git' + kind: 'git', + gitRemoteIdentity: { + canonicalKey: 'github.com/stablyai/orca', + remoteName: 'origin', + remoteUrl: 'https://github.com/stablyai/orca.git' + } } mockStore.getRepos.mockReturnValue([existing]) + mockGitProvider.exec.mockRejectedValue(new Error('fatal: not a git repository')) const result = await handlers.get('repos:cloneRemote')!(null, { connectionId: 'conn-1', @@ -225,9 +292,13 @@ describe('repos:addRemote', () => { destination: '/home/user' }) + expect(mockGitProvider.clone).toHaveBeenCalledTimes(1) expect(result).toBe(existing) - expect(mockGitProvider.clone).not.toHaveBeenCalled() expect(mockStore.addRepo).not.toHaveBeenCalled() + // Without this the relay never learns the path came back, so sessions there stay unauthorized. + expect(mockMultiplexer.notify).toHaveBeenCalledWith('session.registerRoot', { + rootPath: '/home/user/orca' + }) }) it('upgrades an existing SSH folder repo after cloning into that path', async () => { diff --git a/src/main/ipc/repos/remote-repo-clone.ts b/src/main/ipc/repos/remote-repo-clone.ts index 1d01f667ab4..8234a65d277 100644 --- a/src/main/ipc/repos/remote-repo-clone.ts +++ b/src/main/ipc/repos/remote-repo-clone.ts @@ -9,6 +9,8 @@ import { } from '../../../shared/cross-platform-path' import { getGitCloneFailureMessage } from '../../../shared/git-clone-failure-message' import { deriveCloneRepoNameFromUrl } from '../../git/repo-clone-path' +import { reuseSavedCloneTarget } from '../../git/saved-clone-target' +import { getRepoSshConnectionId, toSshExecutionHostId } from '../../../shared/execution-host' import { getSshGitProvider } from '../../providers/ssh-git-dispatch' import { getSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' import { joinRemotePath } from '../../ssh/ssh-remote-platform' @@ -57,16 +59,13 @@ export async function cloneRemoteRepo( throw new Error('Clone path must be inside the destination directory') } const clonePathKey = normalizeRuntimePathForComparison(clonePath) - const existing = store.getRepos().find((repo) => { - return ( - repo.connectionId === args.connectionId && - normalizeRuntimePathForComparison(repo.path) === clonePathKey - ) - }) - if (existing && !isFolderRepo(existing)) { - emitRepoAdded('clone_url', true) - return existing - } + const findSaved = (): Repo | undefined => + store.getRepos().find((repo) => { + return ( + getRepoSshConnectionId(repo) === args.connectionId && + normalizeRuntimePathForComparison(repo.path) === clonePathKey + ) + }) const remoteCloneKey = `${args.connectionId}:${clonePathKey}` if (remoteCloneInFlightByPath.has(remoteCloneKey)) { @@ -81,6 +80,17 @@ export async function cloneRemoteRepo( activeRemoteClone = metadata remoteCloneInFlightByPath.add(remoteCloneKey) try { + // Why: after the in-flight guard, so a retry never inspects a clone that is still running. + const reused = await reuseSavedCloneTarget( + findSaved, + args.url.trim(), + toSshExecutionHostId(args.connectionId), + controller.signal + ) + if (reused) { + emitRepoAdded('clone_url', true) + return reused + } // Why: match local clone by creating the parent first, or a fresh remote parent surfaces as spawn ENOENT. await fsProvider.createDir(trimmedDestination) // Why: the SSH relay runs git argv, not a shell; use the repo folder name so git creates it under the chosen parent. @@ -112,17 +122,28 @@ export async function cloneRemoteRepo( } remoteCloneInFlightByPath.delete(remoteCloneKey) } - if (existing && isFolderRepo(existing)) { - const updated = store.updateRepo(existing.id, { - kind: 'git', - projectHostSetupMethod: 'cloned' - }) - if (updated) { - emitRepoAdded('clone_url', false) + const existing = findSaved() + if (existing) { + if (isFolderRepo(existing)) { + const updated = store.updateRepo(existing.id, { + kind: 'git', + projectHostSetupMethod: 'cloned' + }) + if (updated) { + emitRepoAdded('clone_url', false) + getActiveMultiplexer(args.connectionId)?.notify('session.registerRoot', { + rootPath: clonePath + }) + return updated + } + } else { + // Why: git re-created this project's folder, and `addRemoteRepoFromPath` returns an existing + // project without registering the root, so the relay would never learn the path came back. getActiveMultiplexer(args.connectionId)?.notify('session.registerRoot', { rootPath: clonePath }) - return updated + emitRepoAdded('clone_url', true) + return existing } } const result = await addRemoteRepoFromPath(store, { diff --git a/src/main/ipc/repos/repo-clone-lifecycle.ts b/src/main/ipc/repos/repo-clone-lifecycle.ts index 0fa48ff3667..bbc8c96f7f3 100644 --- a/src/main/ipc/repos/repo-clone-lifecycle.ts +++ b/src/main/ipc/repos/repo-clone-lifecycle.ts @@ -9,6 +9,7 @@ import { isFolderRepo } from '../../../shared/repo-kind' import { DEFAULT_REPO_BADGE_COLOR } from '../../../shared/constants' import { getGitCloneFailureMessage } from '../../../shared/git-clone-failure-message' import { gitSpawnAfterWindowsEnvironmentReady, promptGuardGitEnv } from '../../git/runner' +import { reuseSavedCloneTarget } from '../../git/saved-clone-target' import { getRepoName } from '../../git/repo' import type { ClaimedCloneTarget } from '../../git/repo-clone-path' import { @@ -17,7 +18,7 @@ import { deriveValidatedClonePath, getClonePathComparisonKey } from '../../git/repo-clone-path' -import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { detectRepoIconAndUpstream } from '../../repo-icon-autodetect' import { prepareLocalWorktreeRootForRepo } from '../../worktree-root-preparation' import { invalidateAuthorizedRootsCache } from '../registered-worktree-roots-cache' @@ -119,44 +120,59 @@ export function registerRepoCloneHandlers(mainWindow: BrowserWindow, store: Stor // Why: derive the repo folder name from the URL's last segment, matching default git clone behavior. const clonePath = deriveValidatedClonePath(args) const clonePathKey = getClonePathComparisonKey(clonePath) + // Remote projects can share this path string without belonging to this clone host. + const findSaved = (): Repo | undefined => + store + .getRepos() + .find( + (r) => + getClonePathComparisonKey(r.path) === clonePathKey && + getRepoExecutionHostId(r) === LOCAL_EXECUTION_HOST_ID + ) return runWithClonePathLock(clonePathKey, async () => { await pendingAbortCleanupByPath.get(clonePathKey) - const existingAfterPendingClone = store - .getRepos() - .find((r) => getClonePathComparisonKey(r.path) === clonePathKey) - if (existingAfterPendingClone && !isFolderRepo(existingAfterPendingClone)) { - // Why: clone_url always produces a git repo. - emitRepoAdded('clone_url', true, true) - return existingAfterPendingClone - } - // Why: gitSpawn cwd is args.destination, so it must exist before spawn (fresh installs may lack the defaulted parent). - await mkdir(args.destination, { recursive: true }) - const claimedTarget = await claimCloneTarget(clonePath) - - // Why: spawn (not execFile) avoids the maxBuffer limit — clone progress on stderr can exceed Node's 1 MB default. - // Why: --progress forces git to emit progress even when stderr isn't a TTY. const cloneMetadataRef: { current: ActiveCloneMetadata | null } = { current: null } + let claimedTarget: ClaimedCloneTarget let proc: Awaited> + // Why: registered before the saved-project check so Cancel also stops that git read. const pendingController = new AbortController() pendingLocalCloneControllers.add(pendingController) try { - // Why: use the parent destination as cwd so the runner detects a WSL path and routes through wsl.exe. - // Why: '--' isolates the URL so a malicious URL can't be read as git flags (command injection). - proc = await gitSpawnAfterWindowsEnvironmentReady( - ['clone', '--progress', '--', args.url, clonePath], - { - cwd: args.destination, - admissionTier: 'interactive', - // Why: without this, an auth-needing clone pops Git Credential Manager's OAuth window on Windows, unclosable in a restricted env (issue #7652). - env: promptGuardGitEnv(), - signal: pendingController.signal, - stdio: ['ignore', 'ignore', 'pipe'] - } + const reused = await reuseSavedCloneTarget( + findSaved, + args.url, + LOCAL_EXECUTION_HOST_ID, + pendingController.signal ) - } catch (err) { - await cleanupClaimedCloneTarget(clonePath, claimedTarget) - const message = err instanceof Error ? err.message : String(err) - throw new Error(`Clone failed: ${message}`) + if (reused) { + // Why: clone_url always produces a git repo. + emitRepoAdded('clone_url', true, true) + return reused + } + // Why: gitSpawn cwd is args.destination, so it must exist before spawn (fresh installs may lack the defaulted parent). + await mkdir(args.destination, { recursive: true }) + claimedTarget = await claimCloneTarget(clonePath) + // Why: spawn (not execFile) avoids the maxBuffer limit — clone progress on stderr can exceed Node's 1 MB default. + // Why: --progress forces git to emit progress even when stderr isn't a TTY. + try { + // Why: use the parent destination as cwd so the runner detects a WSL path and routes through wsl.exe. + // Why: '--' isolates the URL so a malicious URL can't be read as git flags (command injection). + proc = await gitSpawnAfterWindowsEnvironmentReady( + ['clone', '--progress', '--', args.url, clonePath], + { + cwd: args.destination, + admissionTier: 'interactive', + // Why: without this, an auth-needing clone pops Git Credential Manager's OAuth window on Windows, unclosable in a restricted env (issue #7652). + env: promptGuardGitEnv(), + signal: pendingController.signal, + stdio: ['ignore', 'ignore', 'pipe'] + } + ) + } catch (err) { + await cleanupClaimedCloneTarget(clonePath, claimedTarget) + const message = err instanceof Error ? err.message : String(err) + throw new Error(`Clone failed: ${message}`) + } } finally { pendingLocalCloneControllers.delete(pendingController) } @@ -236,9 +252,7 @@ export function registerRepoCloneHandlers(mainWindow: BrowserWindow, store: Stor try { // Why: check after clone (path didn't exist before); reuse+upgrade a folder repo clone landed into instead of duplicating. - const existing = store - .getRepos() - .find((r) => getClonePathComparisonKey(r.path) === clonePathKey) + const existing = findSaved() if (existing) { if (isFolderRepo(existing)) { const updated = store.updateRepo(existing.id, { @@ -254,6 +268,11 @@ export function registerRepoCloneHandlers(mainWindow: BrowserWindow, store: Stor 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. + await prepareLocalWorktreeRootForRepo(store, existing) + invalidateAuthorizedRootsCache() + notifyReposChanged(mainWindow) emitRepoAdded('clone_url', true, true) return existing } diff --git a/src/main/repo-git-remote-identity.ts b/src/main/repo-git-remote-identity.ts index 7badf6370a9..cb53319db24 100644 --- a/src/main/repo-git-remote-identity.ts +++ b/src/main/repo-git-remote-identity.ts @@ -25,6 +25,34 @@ export type GitRemoteIdentityProbe = | { status: 'no-remote' } | { status: 'unavailable' } +/** Runs a read-only git command in `repoPath` on the host that owns it, bounded by the probe + * timeouts. Resolves null when this process has no route to that host's git; throws on git errors. */ +export async function runGitProbeOnHost( + args: string[], + repoPath: string, + executionHostId: ExecutionHostId, + options: GitRemoteIdentityProbeOptions = {} +): Promise<{ stdout: string } | null> { + const route = resolveGitRouteForHost(executionHostId) + if (route.kind === 'runtime') { + // That environment's server runs its own git, and the SSH target on its repo row is nested in + // that server's namespace — dialing it here answers for a same-named box of ours. + return null + } + if (route.kind === 'ssh') { + const result = await route.provider?.exec(args, repoPath, { + signal: options.signal, + timeoutMs: options.timeoutMs ?? SSH_PROBE_TIMEOUT_MS + }) + return result ?? null + } + return gitExecFileAsync(args, { + cwd: repoPath, + timeout: options.timeoutMs ?? LOCAL_PROBE_TIMEOUT_MS, + signal: options.signal + }) +} + export async function probeGitRemoteIdentity( repoPath: string, executionHostId: ExecutionHostId, @@ -33,23 +61,7 @@ export async function probeGitRemoteIdentity( try { // Inside the try on purpose: an id naming no host must land on `unavailable` like every other // probe that never reached git. It must never become the local answer for a remote path. - const route = resolveGitRouteForHost(executionHostId) - if (route.kind === 'runtime') { - // That environment's server runs its own git, and the SSH target on its repo row is nested in - // that server's namespace — dialing it here answers for a same-named box of ours. - return { status: 'unavailable' } - } - const result = - route.kind === 'ssh' - ? await route.provider?.exec(['remote', '-v'], repoPath, { - signal: options.signal, - timeoutMs: options.timeoutMs ?? SSH_PROBE_TIMEOUT_MS - }) - : await gitExecFileAsync(['remote', '-v'], { - cwd: repoPath, - timeout: options.timeoutMs ?? LOCAL_PROBE_TIMEOUT_MS, - signal: options.signal - }) + const result = await runGitProbeOnHost(['remote', '-v'], repoPath, executionHostId, options) if (!result) { return { status: 'unavailable' } } diff --git a/src/main/runtime/orca-runtime-tests/repository-project-operations-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/repository-project-operations-part-02.spec.ts index 29fb4bc111b..cd1f188a6f3 100644 --- a/src/main/runtime/orca-runtime-tests/repository-project-operations-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/repository-project-operations-part-02.spec.ts @@ -581,18 +581,30 @@ describe('OrcaRuntimeService', () => { const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) try { - const firstClonePromise = runtime.cloneRepo( - 'https://example.com/repo-badge-color.git', - destination - ) - const secondClonePromise = runtime.cloneRepo( - 'https://example.com/repo-badge-color.git', - destination - ) + const url = 'https://example.com/repo-badge-color.git' + const firstClonePromise = runtime.cloneRepo(url, destination) + const secondClonePromise = runtime.cloneRepo(url, destination) await vi.waitFor(() => expect(spawnSpy).toHaveBeenCalledTimes(1)) await new Promise((resolve) => setImmediate(resolve)) expect(spawnSpy).toHaveBeenCalledTimes(1) + // The queued request must find a finished clone of the URL where the first one landed. + const clonePath = join(destination, 'repo-badge-color') + execFileSync('git', ['init', '-q', clonePath]) + execFileSync('git', ['-C', clonePath, 'remote', 'add', 'origin', url]) + execFileSync('git', [ + '-C', + clonePath, + '-c', + 'user.name=Orca Test', + '-c', + 'user.email=test@orca.invalid', + 'commit', + '-q', + '--allow-empty', + '-m', + 'init' + ]) firstProc.emit('close', 0, null) await expect(firstClonePromise).resolves.toMatchObject({ path: join(destination, 'repo-badge-color') @@ -606,4 +618,54 @@ describe('OrcaRuntimeService', () => { await rm(destination, { recursive: true, force: true }) } }) + + it('refuses a saved project at the clone path whose folder is not a clone of the URL', async () => { + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') + const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) + const saved = { + id: 'saved-project', + path: join(destination, 'orca'), + displayName: 'orca', + badgeColor: DEFAULT_REPO_BADGE_COLOR, + addedAt: 1 + } + const getReposSpy = vi.spyOn(store, 'getRepos').mockReturnValue([saved]) + + try { + await expect( + createRuntime().cloneRepo('https://github.com/me/orca.git', destination) + ).rejects.toThrow('"orca" is already an Orca project') + expect(spawnSpy).not.toHaveBeenCalled() + } finally { + getReposSpy.mockRestore() + spawnSpy.mockRestore() + await rm(destination, { recursive: true, force: true }) + } + }) + + it('clones past a saved SSH project that shares the clone path', async () => { + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawnAfterWindowsEnvironmentReady') + const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-clone-')) + const sshProject = { + id: 'ssh-project', + path: join(destination, 'orca'), + displayName: 'orca', + badgeColor: DEFAULT_REPO_BADGE_COLOR, + addedAt: 1, + connectionId: 'conn-1' + } + const getReposSpy = vi.spyOn(store, 'getRepos').mockReturnValue([sshProject]) + + try { + // The SSH project's folder is on another machine, so git runs here; this source doesn't exist. + await expect( + createRuntime().cloneRepo(join(destination, 'missing', 'orca'), destination) + ).rejects.toThrow('Clone failed') + expect(spawnSpy).toHaveBeenCalledTimes(1) + } finally { + getReposSpy.mockRestore() + spawnSpy.mockRestore() + await rm(destination, { recursive: true, force: true }) + } + }) }) diff --git a/src/main/runtime/runtime-repository-clone-controller.ts b/src/main/runtime/runtime-repository-clone-controller.ts index 8c3b9b90566..dd7ecce7905 100644 --- a/src/main/runtime/runtime-repository-clone-controller.ts +++ b/src/main/runtime/runtime-repository-clone-controller.ts @@ -1,7 +1,11 @@ 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 { + getRepoSshConnectionId, + LOCAL_EXECUTION_HOST_ID, + type ExecutionHostId +} from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message' import { @@ -11,6 +15,7 @@ import { 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/filesystem-auth' import { isFolderRepo } from '../../shared/repo-kind' @@ -87,14 +92,18 @@ export class RuntimeRepositoryCloneController { if (!store) { throw new Error('runtime_unavailable') } - const existingBeforeClone = store.getRepos().find((repo) => { - return ( - getClonePathComparisonKey(repo.path) === clonePathKey && - runtimeRepoMatchesExecutionHost(repo, executionHostId) - ) - }) - if (existingBeforeClone && !isFolderRepo(existingBeforeClone)) { - return existingBeforeClone + // 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 && + runtimeRepoMatchesExecutionHost(repo, executionHostId) && + !getRepoSshConnectionId(repo) + ) + }) + const reused = await reuseSavedCloneTarget(findSaved, trimmedUrl, LOCAL_EXECUTION_HOST_ID) + if (reused) { + return reused } await mkdir(trimmedDestination, { recursive: true }) @@ -143,12 +152,7 @@ export class RuntimeRepositoryCloneController { proc.on('close', (code, signal) => void finish(code, signal)) }) - const existing = store.getRepos().find((repo) => { - return ( - getClonePathComparisonKey(repo.path) === clonePathKey && - runtimeRepoMatchesExecutionHost(repo, executionHostId) - ) - }) + const existing = findSaved() if (existing) { if (isFolderRepo(existing)) { const updated = store.updateRepo(existing.id, { kind: 'git' }) @@ -159,6 +163,11 @@ export class RuntimeRepositoryCloneController { 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. + await prepareLocalWorktreeRootForRepo(store, existing) + invalidateAuthorizedRootsCache() + this.invalidate(existing.id) return existing } // `cloneRepo` ran `git clone` in this process (see `assertCloneHostIsSupported`), so the