mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 08:03:58 +00:00
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:
co-authored by
erishforG
parent
012e9f410c
commit
5e0d243126
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()])
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user