diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index e2544461688..d62866f3e95 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -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', diff --git a/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts b/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts index e5d840f73a0..9e9dd7e7e26 100644 --- a/src/main/ipc/hooks/register-worktree-hook-file-handlers.ts +++ b/src/main/ipc/hooks/register-worktree-hook-file-handlers.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.') } diff --git a/src/main/ipc/repos/folder-workspace-handlers.ts b/src/main/ipc/repos/folder-workspace-handlers.ts index 638591ce227..353093a2bd6 100644 --- a/src/main/ipc/repos/folder-workspace-handlers.ts +++ b/src/main/ipc/repos/folder-workspace-handlers.ts @@ -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 diff --git a/src/main/ipc/repos/project-group-handlers.ts b/src/main/ipc/repos/project-group-handlers.ts index 25f9c4506a4..89839bfef11 100644 --- a/src/main/ipc/repos/project-group-handlers.ts +++ b/src/main/ipc/repos/project-group-handlers.ts @@ -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) diff --git a/src/main/ipc/repos/project-host-setup-handlers.ts b/src/main/ipc/repos/project-host-setup-handlers.ts index 79d7976ecec..07d6119dfc8 100644 --- a/src/main/ipc/repos/project-host-setup-handlers.ts +++ b/src/main/ipc/repos/project-host-setup-handlers.ts @@ -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}`) diff --git a/src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts b/src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts new file mode 100644 index 00000000000..1c6f5cea7d8 --- /dev/null +++ b/src/main/ipc/repos/project-host-setup-wsl-move-fence.test.ts @@ -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 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 { + 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 { + 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) + }) +}) diff --git a/src/main/ipc/repos/repo-catalog-handlers.ts b/src/main/ipc/repos/repo-catalog-handlers.ts index ddb5d058056..071c8d9dfd9 100644 --- a/src/main/ipc/repos/repo-catalog-handlers.ts +++ b/src/main/ipc/repos/repo-catalog-handlers.ts @@ -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) diff --git a/src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts b/src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts new file mode 100644 index 00000000000..a874ce1b694 --- /dev/null +++ b/src/main/ipc/repos/repo-ipc-wsl-move-fence.test.ts @@ -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 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 { + 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() + }) +}) diff --git a/src/main/ipc/repos/repo-update-handler.ts b/src/main/ipc/repos/repo-update-handler.ts index 5af9e097121..9ef2537aecb 100644 --- a/src/main/ipc/repos/repo-update-handler.ts +++ b/src/main/ipc/repos/repo-update-handler.ts @@ -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 ( diff --git a/src/main/ipc/repos/sparse-preset-handlers.ts b/src/main/ipc/repos/sparse-preset-handlers.ts index 8d426c74677..c9743c0fdf3 100644 --- a/src/main/ipc/repos/sparse-preset-handlers.ts +++ b/src/main/ipc/repos/sparse-preset-handlers.ts @@ -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) }) diff --git a/src/main/ipc/worktrees-wsl-move-fence.test.ts b/src/main/ipc/worktrees-wsl-move-fence.test.ts new file mode 100644 index 00000000000..81e98bd3b9e --- /dev/null +++ b/src/main/ipc/worktrees-wsl-move-fence.test.ts @@ -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) => + 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) + }) + }) +}) diff --git a/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts index f1848b737c9..cf76eb8246b 100644 --- a/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts +++ b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts @@ -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({ diff --git a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts index 2378d271cff..2c24d9c1526 100644 --- a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts @@ -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>() diff --git a/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts b/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts index 1cb36b600b1..61f56990bb2 100644 --- a/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts +++ b/src/main/ipc/worktrees/metadata/register-worktree-metadata-handlers.ts @@ -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 }) } diff --git a/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts index 3b02e1d57eb..c330c3d217f 100644 --- a/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts +++ b/src/main/ipc/worktrees/removal/register-worktree-forget-handlers.ts @@ -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.') } diff --git a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts index 661cd9111e1..7a3a43dfe27 100644 --- a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts +++ b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts @@ -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. diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.test.ts index 3889293fdac..11c56178b00 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.test.ts @@ -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 + ) }) }) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.ts b/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.ts index 191cdc637fe..8271f01f2a0 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-scope-guard.ts @@ -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 = new Set(['isUnread', 'lastActivityAt']) + +/** A worktree meta or folder workspace patch, as the edit entry points receive it. */ +export type WslMoveProjectUpdate = Readonly> & { + 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(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(repos: T[]): T[] { + return withoutHidden(repos, (repo) => ({ repoId: repo.id })) +} + +export function withoutWslMovedFolderWorkspaces( + folderWorkspaces: T[] +): T[] { + return withoutHidden(folderWorkspaces, (folder) => ({ folderWorkspaceId: folder.id })) +} + +function withoutHidden(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(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) } diff --git a/src/main/runtime/orca-runtime-file-commands.ts b/src/main/runtime/orca-runtime-file-commands.ts index 97acccae806..b40e7f61367 100644 --- a/src/main/runtime/orca-runtime-file-commands.ts +++ b/src/main/runtime/orca-runtime-file-commands.ts @@ -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({ diff --git a/src/main/runtime/runtime-managed-worktree-metadata-wsl-move.test.ts b/src/main/runtime/runtime-managed-worktree-metadata-wsl-move.test.ts new file mode 100644 index 00000000000..24b2b09a6b5 --- /dev/null +++ b/src/main/runtime/runtime-managed-worktree-metadata-wsl-move.test.ts @@ -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[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) + }) +}) diff --git a/src/main/runtime/runtime-managed-worktree-metadata.ts b/src/main/runtime/runtime-managed-worktree-metadata.ts index 0ee206ed7e0..ec92c43fd43 100644 --- a/src/main/runtime/runtime-managed-worktree-metadata.ts +++ b/src/main/runtime/runtime-managed-worktree-metadata.ts @@ -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 { 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 }) } diff --git a/src/main/runtime/runtime-preserved-branch-cleanup.ts b/src/main/runtime/runtime-preserved-branch-cleanup.ts index 99b1ccf6325..f9d34ed390b 100644 --- a/src/main/runtime/runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/runtime-preserved-branch-cleanup.ts @@ -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.') } diff --git a/src/main/runtime/runtime-project-group-controller.ts b/src/main/runtime/runtime-project-group-controller.ts index 41378b45b16..491318deb43 100644 --- a/src/main/runtime/runtime-project-group-controller.ts +++ b/src/main/runtime/runtime-project-group-controller.ts @@ -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 diff --git a/src/main/runtime/runtime-project-host-setup-controller.ts b/src/main/runtime/runtime-project-host-setup-controller.ts index a29e999d1a2..58b9101cf80 100644 --- a/src/main/runtime/runtime-project-host-setup-controller.ts +++ b/src/main/runtime/runtime-project-host-setup-controller.ts @@ -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) { diff --git a/src/main/runtime/runtime-provisioned-root-adoption.test.ts b/src/main/runtime/runtime-provisioned-root-adoption.test.ts index 5a6f3261a0e..18addb123b8 100644 --- a/src/main/runtime/runtime-provisioned-root-adoption.test.ts +++ b/src/main/runtime/runtime-provisioned-root-adoption.test.ts @@ -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 }) + } + }) }) diff --git a/src/main/runtime/runtime-provisioned-root-adoption.ts b/src/main/runtime/runtime-provisioned-root-adoption.ts index 3becab13b6a..42673dd0002 100644 --- a/src/main/runtime/runtime-provisioned-root-adoption.ts +++ b/src/main/runtime/runtime-provisioned-root-adoption.ts @@ -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 + store: ProvisionedRootMetaStore & Pick resolveRepo: (selector: string) => Promise listWorktrees: (repoPath: string) => Promise isSparseCheckoutEnabled: (checkoutPath: string) => Promise @@ -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.') } diff --git a/src/main/runtime/runtime-repository-issue-command.ts b/src/main/runtime/runtime-repository-issue-command.ts index 9b4d5ed3794..0c3f540ac22 100644 --- a/src/main/runtime/runtime-repository-issue-command.ts +++ b/src/main/runtime/runtime-repository-issue-command.ts @@ -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 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 } } diff --git a/src/main/runtime/runtime-repository-settings-controller.ts b/src/main/runtime/runtime-repository-settings-controller.ts index 1cc0897d566..ce7384e2297 100644 --- a/src/main/runtime/runtime-repository-settings-controller.ts +++ b/src/main/runtime/runtime-repository-settings-controller.ts @@ -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 { 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 { 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) diff --git a/src/main/runtime/runtime-repository-sparse-presets.ts b/src/main/runtime/runtime-repository-sparse-presets.ts index 664798a271d..bd5030ae12d 100644 --- a/src/main/runtime/runtime-repository-sparse-presets.ts +++ b/src/main/runtime/runtime-repository-sparse-presets.ts @@ -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() diff --git a/src/main/runtime/workspaces/workspace-creation.ts b/src/main/runtime/workspaces/workspace-creation.ts index f4c30418c76..4dbb9200dbc 100644 --- a/src/main/runtime/workspaces/workspace-creation.ts +++ b/src/main/runtime/workspaces/workspace-creation.ts @@ -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 = diff --git a/src/main/runtime/workspaces/workspace-removal.ts b/src/main/runtime/workspaces/workspace-removal.ts index a710800b65e..8d5340b3c2a 100644 --- a/src/main/runtime/workspaces/workspace-removal.ts +++ b/src/main/runtime/workspaces/workspace-removal.ts @@ -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) { diff --git a/src/main/ssh/orcad-retained-source.ts b/src/main/ssh/orcad-retained-source.ts index 973984c3bce..762eda19a00 100644 --- a/src/main/ssh/orcad-retained-source.ts +++ b/src/main/ssh/orcad-retained-source.ts @@ -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 } diff --git a/tests/e2e/wsl-move-fence.spec.ts b/tests/e2e/wsl-move-fence.spec.ts index 4a5bb38d9fd..3d6f8cbddd3 100644 --- a/tests/e2e/wsl-move-fence.spec.ts +++ b/tests/e2e/wsl-move-fence.spec.ts @@ -75,6 +75,31 @@ async function spawnIn(page: Page, worktreeId: string, cwd: string): Promise +): Promise { + 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'))