fix(projects): move a registered project in place instead of refusing it

A project's registered path is baked into every `<repoId>::<path>` workspace
id, so changing it as a plain field write would strand the project's
workspaces. Persistence refused the write outright, leaving a renamed folder
project with no recovery at all: new terminals failed on the old path and
`project setup-update --path` was rejected.

Add `Store.relocateRepoPath`, which re-keys every workspace at the checkout
through the same identity migration a worktree folder rename already uses, and
one authority (`relocateProjectPath`) that both the IPC and RPC setup-update
entry points run first. The persistence refusal stays as the backstop for
anything that skips the migration.

Also make the folder->git kind transition reach one verdict. The `.git`
upgrade watch refuses a folder project that has folder workspaces, while a
project host setup update wrote `kind` blind and did silently what the watch
refuses. Both now share `refuseFolderProjectGitUpgradeReason`.

And read a folder workspace's backing directory in lineage pruning: the
`::workspace:<uuid>` suffix is identity, not path, so reading it raw made
every folder workspace look like a checkout git had dropped.

Closes STA-6870
This commit is contained in:
Merge Sim
2026-09-09 05:13:00 -07:00
parent 65631e449a
commit ccd54a8fdf
13 changed files with 575 additions and 25 deletions
+2 -16
View File
@@ -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:<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))
}
/** Identity of the `.git` entry, or null when there is none. */
async function readGitMarkerSignature(repoPath: string): Promise<string | null> {
try {
@@ -122,7 +108,7 @@ async function upgradeFolderRepo(watch: UpgradeWatch, repoId: string): Promise<U
if (!current || !isUpgradeCandidate(current)) {
return 'blocked'
}
if (hasExtraFolderWorkspaces(watch.store, current)) {
if (folderProjectHasExtraWorkspaces(watch.store.getAllWorktreeMeta(), current)) {
return 'blocked'
}
const updates = resolveUpgrade(current.path)
@@ -16,6 +16,7 @@ import { getProjectIdForProviderIdentity } from '../../../shared/project-host-se
import { getProjectHostSetupForRepo } from '../../../shared/project-host-setup-lookup'
import { parseExecutionHostId } from '../../../shared/execution-host'
import { prepareLocalWorktreeRootForRepo } from '../../worktree-root-preparation'
import { applyProjectHostSetupPathRelocation } from '../../project-path-relocation'
import { invalidateAuthorizedRootsCache } from '../registered-worktree-roots-cache'
import { emitRepoAdded } from './repo-added-telemetry'
import { notifyReposChanged } from './repos-changed-notification'
@@ -103,11 +104,15 @@ export function registerProjectHostSetupHandlers(mainWindow: BrowserWindow, stor
rawArgs,
'project_host_setup_update_invalid_args'
)
const result = store.updateProjectHostSetup(args)
const { updates, relocatedRepo } = applyProjectHostSetupPathRelocation(store, args)
if (relocatedRepo) {
invalidateAuthorizedRootsCache()
}
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()
}
+41 -2
View File
@@ -321,16 +321,55 @@ describe('Store', () => {
})
})
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
// `<repoId>::<path>` 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 () => {
@@ -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 `<repoId>::<path>` 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
@@ -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 `<repoId>::<path>` 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') {
@@ -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 `<repoId>::<path>` with an optional `::workspace:<uuid>` 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<Repo, 'id' | 'path'>,
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
}
+37
View File
@@ -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
* `<repoId>::<path>::workspace:<uuid>`, 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<Record<string, WorktreeMeta>>,
repo: Pick<Repo, 'id' | 'path'>
): 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<Record<string, WorktreeMeta>>,
repo: Pick<Repo, 'id' | 'path' | 'kind'>
): 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
}
+213
View File
@@ -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<string, { worktreeId: string }[]>
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/)
})
})
+118
View File
@@ -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
}
}
@@ -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()
}
@@ -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']
+25
View File
@@ -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: `<repoId>::<path>::workspace:<uuid>` 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'
+5 -2
View File
@@ -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:<uuid>` 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
}