mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
feat(wsl-mover): refuse edits to a project a WSL move is taking (D1b-5c-2) (#28761)
* feat(wsl-mover): refuse edits to a project a WSL move is taking, and drop its background bookkeeping quietly (D1b-5c-2) * fix(wsl-mover): hide only local rows of a moved project; the host's own row keeps its id (D1b-5c-2) * fix(wsl-mover): refuse a user's unread toggle, fence branch deletes, host setups and issue commands; settings unfreeze once the host owns a repo (D1b-5c-2) * test(wsl-mover): the fence e2e refuses edits to moving projects and drops their bookkeeping quietly (D1b-5c-2) * refactor(wsl-mover): name the update type the fence inspects (D1b-5c-2) * test(wsl-mover): run the fence tests that open a real store in the Node runtime (D1b-5c-2) * fix(wsl-mover): a host setup's own repo stays frozen once the host owns it; e2e pins the quiet drop (D1b-5c-2)
This commit is contained in:
@@ -3,6 +3,9 @@ import { SQLITE_DATABASE_RUNTIME_INCLUDE } from './vitest-sqlite-database-runtim
|
||||
// Real SQLite fixtures keep Node publication and close semantics.
|
||||
export const SQLITE_RUNTIME_INCLUDE = [
|
||||
...SQLITE_DATABASE_RUNTIME_INCLUDE,
|
||||
'src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts',
|
||||
'src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts',
|
||||
'src/main/runtime/runtime-managed-worktree-metadata-wsl-move.test.ts',
|
||||
'src/main/acp/acp-structured-host-live-listing.test.ts',
|
||||
'src/main/acp/acp-structured-host-configured-default.test.ts',
|
||||
'src/main/pi/rpc-configured-default.test.ts',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths'
|
||||
import { readIssueCommand, writeIssueCommand } from '../../issue-command-file'
|
||||
import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership'
|
||||
import type { WorktreeIpcContext } from '../worktrees/worktree-ipc-context'
|
||||
import { assertWslMoveRepoWritable } from '../../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
|
||||
/** Route private command overrides to the owning host without changing shared hook settings. */
|
||||
export function registerWorktreeHookFileHandlers(context: WorktreeIpcContext): void {
|
||||
@@ -49,6 +50,7 @@ export function registerWorktreeHookFileHandlers(context: WorktreeIpcContext): v
|
||||
if (!repo || isFolderRepo(repo)) {
|
||||
return
|
||||
}
|
||||
assertWslMoveRepoWritable(store, repo)
|
||||
if (getStoredRepoSshConnectionId(repo)) {
|
||||
throw new Error('Remote filesystem unavailable. Reconnect the SSH target before retrying.')
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
parseProjectGroupIpcArgs
|
||||
} from './repo-ipc-arg-schemas'
|
||||
import { visibleFolderWorkspaces } from '../../ssh/orcad-retained-source'
|
||||
import {
|
||||
isLocalWslMoveRow,
|
||||
shouldDropWslMoveProjectUpdate
|
||||
} from '../../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
|
||||
export function registerFolderWorkspaceHandlers(
|
||||
mainWindow: BrowserWindow,
|
||||
@@ -77,6 +81,15 @@ export function registerFolderWorkspaceHandlers(
|
||||
rawArgs,
|
||||
'invalid_folder_workspace_update_args'
|
||||
)
|
||||
const existing = store.getFolderWorkspace(args.folderWorkspaceId)
|
||||
const ref = { folderWorkspaceId: args.folderWorkspaceId }
|
||||
if (
|
||||
existing &&
|
||||
isLocalWslMoveRow(existing) &&
|
||||
shouldDropWslMoveProjectUpdate(store, ref, args.updates)
|
||||
) {
|
||||
return existing
|
||||
}
|
||||
if (
|
||||
typeof args.updates.folderPath === 'string' &&
|
||||
args.updates.folderPath.trim().length > 0
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ProjectGroupUpdateArgs,
|
||||
parseProjectGroupIpcArgs
|
||||
} from './repo-ipc-arg-schemas'
|
||||
import { assertWslMoveProjectWritable } from '../../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
import { activeNestedRepoScans, runNestedRepoScanForIpc } from './nested-repo-scan-ipc'
|
||||
import { visibleProjectGroups } from '../../ssh/orcad-retained-source'
|
||||
|
||||
@@ -68,6 +69,7 @@ export function registerProjectGroupHandlers(mainWindow: BrowserWindow, store: S
|
||||
rawArgs,
|
||||
'invalid_project_group_move_repo_args'
|
||||
)
|
||||
assertWslMoveProjectWritable(store, { repoId: args.projectId })
|
||||
const moved = store.moveProjectToGroup(args.projectId, args.groupId, args.order)
|
||||
if (moved) {
|
||||
notifyReposChanged(mainWindow)
|
||||
|
||||
@@ -28,6 +28,10 @@ import {
|
||||
} from './repo-ipc-arg-schemas'
|
||||
import { addLocalRepoFromPath } from './local-repo-registration'
|
||||
import { sshTargetNotConnectedMessage } from '../../providers/ssh-host-not-served-error'
|
||||
import {
|
||||
assertWslMoveHostSetupIdWritable,
|
||||
assertWslMoveHostSetupWritable
|
||||
} from '../../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
|
||||
function buildProjectHostSetupResult(store: Store, repo: Repo): ProjectHostSetupResult {
|
||||
const setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
|
||||
@@ -45,6 +49,11 @@ function alignRepoWithRequestedProject(
|
||||
setupMethod: ProjectHostSetupExistingFolderArgs['setupMethod'] = 'imported-existing-folder',
|
||||
requestedProviderIdentity?: ProjectHostSetupExistingFolderArgs['projectProviderIdentity']
|
||||
): ProjectHostSetupResult {
|
||||
assertWslMoveHostSetupWritable(store, {
|
||||
projectId,
|
||||
hostId: getRepoExecutionHostId(repo),
|
||||
repoId: repo.id
|
||||
})
|
||||
let setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
|
||||
if (setup.projectId !== projectId) {
|
||||
const project = store.getProjects().find((entry) => entry.id === projectId)
|
||||
@@ -86,6 +95,7 @@ export function registerProjectHostSetupHandlers(mainWindow: BrowserWindow, stor
|
||||
rawArgs,
|
||||
'project_host_setup_create_invalid_args'
|
||||
)
|
||||
assertWslMoveHostSetupWritable(store, args)
|
||||
const result = store.createProjectHostSetup(args)
|
||||
if (!result) {
|
||||
throw new Error(`Project not found: ${args.projectId}`)
|
||||
@@ -103,6 +113,7 @@ export function registerProjectHostSetupHandlers(mainWindow: BrowserWindow, stor
|
||||
rawArgs,
|
||||
'project_host_setup_update_invalid_args'
|
||||
)
|
||||
assertWslMoveHostSetupIdWritable(store, args.setupId)
|
||||
const result = store.updateProjectHostSetup(args)
|
||||
if (!result) {
|
||||
throw new Error(`Project host setup not found: ${args.setupId}`)
|
||||
@@ -124,6 +135,7 @@ export function registerProjectHostSetupHandlers(mainWindow: BrowserWindow, stor
|
||||
rawArgs,
|
||||
'project_host_setup_delete_invalid_args'
|
||||
)
|
||||
assertWslMoveHostSetupIdWritable(store, args.setupId)
|
||||
const result = store.deleteProjectHostSetup(args)
|
||||
if (!result) {
|
||||
throw new Error(`Project host setup not found: ${args.setupId}`)
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BrowserWindow } from 'electron'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { closeTestStores, createStore, testState } from '../../persistence-test-harness'
|
||||
import type { Store } from '../../persistence'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import { wslMoveCutoverFixture as cutover } from '../../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
|
||||
import { writeWslMoveCutover } from '../../persistence/moving-wsl-projects/wsl-move-cutover-journal'
|
||||
import {
|
||||
WslMoveProjectFencedError,
|
||||
loadWslMoveFence,
|
||||
resetWslMoveFenceForTests
|
||||
} from '../../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
import { RuntimeProjectHostSetupController } from '../../runtime/runtime-project-host-setup-controller'
|
||||
import { RuntimeRepositoryIssueCommand } from '../../runtime/runtime-repository-issue-command'
|
||||
import { RuntimeRepositorySettingsController } from '../../runtime/runtime-repository-settings-controller'
|
||||
import * as issueCommandFile from '../../issue-command-file'
|
||||
import { registerProjectHostSetupHandlers } from './project-host-setup-handlers'
|
||||
import { registerRepoCatalogHandlers } from './repo-catalog-handlers'
|
||||
|
||||
const { handlers } = vi.hoisted(() => ({
|
||||
handlers: new Map<string, (event: unknown, args: unknown) => unknown>()
|
||||
}))
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: class {},
|
||||
app: { getPath: () => testState.dir },
|
||||
safeStorage: { isEncryptionAvailable: () => false },
|
||||
ipcMain: {
|
||||
handle: (channel: string, handler: (event: unknown, args: unknown) => unknown) => {
|
||||
handlers.set(channel, handler)
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('../../telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() }))
|
||||
vi.mock('./repos-changed-notification', () => ({ notifyReposChanged: vi.fn() }))
|
||||
|
||||
async function invoke(channel: string, args: unknown): Promise<unknown> {
|
||||
const handler = handlers.get(channel)
|
||||
if (!handler) {
|
||||
throw new Error(`Handler not registered: ${channel}`)
|
||||
}
|
||||
return handler(undefined, args)
|
||||
}
|
||||
|
||||
function repo(id: string, extra: Partial<Repo> = {}): Repo {
|
||||
return {
|
||||
id,
|
||||
path: join(testState.dir, id),
|
||||
displayName: id,
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'git',
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
let store: Store
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
testState.dir = mkdtempSync(join(tmpdir(), 'wsl-move-setup-ipc-'))
|
||||
store = createStore()
|
||||
loadWslMoveFence(testState.dir)
|
||||
})
|
||||
afterEach(async () => {
|
||||
resetWslMoveFenceForTests()
|
||||
await closeTestStores()
|
||||
rmSync(testState.dir, { recursive: true, force: true })
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const setupFor = (repoId: string) => {
|
||||
const setup = store.getProjectHostSetups().find((entry) => entry.repoId === repoId)
|
||||
if (!setup) {
|
||||
throw new Error(`No setup for ${repoId}`)
|
||||
}
|
||||
return setup
|
||||
}
|
||||
|
||||
describe('project host setups under a WSL move', () => {
|
||||
it('refuses IPC edits to a local setup of a moving repo and leaves others alone', async () => {
|
||||
store.addRepo(repo('repo-1'))
|
||||
store.addRepo(repo('repo-2'))
|
||||
writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
|
||||
registerProjectHostSetupHandlers(new BrowserWindow(), store)
|
||||
const moving = setupFor('repo-1')
|
||||
|
||||
await expect(
|
||||
invoke('projectHostSetups:update', { setupId: moving.id, updates: { displayName: 'x' } })
|
||||
).rejects.toBeInstanceOf(WslMoveProjectFencedError)
|
||||
await expect(invoke('projectHostSetups:delete', { setupId: moving.id })).rejects.toBeInstanceOf(
|
||||
WslMoveProjectFencedError
|
||||
)
|
||||
await expect(
|
||||
invoke('projectHostSetups:create', { projectId: moving.projectId, hostId: 'local' })
|
||||
).rejects.toBeInstanceOf(WslMoveProjectFencedError)
|
||||
expect(setupFor('repo-1').displayName).toBe(moving.displayName)
|
||||
await expect(
|
||||
invoke('projectHostSetups:update', {
|
||||
setupId: setupFor('repo-2').id,
|
||||
updates: { displayName: 'renamed' }
|
||||
})
|
||||
).resolves.toMatchObject({ setup: { displayName: 'renamed' } })
|
||||
})
|
||||
|
||||
it("keeps a setup's own repo frozen once the WSL host owns it", async () => {
|
||||
store.addRepo(repo('repo-1'))
|
||||
writeWslMoveCutover(testState.dir, cutover('move-1', 'destination-committed'))
|
||||
registerProjectHostSetupHandlers(new BrowserWindow(), store)
|
||||
const owned = setupFor('repo-1')
|
||||
|
||||
await expect(
|
||||
invoke('projectHostSetups:update', { setupId: owned.id, updates: { displayName: 'x' } })
|
||||
).rejects.toBeInstanceOf(WslMoveProjectFencedError)
|
||||
await expect(invoke('projectHostSetups:delete', { setupId: owned.id })).rejects.toBeInstanceOf(
|
||||
WslMoveProjectFencedError
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a runtime setup delete for a moving repo', () => {
|
||||
store.addRepo(repo('repo-1'))
|
||||
writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
|
||||
const controller = new RuntimeProjectHostSetupController({
|
||||
getStore: () => store,
|
||||
listRepos: () => store.getRepos(),
|
||||
addRepo: vi.fn(),
|
||||
cloneRepo: vi.fn(),
|
||||
invalidateResolvedWorktrees: vi.fn(),
|
||||
invalidateWorktreeScan: vi.fn(),
|
||||
notifyReposChanged: vi.fn(),
|
||||
countLiveTerminalsForRepo: () => 0
|
||||
})
|
||||
const setupId = setupFor('repo-1').id
|
||||
|
||||
expect(() => controller.deleteSetup({ setupId })).toThrow(WslMoveProjectFencedError)
|
||||
expect(() => controller.updateSetup({ setupId, updates: { displayName: 'x' } })).toThrow(
|
||||
WslMoveProjectFencedError
|
||||
)
|
||||
expect(store.getRepo('repo-1')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('projects:update once the WSL host owns a repo', () => {
|
||||
it('lets project-wide settings change again', async () => {
|
||||
store.addRepo(repo('repo-1'))
|
||||
writeWslMoveCutover(testState.dir, cutover('move-1', 'destination-committed'))
|
||||
registerRepoCatalogHandlers(new BrowserWindow(), store)
|
||||
const project = store.getProjects().find((entry) => entry.sourceRepoIds.includes('repo-1'))
|
||||
const updates = { localWindowsRuntimePreference: { kind: 'windows-host' } }
|
||||
|
||||
await expect(invoke('projects:update', { projectId: project?.id, updates })).resolves.toEqual(
|
||||
expect.objectContaining({ sourceRepoIds: ['repo-1'] })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue command writes under a WSL move', () => {
|
||||
it('refuses a runtime write to a moving repo before touching the file', async () => {
|
||||
store.addRepo(repo('repo-1'))
|
||||
writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
|
||||
const write = vi.spyOn(issueCommandFile, 'writeIssueCommand')
|
||||
const commands = new RuntimeRepositoryIssueCommand({
|
||||
resolveRepo: async (id) => repo(id),
|
||||
getLocalGitArgs: () => [],
|
||||
getStore: () => store
|
||||
})
|
||||
|
||||
await expect(commands.write('repo-1', 'run')).rejects.toBeInstanceOf(WslMoveProjectFencedError)
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('repo settings on another host under a WSL move', () => {
|
||||
it('leaves a same-id repo row on another host writable', async () => {
|
||||
writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
|
||||
const onHost = repo('repo-1', { executionHostId: 'runtime:wsl:ubuntu' })
|
||||
vi.spyOn(store, 'updateRepo').mockReturnValue(onHost)
|
||||
const controller = new RuntimeRepositorySettingsController({
|
||||
getStore: () => store,
|
||||
resolveRepo: async () => onHost,
|
||||
forgetTerminalTopology: vi.fn(),
|
||||
invalidateResolvedWorktrees: vi.fn(),
|
||||
invalidateWorktreeScan: vi.fn(),
|
||||
notifyReposChanged: vi.fn()
|
||||
})
|
||||
|
||||
await expect(controller.setBaseRef('repo-1', 'origin/dev')).resolves.toBe(onHost)
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,11 @@ import { invalidateAuthorizedRootsCache } from '../registered-worktree-roots-cac
|
||||
import { notifyReposChanged } from './repos-changed-notification'
|
||||
import { ProjectUpdateIpcArgs, parseProjectGroupIpcArgs } from './repo-ipc-arg-schemas'
|
||||
import { visibleRepos } from '../../ssh/orcad-retained-source'
|
||||
import {
|
||||
assertWslMoveProjectWritable,
|
||||
assertWslMoveReposWritable,
|
||||
isWslMoveSourceHost
|
||||
} from '../../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
|
||||
export function registerRepoCatalogHandlers(mainWindow: BrowserWindow, store: Store): void {
|
||||
// Why one shared reference: enrichment dedupes coalesced callers by callback identity, so a fresh
|
||||
@@ -33,6 +38,8 @@ export function registerRepoCatalogHandlers(mainWindow: BrowserWindow, store: St
|
||||
rawArgs,
|
||||
'project_update_invalid_args'
|
||||
)
|
||||
const project = store.getProjects().find((entry) => entry.id === args.projectId)
|
||||
assertWslMoveReposWritable(store, project?.sourceRepoIds ?? [])
|
||||
return store.updateProject(args.projectId, args.updates)
|
||||
})
|
||||
|
||||
@@ -83,6 +90,9 @@ export function registerRepoCatalogHandlers(mainWindow: BrowserWindow, store: St
|
||||
if (!hostId) {
|
||||
throw new Error(`Invalid host ID: ${args.hostId}`)
|
||||
}
|
||||
if (isWslMoveSourceHost(hostId)) {
|
||||
assertWslMoveProjectWritable(store, { repoId: args.repoId })
|
||||
}
|
||||
store.removeProjectForHost(args.repoId, hostId)
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyReposChanged(mainWindow)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BrowserWindow } from 'electron'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { closeTestStores, createStore, testState } from '../../persistence-test-harness'
|
||||
import type { Store } from '../../persistence'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import { wslMoveCutoverFixture as cutover } from '../../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
|
||||
import { writeWslMoveCutover } from '../../persistence/moving-wsl-projects/wsl-move-cutover-journal'
|
||||
import {
|
||||
WslMoveProjectFencedError,
|
||||
loadWslMoveFence,
|
||||
resetWslMoveFenceForTests
|
||||
} from '../../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
import { RuntimeRepositorySettingsController } from '../../runtime/runtime-repository-settings-controller'
|
||||
import { registerFolderWorkspaceHandlers } from './folder-workspace-handlers'
|
||||
import { registerRepoCatalogHandlers } from './repo-catalog-handlers'
|
||||
|
||||
const { handlers } = vi.hoisted(() => ({
|
||||
handlers: new Map<string, (event: unknown, args: unknown) => unknown>()
|
||||
}))
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: class {},
|
||||
app: { getPath: () => testState.dir },
|
||||
safeStorage: { isEncryptionAvailable: () => false },
|
||||
ipcMain: {
|
||||
handle: (channel: string, handler: (event: unknown, args: unknown) => unknown) => {
|
||||
handlers.set(channel, handler)
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('../../telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() }))
|
||||
vi.mock('./repos-changed-notification', () => ({ notifyReposChanged: vi.fn() }))
|
||||
|
||||
async function invoke(channel: string, args: unknown): Promise<unknown> {
|
||||
const handler = handlers.get(channel)
|
||||
if (!handler) {
|
||||
throw new Error(`Handler not registered: ${channel}`)
|
||||
}
|
||||
return handler(undefined, args)
|
||||
}
|
||||
|
||||
function repo(id: string): Repo {
|
||||
return {
|
||||
id,
|
||||
path: join(testState.dir, id),
|
||||
displayName: id,
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'git'
|
||||
}
|
||||
}
|
||||
|
||||
let store: Store
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
testState.dir = mkdtempSync(join(tmpdir(), 'wsl-move-repo-ipc-'))
|
||||
store = createStore()
|
||||
loadWslMoveFence(testState.dir)
|
||||
})
|
||||
afterEach(async () => {
|
||||
resetWslMoveFenceForTests()
|
||||
await closeTestStores()
|
||||
rmSync(testState.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('folderWorkspaces:update under a WSL move', () => {
|
||||
it('drops a passive update quietly and refuses a user edit', async () => {
|
||||
const group = store.createProjectGroup({
|
||||
name: 'Docs',
|
||||
parentPath: testState.dir,
|
||||
createdFrom: 'folder-scan'
|
||||
})
|
||||
const workspace = store.createFolderWorkspace({ projectGroupId: group.id, name: 'notes' })
|
||||
writeWslMoveCutover(
|
||||
testState.dir,
|
||||
cutover('move-1', 'source-fenced', {}, { folderWorkspaceIds: [workspace.id] })
|
||||
)
|
||||
registerFolderWorkspaceHandlers(new BrowserWindow(), store, {
|
||||
deleteFolderWorkspace: vi.fn(async () => ({ deleted: true }))
|
||||
})
|
||||
|
||||
await expect(
|
||||
invoke('folderWorkspaces:update', {
|
||||
folderWorkspaceId: workspace.id,
|
||||
updates: { isUnread: true, lastActivityAt: 1 }
|
||||
})
|
||||
).resolves.toMatchObject({ id: workspace.id, isUnread: workspace.isUnread })
|
||||
await expect(
|
||||
invoke('folderWorkspaces:update', {
|
||||
folderWorkspaceId: workspace.id,
|
||||
updates: { isUnread: true }
|
||||
})
|
||||
).rejects.toBeInstanceOf(WslMoveProjectFencedError)
|
||||
await expect(
|
||||
invoke('folderWorkspaces:update', {
|
||||
folderWorkspaceId: workspace.id,
|
||||
updates: { name: 'renamed' }
|
||||
})
|
||||
).rejects.toBeInstanceOf(WslMoveProjectFencedError)
|
||||
expect(store.getFolderWorkspace(workspace.id)).toMatchObject({
|
||||
name: 'notes',
|
||||
isUnread: workspace.isUnread
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('projects:update under a WSL move', () => {
|
||||
it('refuses a runtime preference change when a source repo is moving', async () => {
|
||||
store.addRepo(repo('repo-1'))
|
||||
store.addRepo(repo('repo-2'))
|
||||
writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
|
||||
registerRepoCatalogHandlers(new BrowserWindow(), store)
|
||||
const projectFor = (repoId: string) =>
|
||||
store.getProjects().find((project) => project.sourceRepoIds.includes(repoId))
|
||||
const updates = { localWindowsRuntimePreference: { kind: 'windows-host' } }
|
||||
|
||||
await expect(
|
||||
invoke('projects:update', { projectId: projectFor('repo-1')?.id, updates })
|
||||
).rejects.toBeInstanceOf(WslMoveProjectFencedError)
|
||||
await expect(
|
||||
invoke('projects:update', { projectId: projectFor('repo-2')?.id, updates })
|
||||
).resolves.toMatchObject({ sourceRepoIds: ['repo-2'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('RuntimeRepositorySettingsController under a WSL move', () => {
|
||||
it('refuses a settings edit to a moving repo and writes nothing', async () => {
|
||||
store.addRepo(repo('repo-1'))
|
||||
writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
|
||||
const notifyReposChanged = vi.fn()
|
||||
const controller = new RuntimeRepositorySettingsController({
|
||||
getStore: () => store,
|
||||
resolveRepo: async (id) => repo(id),
|
||||
forgetTerminalTopology: vi.fn(),
|
||||
invalidateResolvedWorktrees: vi.fn(),
|
||||
invalidateWorktreeScan: vi.fn(),
|
||||
notifyReposChanged
|
||||
})
|
||||
|
||||
await expect(controller.update('repo-1', { displayName: 'renamed' })).rejects.toBeInstanceOf(
|
||||
WslMoveProjectFencedError
|
||||
)
|
||||
await expect(controller.setBaseRef('repo-1', 'origin/dev')).rejects.toBeInstanceOf(
|
||||
WslMoveProjectFencedError
|
||||
)
|
||||
expect(store.getRepo('repo-1')?.displayName).toBe('repo-1')
|
||||
expect(notifyReposChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
import { prepareLocalWorktreeRootForRepo } from '../../worktree-root-preparation'
|
||||
import { invalidateAuthorizedRootsCache } from '../registered-worktree-roots-cache'
|
||||
import { notifyReposChanged } from './repos-changed-notification'
|
||||
import {
|
||||
assertWslMoveProjectWritable,
|
||||
isWslMoveSourceHost
|
||||
} from '../../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
|
||||
export function registerRepoUpdateHandler(mainWindow: BrowserWindow, store: Store): void {
|
||||
ipcMain.handle(
|
||||
@@ -58,6 +62,9 @@ export function registerRepoUpdateHandler(mainWindow: BrowserWindow, store: Stor
|
||||
}
|
||||
}
|
||||
) => {
|
||||
if (isWslMoveSourceHost(args.hostId)) {
|
||||
assertWslMoveProjectWritable(store, { repoId: args.repoId })
|
||||
}
|
||||
// Why: TS is erased at runtime, so a garbage preference would silently collapse to 'auto' in resolveIssueSource; strip it, keeping other fields.
|
||||
const updates = { ...args.updates }
|
||||
if (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import type { Store } from '../../persistence'
|
||||
import type { SparsePreset } from '../../../shared/worktree/create-types'
|
||||
import { normalizeSparseDirectories } from '../sparse-checkout-directories'
|
||||
import { assertWslMoveProjectWritable } from '../../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
|
||||
export function registerSparsePresetHandlers(mainWindow: BrowserWindow, store: Store): void {
|
||||
// ── Sparse presets ─────────────────────────────────────────────
|
||||
@@ -23,6 +24,7 @@ export function registerSparsePresetHandlers(mainWindow: BrowserWindow, store: S
|
||||
if (!repo) {
|
||||
throw new Error(`Repo "${args.repoId}" not found`)
|
||||
}
|
||||
assertWslMoveProjectWritable(store, { repoId: args.repoId })
|
||||
const name = normalizeSparsePresetName(args.name)
|
||||
const directories = normalizeSparsePresetDirectories(args.directories)
|
||||
const now = Date.now()
|
||||
@@ -48,6 +50,7 @@ export function registerSparsePresetHandlers(mainWindow: BrowserWindow, store: S
|
||||
if (!repo) {
|
||||
throw new Error(`Repo "${args.repoId}" not found`)
|
||||
}
|
||||
assertWslMoveProjectWritable(store, { repoId: args.repoId })
|
||||
store.removeSparsePreset(args.repoId, args.presetId)
|
||||
notifySparsePresetsChanged(mainWindow, args.repoId)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { wslMoveCutoverFixture as cutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
|
||||
import { writeWslMoveCutover } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal'
|
||||
import {
|
||||
WslMoveProjectFencedError,
|
||||
loadWslMoveFence,
|
||||
resetWslMoveFenceForTests
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
import {
|
||||
addWorktreeMock,
|
||||
assertWorktreeCleanForRemovalMock,
|
||||
forceDeleteLocalBranchMock,
|
||||
gitExecFileAsyncMock,
|
||||
killAllProcessesForWorktreeMock,
|
||||
listWorktreesMock,
|
||||
removeWorktreeMock
|
||||
} from './worktrees-test-module-mocks'
|
||||
import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness'
|
||||
import { rememberPreservedBranchCleanupTarget } from './worktrees/removal/preserved-branch-cleanup'
|
||||
import * as issueCommandFile from '../issue-command-file'
|
||||
|
||||
vi.mock('electron', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).electronModuleMock()
|
||||
)
|
||||
vi.mock('../git/worktree', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock()
|
||||
)
|
||||
vi.mock('../git/runner', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).gitRunnerModuleMock()
|
||||
)
|
||||
vi.mock('../git/repo', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).gitRepoModuleMock()
|
||||
)
|
||||
vi.mock('../runtime/worktree-teardown', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock()
|
||||
)
|
||||
vi.mock('./pty/provider/registry', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).ptyRegistryModuleMock()
|
||||
)
|
||||
vi.mock('./pty/provider/state-cleanup', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).ptyStateCleanupModuleMock()
|
||||
)
|
||||
|
||||
const MOVING = 'repo-1::/workspace/feature-wt'
|
||||
const STAYING = 'repo-2::/workspace/other-wt'
|
||||
|
||||
let userDataPath: string
|
||||
beforeEach(() => {
|
||||
setupWorktreeHandlers()
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-worktree-ipc-'))
|
||||
loadWslMoveFence(userDataPath)
|
||||
writeWslMoveCutover(userDataPath, cutover('move-1', 'source-fenced'))
|
||||
})
|
||||
afterEach(() => {
|
||||
resetWslMoveFenceForTests()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('worktree IPC under a WSL move', () => {
|
||||
it('refuses a create before any Git work', async () => {
|
||||
await expect(
|
||||
handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'new-feature' })
|
||||
).rejects.toBeInstanceOf(WslMoveProjectFencedError)
|
||||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(listWorktreesMock).not.toHaveBeenCalled()
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a delete before any teardown or Git work', async () => {
|
||||
await expect(handlers['worktrees:remove'](null, { worktreeId: MOVING })).rejects.toBeInstanceOf(
|
||||
WslMoveProjectFencedError
|
||||
)
|
||||
expect(assertWorktreeCleanForRemovalMock).not.toHaveBeenCalled()
|
||||
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a forget before touching metadata', async () => {
|
||||
await expect(
|
||||
handlers['worktrees:forgetLocal'](null, { worktreeId: MOVING })
|
||||
).rejects.toBeInstanceOf(WslMoveProjectFencedError)
|
||||
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a preserved-branch delete before any Git work', async () => {
|
||||
rememberPreservedBranchCleanupTarget(
|
||||
MOVING,
|
||||
'local',
|
||||
{ preservedBranch: { branchName: 'feature', head: 'abc123' } },
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
await expect(
|
||||
handlers['worktrees:forceDeletePreservedBranch'](null, {
|
||||
worktreeId: MOVING,
|
||||
branchName: 'feature',
|
||||
expectedHead: 'abc123',
|
||||
hostId: 'local'
|
||||
})
|
||||
).rejects.toBeInstanceOf(WslMoveProjectFencedError)
|
||||
expect(forceDeleteLocalBranchMock).not.toHaveBeenCalled()
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses an issue-command write before touching the file', async () => {
|
||||
const write = vi.spyOn(issueCommandFile, 'writeIssueCommand').mockResolvedValue(undefined)
|
||||
await expect(
|
||||
handlers['hooks:writeIssueCommand'](null, { repoId: 'repo-1', content: 'run' })
|
||||
).rejects.toBeInstanceOf(WslMoveProjectFencedError)
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
write.mockRestore()
|
||||
})
|
||||
|
||||
it('drops passive metadata quietly and refuses a user edit', () => {
|
||||
const updateMeta = (worktreeId: string, updates: Partial<WorktreeMeta>) =>
|
||||
handlers['worktrees:updateMeta'](null, { worktreeId, updates })
|
||||
expect(updateMeta(MOVING, { isUnread: false })).toBeNull()
|
||||
expect(updateMeta(MOVING, { isUnread: true, lastActivityAt: 1 })).toBeNull()
|
||||
expect(() => updateMeta(MOVING, { isUnread: true })).toThrow(WslMoveProjectFencedError)
|
||||
expect(() => updateMeta(MOVING, { comment: 'x' })).toThrow(WslMoveProjectFencedError)
|
||||
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
|
||||
updateMeta(STAYING, { comment: 'x' })
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(STAYING, { comment: 'x' })
|
||||
})
|
||||
|
||||
it('drops the moving worktree from a sort-order batch', async () => {
|
||||
store.getWorktreeMeta.mockImplementation((id) =>
|
||||
id === MOVING ? { sortOrder: 1 } : id === STAYING ? { sortOrder: 2 } : undefined
|
||||
)
|
||||
await handlers['worktrees:persistSortOrder'](null, { orderedIds: [MOVING, STAYING] })
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledTimes(1)
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(STAYING, {
|
||||
sortOrder: expect.any(Number)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -30,6 +30,7 @@ import { findExactRepoOwner, isCapturedRepoCurrent } from '../listing/worktree-h
|
||||
import { requireWorktreeCreateRoute } from '../../../worktree-create-execution-host-route'
|
||||
import type { WorktreeIpcContext } from '../worktree-ipc-context'
|
||||
import { beginWorkspaceCreateTelemetry } from '../../../workspace-create-telemetry'
|
||||
import { assertWslMoveRepoWritable } from '../../../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
|
||||
export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): void {
|
||||
const { mainWindow, store, runtime, options } = context
|
||||
@@ -44,6 +45,7 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi
|
||||
if (!repo) {
|
||||
throw new Error(`Repo not found: ${args.repoId}`)
|
||||
}
|
||||
assertWslMoveRepoWritable(store, repo)
|
||||
|
||||
const automationProvenance = resolveAutomationWorkspaceProvenance({
|
||||
authority: runtime,
|
||||
@@ -129,6 +131,7 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi
|
||||
if (!repo || isFolderRepo(repo)) {
|
||||
throw new Error('Provisioned-root repository ownership is missing or ambiguous.')
|
||||
}
|
||||
assertWslMoveRepoWritable(store, repo)
|
||||
const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource)
|
||||
const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown'
|
||||
const automationProvenance = resolveAutomationWorkspaceProvenance({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { withoutWslMovedProjects } from '../../../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
import { withoutWslMovedRepos } from '../../../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
@@ -69,7 +69,7 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo
|
||||
|
||||
ipcMain.handle('worktrees:listAll', async () => {
|
||||
// A repo the WSL host owns is listed by the host; this build must not scan its old path.
|
||||
const repos = withoutWslMovedProjects(store.getRepos(), (repo) => ({ repoId: repo.id }))
|
||||
const repos = withoutWslMovedRepos(store.getRepos())
|
||||
const legacyMetadata =
|
||||
typeof store.getAllWorktreeMetaForHost === 'function' ? undefined : store.getAllWorktreeMeta()
|
||||
const metadataByHost = new Map<ExecutionHostId, Record<string, WorktreeMeta>>()
|
||||
|
||||
@@ -11,6 +11,12 @@ import { readBranchRenameFailureOutputForDisplay } from '../../../agent-hooks/br
|
||||
import { normalizeLinkedWorkItemFields } from '../ipc-context-schemas'
|
||||
import { isRemovedFolderWorkspaceInstance } from '../../../folder-workspace-meta-presence'
|
||||
import type { WorktreeIpcContext } from '../worktree-ipc-context'
|
||||
import {
|
||||
isWslMoveSourceHost,
|
||||
isWslMoveWorkspaceBlocked,
|
||||
shouldDropWslMoveProjectUpdate,
|
||||
wslMoveProjectRefForWorkspace
|
||||
} from '../../../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
|
||||
export function registerWorktreeMetadataHandlers(context: WorktreeIpcContext): void {
|
||||
const { mainWindow, store, runtime } = context
|
||||
@@ -31,6 +37,16 @@ export function registerWorktreeMetadataHandlers(context: WorktreeIpcContext): v
|
||||
if (args.executionHostId !== undefined && !executionHostId) {
|
||||
throw new Error('Invalid execution host identity.')
|
||||
}
|
||||
if (
|
||||
isWslMoveSourceHost(executionHostId) &&
|
||||
shouldDropWslMoveProjectUpdate(
|
||||
store,
|
||||
wslMoveProjectRefForWorkspace(args.worktreeId),
|
||||
args.updates
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const validatedUpdates = normalizeLinkedWorkItemFields(args.updates)
|
||||
const updates =
|
||||
validatedUpdates.displayName !== undefined
|
||||
@@ -91,7 +107,7 @@ export function registerWorktreeMetadataHandlers(context: WorktreeIpcContext): v
|
||||
args.orderedIds,
|
||||
(worktreeId) => store.getWorktreeMeta(worktreeId),
|
||||
Date.now()
|
||||
)
|
||||
).filter((update) => !isWslMoveWorkspaceBlocked(store, update.worktreeId))
|
||||
for (const update of updates) {
|
||||
store.setWorktreeMeta(update.worktreeId, { sortOrder: update.sortOrder })
|
||||
}
|
||||
|
||||
@@ -35,6 +35,11 @@ import {
|
||||
preservedBranchCleanupByScope
|
||||
} from './preserved-branch-cleanup'
|
||||
import type { WorktreeIpcContext } from '../worktree-ipc-context'
|
||||
import {
|
||||
assertWslMoveProjectWritable,
|
||||
assertWslMoveRepoWritable,
|
||||
isWslMoveSourceHost
|
||||
} from '../../../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
|
||||
export function registerWorktreeForgetHandlers(context: WorktreeIpcContext): void {
|
||||
const { mainWindow, store, runtime, worktreeRemovalsInFlight } = context
|
||||
@@ -53,6 +58,11 @@ export function registerWorktreeForgetHandlers(context: WorktreeIpcContext): voi
|
||||
)
|
||||
}
|
||||
const repo = repoOwner.kind === 'resolved' ? repoOwner.repo : undefined
|
||||
if (repo) {
|
||||
assertWslMoveRepoWritable(store, repo)
|
||||
} else if (isWslMoveSourceHost(args.hostId)) {
|
||||
assertWslMoveProjectWritable(store, { repoId })
|
||||
}
|
||||
// Repo-first (unlike owner resolution below) so this key matches worktrees:remove's; meta only covers ownerless forgets.
|
||||
const inFlightKey = getWorktreeRemovalInFlightKey(
|
||||
args.worktreeId,
|
||||
@@ -166,6 +176,7 @@ export function registerWorktreeForgetHandlers(context: WorktreeIpcContext): voi
|
||||
if (!repo) {
|
||||
throw new Error(`Repo not found: ${repoId}`)
|
||||
}
|
||||
assertWslMoveRepoWritable(store, repo)
|
||||
if (isFolderRepo(repo)) {
|
||||
throw new Error('Folder workspaces do not have local Git branches.')
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
waitForPendingWorktreeRemoval
|
||||
} from '../../../worktree-background-removal'
|
||||
import { runSerializedWorktreeRemovalAcceptance } from '../../../worktree-removal-acceptance-queue'
|
||||
import { assertWslMoveRepoWritable } from '../../../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
|
||||
export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): void {
|
||||
const { store, options, worktreeRemovalsInFlight } = context
|
||||
@@ -40,6 +41,7 @@ export function registerWorktreeRemovalHandlers(context: WorktreeIpcContext): vo
|
||||
if (!repo) {
|
||||
throw new Error(`Repo not found: ${repoId}`)
|
||||
}
|
||||
assertWslMoveRepoWritable(store, repo)
|
||||
// The resolved repo supplies host ownership when legacy callers omit args.hostId.
|
||||
const removalHostId = getRepoExecutionHostId(repo)
|
||||
// Why: a retry or a second window asking while Git still deletes joins that removal.
|
||||
|
||||
@@ -10,11 +10,20 @@ import {
|
||||
} from './wsl-move-cutover-journal'
|
||||
import {
|
||||
WslMoveProjectFencedError,
|
||||
assertWslMoveProjectWritable,
|
||||
assertWslMoveRepoWritable,
|
||||
assertWslMoveReposWritable,
|
||||
assertWslMoveWorkspaceWritable,
|
||||
isPassiveWorkspaceUpdate,
|
||||
isWslMoveFenceLoaded,
|
||||
isWslMoveProjectBlocked,
|
||||
isWslMoveProjectFenced,
|
||||
isWslMoveSourceHost,
|
||||
loadWslMoveFence,
|
||||
shouldDropWslMoveProjectUpdate,
|
||||
resetWslMoveFenceForTests,
|
||||
withoutWslMovedProjects,
|
||||
withoutWslMovedFolderWorkspaces,
|
||||
withoutWslMovedRepos,
|
||||
wslMoveProjectRefForWorkspace
|
||||
} from './wsl-move-scope-guard'
|
||||
|
||||
@@ -28,7 +37,6 @@ afterEach(() => {
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
const asRepo = (row: { id: string }) => ({ repoId: row.id })
|
||||
|
||||
const repoPaths = new Map([
|
||||
['repo-wsl', '\\\\wsl.localhost\\Ubuntu\\home\\dev\\app'],
|
||||
@@ -91,7 +99,7 @@ describe('WSL move scope guard', () => {
|
||||
|
||||
it('lets every project through when no move is recorded', () => {
|
||||
expect(writable('repo-1::/x')).not.toThrow()
|
||||
expect(withoutWslMovedProjects([{ id: 'repo-1' }], asRepo)).toHaveLength(1)
|
||||
expect(withoutWslMovedRepos([{ id: 'repo-1' }])).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('leaves a prepared move untouched', () => {
|
||||
@@ -108,7 +116,7 @@ describe('WSL move scope guard', () => {
|
||||
expect(writable('folder:notes')).toThrow(WslMoveProjectFencedError)
|
||||
expect(writable('repo-2::/x')).not.toThrow()
|
||||
const rows = [{ id: 'repo-1' }, { id: 'repo-2' }]
|
||||
expect(withoutWslMovedProjects(rows, asRepo)).toEqual(rows)
|
||||
expect(withoutWslMovedRepos(rows)).toEqual(rows)
|
||||
})
|
||||
|
||||
it('hides and refuses a project the WSL host owns', () => {
|
||||
@@ -117,11 +125,19 @@ describe('WSL move scope guard', () => {
|
||||
cutover('move-1', 'destination-committed', {}, { folderWorkspaceIds: ['notes'] })
|
||||
)
|
||||
expect(writable('repo-1::/x')).toThrow(WslMoveProjectFencedError)
|
||||
expect(
|
||||
withoutWslMovedProjects([{ id: 'notes' }, { id: 'other' }], (f) => ({
|
||||
folderWorkspaceId: f.id
|
||||
}))
|
||||
).toEqual([{ id: 'other' }])
|
||||
expect(withoutWslMovedFolderWorkspaces([{ id: 'notes' }, { id: 'other' }])).toEqual([
|
||||
{ id: 'other' }
|
||||
])
|
||||
})
|
||||
|
||||
it("keeps the host's own row, which carries the moved project's id", () => {
|
||||
writeWslMoveCutover(
|
||||
userDataPath,
|
||||
cutover('move-1', 'destination-committed', {}, { repoIds: ['repo-1'] })
|
||||
)
|
||||
const onHost = { id: 'repo-1', executionHostId: 'runtime:wsl:ubuntu' }
|
||||
const viaSsh = { id: 'repo-1', connectionId: 'target-1' }
|
||||
expect(withoutWslMovedRepos([{ id: 'repo-1' }, onHost, viaSsh])).toEqual([onHost, viaSsh])
|
||||
})
|
||||
|
||||
it('keeps WSL projects read-only and every row listed when the journal is unreadable', () => {
|
||||
@@ -133,6 +149,96 @@ describe('WSL move scope guard', () => {
|
||||
expect(writable('repo-wsl::/x')).toThrow(WslMoveProjectFencedError)
|
||||
expect(writable('repo-win::/x')).not.toThrow()
|
||||
const rows = [{ id: 'repo-wsl' }]
|
||||
expect(withoutWslMovedProjects(rows, asRepo)).toEqual(rows)
|
||||
expect(withoutWslMovedRepos(rows)).toEqual(rows)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WSL move edit guard', () => {
|
||||
const fenceRepo1AndNotes = () =>
|
||||
writeWslMoveCutover(
|
||||
userDataPath,
|
||||
cutover('move-1', 'source-fenced', {}, { repoIds: ['repo-1'], folderWorkspaceIds: ['notes'] })
|
||||
)
|
||||
|
||||
it.each([
|
||||
[{ isUnread: true, lastActivityAt: 1 }, true],
|
||||
[{ lastActivityAt: 1 }, true],
|
||||
[{ isUnread: false }, true],
|
||||
[{ isUnread: true }, false],
|
||||
[{}, false],
|
||||
[{ isUnread: true, displayName: 'x' }, false],
|
||||
[{ comment: 'x' }, false]
|
||||
])('treats %o as passive: %s', (updates, passive) => {
|
||||
expect(isPassiveWorkspaceUpdate(updates)).toBe(passive)
|
||||
})
|
||||
|
||||
it('drops passive updates to a blocked project and refuses user edits', () => {
|
||||
fenceRepo1AndNotes()
|
||||
const notes = { folderWorkspaceId: 'notes' }
|
||||
expect(shouldDropWslMoveProjectUpdate(undefined, notes, { isUnread: false })).toBe(true)
|
||||
expect(() => shouldDropWslMoveProjectUpdate(undefined, notes, { isUnread: true })).toThrow(
|
||||
WslMoveProjectFencedError
|
||||
)
|
||||
expect(() => shouldDropWslMoveProjectUpdate(undefined, notes, { name: 'x' })).toThrow(
|
||||
WslMoveProjectFencedError
|
||||
)
|
||||
expect(shouldDropWslMoveProjectUpdate(undefined, { repoId: 'repo-2' }, { name: 'x' })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a blocked project, and a catalog project when any source repo is blocked', () => {
|
||||
fenceRepo1AndNotes()
|
||||
expect(() => assertWslMoveProjectWritable(undefined, { repoId: 'repo-1' })).toThrow(
|
||||
WslMoveProjectFencedError
|
||||
)
|
||||
expect(() => assertWslMoveProjectWritable(undefined, { repoId: 'repo-2' })).not.toThrow()
|
||||
expect(() => assertWslMoveReposWritable(undefined, ['repo-2', 'repo-1'])).toThrow(
|
||||
WslMoveProjectFencedError
|
||||
)
|
||||
expect(() => assertWslMoveReposWritable(undefined, ['repo-2'])).not.toThrow()
|
||||
})
|
||||
|
||||
it('freezes catalog settings only while a source repo is mid-move', () => {
|
||||
writeWslMoveCutover(
|
||||
userDataPath,
|
||||
cutover('move-1', 'destination-committed', {}, { repoIds: ['repo-1'] })
|
||||
)
|
||||
expect(isWslMoveProjectFenced(undefined, { repoId: 'repo-1' })).toBe(false)
|
||||
expect(isWslMoveProjectBlocked(undefined, { repoId: 'repo-1' })).toBe(true)
|
||||
expect(() => assertWslMoveReposWritable(undefined, ['repo-1', 'repo-2'])).not.toThrow()
|
||||
})
|
||||
|
||||
it('only fences a repo row that runs on this machine', () => {
|
||||
fenceRepo1AndNotes()
|
||||
expect(isWslMoveSourceHost(undefined)).toBe(true)
|
||||
expect(isWslMoveSourceHost('local')).toBe(true)
|
||||
expect(isWslMoveSourceHost('ssh:box')).toBe(false)
|
||||
expect(() => assertWslMoveRepoWritable(undefined, { id: 'repo-1' })).toThrow(
|
||||
WslMoveProjectFencedError
|
||||
)
|
||||
expect(() =>
|
||||
assertWslMoveRepoWritable(undefined, { id: 'repo-1', executionHostId: 'ssh:box' })
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('finds a folder path through the list when the store has no single lookup', () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const directory = wslMoveJournalDirectory(userDataPath)
|
||||
mkdirSync(directory, { recursive: true })
|
||||
writeFileSync(join(directory, 'move-1.json'), '{not json')
|
||||
loadWslMoveFence(userDataPath)
|
||||
const listOnly = {
|
||||
getRepo: () => undefined,
|
||||
getFolderWorkspaces: () => [
|
||||
{ id: 'wsl-notes', folderPath: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\notes' },
|
||||
{ id: 'win-notes', folderPath: 'C:\\notes' }
|
||||
]
|
||||
}
|
||||
expect(isWslMoveProjectBlocked(listOnly, { folderWorkspaceId: 'wsl-notes' })).toBe(true)
|
||||
expect(isWslMoveProjectBlocked(listOnly, { folderWorkspaceId: 'win-notes' })).toBe(false)
|
||||
expect(isWslMoveProjectBlocked({ getRepo: () => undefined }, { folderWorkspaceId: 'x' })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import { resolve } from 'node:path'
|
||||
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { isWslUncPath } from '../../../shared/wsl-paths'
|
||||
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
|
||||
import { onWslMoveJournalChange } from './wsl-move-cutover-journal'
|
||||
import { resolveWslMoveFence, type WslMoveFenceState } from './wsl-move-fence'
|
||||
|
||||
@@ -28,7 +29,9 @@ export class WslMoveProjectFencedError extends Error {
|
||||
/** Only the project paths, read when the journal is unreadable. */
|
||||
type ProjectStore = {
|
||||
getRepo: (repoId: string) => { path: string } | undefined
|
||||
getFolderWorkspace: (folderWorkspaceId: string) => { folderPath: string } | undefined
|
||||
// RuntimeStore has only the list.
|
||||
getFolderWorkspace?: (folderWorkspaceId: string) => { folderPath: string } | undefined
|
||||
getFolderWorkspaces?: () => readonly { id: string; folderPath: string }[]
|
||||
}
|
||||
|
||||
/** An unreadable journal fences every WSL project and hides none: never edit, never lose a row. */
|
||||
@@ -90,9 +93,14 @@ function inScope(scope: WslMoveFenceState['fenced'], ref: WslMoveProjectRef): bo
|
||||
}
|
||||
|
||||
function projectPath(store: ProjectStore, ref: WslMoveProjectRef): string | undefined {
|
||||
return 'repoId' in ref
|
||||
? store.getRepo(ref.repoId)?.path
|
||||
: store.getFolderWorkspace(ref.folderWorkspaceId)?.folderPath
|
||||
if ('repoId' in ref) {
|
||||
return store.getRepo(ref.repoId)?.path
|
||||
}
|
||||
const id = ref.folderWorkspaceId
|
||||
if (store.getFolderWorkspace) {
|
||||
return store.getFolderWorkspace(id)?.folderPath
|
||||
}
|
||||
return store.getFolderWorkspaces?.().find((folder) => folder.id === id)?.folderPath
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,17 +110,140 @@ function projectPath(store: ProjectStore, ref: WslMoveProjectRef): string | unde
|
||||
export function isWslMoveProjectBlocked(
|
||||
store: ProjectStore | undefined,
|
||||
ref: WslMoveProjectRef | null
|
||||
): boolean {
|
||||
return matchesFence(store, ref, true)
|
||||
}
|
||||
|
||||
/** Only while a move is taking the project; one the WSL host already owns is not fenced. */
|
||||
export function isWslMoveProjectFenced(
|
||||
store: ProjectStore | undefined,
|
||||
ref: WslMoveProjectRef | null
|
||||
): boolean {
|
||||
return matchesFence(store, ref, false)
|
||||
}
|
||||
|
||||
function matchesFence(
|
||||
store: ProjectStore | undefined,
|
||||
ref: WslMoveProjectRef | null,
|
||||
includeHidden: boolean
|
||||
): boolean {
|
||||
const fence = loaded?.view
|
||||
if (!ref || !fence) {
|
||||
return false
|
||||
}
|
||||
if (fence.readable) {
|
||||
return inScope(fence.state.fenced, ref) || inScope(fence.state.hidden, ref)
|
||||
return inScope(fence.state.fenced, ref) || (includeHidden && inScope(fence.state.hidden, ref))
|
||||
}
|
||||
return !store || isWslUncPath(projectPath(store, ref) ?? '')
|
||||
}
|
||||
|
||||
export function assertWslMoveProjectWritable(
|
||||
store: ProjectStore | undefined,
|
||||
ref: WslMoveProjectRef | null
|
||||
): void {
|
||||
if (isWslMoveProjectBlocked(store, ref)) {
|
||||
throw new WslMoveProjectFencedError()
|
||||
}
|
||||
}
|
||||
|
||||
// Background bookkeeping a move drops quietly; anything else is a user edit and is refused.
|
||||
const PASSIVE_WORKSPACE_UPDATE_KEYS: ReadonlySet<string> = new Set(['isUnread', 'lastActivityAt'])
|
||||
|
||||
/** A worktree meta or folder workspace patch, as the edit entry points receive it. */
|
||||
export type WslMoveProjectUpdate = Readonly<Record<string, unknown>> & {
|
||||
readonly isUnread?: boolean
|
||||
readonly lastActivityAt?: number
|
||||
}
|
||||
|
||||
// Activity carries lastActivityAt and a read clears unread; a bare `isUnread: true` is a user's mark-unread.
|
||||
export function isPassiveWorkspaceUpdate(updates: WslMoveProjectUpdate): boolean {
|
||||
const keys = Object.keys(updates)
|
||||
if (keys.length === 0 || !keys.every((key) => PASSIVE_WORKSPACE_UPDATE_KEYS.has(key))) {
|
||||
return false
|
||||
}
|
||||
return 'lastActivityAt' in updates || updates.isUnread === false
|
||||
}
|
||||
|
||||
/** A move only takes local projects; the same repo id on another host is a different project. */
|
||||
export function isWslMoveSourceHost(hostId: string | null | undefined): boolean {
|
||||
return !hostId || hostId === LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
|
||||
/** For entry points that already hold the repo row, which names its host. */
|
||||
export function assertWslMoveRepoWritable(
|
||||
store: ProjectStore | undefined,
|
||||
repo: { id: string; connectionId?: string | null; executionHostId?: string | null }
|
||||
): void {
|
||||
if (isWslMoveSourceHost(getRepoExecutionHostId(repo))) {
|
||||
assertWslMoveProjectWritable(store, { repoId: repo.id })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A catalog project spans its source repos; a move taking any of them freezes the whole project.
|
||||
* A repo the WSL host already owns does not, or the project's settings would freeze for good.
|
||||
*/
|
||||
export function assertWslMoveReposWritable(
|
||||
store: ProjectStore | undefined,
|
||||
repoIds: readonly string[]
|
||||
): void {
|
||||
if (repoIds.some((repoId) => isWslMoveProjectFenced(store, { repoId }))) {
|
||||
throw new WslMoveProjectFencedError()
|
||||
}
|
||||
}
|
||||
|
||||
type HostSetupStore = ProjectStore & {
|
||||
getProjects?: () => readonly { id: string; sourceRepoIds: readonly string[] }[]
|
||||
getProjectHostSetups?: () => readonly {
|
||||
id: string
|
||||
projectId: string
|
||||
hostId: string
|
||||
repoId: string
|
||||
}[]
|
||||
}
|
||||
|
||||
/** A setup on this machine edits its project's local checkouts, so it waits out a move taking one. */
|
||||
export function assertWslMoveHostSetupWritable(
|
||||
store: HostSetupStore | undefined,
|
||||
setup: { projectId: string; hostId: string; repoId?: string }
|
||||
): void {
|
||||
if (!isWslMoveSourceHost(setup.hostId)) {
|
||||
return
|
||||
}
|
||||
// The setup's own repo stays frozen once the host owns it; shared project repos only mid-move.
|
||||
if (setup.repoId) {
|
||||
assertWslMoveProjectWritable(store, { repoId: setup.repoId })
|
||||
}
|
||||
const project = store?.getProjects?.().find((entry) => entry.id === setup.projectId)
|
||||
assertWslMoveReposWritable(store, project?.sourceRepoIds ?? [])
|
||||
}
|
||||
|
||||
/** By id, for update and delete; an unknown id is left to the store's own not-found. */
|
||||
export function assertWslMoveHostSetupIdWritable(
|
||||
store: HostSetupStore | undefined,
|
||||
setupId: string
|
||||
): void {
|
||||
const setup = store?.getProjectHostSetups?.().find((entry) => entry.id === setupId)
|
||||
if (setup) {
|
||||
assertWslMoveHostSetupWritable(store, setup)
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the caller should drop the update quietly; throws for a user edit to a blocked project. */
|
||||
export function shouldDropWslMoveProjectUpdate(
|
||||
store: ProjectStore | undefined,
|
||||
ref: WslMoveProjectRef | null,
|
||||
updates: WslMoveProjectUpdate
|
||||
): boolean {
|
||||
if (!isWslMoveProjectBlocked(store, ref)) {
|
||||
return false
|
||||
}
|
||||
if (!isPassiveWorkspaceUpdate(updates)) {
|
||||
throw new WslMoveProjectFencedError()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function isWslMoveWorkspaceBlocked(
|
||||
store: ProjectStore | undefined,
|
||||
workspaceId: string | null | undefined
|
||||
@@ -129,8 +260,23 @@ export function assertWslMoveWorkspaceWritable(
|
||||
}
|
||||
}
|
||||
|
||||
/** Rows the WSL host owns stay in the profile for a downgrade; this build lists the host instead. */
|
||||
export function withoutWslMovedProjects<T>(rows: T[], toRef: (row: T) => WslMoveProjectRef): T[] {
|
||||
type HostedRow = { connectionId?: string | null; executionHostId?: string | null }
|
||||
|
||||
/**
|
||||
* Rows the WSL host owns stay in the profile for a downgrade; this build lists the host instead.
|
||||
* Only local rows: after a move the host's own row carries the same id and must stay listed.
|
||||
*/
|
||||
export function withoutWslMovedRepos<T extends HostedRow & { id: string }>(repos: T[]): T[] {
|
||||
return withoutHidden(repos, (repo) => ({ repoId: repo.id }))
|
||||
}
|
||||
|
||||
export function withoutWslMovedFolderWorkspaces<T extends HostedRow & { id: string }>(
|
||||
folderWorkspaces: T[]
|
||||
): T[] {
|
||||
return withoutHidden(folderWorkspaces, (folder) => ({ folderWorkspaceId: folder.id }))
|
||||
}
|
||||
|
||||
function withoutHidden<T extends HostedRow>(rows: T[], toRef: (row: T) => WslMoveProjectRef): T[] {
|
||||
const fence = loaded?.view
|
||||
if (rows.length === 0 || !fence?.readable) {
|
||||
return rows
|
||||
@@ -139,5 +285,10 @@ export function withoutWslMovedProjects<T>(rows: T[], toRef: (row: T) => WslMove
|
||||
if (hidden.repoIds.size === 0 && hidden.folderWorkspaceIds.size === 0) {
|
||||
return rows
|
||||
}
|
||||
return rows.filter((row) => !inScope(hidden, toRef(row)))
|
||||
return rows.filter((row) => !isLocalWslMoveRow(row) || !inScope(hidden, toRef(row)))
|
||||
}
|
||||
|
||||
/** A row on this machine, the only kind a move takes. */
|
||||
export function isLocalWslMoveRow(row: HostedRow): boolean {
|
||||
return !row.connectionId && isWslMoveSourceHost(row.executionHostId)
|
||||
}
|
||||
|
||||
@@ -246,7 +246,8 @@ export abstract class OrcaRuntimeWithFileCommands extends OrcaRuntimeWithPreserv
|
||||
|
||||
protected readonly repositoryIssueCommand = new RuntimeRepositoryIssueCommand({
|
||||
resolveRepo: (selector) => this.resolveRepoSelector(selector),
|
||||
getLocalGitArgs: (repo) => this.getLocalGitExecutionOptionArgs(repo)
|
||||
getLocalGitArgs: (repo) => this.getLocalGitExecutionOptionArgs(repo),
|
||||
getStore: () => this.store
|
||||
})
|
||||
|
||||
protected readonly clientHostedBrowserRows = new ClientHostedBrowserRowPublisher({
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { closeTestStores, createStore, testState } from '../persistence-test-harness'
|
||||
import type { Store } from '../persistence'
|
||||
import { wslMoveCutoverFixture as cutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
|
||||
import { writeWslMoveCutover } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal'
|
||||
import {
|
||||
WslMoveProjectFencedError,
|
||||
loadWslMoveFence,
|
||||
resetWslMoveFenceForTests
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
import {
|
||||
persistRuntimeManagedWorktreeSortOrder,
|
||||
updateRuntimeManagedWorktreeMetadata
|
||||
} from './runtime-managed-worktree-metadata'
|
||||
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => testState.dir },
|
||||
safeStorage: { isEncryptionAvailable: () => false }
|
||||
}))
|
||||
|
||||
const MOVING = 'repo-1::/src/app'
|
||||
const STAYING = 'repo-2::/src/other'
|
||||
|
||||
function resolved(id: string): ResolvedWorktree {
|
||||
const repoId = id.slice(0, id.indexOf('::'))
|
||||
const path = id.slice(repoId.length + 2)
|
||||
return {
|
||||
id,
|
||||
repoId,
|
||||
instanceId: `${id}#instance`,
|
||||
hostId: 'local',
|
||||
path,
|
||||
head: 'abc',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: 'app',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [],
|
||||
lineage: null,
|
||||
git: { path, head: 'abc', branch: 'refs/heads/main', isBare: false, isMainWorktree: false }
|
||||
}
|
||||
}
|
||||
|
||||
function ports() {
|
||||
return {
|
||||
resolveWorktree: vi.fn(async (selector: string) => resolved(selector.replace(/^id:/, ''))),
|
||||
validateParent: vi.fn(),
|
||||
invalidateResolved: vi.fn(),
|
||||
invalidateScan: vi.fn(),
|
||||
notifyChanged: vi.fn(),
|
||||
showWorktree: vi.fn(async (selector: string) => resolved(selector.replace(/^id:/, '')))
|
||||
}
|
||||
}
|
||||
|
||||
let store: Store
|
||||
beforeEach(() => {
|
||||
testState.dir = mkdtempSync(join(tmpdir(), 'wsl-move-meta-'))
|
||||
store = createStore()
|
||||
loadWslMoveFence(testState.dir)
|
||||
writeWslMoveCutover(testState.dir, cutover('move-1', 'source-fenced'))
|
||||
})
|
||||
afterEach(async () => {
|
||||
resetWslMoveFenceForTests()
|
||||
await closeTestStores()
|
||||
rmSync(testState.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('updateRuntimeManagedWorktreeMetadata under a WSL move', () => {
|
||||
type Updates = Parameters<typeof updateRuntimeManagedWorktreeMetadata>[0]['updates']
|
||||
const update = (selector: string, updates: Updates, p = ports()) =>
|
||||
updateRuntimeManagedWorktreeMetadata({ selector: `id:${selector}`, updates, store, ports: p })
|
||||
|
||||
it('drops background bookkeeping quietly and writes nothing', async () => {
|
||||
const p = ports()
|
||||
const result = await update(MOVING, { isUnread: true, lastActivityAt: 5 }, p)
|
||||
expect(result.id).toBe(MOVING)
|
||||
expect(store.getWorktreeMeta(MOVING)).toBeUndefined()
|
||||
expect(p.notifyChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a user edit', async () => {
|
||||
await expect(update(MOVING, { comment: 'mine' })).rejects.toBeInstanceOf(
|
||||
WslMoveProjectFencedError
|
||||
)
|
||||
expect(store.getWorktreeMeta(MOVING)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses a lineage change even alongside passive fields', async () => {
|
||||
await expect(
|
||||
update(MOVING, { isUnread: true, lineage: { noParent: true } })
|
||||
).rejects.toBeInstanceOf(WslMoveProjectFencedError)
|
||||
})
|
||||
|
||||
it('leaves a project the move is not taking untouched', async () => {
|
||||
await update(STAYING, { comment: 'mine' })
|
||||
expect(store.getWorktreeMeta(STAYING)?.comment).toBe('mine')
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistRuntimeManagedWorktreeSortOrder under a WSL move', () => {
|
||||
it('drops the moving project from the batch and writes the rest', () => {
|
||||
store.setWorktreeMeta(MOVING, { sortOrder: 1 })
|
||||
store.setWorktreeMeta(STAYING, { sortOrder: 2 })
|
||||
const result = persistRuntimeManagedWorktreeSortOrder({
|
||||
orderedIds: [MOVING, STAYING],
|
||||
store,
|
||||
invalidateResolved: vi.fn(),
|
||||
notifyChanged: vi.fn()
|
||||
})
|
||||
expect(result).toEqual({ updated: 1 })
|
||||
expect(store.getWorktreeMeta(MOVING)?.sortOrder).toBe(1)
|
||||
expect(store.getWorktreeMeta(STAYING)?.sortOrder).toBeGreaterThan(2)
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,12 @@ import { planWorktreeSortOrderUpdates } from '../../shared/worktree/sort-order-u
|
||||
import { stripOrcaProvenanceMetaUpdates } from '../worktree-removal-safety'
|
||||
import { isRemovedFolderWorkspaceInstance } from '../folder-workspace-meta-presence'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
import {
|
||||
isWslMoveSourceHost,
|
||||
isWslMoveWorkspaceBlocked,
|
||||
shouldDropWslMoveProjectUpdate,
|
||||
wslMoveProjectRefForWorkspace
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
import { RuntimeLineageError } from './runtime-worktree-lineage-resolution'
|
||||
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
|
||||
|
||||
@@ -31,6 +37,17 @@ export async function updateRuntimeManagedWorktreeMetadata(args: {
|
||||
}): Promise<Worktree> {
|
||||
const worktree = await args.ports.resolveWorktree(args.selector)
|
||||
const { lineage, ...metaUpdates } = args.updates
|
||||
const executionHostId = worktree.identity?.executionHostId ?? worktree.hostId
|
||||
if (
|
||||
isWslMoveSourceHost(executionHostId) &&
|
||||
shouldDropWslMoveProjectUpdate(
|
||||
args.store,
|
||||
wslMoveProjectRefForWorkspace(worktree.id),
|
||||
lineage ? args.updates : metaUpdates
|
||||
)
|
||||
) {
|
||||
return worktree
|
||||
}
|
||||
if (lineage?.parentWorktree) {
|
||||
args.ports.invalidateResolved()
|
||||
args.ports.invalidateScan(worktree.repoId)
|
||||
@@ -91,7 +108,6 @@ export async function updateRuntimeManagedWorktreeMetadata(args: {
|
||||
})
|
||||
}
|
||||
const metadataUpdates = stripOrcaProvenanceMetaUpdates(persisted)
|
||||
const executionHostId = worktree.identity?.executionHostId ?? worktree.hostId
|
||||
// Why: a resolution snapshot taken before a removal still returns the workspace (#22712).
|
||||
if (isRemovedFolderWorkspaceInstance(args.store, worktree.id, executionHostId)) {
|
||||
throw new Error('selector_not_found')
|
||||
@@ -113,11 +129,12 @@ export function persistRuntimeManagedWorktreeSortOrder(args: {
|
||||
invalidateResolved: () => void
|
||||
notifyChanged: (repoId: string) => void
|
||||
}): { updated: number } {
|
||||
// A moving project keeps its old order; the rest of the batch still lands.
|
||||
const updates = planWorktreeSortOrderUpdates(
|
||||
args.orderedIds,
|
||||
(worktreeId) => args.store.getWorktreeMeta(worktreeId),
|
||||
Date.now()
|
||||
)
|
||||
).filter((update) => !isWslMoveWorkspaceBlocked(args.store, update.worktreeId))
|
||||
for (const update of updates) {
|
||||
args.store.setWorktreeMeta(update.worktreeId, { sortOrder: update.sortOrder })
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { cleanupUnusedWorktreePushTargetRemote } from '../ipc/worktree-remote'
|
||||
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
|
||||
import { SshHostNotServedError } from '../providers/ssh-host-not-served-error'
|
||||
import type { Store } from '../persistence'
|
||||
import { assertWslMoveRepoWritable } from '../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
import { resolveWorktreeRemovalRepoOwner } from '../worktree-removal-repo-owner'
|
||||
import { parseExactWorktreeIdSelector } from './runtime-worktree-selection'
|
||||
|
||||
@@ -116,6 +117,7 @@ export class RuntimePreservedBranchCleanup {
|
||||
if (!repo) {
|
||||
throw new Error('repo_not_found')
|
||||
}
|
||||
assertWslMoveRepoWritable(store, repo)
|
||||
if (isFolderRepo(repo)) {
|
||||
throw new Error('Folder workspaces do not have local Git branches.')
|
||||
}
|
||||
|
||||
@@ -19,6 +19,12 @@ import {
|
||||
runtimeVisibleProjectGroups
|
||||
} from './runtime-visible-catalog'
|
||||
import { folderWorkspaceKey } from '../../shared/workspace-scope'
|
||||
import {
|
||||
assertWslMoveProjectWritable,
|
||||
assertWslMoveRepoWritable,
|
||||
isLocalWslMoveRow,
|
||||
shouldDropWslMoveProjectUpdate
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
|
||||
type RuntimeProjectGroupDependencies = {
|
||||
getStore: () => RuntimeStore | null
|
||||
@@ -128,6 +134,7 @@ export class RuntimeProjectGroupController {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const repo = await this.deps.resolveRepo(repoSelector)
|
||||
assertWslMoveRepoWritable(store, repo)
|
||||
const moved = store.moveProjectToGroup(repo.id, groupId, order)
|
||||
if (!moved) {
|
||||
throw new Error('repo_not_found')
|
||||
@@ -199,6 +206,14 @@ export class RuntimeProjectGroupController {
|
||||
if (!store?.updateFolderWorkspace) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const existing = store.getFolderWorkspaces?.().find((entry) => entry.id === folderWorkspaceId)
|
||||
if (
|
||||
existing &&
|
||||
isLocalWslMoveRow(existing) &&
|
||||
shouldDropWslMoveProjectUpdate(store, { folderWorkspaceId }, updates)
|
||||
) {
|
||||
return existing
|
||||
}
|
||||
if (typeof updates.folderPath === 'string' && updates.folderPath.trim().length > 0) {
|
||||
const workspace = store
|
||||
.getFolderWorkspaces?.()
|
||||
@@ -232,6 +247,9 @@ export class RuntimeProjectGroupController {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const workspace = store.getFolderWorkspaces?.().find((entry) => entry.id === folderWorkspaceId)
|
||||
if (workspace && isLocalWslMoveRow(workspace)) {
|
||||
assertWslMoveProjectWritable(store, { folderWorkspaceId })
|
||||
}
|
||||
if (workspace) {
|
||||
const worktreeId = folderWorkspaceKey(folderWorkspaceId)
|
||||
// Why: a mixed-host group has no single PTY target; forgetting the
|
||||
|
||||
@@ -34,6 +34,11 @@ import {
|
||||
countRepoWorkspacesWithMetadata,
|
||||
describeProjectRemovalRefusal
|
||||
} from './project-removal-impact'
|
||||
import {
|
||||
assertWslMoveHostSetupIdWritable,
|
||||
assertWslMoveHostSetupWritable,
|
||||
assertWslMoveReposWritable
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
|
||||
type RuntimeProjectHostSetupDependencies = {
|
||||
getStore: () => RuntimeStore | null
|
||||
@@ -73,6 +78,8 @@ export class RuntimeProjectHostSetupController {
|
||||
if (!store?.updateProject) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const existing = store.getProjects?.().find((entry) => entry.id === projectId)
|
||||
assertWslMoveReposWritable(store, existing?.sourceRepoIds ?? [])
|
||||
const project = store.updateProject(projectId, updates)
|
||||
if (!project) {
|
||||
throw new Error(`Project not found: ${projectId}`)
|
||||
@@ -91,6 +98,7 @@ export class RuntimeProjectHostSetupController {
|
||||
if (!store?.createProjectHostSetup) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
assertWslMoveHostSetupWritable(store, args)
|
||||
const result = store.createProjectHostSetup(args)
|
||||
if (!result) {
|
||||
throw new Error(`Project not found: ${args.projectId}`)
|
||||
@@ -139,6 +147,7 @@ export class RuntimeProjectHostSetupController {
|
||||
if (!store?.updateProjectHostSetup) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
assertWslMoveHostSetupIdWritable(store, args.setupId)
|
||||
const result = store.updateProjectHostSetup(args)
|
||||
if (!result) {
|
||||
throw new Error(`Project host setup not found: ${args.setupId}`)
|
||||
@@ -160,6 +169,7 @@ export class RuntimeProjectHostSetupController {
|
||||
if (!store?.deleteProjectHostSetup) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
assertWslMoveHostSetupIdWritable(store, args.setupId)
|
||||
if (args.force === false) {
|
||||
this.assertSetupRemovalIsUnused(store, args.setupId)
|
||||
}
|
||||
@@ -238,6 +248,11 @@ export class RuntimeProjectHostSetupController {
|
||||
if (!store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
assertWslMoveHostSetupWritable(store, {
|
||||
projectId: args.projectId,
|
||||
hostId: getRepoExecutionHostId(initialRepo),
|
||||
repoId: initialRepo.id
|
||||
})
|
||||
let repo = initialRepo
|
||||
let setup = getProjectHostSetupForRepo(this.listSetups(), repo)
|
||||
if (setup.projectId !== args.projectId) {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { wslMoveCutoverFixture as cutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
|
||||
import { writeWslMoveCutover } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal'
|
||||
import {
|
||||
WslMoveProjectFencedError,
|
||||
loadWslMoveFence,
|
||||
resetWslMoveFenceForTests
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { GitWorktreeInfo } from '../../shared/worktree/types'
|
||||
import {
|
||||
@@ -32,6 +42,7 @@ function ports(worktrees: GitWorktreeInfo[], sparse = false) {
|
||||
const value: RuntimeProvisionedRootPorts = {
|
||||
store: {
|
||||
getSettings: () => ({ workspaceDir: '/workspaces', nestWorkspaces: false }),
|
||||
getRepo: () => repo,
|
||||
setWorktreeMeta
|
||||
},
|
||||
resolveRepo: async () => repo,
|
||||
@@ -91,4 +102,29 @@ describe('adoptRuntimeProvisionedRoot', () => {
|
||||
adoptRuntimeProvisionedRoot('repo-vm', { name: 'fix-login', expectedPath: '/other' }, value)
|
||||
).rejects.toThrow('does not match')
|
||||
})
|
||||
|
||||
it('refuses a project a WSL move is taking before reading its checkout', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-adopt-'))
|
||||
try {
|
||||
loadWslMoveFence(userDataPath)
|
||||
writeWslMoveCutover(
|
||||
userDataPath,
|
||||
cutover('move-1', 'source-fenced', {}, { repoIds: ['repo-vm'] })
|
||||
)
|
||||
const listWorktrees = vi.fn(async () => [checkout()])
|
||||
const { value, setWorktreeMeta } = ports([checkout()])
|
||||
await expect(
|
||||
adoptRuntimeProvisionedRoot(
|
||||
'repo-vm',
|
||||
{ name: 'fix-login', expectedPath: root },
|
||||
{ ...value, listWorktrees }
|
||||
)
|
||||
).rejects.toBeInstanceOf(WslMoveProjectFencedError)
|
||||
expect(listWorktrees).not.toHaveBeenCalled()
|
||||
expect(setWorktreeMeta).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
resetWslMoveFenceForTests()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,11 +16,12 @@ import {
|
||||
} from '../provisioned-root-checkout'
|
||||
import { gitExecFileAsync } from '../git/runner'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
import { assertWslMoveRepoWritable } from '../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
|
||||
export type RuntimeProvisionedRootRequest = ProvisionedRootRequest & { expectedPath: string }
|
||||
|
||||
export type RuntimeProvisionedRootPorts = {
|
||||
store: ProvisionedRootMetaStore & Pick<RuntimeStore, 'setWorktreeMeta'>
|
||||
store: ProvisionedRootMetaStore & Pick<RuntimeStore, 'getRepo' | 'setWorktreeMeta'>
|
||||
resolveRepo: (selector: string) => Promise<Repo>
|
||||
listWorktrees: (repoPath: string) => Promise<GitWorktreeInfo[]>
|
||||
isSparseCheckoutEnabled: (checkoutPath: string) => Promise<boolean>
|
||||
@@ -41,6 +42,7 @@ export async function adoptRuntimeProvisionedRoot(
|
||||
if (isFolderRepo(repo) || getRepoSshConnectionId(repo)) {
|
||||
throw new Error('Provisioned-root adoption requires a Git checkout on this host.')
|
||||
}
|
||||
assertWslMoveRepoWritable(ports.store, repo)
|
||||
if (!provisionedRootPathsEqual(repo.path, request.expectedPath)) {
|
||||
throw new Error('The recipe projectRoot does not match the imported Git checkout root.')
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ import type { Repo } from '../../shared/repo-types'
|
||||
import { readIssueCommand, writeIssueCommand } from '../issue-command-file'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { joinWorktreeRelativePath } from './runtime-relative-paths'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
import { assertWslMoveRepoWritable } from '../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
|
||||
type RuntimeRepositoryIssueCommandDeps = {
|
||||
resolveRepo: (selector: string) => Promise<Repo>
|
||||
getLocalGitArgs: (repo: Repo) => [] | [GitRuntimeOptions]
|
||||
getStore?: () => RuntimeStore | null
|
||||
}
|
||||
|
||||
export class RuntimeRepositoryIssueCommand {
|
||||
@@ -41,6 +44,7 @@ export class RuntimeRepositoryIssueCommand {
|
||||
/** Save a private override on its execution host; blank content restores the shared command. */
|
||||
async write(repoSelector: string, content: string): Promise<{ ok: true }> {
|
||||
const repo = await this.deps.resolveRepo(repoSelector)
|
||||
assertWslMoveRepoWritable(this.deps.getStore?.() ?? undefined, repo)
|
||||
if (isFolderRepo(repo)) {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { GhAccountBinding } from '../../shared/github/account-binding'
|
||||
import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth'
|
||||
import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
import { assertWslMoveRepoWritable } from '../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
|
||||
type RuntimeRepositorySettingsDependencies = {
|
||||
getStore: () => RuntimeStore | null
|
||||
@@ -60,6 +61,7 @@ export class RuntimeRepositorySettingsController {
|
||||
async setBaseRef(repoSelector: string, baseRef: string): Promise<Repo> {
|
||||
const store = this.requireStore()
|
||||
const repo = await this.deps.resolveRepo(repoSelector)
|
||||
assertWslMoveRepoWritable(store, repo)
|
||||
if (isFolderRepo(repo)) {
|
||||
throw new Error('Folder mode does not support base refs.')
|
||||
}
|
||||
@@ -75,6 +77,7 @@ export class RuntimeRepositorySettingsController {
|
||||
async update(repoSelector: string, updates: RepositoryUpdates): Promise<Repo> {
|
||||
const store = this.requireStore()
|
||||
const repo = await this.deps.resolveRepo(repoSelector)
|
||||
assertWslMoveRepoWritable(store, repo)
|
||||
const sanitizedUpdates = omitUndefined(updates)
|
||||
if ('worktreeBasePath' in updates && updates.worktreeBasePath === undefined) {
|
||||
sanitizedUpdates.worktreeBasePath = undefined
|
||||
@@ -110,7 +113,9 @@ export class RuntimeRepositorySettingsController {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const repo = await this.deps.resolveRepo(repoSelector)
|
||||
store.removeProjectForHost(repo.id, getRepoExecutionHostId(repo))
|
||||
const hostId = getRepoExecutionHostId(repo)
|
||||
assertWslMoveRepoWritable(store, repo)
|
||||
store.removeProjectForHost(repo.id, hostId)
|
||||
this.deps.forgetTerminalTopology(repo.id)
|
||||
this.deps.invalidateResolvedWorktrees()
|
||||
this.deps.invalidateWorktreeScan(repo.id)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { normalizeSparseDirectories } from '../ipc/sparse-checkout-directories'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
import { assertWslMoveRepoWritable } from '../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
|
||||
type RuntimeRepositorySparsePresetDependencies = {
|
||||
getStore: () => RuntimeStore | null
|
||||
@@ -56,6 +57,7 @@ export class RuntimeRepositorySparsePresets {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const repo = await this.deps.resolveRepo(repoSelector)
|
||||
assertWslMoveRepoWritable(store, repo)
|
||||
const name = normalizeName(args.name)
|
||||
const directories = normalizeDirectories(args.directories)
|
||||
const now = Date.now()
|
||||
|
||||
@@ -22,6 +22,7 @@ import { trackRuntimeWorkspaceCreate } from '../../workspace-create-telemetry'
|
||||
import { assertOpenCodeModelLaunchPreferencesAbsent } from '../../opencode/opencode-model-startup-plan'
|
||||
import { resolveWorktreeCreateAgentStartup } from '../runtime-worktree-agent-startup'
|
||||
import type { RuntimeWorkspaceCreateEvents } from '../../workspace-create-telemetry'
|
||||
import { assertWslMoveRepoWritable } from '../../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
|
||||
export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWorktreeTerminalProvisioningHost {
|
||||
protected abstract resolveLineageForWorktreeCreate(
|
||||
@@ -70,6 +71,7 @@ export abstract class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWi
|
||||
)
|
||||
|
||||
const repo = await this.resolveRepoSelector(args.repoSelector)
|
||||
assertWslMoveRepoWritable(this.store, repo)
|
||||
const createSettings = this.store.getSettings()
|
||||
const requestedAgent = args.startupAgent ?? args.createdWithAgent
|
||||
const requestedAgentEnabled =
|
||||
|
||||
@@ -36,6 +36,10 @@ import { removeStaleLocalWorktreeRegistration } from '../../local-worktree-remov
|
||||
import { cleanupUnusedWorktreePushTargetRemote } from '../../ipc/worktree-remote'
|
||||
import { removeRuntimeRegisteredLocalWorktree } from '../runtime-registered-local-worktree-removal'
|
||||
import { removeOrphanOrFolderWorktree } from './workspace-registration-removal'
|
||||
import {
|
||||
assertWslMoveWorkspaceWritable,
|
||||
isWslMoveSourceHost
|
||||
} from '../../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
|
||||
export abstract class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateManagedRemoteWorktree {
|
||||
abstract forgetStructuredSessionMail(sessionId: string): void
|
||||
@@ -85,6 +89,13 @@ export abstract class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWi
|
||||
const store = this.store
|
||||
const cleanupHostId = parseExecutionHostId(hostId)?.id
|
||||
const removalTarget = await this.resolveWorktreeRemovalTarget(worktreeSelector, cleanupHostId)
|
||||
// The repo row names the host; a caller may omit hostId for a worktree on another host.
|
||||
const fenceOwner = resolveWorktreeRemovalRepoOwner(store, removalTarget.repoId, cleanupHostId)
|
||||
const fenceHostId =
|
||||
fenceOwner.kind === 'resolved' ? getRepoExecutionHostId(fenceOwner.repo) : cleanupHostId
|
||||
if (isWslMoveSourceHost(fenceHostId)) {
|
||||
assertWslMoveWorkspaceWritable(store, removalTarget.id)
|
||||
}
|
||||
// Why: a retry or a second client asking while Git still deletes joins that removal.
|
||||
const pending = this.joinPendingWorktreeRemoval(removalTarget.id, options)
|
||||
if (pending) {
|
||||
|
||||
@@ -29,7 +29,10 @@ import {
|
||||
repoBelongsToOrcadSource
|
||||
} from '../persistence/migrating-orcad-catalog/orcad-source-ownership'
|
||||
import { findOrcadMigrationSourceCutoverForTarget } from './orcad-migration-cutover-journal'
|
||||
import { withoutWslMovedProjects } from '../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
import {
|
||||
withoutWslMovedFolderWorkspaces,
|
||||
withoutWslMovedRepos
|
||||
} from '../persistence/moving-wsl-projects/wsl-move-scope-guard'
|
||||
|
||||
const appUserDataPath = (): string => getAppEnvironment().getPath('userData')
|
||||
|
||||
@@ -92,7 +95,7 @@ export function visibleRepos(
|
||||
getUserDataPath = appUserDataPath
|
||||
): Repo[] {
|
||||
const hidden = hiddenRetainedSourceTargetIds(getUserDataPath, store.getSshTargets())
|
||||
const repos = withoutWslMovedProjects(store.getRepos(), (repo) => ({ repoId: repo.id }))
|
||||
const repos = withoutWslMovedRepos(store.getRepos())
|
||||
if (hidden.length === 0) {
|
||||
return repos
|
||||
}
|
||||
@@ -121,9 +124,7 @@ export function visibleFolderWorkspaces(
|
||||
getUserDataPath = appUserDataPath
|
||||
): FolderWorkspace[] {
|
||||
const hidden = hiddenRetainedSourceTargetIds(getUserDataPath, store.getSshTargets())
|
||||
const folderWorkspaces = withoutWslMovedProjects(store.getFolderWorkspaces(), (workspace) => ({
|
||||
folderWorkspaceId: workspace.id
|
||||
}))
|
||||
const folderWorkspaces = withoutWslMovedFolderWorkspaces(store.getFolderWorkspaces())
|
||||
if (hidden.length === 0) {
|
||||
return folderWorkspaces
|
||||
}
|
||||
|
||||
@@ -75,6 +75,31 @@ async function spawnIn(page: Page, worktreeId: string, cwd: string): Promise<str
|
||||
)
|
||||
}
|
||||
|
||||
/** An edit's outcome as the renderer sees it: 'saved', 'ignored' (dropped quietly) or the refusal. */
|
||||
async function edit(
|
||||
page: Page,
|
||||
target: { worktreeId: string } | { folderWorkspaceId: string },
|
||||
updates: Record<string, unknown>
|
||||
): Promise<string> {
|
||||
return page.evaluate(
|
||||
async ({ target, updates }) => {
|
||||
try {
|
||||
const result =
|
||||
'worktreeId' in target
|
||||
? await window.api.worktrees.updateMeta({ worktreeId: target.worktreeId, updates })
|
||||
: await window.api.folderWorkspaces.update({
|
||||
folderWorkspaceId: target.folderWorkspaceId,
|
||||
updates
|
||||
})
|
||||
return result ? 'saved' : 'ignored'
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
},
|
||||
{ target, updates }
|
||||
)
|
||||
}
|
||||
|
||||
async function listed(page: Page) {
|
||||
return page.evaluate(async () => ({
|
||||
folders: (await window.api.folderWorkspaces.list()).map((workspace) => workspace.id),
|
||||
@@ -122,6 +147,19 @@ test('a WSL move journal fences the moving project, then hides it once the host
|
||||
)
|
||||
expect(await spawnIn(page, repoWorktreeId, repoPath)).toContain('moving to its WSL host')
|
||||
expect(await spawnIn(page, `folder:${stayingId}`, folders[1].folderPath)).toBe('spawned')
|
||||
// A user's edit is refused; background bookkeeping is dropped quietly, never an error.
|
||||
expect(
|
||||
await edit(page, { worktreeId: repoWorktreeId }, { displayName: 'Renamed' })
|
||||
).toContain('moving to its WSL host')
|
||||
expect(
|
||||
await edit(page, { folderWorkspaceId: movingId }, { name: 'Renamed notes' })
|
||||
).toContain('moving to its WSL host')
|
||||
expect(
|
||||
await edit(page, { worktreeId: repoWorktreeId }, { isUnread: true, lastActivityAt: 1 })
|
||||
).toBe('ignored')
|
||||
expect(await edit(page, { folderWorkspaceId: stayingId }, { name: 'Still mine' })).toBe(
|
||||
'saved'
|
||||
)
|
||||
}
|
||||
|
||||
writeWslMoveCutover(session.userDataDir, record('source-fenced'))
|
||||
|
||||
Reference in New Issue
Block a user