From 5e0d243126158c54fd58d45be039b2aa52093db8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:25:34 -0700 Subject: [PATCH] fix(repo): promote folders that become git repos without re-add Local folder projects already upgrade after git init. Re-adding the same path as git still returned the stale folder record, and SSH folders were never probed. Share that promotion, apply it on explicit re-add, and poll remote .git on the execution host so a dropped connection stays unverifiable instead of "not a repo". Fixes #13630 STA-3839 Co-authored-by: erishforG --- src/main/folder-repo-git-promotion.ts | 210 ++++++++++++++++++ src/main/ipc/folder-repo-git-upgrade.test.ts | 84 ++++++- src/main/ipc/folder-repo-git-upgrade.ts | 166 +++++++------- .../ipc/repos-add-linked-worktree.test.ts | 47 +++- src/main/ipc/repos-remote-test-harness.ts | 6 +- src/main/ipc/repos-remote.test.ts | 64 ++++++ src/main/ipc/repos.ts | 32 ++- .../orca-runtime-folder-git-promotion.test.ts | 103 +++++++++ src/main/runtime/orca-runtime.ts | 26 ++- 9 files changed, 640 insertions(+), 98 deletions(-) create mode 100644 src/main/folder-repo-git-promotion.ts create mode 100644 src/main/runtime/orca-runtime-folder-git-promotion.test.ts diff --git a/src/main/folder-repo-git-promotion.ts b/src/main/folder-repo-git-promotion.ts new file mode 100644 index 00000000000..2cf763af7de --- /dev/null +++ b/src/main/folder-repo-git-promotion.ts @@ -0,0 +1,210 @@ +import { realpathSync } from 'node:fs' +import type { GlobalSettings } from '../shared/global-settings-types' +import type { Repo } from '../shared/repo-types' +import { isFolderRepo } from '../shared/repo-kind' +import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../shared/worktree/id' +import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' +import { getGitRepoRoot, isGitRepo } from './git/repo' +import { prepareLocalWorktreeRootForRepo } from './worktree-root-preparation' +import { invalidateAuthorizedRootsCache } from './ipc/registered-worktree-roots-cache' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from './providers/ssh-git-dispatch' +import { + getSshFilesystemProvider, + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE +} from './providers/ssh-filesystem-dispatch' +import { joinRemotePath } from './ssh/ssh-remote-platform' + +export type FolderGitPromotionStore = { + getRepo(id: string): Repo | undefined + updateRepo( + id: string, + updates: Pick, 'kind' | 'externalWorktreeVisibility' | 'projectHostSetupMethod'> + ): Repo | null + getAllWorktreeMeta?: () => Record + getSettings: () => Pick +} + +type LocalFolderGitUpgrade = { externalWorktreeVisibility?: 'hide' } + +export type RemoteFolderGitProbe = + | { status: 'upgrade'; updates: LocalFolderGitUpgrade } + | { status: 'rejected' } + | { status: 'unverifiable' } + +/** + * A folder project's extra workspaces are `worktreeMeta` rows keyed + * `repoId::path::workspace:`, and only the folder branch of the worktree listing + * knows those keys exist. Flipping `kind` moves the repo onto the git branch, which lists + * `git worktree list` (one path) and prunes every lineage id under the repo that is not in + * it — so those workspaces vanish from the sidebar and their lineage is deleted. + */ +export function folderRepoHasExtraWorkspaces( + store: Pick, + repo: Pick +): boolean { + const prefix = `${repo.id}::${repo.path}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` + const meta = store.getAllWorktreeMeta?.() ?? {} + return Object.keys(meta).some((key) => key.startsWith(prefix)) +} + +function resolveRealPath(pathValue: string): string { + try { + return realpathSync(pathValue) + } catch { + return pathValue + } +} + +/** + * Git's verdict on the folder, or null when it must stay a folder project. + * + * Two comparisons, deliberately at different strictness: + * - Refuse unless the folder *is* the work-tree root. `.git` existing proves nothing — + * git accepts any path inside a work tree, so a stray marker in a subdirectory of + * another repo would otherwise flip a project whose path is not a repo root. + * - Only hide external worktrees when the stored spelling also matches. Add Project + * stores the root as `rev-parse --show-toplevel` spells it while a folder project keeps + * 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. + */ +export function resolveLocalFolderGitUpgrade(repoPath: string): LocalFolderGitUpgrade | null { + if (!isGitRepo(repoPath)) { + return null + } + const gitRoot = getGitRepoRoot(repoPath) + if (resolveRealPath(gitRoot) !== resolveRealPath(repoPath)) { + return null + } + return normalizeRuntimePathForComparison(gitRoot) === normalizeRuntimePathForComparison(repoPath) + ? { externalWorktreeVisibility: 'hide' } + : {} +} + +function isRemoteContactLoss(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return ( + message.includes(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) || + message.includes(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) || + /Reconnect|dropped|unavailable/i.test(message) + ) +} + +function isRemoteMissingPathError(error: unknown): boolean { + if (isRemoteContactLoss(error)) { + return false + } + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return true + } + const message = error instanceof Error ? error.message : String(error) + return /\bENOENT\b|no such file|not found/i.test(message) +} + +export async function readRemoteGitMarkerSignature( + repo: Pick +): Promise { + const connectionId = repo.connectionId + if (!connectionId) { + return 'unverifiable' + } + const filesystem = getSshFilesystemProvider(connectionId) + const git = getSshGitProvider(connectionId) + const host = git?.getHostPlatform() + if (!filesystem || !git || !host) { + return 'unverifiable' + } + try { + const marker = await filesystem.stat(joinRemotePath(host, repo.path, '.git')) + return `${marker.mtimeMs ?? marker.mtime}:${marker.ino ?? 0}:${marker.type}` + } catch (error) { + if (isRemoteMissingPathError(error)) { + return null + } + return 'unverifiable' + } +} + +export async function resolveRemoteFolderGitUpgrade( + repo: Pick +): Promise { + const connectionId = repo.connectionId + if (!connectionId) { + return { status: 'unverifiable' } + } + const git = getSshGitProvider(connectionId) + if (!git) { + return { status: 'unverifiable' } + } + let check: { isRepo: boolean; rootPath: string | null } + try { + check = await git.isGitRepoAsync(repo.path) + } catch { + // Why: a thrown probe is loss of contact or a spawn failure, not a negative + // git verdict. Treat it as unverifiable so a down host cannot freeze the folder. + return { status: 'unverifiable' } + } + if (!check.isRepo) { + return { status: 'rejected' } + } + if (!check.rootPath) { + return { status: 'upgrade', updates: {} } + } + if ( + normalizeRuntimePathForComparison(check.rootPath) !== + normalizeRuntimePathForComparison(repo.path) + ) { + return { status: 'rejected' } + } + return { status: 'upgrade', updates: { externalWorktreeVisibility: 'hide' } } +} + +export async function promoteFolderRepoToGit( + store: FolderGitPromotionStore, + repoId: string, + updates: LocalFolderGitUpgrade & Pick, 'projectHostSetupMethod'> +): Promise { + const current = store.getRepo(repoId) + if (!current || !isFolderRepo(current)) { + return null + } + const upgraded = store.updateRepo(repoId, { kind: 'git', ...updates }) + if (!upgraded) { + return null + } + await prepareLocalWorktreeRootForRepo(store, upgraded) + invalidateAuthorizedRootsCache() + return upgraded +} + +export async function tryPromoteLocalFolderRepoToGit( + store: FolderGitPromotionStore, + repo: Repo, + extraUpdates: Pick, 'projectHostSetupMethod'> = {} +): Promise { + if (!isFolderRepo(repo) || folderRepoHasExtraWorkspaces(store, repo)) { + return null + } + const updates = resolveLocalFolderGitUpgrade(repo.path) + if (!updates) { + return null + } + return promoteFolderRepoToGit(store, repo.id, { ...updates, ...extraUpdates }) +} + +export async function tryPromoteRemoteFolderRepoToGit( + store: FolderGitPromotionStore, + repo: Repo, + extraUpdates: Pick, 'projectHostSetupMethod'> = {} +): Promise { + if (!isFolderRepo(repo) || folderRepoHasExtraWorkspaces(store, repo)) { + return null + } + const probe = await resolveRemoteFolderGitUpgrade(repo) + if (probe.status !== 'upgrade') { + return null + } + return promoteFolderRepoToGit(store, repo.id, { ...probe.updates, ...extraUpdates }) +} diff --git a/src/main/ipc/folder-repo-git-upgrade.test.ts b/src/main/ipc/folder-repo-git-upgrade.test.ts index 9ba1154c84d..59aed29261f 100644 --- a/src/main/ipc/folder-repo-git-upgrade.test.ts +++ b/src/main/ipc/folder-repo-git-upgrade.test.ts @@ -10,13 +10,16 @@ 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(() => ({ - statCalls: [] as string[], - readdirSpy: vi.fn(), - // 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[] -})) +const { statCalls, readdirSpy, gitProbes, getSshGitProviderMock, getSshFilesystemProviderMock } = + vi.hoisted(() => ({ + statCalls: [] as string[], + readdirSpy: vi.fn(), + // 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[], + getSshGitProviderMock: vi.fn(), + getSshFilesystemProviderMock: vi.fn() + })) vi.mock('../git/repo', async (importOriginal) => { const actual = await importOriginal() @@ -56,6 +59,12 @@ vi.mock('./registered-worktree-roots-cache', () => ({ vi.mock('../worktree-root-preparation', () => ({ prepareLocalWorktreeRootForRepo: vi.fn(async () => {}) })) +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: (connectionId: string) => getSshGitProviderMock(connectionId) +})) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: (connectionId: string) => getSshFilesystemProviderMock(connectionId) +})) import { notifyWorktreesChanged } from './worktree-remote' import { notifyReposChanged } from './repos' @@ -145,6 +154,8 @@ describe('folder repo git upgrade watch', () => { await symlink(root, symlinkedRoot, 'dir') statCalls.length = 0 gitProbes.length = 0 + getSshGitProviderMock.mockReset() + getSshFilesystemProviderMock.mockReset() }) afterEach(async () => { @@ -425,6 +436,65 @@ describe('folder repo git upgrade watch', () => { expect(store.updateRepo).toHaveBeenCalledTimes(1) }) + it('upgrades an SSH folder repo once the remote host reports a git root', async () => { + const remotePath = '/home/user/project' + const store = makeStore([ + makeRepo({ id: 'ssh-folder', path: remotePath, connectionId: 'conn-1' }) + ]) + const isGitRepoAsync = vi.fn().mockResolvedValue({ isRepo: true, rootPath: remotePath }) + getSshGitProviderMock.mockReturnValue({ + getHostPlatform: () => ({ + relayPlatform: 'linux-x64', + os: 'linux', + arch: 'x64', + pathFlavor: 'posix', + commandDialect: 'posix', + pathSeparator: '/', + pathDelimiter: ':' + }), + isGitRepoAsync + }) + getSshFilesystemProviderMock.mockReturnValue({ + stat: vi.fn().mockResolvedValue({ + type: 'directory', + mtime: 1, + mtimeMs: 1, + ino: 42, + size: 0 + }) + }) + + startFolderRepoGitUpgradeWatch(store as never, makeWindow() as never, { + pollIntervalMs: POLL_MS, + idlePollIntervalMs: IDLE_POLL_MS + }) + await tick() + + expect(store.updateRepo).toHaveBeenCalledWith( + 'ssh-folder', + expect.objectContaining({ kind: 'git' }) + ) + expect(isGitRepoAsync).toHaveBeenCalledWith(remotePath) + expect(statCalls).toHaveLength(0) + }) + + it('does not treat an SSH disconnect as evidence the folder is not a repo', async () => { + const remotePath = '/home/user/project' + const store = makeStore([ + makeRepo({ id: 'ssh-folder', path: remotePath, connectionId: 'conn-1' }) + ]) + getSshGitProviderMock.mockReturnValue(undefined) + getSshFilesystemProviderMock.mockReturnValue(undefined) + + startFolderRepoGitUpgradeWatch(store as never, makeWindow() as never, { + pollIntervalMs: POLL_MS, + idlePollIntervalMs: IDLE_POLL_MS + }) + await tick(2) + + expect(store.updateRepo).not.toHaveBeenCalled() + }) + it('skips remote and WSL folder repos a local stat cannot answer for', async () => { const sshPath = join(root, 'ssh-project') await mkdir(sshPath) diff --git a/src/main/ipc/folder-repo-git-upgrade.ts b/src/main/ipc/folder-repo-git-upgrade.ts index f1463022eee..2f1a01c78eb 100644 --- a/src/main/ipc/folder-repo-git-upgrade.ts +++ b/src/main/ipc/folder-repo-git-upgrade.ts @@ -1,17 +1,19 @@ import { stat } from 'node:fs/promises' -import { realpathSync } from 'node:fs' import { join } from 'node:path' import type { BrowserWindow } from 'electron' import type { Repo } from '../../shared/repo-types' import type { Store } from '../persistence' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' 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 { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' -import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache' +import { + folderRepoHasExtraWorkspaces, + promoteFolderRepoToGit, + readRemoteGitMarkerSignature, + resolveLocalFolderGitUpgrade, + resolveRemoteFolderGitUpgrade +} from '../folder-repo-git-promotion' import { notifyReposChanged } from './repos' import { notifyWorktreesChanged } from './worktree-remote' import { setFolderRepoGitUpgradeWakeListener } from './folder-repo-git-upgrade-wake' @@ -45,10 +47,10 @@ let activeWatch: UpgradeWatch | null = null // A rejected marker must not respawn git every tick forever. const rejectedMarkers = new Map() -// Why: `git init` on an SSH host or behind a WSL UNC root is not observable with a -// local stat, and those roots are exactly the ones the base watcher already refuses -// to poll natively. Desktop-local folder projects are the reported case (#11477). -function isUpgradeCandidate(repo: Repo): boolean { +// Why: a local `stat` cannot observe `git init` on an SSH host, a WSL UNC root, or a +// runtime-owned filesystem. Desktop-local folder projects are the cheap-stat case +// (#11477); SSH folders are probed on the execution host instead of guessed locally. +function isLocalFilesystemCandidate(repo: Repo): boolean { return ( isFolderRepo(repo) && !repo.connectionId && @@ -57,22 +59,20 @@ function isUpgradeCandidate(repo: Repo): boolean { ) } -/** - * A folder project's extra workspaces are `worktreeMeta` rows keyed - * `repoId::path::workspace:`, and only the folder branch of the worktree listing - * knows those keys exist. Flipping `kind` moves the repo onto the git branch, which lists - * `git worktree list` (one path) and prunes every lineage id under the repo that is not in - * it — so those workspaces vanish from the sidebar and their lineage is deleted. Migrating - * them belongs to the listing code that owns both shapes, not to this watch, so refuse the - * upgrade instead of destroying them. Such a project keeps working exactly as it does today. - */ -function hasExtraFolderWorkspaces(store: Store, repo: Repo): boolean { - const prefix = `${repo.id}::${repo.path}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` - return Object.keys(store.getAllWorktreeMeta()).some((key) => key.startsWith(prefix)) +function isSshCandidate(repo: Repo): boolean { + return isFolderRepo(repo) && Boolean(repo.connectionId) } -/** Identity of the `.git` entry, or null when there is none. */ -async function readGitMarkerSignature(repoPath: string): Promise { +function isUpgradeCandidate(repo: Repo): boolean { + return isLocalFilesystemCandidate(repo) || isSshCandidate(repo) +} + +function candidateKey(repo: Repo): string { + return `${repo.connectionId ?? 'local'}\0${normalizeRuntimePathForComparison(repo.path)}` +} + +/** Identity of the local `.git` entry, or null when there is none. */ +async function readLocalGitMarkerSignature(repoPath: string): Promise { try { const marker = await stat(join(repoPath, '.git')) return `${marker.mtimeMs}:${marker.ctimeMs}:${marker.ino}` @@ -81,68 +81,58 @@ async function readGitMarkerSignature(repoPath: string): Promise } } -function resolveRealPath(pathValue: string): string { - try { - return realpathSync(pathValue) - } catch { - return pathValue - } -} - -/** - * Git's verdict on the folder, or null when it must stay a folder project. - * - * Two comparisons, deliberately at different strictness: - * - Refuse unless the folder *is* the work-tree root. `.git` existing proves nothing — - * git accepts any path inside a work tree, so a stray marker in a subdirectory of - * another repo would otherwise flip a project whose path is not a repo root. - * - Only hide external worktrees when the stored spelling also matches. Add Project - * stores the root as `rev-parse --show-toplevel` spells it while a folder project keeps - * 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(repoPath: string): { externalWorktreeVisibility?: 'hide' } | null { - if (!isGitRepo(repoPath)) { - return null - } - const gitRoot = getGitRepoRoot(repoPath) - if (resolveRealPath(gitRoot) !== resolveRealPath(repoPath)) { - return null - } - return normalizeRuntimePathForComparison(gitRoot) === normalizeRuntimePathForComparison(repoPath) - ? { externalWorktreeVisibility: 'hide' } - : {} -} - type UpgradeResult = 'upgraded' | 'blocked' | 'rejected' -async function upgradeFolderRepo(watch: UpgradeWatch, repoId: string): Promise { - // Re-read after the marker stat: the repo can be removed or already upgraded mid-tick. - const current = watch.store.getRepo(repoId) - if (!current || !isUpgradeCandidate(current)) { - return 'blocked' - } - if (hasExtraFolderWorkspaces(watch.store, current)) { - return 'blocked' - } - const updates = resolveUpgrade(current.path) - if (!updates) { - return 'rejected' - } - const upgraded = watch.store.updateRepo(repoId, { kind: 'git', ...updates }) - if (!upgraded) { - return 'upgraded' - } - // Adding a git project prepares its worktree root; an upgrade has to do the same. - await prepareLocalWorktreeRootForRepo(watch.store, upgraded) - invalidateAuthorizedRootsCache() +function notifyPromotion(watch: UpgradeWatch, repoId: string): void { if (watch.disposed) { - return 'upgraded' + return } // Why: reuse the repo-mutation notifier so paired clients refetch too (#11994) and // the repo picks up the base/common-dir watchers it was skipped for as a folder. notifyReposChanged(watch.mainWindow) notifyWorktreesChanged(watch.mainWindow, repoId) +} + +async function upgradeLocalFolderRepo(watch: UpgradeWatch, repo: Repo): Promise { + const current = watch.store.getRepo(repo.id) + if (!current || !isLocalFilesystemCandidate(current)) { + return 'blocked' + } + if (folderRepoHasExtraWorkspaces(watch.store, current)) { + return 'blocked' + } + const updates = resolveLocalFolderGitUpgrade(current.path) + if (!updates) { + return 'rejected' + } + const upgraded = await promoteFolderRepoToGit(watch.store, current.id, updates) + if (!upgraded) { + return 'upgraded' + } + notifyPromotion(watch, current.id) + return 'upgraded' +} + +async function upgradeSshFolderRepo(watch: UpgradeWatch, repo: Repo): Promise { + const current = watch.store.getRepo(repo.id) + if (!current || !isSshCandidate(current)) { + return 'blocked' + } + if (folderRepoHasExtraWorkspaces(watch.store, current)) { + return 'blocked' + } + const probe = await resolveRemoteFolderGitUpgrade(current) + if (probe.status === 'unverifiable') { + return 'blocked' + } + if (probe.status === 'rejected') { + return 'rejected' + } + const upgraded = await promoteFolderRepoToGit(watch.store, current.id, probe.updates) + if (!upgraded) { + return 'upgraded' + } + notifyPromotion(watch, current.id) return 'upgraded' } @@ -159,13 +149,27 @@ async function pollOnce(watch: UpgradeWatch): Promise { if (watch.disposed) { return } - const key = normalizeRuntimePathForComparison(repo.path) + const key = candidateKey(repo) liveKeys.add(key) - const signature = await readGitMarkerSignature(repo.path) + if (isSshCandidate(repo)) { + const signature = await readRemoteGitMarkerSignature(repo) + if ( + signature === 'unverifiable' || + signature === null || + rejectedMarkers.get(key) === signature + ) { + continue + } + if ((await upgradeSshFolderRepo(watch, repo)) === 'rejected') { + rejectedMarkers.set(key, signature) + } + continue + } + const signature = await readLocalGitMarkerSignature(repo.path) if (signature === null || rejectedMarkers.get(key) === signature) { continue } - if ((await upgradeFolderRepo(watch, repo.id)) === 'rejected') { + if ((await upgradeLocalFolderRepo(watch, repo)) === 'rejected') { rejectedMarkers.set(key, signature) } } @@ -218,6 +222,8 @@ async function runTick(watch: UpgradeWatch): Promise { /** * Polls `/.git` for every local folder project and upgrades it to a git repo * once an external `git init` lands, so git affordances appear without a restart. + * SSH folders are probed on the execution host (`fs.stat` then `git.isGitRepo`); a + * missing provider or thrown probe is unverifiable, never "not a repo". * Idempotent: a re-attached main window replaces the one the running watch holds. * Parks while the window is hidden — one stat per folder project per tick, never a * directory listing. diff --git a/src/main/ipc/repos-add-linked-worktree.test.ts b/src/main/ipc/repos-add-linked-worktree.test.ts index de9da98ae38..ce8640c9107 100644 --- a/src/main/ipc/repos-add-linked-worktree.test.ts +++ b/src/main/ipc/repos-add-linked-worktree.test.ts @@ -28,7 +28,8 @@ const { addRepo: vi.fn(), removeProject: vi.fn(), getRepo: vi.fn(), - updateRepo: vi.fn() + updateRepo: vi.fn(), + getAllWorktreeMeta: vi.fn().mockReturnValue({}) }, isGitRepoMock: vi.fn().mockReturnValue(true), getGitRepoRootMock: vi.fn(), @@ -105,6 +106,9 @@ describe('repos:add with git worktrees', () => { removeHandlerMock.mockReset() mockStore.getRepos.mockReset().mockReturnValue([]) mockStore.addRepo.mockReset() + mockStore.getRepo.mockReset() + mockStore.updateRepo.mockReset() + mockStore.getAllWorktreeMeta.mockReset().mockReturnValue({}) isGitRepoMock.mockReset().mockReturnValue(true) // A linked worktree is its own toplevel — this is exactly why path dedupe alone misses it. getGitRepoRootMock.mockReset().mockImplementation((path: string) => path) @@ -146,6 +150,47 @@ describe('repos:add with git worktrees', () => { expect(result).toEqual({ repo: expect.objectContaining({ path: '/Users/dev/projects/other' }) }) }) + it('promotes a tracked folder when the same path is re-added as git', async () => { + const folderRepo = { + ...trackedMainRepo(), + id: 'folder-repo-id', + path: '/Users/dev/projects/promoted', + kind: 'folder', + projectHostSetupMethod: 'cloned' + } as Repo + mockStore.getRepos.mockReturnValue([folderRepo]) + mockStore.getRepo.mockImplementation((id: string) => + id === folderRepo.id ? folderRepo : undefined + ) + mockStore.updateRepo.mockImplementation((_id: string, updates: Partial) => ({ + ...folderRepo, + ...updates + })) + + const result = await callAdd({ path: folderRepo.path, kind: 'git' }) + + expect(isGitRepoMock).toHaveBeenCalledWith(folderRepo.path) + expect(result).toEqual({ + repo: expect.objectContaining({ id: folderRepo.id, kind: 'git' }) + }) + expect(mockStore.updateRepo).toHaveBeenCalledWith( + folderRepo.id, + expect.objectContaining({ kind: 'git' }) + ) + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + + it('leaves an already-git repo unchanged when the same path is re-added', async () => { + const gitRepo = trackedMainRepo() + mockStore.getRepos.mockReturnValue([gitRepo]) + + const result = await callAdd({ path: gitRepo.path, kind: 'git' }) + + expect(result).toEqual({ repo: gitRepo }) + expect(mockStore.updateRepo).not.toHaveBeenCalled() + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + it('does not consult worktree detection for folder projects', async () => { mockStore.getRepos.mockReturnValue([trackedMainRepo()]) diff --git a/src/main/ipc/repos-remote-test-harness.ts b/src/main/ipc/repos-remote-test-harness.ts index 4669e9f9900..f3fae0bda5b 100644 --- a/src/main/ipc/repos-remote-test-harness.ts +++ b/src/main/ipc/repos-remote-test-harness.ts @@ -25,7 +25,8 @@ export type ReposIpcMocks = { | 'updateProjectGroup' | 'deleteProjectGroup' | 'moveProjectToGroup' - | 'getSshTarget', + | 'getSshTarget' + | 'getAllWorktreeMeta', ReposIpcSpy > & { updateRepo: Mock<(repoId: string, updates: Record) => unknown> } mockGitProvider: Record< @@ -62,7 +63,8 @@ export function createReposIpcMocks(): ReposIpcMocks { updateProjectGroup: vi.fn(), deleteProjectGroup: vi.fn(), moveProjectToGroup: vi.fn(), - getSshTarget: vi.fn() + getSshTarget: vi.fn(), + getAllWorktreeMeta: vi.fn().mockReturnValue({}) }, mockGitProvider: { isGitRepo: vi.fn().mockReturnValue(true), diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 7145db75db2..d596b4c5b32 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -516,6 +516,70 @@ describe('repos:addRemote', () => { expect(mockStore.addRepo).not.toHaveBeenCalled() }) + it('promotes a tracked SSH folder when the same path is re-added as git', async () => { + const folderRepo = { + id: 'ssh-folder-id', + path: '/home/user/project', + connectionId: 'conn-1', + displayName: 'project', + badgeColor: '#fff', + addedAt: 1000, + kind: 'folder' as const + } + mockStore.getRepos.mockReturnValue([folderRepo]) + mockStore.getRepo.mockImplementation((id: string) => + id === folderRepo.id ? folderRepo : undefined + ) + mockStore.updateRepo.mockImplementation((_id: string, updates: Record) => ({ + ...folderRepo, + ...updates + })) + mockStore.getAllWorktreeMeta.mockReturnValue({}) + mockGitProvider.isGitRepoAsync.mockResolvedValueOnce({ + isRepo: true, + rootPath: '/home/user/project' + }) + + const result = await handlers.get('repos:addRemote')!(null, { + connectionId: 'conn-1', + remotePath: '/home/user/project', + kind: 'git' + }) + + expect(result).toEqual({ + repo: expect.objectContaining({ id: folderRepo.id, kind: 'git' }) + }) + expect(mockStore.updateRepo).toHaveBeenCalledWith( + folderRepo.id, + expect.objectContaining({ kind: 'git' }) + ) + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + + it('returns an existing SSH folder unchanged when it is still not a git repo', async () => { + const folderRepo = { + id: 'ssh-folder-id', + path: '/home/user/notes', + connectionId: 'conn-1', + displayName: 'notes', + badgeColor: '#fff', + addedAt: 1000, + kind: 'folder' as const + } + mockStore.getRepos.mockReturnValue([folderRepo]) + mockGitProvider.isGitRepoAsync.mockResolvedValueOnce({ isRepo: false, rootPath: null }) + + const result = await handlers.get('repos:addRemote')!(null, { + connectionId: 'conn-1', + remotePath: '/home/user/notes', + kind: 'git' + }) + + expect(result).toEqual({ repo: folderRepo }) + expect(mockStore.updateRepo).not.toHaveBeenCalled() + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + it('returns existing repo if same connectionId and path already added', async () => { const existing = { id: 'existing-id', diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index d4a9fc7f0ab..348a86eda01 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -92,6 +92,11 @@ import { normalizeSparseDirectories } from './sparse-checkout-directories' import { track } from '../telemetry/client' import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher' import { wakeFolderRepoGitUpgradeWatch } from './folder-repo-git-upgrade-wake' +import { + tryPromoteLocalFolderRepoToGit, + tryPromoteRemoteFolderRepoToGit +} from '../folder-repo-git-promotion' +import { notifyWorktreesChanged } from './worktree-remote' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import type { RepoMethod } from '../../shared/telemetry-events' import type { @@ -292,7 +297,7 @@ async function addLocalRepoFromPath( store: Store, path: string, kind: 'git' | 'folder' = 'git' -): Promise<{ repo: Repo; alreadyExisted: boolean } | { error: string }> { +): Promise<{ repo: Repo; alreadyExisted: boolean; promoted?: boolean } | { error: string }> { const repoKind = kind === 'folder' ? 'folder' : 'git' if (repoKind === 'git') { await awaitWindowsHostGitEnvironmentReady({ cwd: path }) @@ -307,6 +312,14 @@ async function addLocalRepoFromPath( .getRepos() .find((repo) => !repo.connectionId && normalizeRuntimePathForComparison(repo.path) === pathKey) if (existing) { + if (repoKind === 'git' && isFolderRepo(existing)) { + const promoted = await tryPromoteLocalFolderRepoToGit(store, existing, { + projectHostSetupMethod: existing.projectHostSetupMethod ?? 'imported-existing-folder' + }) + if (promoted) { + return { repo: promoted, alreadyExisted: true, promoted: true } + } + } return { repo: existing, alreadyExisted: true } } @@ -378,7 +391,7 @@ async function addRemoteRepoFromPath( kind?: 'git' | 'folder' setupMethod?: Repo['projectHostSetupMethod'] } -): Promise<{ repo: Repo; alreadyExisted: boolean } | { error: string }> { +): Promise<{ repo: Repo; alreadyExisted: boolean; promoted?: boolean } | { error: string }> { const gitProvider = getSshGitProvider(args.connectionId) if (!gitProvider) { return { error: `SSH connection "${args.connectionId}" not found or not connected` } @@ -396,6 +409,15 @@ async function addRemoteRepoFromPath( normalizeRuntimePathForComparison(resolvedPath) ) if (existing) { + if (repoKind === 'git' && isFolderRepo(existing)) { + const promoted = await tryPromoteRemoteFolderRepoToGit(store, existing, { + projectHostSetupMethod: + existing.projectHostSetupMethod ?? args.setupMethod ?? 'imported-existing-folder' + }) + if (promoted) { + return { repo: promoted, alreadyExisted: true, promoted: true } + } + } return { repo: existing, alreadyExisted: true } } @@ -1834,6 +1856,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v } invalidateAuthorizedRootsCache() notifyReposChanged(mainWindow) + if (result.promoted) { + notifyWorktreesChanged(mainWindow, result.repo.id) + } emitRepoAdded('folder_picker', result.alreadyExisted, result.repo.kind === 'git') return { repo: result.repo } } @@ -1855,6 +1880,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v return result } notifyReposChanged(mainWindow) + if (result.promoted) { + notifyWorktreesChanged(mainWindow, result.repo.id) + } emitRepoAdded('folder_picker', result.alreadyExisted, result.repo.kind === 'git') return { repo: result.repo } } diff --git a/src/main/runtime/orca-runtime-folder-git-promotion.test.ts b/src/main/runtime/orca-runtime-folder-git-promotion.test.ts new file mode 100644 index 00000000000..67c14ec3038 --- /dev/null +++ b/src/main/runtime/orca-runtime-folder-git-promotion.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('../worktree-root-preparation', () => ({ + prepareLocalWorktreeRootForRepo: vi.fn(async () => {}) +})) +vi.mock('../ipc/registered-worktree-roots-cache', () => ({ + invalidateAuthorizedRootsCache: vi.fn() +})) + +import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' + +function gitInit(repoPath: string): void { + execFileSync('git', ['init', '-q'], { cwd: repoPath, stdio: 'ignore' }) +} + +describe('OrcaRuntimeService folder-to-git promotion', () => { + const roots: string[] = [] + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) + }) + + it('promotes an existing folder after git init and explicit git re-add', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-folder-promotion-')) + roots.push(tempRoot) + const repos: Record[] = [] + const runtimeStore = { + getRepos: () => [...repos] as never, + addRepo: (repo: Record) => { + repos.push(repo) + }, + getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, + getAllWorktreeMeta: () => ({}), + getSettings: () => ({ workspaceDir: tempRoot, nestWorkspaces: false }), + updateRepo: vi.fn((id: string, updates: Record) => { + const index = repos.findIndex((repo) => repo.id === id) + if (index === -1) { + return null + } + repos[index] = { ...repos[index], ...updates } + return repos[index] as never + }) + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + const folder = await runtime.addRepo(tempRoot, 'folder') + runtimeStore.updateRepo(folder.id, { + badgeColor: '#123456', + projectHostSetupMethod: 'cloned' + }) + runtimeStore.updateRepo.mockClear() + gitInit(tempRoot) + const readded = await runtime.addRepo(tempRoot, 'git') + + expect(folder.kind).toBe('folder') + expect(readded).toMatchObject({ + id: folder.id, + kind: 'git', + badgeColor: '#123456', + projectHostSetupMethod: 'cloned' + }) + expect(repos).toHaveLength(1) + expect(runtimeStore.updateRepo).toHaveBeenCalledWith( + folder.id, + expect.objectContaining({ kind: 'git' }) + ) + expect(prepareLocalWorktreeRootForRepo).toHaveBeenCalled() + }) + + it('does not reclassify a folder that never became a git repo', async () => { + const tempRoot = await mkdtemp(join(tmpdir(), 'orca-runtime-folder-stays-folder-')) + roots.push(tempRoot) + await mkdir(join(tempRoot, 'notes'), { recursive: true }) + const repos: Record[] = [] + const runtimeStore = { + getRepos: () => [...repos] as never, + addRepo: (repo: Record) => { + repos.push(repo) + }, + getRepo: (id: string) => repos.find((repo) => repo.id === id) as never, + getAllWorktreeMeta: () => ({}), + getSettings: () => ({ workspaceDir: tempRoot, nestWorkspaces: false }), + updateRepo: vi.fn() + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + const folder = await runtime.addRepo(tempRoot, 'folder') + await expect(runtime.addRepo(tempRoot, 'git')).rejects.toThrow('Not a valid git repository') + + expect(folder).toMatchObject({ + kind: 'folder', + badgeColor: DEFAULT_REPO_BADGE_COLOR + }) + expect(repos).toHaveLength(1) + expect(runtimeStore.updateRepo).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3549b8892f8..5487e6d5367 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -113,6 +113,7 @@ import { } from '../git/runner' import { runWithGitReadCacheInvalidation } from '../git/status' import { wakeFolderRepoGitUpgradeWatch } from '../ipc/folder-repo-git-upgrade-wake' +import { tryPromoteLocalFolderRepoToGit } from '../folder-repo-git-promotion' import { cleanupClaimedCloneTarget, claimCloneTarget, @@ -21069,19 +21070,32 @@ export class OrcaRuntimeService { // connectionId), so we never stamp local/ssh onto it — that would re-attribute a // real local project to the wrong host. Runtime is the only host that lost its // identity to the pre-#7018 path-only import and needs the backfill. + let current = existing if ( existing.executionHostId == null && parseExecutionHostId(executionHostId)?.kind === 'runtime' ) { - const adopted = + current = this.store.updateRepo(existing.id, { executionHostId }) ?? ({ ...existing, executionHostId } as Repo) - this.invalidateResolvedWorktreeCache() - this.invalidateWorktreeScanCacheForRepo(existing.id) - this.notifyReposChanged() - return adopted } - return existing + if (kind === 'git' && isFolderRepo(current)) { + const promoted = await tryPromoteLocalFolderRepoToGit(this.store, current, { + projectHostSetupMethod: current.projectHostSetupMethod ?? 'imported-existing-folder' + }) + if (promoted) { + current = promoted + } + } + if (current !== existing) { + this.invalidateResolvedWorktreeCache() + this.invalidateWorktreeScanCacheForRepo(current.id) + this.notifyReposChanged() + if (!isFolderRepo(current)) { + this.notifyWorktreesChanged(current.id) + } + } + return current } const detected = await detectRepoIconAndUpstream({ repoPath: path, kind })