fix(repos): stop reporting clones that never ran as successful (#23770)

* fix(repos): reuse a saved project only when git shows it's this clone

A saved project outlived its folder, so Clone reported success without running git.

Fixes #23563

* fix(repos): let a repeat clone of an empty repository reuse its project

A clone of a repository with no commits leaves HEAD unborn, which
`rev-parse --verify HEAD` rejects exactly like the `refs/heads/.invalid`
HEAD a killed clone leaves. The saved-project check read both as "not a
finished clone", so cloning a brand-new empty repo a second time — a
double-clicked Clone, a re-run `orca project setup-clone`, or #2981's
queued retry — fell through to git and died on "destination path already
exists and is not an empty directory".

Ask the two questions apart when `--verify` fails: `symbolic-ref HEAD`
resolves an unborn branch but not a killed clone's HEAD. It is already on
the relay's read-only allowlist. Probes also short-circuit once the clone
is cancelled, so no probe after Cancel spawns git.

* fix(repos): prepare the worktree root after re-cloning a project's folder

The branch that re-clones into a saved project's folder returned the
project without the two steps every sibling branch runs: the worktree
root that vanished with the deleted folder is never re-created, and the
authorized-roots cache keeps the answers it cached while the folder was
missing. Creating a worktree or reading files in the recovered project
then fails on a root that is not there.

* fix(ssh): register the root after re-cloning a saved project's folder

`addRemoteRepoFromPath` returns an existing project before it notifies
`session.registerRoot`, which was unreachable before the saved-project
check could re-clone into a project's own path. The relay therefore never
learned the folder came back, so sessions under it stayed unregistered.
Notify from the clone, mirroring the folder-to-git upgrade beside it.

* fix(repos): say which reason refused a clone over a saved project

One message covered both "that project holds a different repository" and
"Orca has no record of what that project held", which need different
things from the user. Name the recorded URL when there is one, and say
plainly that there is no record when there isn't — the second is the case
a project predating remote-identity enrichment lands in, and the user
cannot act on it without being told why.

Also drops the `upstream` remote-name check into a named binding: `origin`
is the only stored remote that names a project's own repo, because
`Repo.upstream` and the GitHub avatar derived from it both name the repo a
fork came from.

* fix(repos): bound saved-clone probes and prove empty clone completion

* fix(repos): keep clone validation host-scoped and locale independent

---------

Co-authored-by: Neil <neil@stably.ai>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
Kelvin Amoaba
2026-10-06 13:14:03 -07:00
committed by GitHub
co-authored by Neil Neil
parent ac8ea9f958
commit 9e8a3a5c67
10 changed files with 895 additions and 95 deletions
@@ -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()
})
})
+186
View File
@@ -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 <root>/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> = {}): 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')
})
})
+152
View File
@@ -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<string>
/** 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<string | null> {
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<string, string>()
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<boolean> {
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<Repo | null> {
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.`
)
}
@@ -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<string, unknown> = {}) => ({
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()
})
})
})
+74 -3
View File
@@ -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 () => {
+39 -18
View File
@@ -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, {
+53 -34
View File
@@ -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<ReturnType<typeof gitSpawnAfterWindowsEnvironmentReady>>
// 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
}
+29 -17
View File
@@ -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' }
}
@@ -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 })
}
})
})
@@ -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