mirror of
https://github.com/stablyai/orca.git
synced 2026-10-04 00:02:21 +00:00
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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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']
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user