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 <erish2150@gmail.com>
This commit is contained in:
Brennan Benson
2026-08-20 18:25:34 -07:00
co-authored by erishforG
parent 012e9f410c
commit 5e0d243126
9 changed files with 640 additions and 98 deletions
+210
View File
@@ -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<Partial<Repo>, 'kind' | 'externalWorktreeVisibility' | 'projectHostSetupMethod'>
): Repo | null
getAllWorktreeMeta?: () => Record<string, unknown>
getSettings: () => Pick<GlobalSettings, 'workspaceDir' | 'nestWorkspaces'>
}
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:<uuid>`, 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<FolderGitPromotionStore, 'getAllWorktreeMeta'>,
repo: Pick<Repo, 'id' | 'path'>
): 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<Repo, 'path' | 'connectionId'>
): Promise<string | null | 'unverifiable'> {
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<Repo, 'path' | 'connectionId'>
): Promise<RemoteFolderGitProbe> {
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<Partial<Repo>, 'projectHostSetupMethod'>
): Promise<Repo | null> {
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<Partial<Repo>, 'projectHostSetupMethod'> = {}
): Promise<Repo | null> {
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<Partial<Repo>, 'projectHostSetupMethod'> = {}
): Promise<Repo | null> {
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 })
}
+77 -7
View File
@@ -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<typeof GitRepo>()
@@ -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)
+86 -80
View File
@@ -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<string, string>()
// 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:<uuid>`, 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<string | null> {
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<string | null> {
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<string | null>
}
}
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<UpgradeResult> {
// 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<UpgradeResult> {
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<UpgradeResult> {
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<void> {
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<void> {
/**
* Polls `<repo>/.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.
+46 -1
View File
@@ -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<Repo>) => ({
...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()])
+4 -2
View File
@@ -25,7 +25,8 @@ export type ReposIpcMocks = {
| 'updateProjectGroup'
| 'deleteProjectGroup'
| 'moveProjectToGroup'
| 'getSshTarget',
| 'getSshTarget'
| 'getAllWorktreeMeta',
ReposIpcSpy
> & { updateRepo: Mock<(repoId: string, updates: Record<string, unknown>) => 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),
+64
View File
@@ -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<string, unknown>) => ({
...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',
+30 -2
View File
@@ -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 }
}
@@ -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<string, unknown>[] = []
const runtimeStore = {
getRepos: () => [...repos] as never,
addRepo: (repo: Record<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>[] = []
const runtimeStore = {
getRepos: () => [...repos] as never,
addRepo: (repo: Record<string, unknown>) => {
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()
})
})
+20 -6
View File
@@ -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 })