Keep folder Git upgrades responsive and recheck project ownership (#26791)

* Keep repository detection off the main IPC thread

Share repository discovery decisions across synchronous compatibility callers and the existing asynchronous Git runner. Await discovery in project registration, nested imports, and folder upgrades, and recheck folder ownership after discovery completes.

* Make repository detection fixtures work on native Windows

* Await asynchronous Git checks in folder upgrade fixtures

* Prevent duplicate project registration after async discovery

* Check authoritative registration identity before renderer convergence

* Narrow repository responsiveness to detection and registration

* Keep one local project identity after concurrent registration

* Run registration SSH coverage in the existing Docker lane

* Keep folder upgrades responsive and recheck pending project ownership

* test(git): cover classifier failure stages and literal path identity

* test(git): use the current process host package

* test(repos): use current process host in folder upgrade fixture

* test(repos): use current process host in registration fixture
This commit is contained in:
Neil
2026-10-10 02:02:16 -07:00
committed by GitHub
parent 803ab30598
commit 991fd5ca4b
4 changed files with 137 additions and 12 deletions
@@ -16,7 +16,7 @@ const fake = vi.hoisted(() => ({
}))
vi.mock('node:fs/promises', () => ({ stat: fake.stat }))
vi.mock('node:fs', () => ({ realpathSync: (path: string) => path }))
vi.mock('../git/repo', () => ({ isGitRepo: fake.git, getGitRepoRoot: fake.root }))
vi.mock('../git/repo', () => ({ isGitRepoAsync: fake.git, getGitRepoRootAsync: fake.root }))
vi.mock('../worktree-root-preparation', () => ({ prepareLocalWorktreeRootForRepo: fake.prepare }))
vi.mock('./registered-worktree-roots-cache', () => ({
invalidateAuthorizedRootsCache: fake.invalidate
@@ -108,6 +108,32 @@ describe('folder repo upgrade poll lifetime', () => {
expect(vi.getTimerCount()).toBe(0)
})
it.each(['stopped', 'removed', 'path changed', 'already upgraded'])(
'refuses a stale folder upgrade when its owner is %s during Git discovery',
async (change) => {
const pending = deferred<boolean>()
fake.git.mockReturnValue(pending.promise)
const repos = [folder()]
const { store } = begin(repos)
await vi.advanceTimersByTimeAsync(25)
expect(fake.git).toHaveBeenCalledTimes(1)
if (change === 'stopped') {
stopFolderRepoGitUpgradeWatch()
} else if (change === 'removed') {
repos.splice(0)
} else if (change === 'path changed') {
repos[0].path = join('mock', 'new-root')
} else {
repos[0].kind = 'git'
}
pending.resolve(true)
await flush()
expect(store.updateRepo).not.toHaveBeenCalled()
expect(fake.prepare).not.toHaveBeenCalled()
expect(fake.reposChanged).not.toHaveBeenCalled()
}
)
it('stops the remaining folder scan when a pending marker fails after stop', async () => {
const pending = deferred<typeof marker>()
fake.stat.mockReturnValue(pending.promise)
+22 -5
View File
@@ -10,22 +10,36 @@ import type { Repo } from '../../shared/repo-types'
// Why: the watch must stay one stat per folder project per tick — counting the real
// calls is what keeps a directory-listing fan-out from creeping back in.
const { statCalls, readdirSpy, gitProbes } = vi.hoisted(() => ({
const { statCalls, readdirSpy, gitProbes, pendingGitChecks } = vi.hoisted(() => ({
statCalls: [] as string[],
readdirSpy: vi.fn(),
pendingGitChecks: new Set<Promise<unknown>>(),
// Why: the rejected-marker cache exists to stop git respawning every tick; counting the
// real probes is the only way that guarantee stays true.
gitProbes: [] as string[]
}))
function trackGitCheck<T>(pending: Promise<T>): Promise<T> {
const completed = pending.finally(() => pendingGitChecks.delete(completed))
pendingGitChecks.add(completed)
return completed
}
async function drainGitChecks(): Promise<void> {
while (pendingGitChecks.size > 0) {
await Promise.allSettled(pendingGitChecks)
}
}
vi.mock('../git/repo', async (importOriginal) => {
const actual = await importOriginal<typeof GitRepo>()
return {
...actual,
isGitRepo: (path: string) => {
isGitRepoAsync: (path: string) => {
gitProbes.push(path)
return actual.isGitRepo(path)
}
return trackGitCheck(actual.isGitRepoAsync(path))
},
getGitRepoRootAsync: (path: string) => trackGitCheck(actual.getGitRepoRootAsync(path))
}
})
@@ -143,13 +157,14 @@ describe('folder repo git upgrade watch', () => {
vi.clearAllMocks()
root = realpathSync(await mkdtemp(join(tmpdir(), 'folder-repo-upgrade-')))
symlinkedRoot = `${root}-link`
await symlink(root, symlinkedRoot, 'dir')
await symlink(root, symlinkedRoot, process.platform === 'win32' ? 'junction' : 'dir')
statCalls.length = 0
gitProbes.length = 0
})
afterEach(async () => {
stopFolderRepoGitUpgradeWatch()
await drainGitChecks()
await rm(symlinkedRoot, { force: true })
await rm(root, { recursive: true, force: true })
})
@@ -157,6 +172,7 @@ describe('folder repo git upgrade watch', () => {
// Real timers: the tick awaits real filesystem stats, which fake timers cannot flush.
async function tick(times = 1): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, POLL_MS * times + POLL_MS))
await drainGitChecks()
}
/** Why: a tick that spawns git can outrun a fixed wait on a loaded machine. */
@@ -165,6 +181,7 @@ describe('folder repo git upgrade watch', () => {
while (statCalls.length < count && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, POLL_MS))
}
await drainGitChecks()
}
it('upgrades a local folder repo once an external git init creates .git', async () => {
+17 -6
View File
@@ -9,7 +9,7 @@ import { isFolderRepo } from '../../shared/repo-kind'
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree/id'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import { isWslUncPath } from '../../shared/wsl-paths'
import { getGitRepoRoot, isGitRepo } from '../git/repo'
import { getGitRepoRootAsync, isGitRepoAsync } from '../git/repo'
import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation'
import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache'
import { notifyReposChanged } from './repos/repos-changed-notification'
@@ -101,13 +101,13 @@ function resolveRealPath(pathValue: string): string {
* the path the user picked; when a symlinked parent makes those differ, the root reads
* as an *external* worktree, and hiding those would hide the project's only workspace.
*/
function resolveUpgrade(
async function resolveUpgrade(
repoPath: string
): { folderUpgradeGitRootPath: string; externalWorktreeVisibility?: 'hide' } | null {
if (!isGitRepo(repoPath)) {
): Promise<{ folderUpgradeGitRootPath: string; externalWorktreeVisibility?: 'hide' } | null> {
if (!(await isGitRepoAsync(repoPath))) {
return null
}
const gitRoot = getGitRepoRoot(repoPath)
const gitRoot = await getGitRepoRootAsync(repoPath)
if (resolveRealPath(gitRoot) !== resolveRealPath(repoPath)) {
return null
}
@@ -127,7 +127,18 @@ async function upgradeFolderRepo(watch: UpgradeWatch, repoId: string): Promise<U
if (hasExtraFolderWorkspaces(watch.store, current)) {
return 'blocked'
}
const updates = resolveUpgrade(current.path)
const inspectedPath = current.path
const updates = await resolveUpgrade(inspectedPath)
const latest = watch.store.getRepo(repoId)
if (
watch.disposed ||
!latest ||
!isUpgradeCandidate(latest) ||
latest.path !== inspectedPath ||
hasExtraFolderWorkspaces(watch.store, latest)
) {
return 'blocked'
}
if (!updates) {
return 'rejected'
}
+71
View File
@@ -0,0 +1,71 @@
import { mkdtempSync, realpathSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test, expect } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
import { openSidebarProjectDialog } from './helpers/sidebar-project-dialog'
import { runProcess } from '@orca/process-host'
test.use({ seedTestRepo: false })
async function runFixtureGit(spec: Parameters<typeof runProcess>[0]): Promise<void> {
const result = await runProcess(spec)
expect(result.code, result.stderr).toBe(0)
}
test('Add Project opens a folder and preserves its workspace when Git appears', async ({
electronApp,
orcaPage,
registerPostElectronShutdownCleanup
}, testInfo) => {
const folderPath = realpathSync(mkdtempSync(join(tmpdir(), 'orca-folder-upgrade-')))
registerPostElectronShutdownCleanup(async () => {
const { rm } = await import('node:fs/promises')
await rm(folderPath, { recursive: true, force: true })
})
await waitForSessionReady(orcaPage)
await electronApp.evaluate(({ dialog }, path) => {
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [path], bookmarks: [] })
}, folderPath)
await openSidebarProjectDialog(orcaPage)
await orcaPage.getByRole('button', { name: /Browse folder/i }).click()
const dialog = orcaPage.getByRole('dialog', { name: 'Open as Folder' })
await expect(dialog).toBeVisible()
await orcaPage.screenshot({ path: testInfo.outputPath('open-folder.png') })
await dialog.getByRole('button', { name: 'Open as Folder', exact: true }).click()
await expect
.poll(() => orcaPage.evaluate(() => window.__store!.getState().repos[0]?.kind))
.toBe('folder')
await expect
.poll(() =>
orcaPage.evaluate(() => {
const state = window.__store!.getState()
return state.worktreesByRepo[state.repos[0].id]?.[0]?.id
})
)
.toBeTruthy()
const before = await orcaPage.evaluate(() => {
const state = window.__store!.getState()
const repo = state.repos[0]
return { repoId: repo.id, worktreeId: state.worktreesByRepo[repo.id]?.[0]?.id }
})
await orcaPage.screenshot({ path: testInfo.outputPath('folder-workspace.png') })
await runFixtureGit({ program: 'git', args: ['init', folderPath] })
await expect
.poll(() => orcaPage.evaluate(() => window.__store!.getState().repos[0]?.kind), {
timeout: 30_000
})
.toBe('git')
await expect
.poll(
() =>
orcaPage.evaluate(() => {
const state = window.__store!.getState()
const repo = state.repos[0]
return { repoId: repo.id, worktreeId: state.worktreesByRepo[repo.id]?.[0]?.id }
}),
{ timeout: 30_000 }
)
.toEqual(before)
await orcaPage.screenshot({ path: testInfo.outputPath('git-workspace.png') })
})