diff --git a/src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts b/src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts index 6d86ec820fa..5e0f063e6f7 100644 --- a/src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts +++ b/src/main/ipc/folder-repo-git-upgrade-lifetime.test.ts @@ -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() + 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() fake.stat.mockReturnValue(pending.promise) diff --git a/src/main/ipc/folder-repo-git-upgrade.test.ts b/src/main/ipc/folder-repo-git-upgrade.test.ts index 5fe485ee3a2..ebc58b92b72 100644 --- a/src/main/ipc/folder-repo-git-upgrade.test.ts +++ b/src/main/ipc/folder-repo-git-upgrade.test.ts @@ -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>(), // 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(pending: Promise): Promise { + const completed = pending.finally(() => pendingGitChecks.delete(completed)) + pendingGitChecks.add(completed) + return completed +} + +async function drainGitChecks(): Promise { + while (pendingGitChecks.size > 0) { + await Promise.allSettled(pendingGitChecks) + } +} + vi.mock('../git/repo', async (importOriginal) => { const actual = await importOriginal() 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 { 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 () => { diff --git a/src/main/ipc/folder-repo-git-upgrade.ts b/src/main/ipc/folder-repo-git-upgrade.ts index 8a0a6130c0e..724c80d0df8 100644 --- a/src/main/ipc/folder-repo-git-upgrade.ts +++ b/src/main/ipc/folder-repo-git-upgrade.ts @@ -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[0]): Promise { + 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') }) +})