diff --git a/src/main/ipc/folder-repo-git-upgrade.ts b/src/main/ipc/folder-repo-git-upgrade.ts index ede90ca6ab9..c0bd2ded824 100644 --- a/src/main/ipc/folder-repo-git-upgrade.ts +++ b/src/main/ipc/folder-repo-git-upgrade.ts @@ -6,7 +6,7 @@ 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 { folderProjectHasExtraWorkspaces } from '../project-kind-transition' import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { isWslUncPath } from '../../shared/wsl-paths' import { getGitRepoRoot, isGitRepo } from '../git/repo' @@ -57,20 +57,6 @@ 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)) -} - /** Identity of the `.git` entry, or null when there is none. */ async function readGitMarkerSignature(repoPath: string): Promise { try { @@ -122,7 +108,7 @@ async function upgradeFolderRepo(watch: UpgradeWatch, repoId: string): Promise { }) }) - it('rejects repo-backed project host setup path changes', async () => { + it('rejects a raw repo-backed project host setup path write', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'r1', path: '/repo' })) + // Persistence stays the backstop: a path write that skipped the relocation would strand every + // `::` workspace id, so it must never land as a plain field update. expect(() => store.updateProjectHostSetup({ setupId: 'r1', updates: { path: '/other' } }) - ).toThrow('Repo-backed project host setup paths must be changed by re-importing the project.') + ).toThrow('must be moved through a project relocation') + }) + + it('refuses a folder project git upgrade that would unlist its folder workspaces', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1', path: '/repo', kind: 'folder' })) + store.setWorktreeMeta('r1::/repo::workspace:11111111-1111-1111-1111-111111111111', { + displayName: 'draft' + }) + + // Why: the `.git` upgrade watch already refuses this. A second kind writer that allowed it would + // do silently what the first one refuses. + expect(() => store.updateProjectHostSetup({ setupId: 'r1', updates: { kind: 'git' } })).toThrow( + /folder workspaces that a Git project cannot list/ + ) + expect(store.getRepo('r1')?.kind).toBe('folder') + }) + + it('upgrades a folder project with no extra folder workspaces', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1', path: '/repo', kind: 'folder' })) + store.setWorktreeMeta('r1::/repo', { displayName: 'repo' }) + + store.updateProjectHostSetup({ setupId: 'r1', updates: { kind: 'git' } }) + + expect(store.getRepo('r1')?.kind).toBe('git') + }) + + it('still lets a git project become a folder project', async () => { + const store = await createStore() + store.addRepo(makeRepo({ id: 'r1', path: '/repo', kind: 'git' })) + store.setWorktreeMeta('r1::/repo::workspace:11111111-1111-1111-1111-111111111111', { + displayName: 'draft' + }) + + store.updateProjectHostSetup({ setupId: 'r1', updates: { kind: 'folder' } }) + + expect(store.getRepo('r1')?.kind).toBe('folder') }) it('deletes independent project host setup records without deleting the project', async () => { diff --git a/src/main/persistence/loading-store/store.ts b/src/main/persistence/loading-store/store.ts index 017583e8f86..176c4f1a76d 100644 --- a/src/main/persistence/loading-store/store.ts +++ b/src/main/persistence/loading-store/store.ts @@ -15,6 +15,9 @@ import { type StoreDomains } from './store-domain-composition' import type { PersistedState } from '../../../shared/persisted-state-types' +import type { Repo } from '../../../shared/repo-types' +import { getRepoExecutionHostId } from '../../../shared/execution-host' +import { planRepoPathRelocation } from '../tracking-repos/repo-path-relocation' import { scheduleSave } from './write-scheduling' import type { WriteSchedulingOperations } from './write-scheduling' import type { PrimaryStateWriteOperations } from './primary-state-writes' @@ -102,6 +105,33 @@ export class Store { this.runtime.writeTimer = null } } + + /** + * Point a registered project at a directory it has moved to, carrying its workspaces across. + * + * Lives on the composition root because it is the one repo mutation that also has to re-key + * worktree-derived identity: `updateRepo` cannot take `path` precisely because changing it alone + * would strand every `::` row. Callers own validating that `newPath` exists on the + * host that runs the project; this only rewrites persisted identity. + */ + relocateRepoPath(repoId: string, newPath: string): Repo | null { + const repo = this.getRepo(repoId) + if (!repo) { + return null + } + const moves = planRepoPathRelocation(this.state, repo, newPath) + // Re-key first: a migration reads the old id, so the repo must still spell the old path. + for (const move of moves) { + this.migrateWorktreeIdentity(move.from, move.to, getRepoExecutionHostId(repo)) + } + const stored = this.state.repos.find((candidate) => candidate.id === repoId) + if (!stored) { + return null + } + stored.path = newPath + scheduleSave(this.domains.scheduling) + return this.getRepo(repoId) ?? stored + } } // oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging derives Store's prototype API directly from the exact concrete domain classes installed below diff --git a/src/main/persistence/tracking-repos/project-host-setup-update.ts b/src/main/persistence/tracking-repos/project-host-setup-update.ts index 05d291bd171..158bfb23908 100644 --- a/src/main/persistence/tracking-repos/project-host-setup-update.ts +++ b/src/main/persistence/tracking-repos/project-host-setup-update.ts @@ -2,6 +2,7 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import type { ProjectHostSetup, ProjectHostSetupUpdateArgs } from '../../../shared/project-types' import type { Repo } from '../../../shared/repo-types' import type { RepoUpdatePersistenceOperations } from './repo-update-operations' +import { refuseFolderProjectGitUpgradeReason } from '../../project-kind-transition' export type ProjectHostSetupUpdateOperations = { state: PersistedState @@ -32,8 +33,11 @@ export class ProjectHostSetupPersistenceOperations { updates: ProjectHostSetupUpdateArgs['updates'] ): { setup: ProjectHostSetup; repo: Repo } | null { if (updates.path !== undefined && updates.path !== repo.path) { + // Why still a throw: the project's path is baked into every `::` workspace id, + // so it can only move through `relocateRepoPath`, which re-keys them. Callers reach that via + // `applyProjectHostSetupPathRelocation`; anything landing here skipped the migration. throw new Error( - 'Repo-backed project host setup paths must be changed by re-importing the project.' + "Repo-backed project host setup paths must be moved through a project relocation, which carries the project's workspaces to the new path." ) } if (updates.setupState !== undefined && updates.setupState !== 'ready') { @@ -47,6 +51,13 @@ export class ProjectHostSetupPersistenceOperations { repoUpdates.worktreeBasePath = updates.worktreeBasePath } if (updates.kind !== undefined) { + const refusal = + updates.kind === 'git' + ? refuseFolderProjectGitUpgradeReason(this.state.worktreeMeta, repo) + : null + if (refusal) { + throw new Error(refusal) + } repoUpdates.kind = updates.kind } if (updates.setupMethod === 'provisioned') { diff --git a/src/main/persistence/tracking-repos/repo-path-relocation.ts b/src/main/persistence/tracking-repos/repo-path-relocation.ts new file mode 100644 index 00000000000..9b390b64342 --- /dev/null +++ b/src/main/persistence/tracking-repos/repo-path-relocation.ts @@ -0,0 +1,60 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import type { Repo } from '../../../shared/repo-types' +import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' +import { + splitWorktreeId, + splitWorktreeIdForFilesystem, + WORKTREE_ID_SEPARATOR +} from '../../../shared/worktree/id' + +/** One workspace's path-derived id before and after the project moves. */ +export type RepoWorkspaceIdentityMove = { + readonly from: string + readonly to: string +} + +/** + * Worktree ids are `::` with an optional `::workspace:` suffix, so a project's + * registered path is baked into every workspace that sits at its checkout. Moving the project has to + * re-key them together; this plans the moves so the caller can apply them through the same + * identity migration a worktree folder rename uses. + * + * Only ids at the checkout itself move. A git project's extra worktrees live under their own base + * path, which the project path does not own, so relocating the checkout must not rewrite them. + */ +export function planRepoPathRelocation( + state: PersistedState, + repo: Pick, + newPath: string +): RepoWorkspaceIdentityMove[] { + const oldKey = normalizeRuntimePathForComparison(repo.path) + if (normalizeRuntimePathForComparison(newPath) === oldKey) { + return [] + } + const candidateIds = new Set([ + ...Object.keys(state.worktreeMeta ?? {}), + ...Object.keys(state.worktreeLineageById ?? {}) + ]) + const moves: RepoWorkspaceIdentityMove[] = [] + for (const worktreeId of candidateIds) { + const parsed = splitWorktreeId(worktreeId) + if (!parsed || parsed.repoId !== repo.id) { + continue + } + const filesystemPath = splitWorktreeIdForFilesystem(worktreeId)?.worktreePath + if (filesystemPath === undefined) { + continue + } + if (normalizeRuntimePathForComparison(filesystemPath) !== oldKey) { + continue + } + // The instance suffix is whatever the filesystem view stripped; carry it across unchanged so + // sibling workspaces stay distinct instead of collapsing onto one id. + const instanceSuffix = parsed.worktreePath.slice(filesystemPath.length) + moves.push({ + from: worktreeId, + to: `${repo.id}${WORKTREE_ID_SEPARATOR}${newPath}${instanceSuffix}` + }) + } + return moves +} diff --git a/src/main/project-kind-transition.ts b/src/main/project-kind-transition.ts new file mode 100644 index 00000000000..1ab50c9b931 --- /dev/null +++ b/src/main/project-kind-transition.ts @@ -0,0 +1,37 @@ +import type { Repo } from '../shared/repo-types' +import type { WorktreeMeta } from '../shared/worktree/meta-types' +import { isFolderRepo } from '../shared/repo-kind' +import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../shared/worktree/id' + +/** + * A folder project's extra workspaces are `worktreeMeta` rows keyed + * `::::workspace:`, and only the folder branch of the worktree listing knows + * those keys exist. Flipping `kind` to `git` moves the repo onto the git branch, which lists + * `git worktree list` — one path — so those workspaces stop being listed at all. + */ +export function folderProjectHasExtraWorkspaces( + allWorktreeMeta: Readonly>, + repo: Pick +): boolean { + const prefix = `${repo.id}::${repo.path}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` + return Object.keys(allWorktreeMeta).some((key) => key.startsWith(prefix)) +} + +/** + * Whether a project may move from `folder` to `git` right now. + * + * Two writers change a project's kind — the `.git` upgrade watch and a project host setup update — + * and both must reach the same verdict, or the setup path silently does what the watch refuses. + * Returns null when the transition is allowed, or the reason it is not. + */ +export function refuseFolderProjectGitUpgradeReason( + allWorktreeMeta: Readonly>, + repo: Pick +): string | null { + if (!isFolderRepo(repo)) { + return null + } + return folderProjectHasExtraWorkspaces(allWorktreeMeta, repo) + ? 'This project has folder workspaces that a Git project cannot list. Remove them before turning it into a Git project.' + : null +} diff --git a/src/main/project-path-relocation.test.ts b/src/main/project-path-relocation.test.ts new file mode 100644 index 00000000000..3fe6fcf5fa6 --- /dev/null +++ b/src/main/project-path-relocation.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import type { Store } from './persistence' +import { testState, createStore, makeRepo } from './persistence-test-harness' +import { applyProjectHostSetupPathRelocation, relocateProjectPath } from './project-path-relocation' + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { isEncryptionAvailable: () => false } +})) + +let root = '' +let oldPath = '' +let newPath = '' + +/** The real Store, so the relocation is driven through the same state the app persists. */ +function storeWithFolderProject(): Store { + const store = createStore() + store.addRepo(makeRepo({ id: 'r1', path: oldPath, kind: 'folder' })) + return store +} + +const rootWorkspaceId = (): string => `r1::${oldPath}` +const instanceWorkspaceId = (): string => + `r1::${oldPath}::workspace:11111111-1111-1111-1111-111111111111` + +describe('relocateProjectPath', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-relocate-')) + root = mkdtempSync(join(tmpdir(), 'orca-projects-')) + oldPath = join(root, 'example-project') + newPath = join(root, 'renamed-project') + mkdirSync(oldPath) + mkdirSync(newPath) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + rmSync(root, { recursive: true, force: true }) + }) + + it('carries a folder project and every workspace identity to the new path', () => { + const store = storeWithFolderProject() + store.setWorktreeMeta(rootWorkspaceId(), { displayName: 'example-project' }) + store.setWorktreeMeta(instanceWorkspaceId(), { displayName: 'draft', isPinned: true }) + + const result = relocateProjectPath(store, 'r1', newPath) + + expect(result.outcome).toBe('relocated') + expect(store.getRepo('r1')?.path).toBe(newPath) + // The instance suffix is identity, so siblings must stay distinct rather than collapse. + expect(store.getWorktreeMeta(`r1::${newPath}`)?.displayName).toBe('example-project') + const movedInstance = store.getWorktreeMeta( + `r1::${newPath}::workspace:11111111-1111-1111-1111-111111111111` + ) + expect(movedInstance?.displayName).toBe('draft') + expect(movedInstance?.isPinned).toBe(true) + expect(store.getWorktreeMeta(rootWorkspaceId())).toBeUndefined() + expect(store.getWorktreeMeta(instanceWorkspaceId())).toBeUndefined() + }) + + it('keeps the session bound to the relocated workspace', () => { + const store = storeWithFolderProject() + store.setWorktreeMeta(instanceWorkspaceId(), { displayName: 'draft' }) + store.setWorkspaceSession({ + tabsByWorktree: { + [instanceWorkspaceId()]: [ + { id: 'tab-1', worktreeId: instanceWorkspaceId(), type: 'terminal', title: 'zsh' } + ] + }, + activeWorktreeId: instanceWorkspaceId() + } as never) + + relocateProjectPath(store, 'r1', newPath) + + const session = store.getWorkspaceSession() as unknown as { + tabsByWorktree?: Record + activeWorktreeId?: string + } + const movedId = `r1::${newPath}::workspace:11111111-1111-1111-1111-111111111111` + expect(session.tabsByWorktree?.[movedId]?.[0]?.worktreeId).toBe(movedId) + expect(session.tabsByWorktree?.[instanceWorkspaceId()]).toBeUndefined() + expect(session.activeWorktreeId).toBe(movedId) + }) + + it('records the prior id so a session minted under it is not reaped', () => { + const store = storeWithFolderProject() + store.setWorktreeMeta(instanceWorkspaceId(), { displayName: 'draft' }) + + relocateProjectPath(store, 'r1', newPath) + + const moved = store.getWorktreeMeta( + `r1::${newPath}::workspace:11111111-1111-1111-1111-111111111111` + ) + expect(moved?.priorWorktreeIds).toContain(instanceWorkspaceId()) + }) + + it('leaves a project untouched when the target directory does not exist', () => { + const store = storeWithFolderProject() + store.setWorktreeMeta(instanceWorkspaceId(), { displayName: 'draft' }) + const missing = join(root, 'not-there') + + const result = relocateProjectPath(store, 'r1', missing) + + expect(result).toMatchObject({ outcome: 'refused' }) + // A refusal must not half-migrate: the old identity is still the live one. + expect(store.getRepo('r1')?.path).toBe(oldPath) + expect(store.getWorktreeMeta(instanceWorkspaceId())?.displayName).toBe('draft') + }) + + it('refuses a path another project already occupies', () => { + const store = storeWithFolderProject() + store.addRepo(makeRepo({ id: 'r2', path: newPath, displayName: 'Other', kind: 'folder' })) + + const result = relocateProjectPath(store, 'r1', newPath) + + expect(result).toMatchObject({ outcome: 'refused' }) + expect(store.getRepo('r1')?.path).toBe(oldPath) + }) + + it('refuses a project whose files live on another execution host', () => { + const store = createStore() + store.addRepo( + makeRepo({ id: 'r1', path: oldPath, kind: 'folder', connectionId: 'ssh-target-1' }) + ) + + const result = relocateProjectPath(store, 'r1', newPath) + + expect(result).toMatchObject({ outcome: 'refused' }) + expect(store.getRepo('r1')?.path).toBe(oldPath) + }) + + it('leaves worktrees outside the project directory where they are', () => { + const store = createStore() + store.addRepo(makeRepo({ id: 'r1', path: oldPath, kind: 'git' })) + const siblingWorktreeId = `r1::${join(root, 'worktrees', 'feature')}` + store.setWorktreeMeta(`r1::${oldPath}`, { displayName: 'main' }) + store.setWorktreeMeta(siblingWorktreeId, { displayName: 'feature' }) + + relocateProjectPath(store, 'r1', newPath) + + // Only the checkout itself is addressed by the project path; a worktree under the base path is not. + expect(store.getWorktreeMeta(siblingWorktreeId)?.displayName).toBe('feature') + expect(store.getWorktreeMeta(`r1::${newPath}`)?.displayName).toBe('main') + }) + + it('reports an unchanged path without rewriting identity', () => { + const store = storeWithFolderProject() + store.setWorktreeMeta(instanceWorkspaceId(), { displayName: 'draft' }) + + const result = relocateProjectPath(store, 'r1', oldPath) + + expect(result.outcome).toBe('unchanged') + expect(store.getWorktreeMeta(instanceWorkspaceId())?.displayName).toBe('draft') + }) +}) + +describe('applyProjectHostSetupPathRelocation', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-relocate-setup-')) + root = mkdtempSync(join(tmpdir(), 'orca-projects-setup-')) + oldPath = join(root, 'example-project') + newPath = join(root, 'renamed-project') + mkdirSync(oldPath) + mkdirSync(newPath) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + rmSync(root, { recursive: true, force: true }) + }) + + it('relocates the project and hands persistence updates without a path', () => { + const store = storeWithFolderProject() + store.setWorktreeMeta(instanceWorkspaceId(), { displayName: 'draft' }) + + const { updates, relocatedRepo } = applyProjectHostSetupPathRelocation(store, { + setupId: 'r1', + updates: { path: newPath, displayName: 'Renamed' } + }) + + expect(relocatedRepo?.path).toBe(newPath) + expect(updates).toEqual({ displayName: 'Renamed' }) + // Persistence still refuses a raw path write, so the stripped update must survive it. + expect(() => store.updateProjectHostSetup({ setupId: 'r1', updates })).not.toThrow() + expect(store.getRepo('r1')?.displayName).toBe('Renamed') + }) + + it('passes an update with no path change straight through', () => { + const store = storeWithFolderProject() + + const { updates, relocatedRepo } = applyProjectHostSetupPathRelocation(store, { + setupId: 'r1', + updates: { displayName: 'Renamed' } + }) + + expect(relocatedRepo).toBeNull() + expect(updates).toEqual({ displayName: 'Renamed' }) + }) + + it('surfaces the refusal instead of silently dropping the path', () => { + const store = storeWithFolderProject() + + expect(() => + applyProjectHostSetupPathRelocation(store, { + setupId: 'r1', + updates: { path: join(root, 'not-there') } + }) + ).toThrow(/No directory exists/) + }) +}) diff --git a/src/main/project-path-relocation.ts b/src/main/project-path-relocation.ts new file mode 100644 index 00000000000..bd6501a86e4 --- /dev/null +++ b/src/main/project-path-relocation.ts @@ -0,0 +1,118 @@ +import { statSync } from 'node:fs' +import { isAbsolute } from 'node:path' +import type { Repo } from '../shared/repo-types' +import type { ProjectHostSetupUpdateArgs } from '../shared/project-types' +import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' +import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' + +/** The store surface a relocation needs; keeps this callable from the IPC and RPC entry points alike. */ +export type ProjectPathRelocationStore = { + getRepo: (id: string) => Repo | undefined + getRepos: () => Repo[] + relocateRepoPath: (repoId: string, newPath: string) => Repo | null +} + +export type ProjectPathRelocationResult = + | { readonly outcome: 'relocated'; readonly repo: Repo } + | { readonly outcome: 'unchanged'; readonly repo: Repo } + | { readonly outcome: 'refused'; readonly error: string } + +function isExistingDirectory(pathValue: string): boolean { + try { + return statSync(pathValue).isDirectory() + } catch { + return false + } +} + +/** + * Move a registered project to the directory it now lives in, keeping its id, its workspaces and + * their sessions. The single authority for a project path change: `updateRepo` deliberately cannot + * take `path`, because every workspace id is derived from it. + * + * Refuses rather than guesses. A project on an SSH host is checked by the host that runs it, never + * from here, so a remote relocation is declined outright instead of validated against local disk. + */ +export function relocateProjectPath( + store: ProjectPathRelocationStore, + repoId: string, + rawNewPath: string, + options: { directoryExists?: (path: string) => boolean } = {} +): ProjectPathRelocationResult { + const directoryExists = options.directoryExists ?? isExistingDirectory + const repo = store.getRepo(repoId) + if (!repo) { + return { outcome: 'refused', error: `Project not found: ${repoId}` } + } + const newPath = rawNewPath.trim() + if (!newPath || !isAbsolute(newPath)) { + return { outcome: 'refused', error: 'The new project location must be an absolute path.' } + } + if (normalizeRuntimePathForComparison(newPath) === normalizeRuntimePathForComparison(repo.path)) { + return { outcome: 'unchanged', repo } + } + if (repo.connectionId || getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) { + return { + outcome: 'refused', + error: + 'Only a project on this machine can be moved from here. Re-import a project that runs on another host from that host.' + } + } + const newPathKey = normalizeRuntimePathForComparison(newPath) + const occupant = store + .getRepos() + .find( + (candidate) => + candidate.id !== repo.id && normalizeRuntimePathForComparison(candidate.path) === newPathKey + ) + if (occupant) { + return { + outcome: 'refused', + error: `Another project ("${occupant.displayName}") is already registered at ${newPath}.` + } + } + if (!directoryExists(newPath)) { + return { outcome: 'refused', error: `No directory exists at ${newPath}.` } + } + const relocated = store.relocateRepoPath(repo.id, newPath) + if (!relocated) { + return { outcome: 'refused', error: `Project could not be moved: ${repoId}` } + } + return { outcome: 'relocated', repo: relocated } +} + +/** + * Resolve the path change a setup update asks for before persistence sees it. + * + * A repo-backed setup's path is the project's registered path, so changing it is a relocation, not + * a field write — persistence refuses it outright for exactly that reason. Both the IPC and RPC + * entry points run this first so a caller reaching either one gets the same answer, and hand the + * remaining fields on with `path` already applied. + */ +export function applyProjectHostSetupPathRelocation( + store: ProjectPathRelocationStore & { + getProjectHostSetups?: () => readonly { id: string; repoId: string }[] + }, + args: ProjectHostSetupUpdateArgs, + options: { directoryExists?: (path: string) => boolean } = {} +): { updates: ProjectHostSetupUpdateArgs['updates']; relocatedRepo: Repo | null } { + const requestedPath = args.updates.path + if (requestedPath === undefined) { + return { updates: args.updates, relocatedRepo: null } + } + const setup = store.getProjectHostSetups?.().find((entry) => entry.id === args.setupId) + const repoId = setup?.repoId + // An independent setup owns its own `path` field; only a repo-backed one is a project location. + if (!repoId || !store.getRepo(repoId)) { + return { updates: args.updates, relocatedRepo: null } + } + const result = relocateProjectPath(store, repoId, requestedPath, options) + if (result.outcome === 'refused') { + throw new Error(result.error) + } + const { path: _path, ...rest } = args.updates + return { + updates: rest, + relocatedRepo: result.outcome === 'relocated' ? result.repo : null + } +} diff --git a/src/main/runtime/runtime-project-host-setup-controller.ts b/src/main/runtime/runtime-project-host-setup-controller.ts index cdc3ab1c60f..e7f8dbd5812 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.ts @@ -22,6 +22,7 @@ import { getProjectIdForProviderIdentity } from '../../shared/project-host-setup import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-lookup' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' +import { applyProjectHostSetupPathRelocation } from '../project-path-relocation' import type { RuntimeStore } from './runtime-store-contract' type RuntimeProjectHostSetupDependencies = { @@ -130,11 +131,32 @@ export class RuntimeProjectHostSetupController { if (!store?.updateProjectHostSetup) { throw new Error('runtime_unavailable') } - const result = store.updateProjectHostSetup(args) + // A repo-backed setup's path is the project's own location, so settle a move before the field + // write; persistence only ever sees updates whose `path` it can apply verbatim. + const { updates, relocatedRepo } = store.relocateRepoPath + ? applyProjectHostSetupPathRelocation( + { + getRepo: store.getRepo, + getRepos: store.getRepos, + relocateRepoPath: store.relocateRepoPath, + ...(store.getProjectHostSetups + ? { getProjectHostSetups: store.getProjectHostSetups } + : {}) + }, + args + ) + : { updates: args.updates, relocatedRepo: null } + if (relocatedRepo) { + this.deps.invalidateResolvedWorktrees() + this.deps.invalidateWorktreeScan(relocatedRepo.id) + invalidateAuthorizedRootsCache() + this.deps.notifyReposChanged() + } + const result = store.updateProjectHostSetup({ ...args, updates }) if (!result) { throw new Error(`Project host setup not found: ${args.setupId}`) } - if ('worktreeBasePath' in args.updates && result.repo) { + if ('worktreeBasePath' in updates && result.repo) { void prepareLocalWorktreeRootForRepo(store, result.repo) invalidateAuthorizedRootsCache() } diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index f3f5d5a8f51..a76951aaa1d 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -16,6 +16,7 @@ export type RuntimeStore = { getProjectHostSetups?: Store['getProjectHostSetups'] createProjectHostSetup?: Store['createProjectHostSetup'] updateProjectHostSetup?: Store['updateProjectHostSetup'] + relocateRepoPath?: Store['relocateRepoPath'] deleteProjectHostSetup?: Store['deleteProjectHostSetup'] getProjectGroups?: Store['getProjectGroups'] createProjectGroup?: Store['createProjectGroup'] diff --git a/src/main/worktree-lineage-pruning.test.ts b/src/main/worktree-lineage-pruning.test.ts index 22b34f70104..fc4ad9d3eef 100644 --- a/src/main/worktree-lineage-pruning.test.ts +++ b/src/main/worktree-lineage-pruning.test.ts @@ -133,6 +133,31 @@ describe('pruneLineageForMissingRepoWorktrees', () => { expect(metaById[parentId].instanceId).toBe(edge.parentWorktreeInstanceId) }) + it('keeps a folder workspace whose backing directory git still lists', () => { + // Why: `::::workspace:` is one directory with several workspace identities. + // Reading the suffix as part of the path makes each one look like a checkout git dropped. + const rootId = 'repo-1::/repo' + const instanceId = 'repo-1::/repo::workspace:11111111-1111-1111-1111-111111111111' + const edge = lineage(instanceId, rootId) + const worktreeLineageById = { [instanceId]: edge } + const workspaceLineageByChildKey = { + [worktreeWorkspaceKey(instanceId)]: workspaceLineage(instanceId, rootId) + } + const metaById = { + [rootId]: { instanceId: edge.parentWorktreeInstanceId } as WorktreeMeta, + [instanceId]: { instanceId: edge.worktreeInstanceId } as WorktreeMeta + } + const store = createStore(worktreeLineageById, workspaceLineageByChildKey, metaById) + + pruneLineageForMissingRepoWorktrees(store as never, repo, [ + { path: '/repo', head: 'a', branch: 'main', isBare: false, isMainWorktree: true } + ]) + + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + expect(store.removeWorkspaceLineage).not.toHaveBeenCalled() + expect(worktreeLineageById[instanceId]).toBeDefined() + }) + it('refuses an empty scan when the repo still has registered lineage', () => { const parentId = 'repo-1::/repo/parent' const childId = 'repo-1::/repo/child' diff --git a/src/main/worktree-lineage-pruning.ts b/src/main/worktree-lineage-pruning.ts index 6040f25c03d..cee0574f0b0 100644 --- a/src/main/worktree-lineage-pruning.ts +++ b/src/main/worktree-lineage-pruning.ts @@ -4,7 +4,7 @@ import type { WorkspaceLineage, WorktreeLineage } from '../shared/worktree/linea import type { GitWorktreeInfo } from '../shared/worktree/types' import { getRepoExecutionHostId } from '../shared/execution-host' import { isWorkspaceKey, parseWorkspaceKey, worktreeWorkspaceKey } from '../shared/workspace-scope' -import { splitWorktreeId } from '../shared/worktree/id' +import { splitWorktreeIdForFilesystem } from '../shared/worktree/id' import { worktreeRetentionPathComparisonKey } from './worktree-retention-path-comparison' import type { Store } from './persistence' @@ -72,7 +72,10 @@ export function pruneLineageForMissingRepoWorktrees( if (liveIds.has(worktreeId) || preservedMetadataCandidateIds?.has(worktreeId)) { return true } - const worktreePath = splitWorktreeId(worktreeId)?.worktreePath + // Why the filesystem view: a folder workspace's `::workspace:` suffix is identity, not + // path. Reading it raw makes every folder workspace look like a directory git no longer lists, + // and prunes the lineage of workspaces whose directory is right there in `livePathKeys`. + const worktreePath = splitWorktreeIdForFilesystem(worktreeId)?.worktreePath if (!worktreePath) { return false }