From 6e42c141eb032cb4769cdce9b0e75f55e6ecd2e4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:48:20 -0700 Subject: [PATCH] fix(persistence): keep the lineage maps when a profile file has no worktreeMeta key The rebuild returned `parsed.worktreeMeta` untouched when it was not a plain record, so an absent key became an explicit `worktreeMeta: undefined` that outranked the defaults spread. `normalizeWorktreeLinkedItemMetadata` reads a non-object `worktreeMeta` as corruption and wipes that file's `worktreeLineageById` and `workspaceLineageByChildKey` with it, then marks the state dirty so the wipe is persisted. Before this branch the spread supplied `{}` and the lineage survived. Also pins the two raw-file readers the projection made load-bearing: the history GC recovering projected ids from the alias keys (a miss deletes shell history a live workspace is using), and the profile-transfer read rebuilding the omitted locator rows (a miss transfers workspaces with no metadata). --- .../profile-project-state-file.test.ts | 59 ++++++++++++++++++- .../normalize-loaded-profile-state.ts | 8 ++- .../worktree-meta-alias-projection.test.ts | 37 ++++++++++++ .../history-gc-profile-worktree-ids.test.ts | 24 ++++++++ 4 files changed, 126 insertions(+), 2 deletions(-) diff --git a/src/main/orca-profiles/profile-project-state-file.test.ts b/src/main/orca-profiles/profile-project-state-file.test.ts index 305ba53d869..5462dbfaa44 100644 --- a/src/main/orca-profiles/profile-project-state-file.test.ts +++ b/src/main/orca-profiles/profile-project-state-file.test.ts @@ -1,9 +1,12 @@ +import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { getDefaultPersistedState } from '../../shared/constants' import type { PersistedState } from '../../shared/persisted-state-types' import type { Project, ProjectHostSetup } from '../../shared/project-types' import type { Repo } from '../../shared/repo-types' -import { rebuildRepoBackedProjectState } from './profile-project-state-file' +import { readProfileState, rebuildRepoBackedProjectState } from './profile-project-state-file' const upstreamIdentity = { canonicalKey: 'git.example.com/acme/app-upstream', @@ -153,3 +156,57 @@ describe('rebuildRepoBackedProjectState', () => { ]) }) }) + +/** + * A profile transfer reads another profile's file directly, bypassing the Store's load path. The + * serializer omits any `worktreeMeta` row the identity map can rebuild, so without the rebuild here + * every transferred workspace would arrive at the target profile stripped of its metadata. + */ +describe('readProfileState on a projected profile file', () => { + function seedProfile(state: unknown): { userDataPath: string; profileId: string } { + const userDataPath = realpathSync(mkdtempSync(join(tmpdir(), 'orca-profile-read-'))) + const profileId = 'source' + mkdirSync(join(userDataPath, 'profiles', profileId), { recursive: true }) + writeFileSync( + join(userDataPath, 'profiles', profileId, 'orca-data.json'), + JSON.stringify(state), + 'utf-8' + ) + return { userDataPath, profileId } + } + + const identityRow = { + hostId: 'local', + instanceId: 'inst-1', + displayName: 'projected workspace', + linkedPR: 41 + } + + it('rebuilds the locator rows the serializer left to the identity map', () => { + const { userDataPath, profileId } = seedProfile({ + repos: [makeRepo({ id: 'repo', path: '/repo' })], + worktreeMeta: {}, + worktreeMetaByIdentity: { 'wt2:local:inst-1': identityRow }, + worktreeIdentityAliases: { 'local|repo::/projected': ['wt2:local:inst-1'] }, + worktreeMetaAliasesWithoutLegacyRow: [] + }) + + const state = readProfileState(profileId, userDataPath) + + expect(state.worktreeMeta['repo::/projected']).toEqual(identityRow) + // Serialize-only: it must never be carried into the state a transfer writes back. + expect(state.worktreeMetaAliasesWithoutLegacyRow).toBeUndefined() + }) + + it('leaves a genuinely removed locator removed', () => { + const { userDataPath, profileId } = seedProfile({ + repos: [makeRepo({ id: 'repo', path: '/repo' })], + worktreeMeta: {}, + worktreeMetaByIdentity: { 'wt2:local:inst-1': identityRow }, + worktreeIdentityAliases: { 'local|repo::/projected': ['wt2:local:inst-1'] }, + worktreeMetaAliasesWithoutLegacyRow: ['local|repo::/projected'] + }) + + expect(readProfileState(profileId, userDataPath).worktreeMeta).toEqual({}) + }) +}) diff --git a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts index 7b336b61272..bb9cc46cc27 100644 --- a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts +++ b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts @@ -56,7 +56,13 @@ export function normalizeLoadedProfileState( ), // Rebuilds the locator rows the serializer left to the identity map, and restores the shared // object reference JSON.parse splits. Not `markNeedsSave`: this IS the canonical on-disk shape. - worktreeMeta: hydrateWorktreeMetaAliasProjection(parsed), + // Conditional so an absent key stays absent and the defaults spread still supplies `{}`: an + // explicit `worktreeMeta: undefined` outranks the spread and sends + // `normalizeWorktreeLinkedItemMetadata` down its corrupt-value branch, which wipes the file's + // `worktreeLineageById` / `workspaceLineageByChildKey` and persists the wipe. + ...(parsed.worktreeMeta === undefined + ? {} + : { worktreeMeta: hydrateWorktreeMetaAliasProjection(parsed) }), worktreeMetaAliasesWithoutLegacyRow: undefined, worktreeLineageById: parsed.worktreeLineageById ?? {}, mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections( diff --git a/src/main/persistence/loading-store/worktree-meta-alias-projection.test.ts b/src/main/persistence/loading-store/worktree-meta-alias-projection.test.ts index 58ea120c446..e77a25c058e 100644 --- a/src/main/persistence/loading-store/worktree-meta-alias-projection.test.ts +++ b/src/main/persistence/loading-store/worktree-meta-alias-projection.test.ts @@ -310,6 +310,43 @@ describe('worktree meta alias projection', () => { ) }) + // The rebuild must not materialize a `worktreeMeta` key the file did not have: an explicit + // `undefined` outranks the defaults spread, and `normalizeWorktreeLinkedItemMetadata` reads a + // non-object `worktreeMeta` as corruption and wipes the lineage maps with it — then persists it. + it('keeps the lineage maps when the file carries no worktreeMeta key at all', () => { + const dataFile = tempDataFile() + writeFileSync( + dataFile, + JSON.stringify({ + repos: [{ id: REPO_ID, name: REPO_ID, path: '/tmp/repo-1', worktreesPath: '/tmp' }], + worktreeLineageById: { + [`${REPO_ID}::/tmp/child`]: { + parentWorktreeId: `${REPO_ID}::/tmp/parent`, + createdAt: RECENTLY + } + }, + workspaceLineageByChildKey: { + [`worktree:${REPO_ID}::/tmp/child`]: { + parentWorkspaceKey: `worktree:${REPO_ID}::/tmp/parent`, + createdAt: RECENTLY + } + } + }), + 'utf-8' + ) + + const store = openStore(dataFile) + + expect(Object.keys(store.getAllWorktreeLineage())).toEqual([`${REPO_ID}::/tmp/child`]) + expect(store.getAllWorktreeMeta()).toEqual({}) + store.flush() + const onDisk = JSON.parse(readFileSync(dataFile, 'utf-8')) as PersistedState + expect(Object.keys(onDisk.worktreeLineageById)).toEqual([`${REPO_ID}::/tmp/child`]) + expect(Object.keys(onDisk.workspaceLineageByChildKey)).toEqual([ + `worktree:${REPO_ID}::/tmp/child` + ]) + }) + it('keeps every row when the alias map is missing or unreadable', () => { for (const aliases of [undefined, null, [], { 'local|x': 'not-an-array' }]) { const fixture = buildFixture() diff --git a/src/main/window/history-gc-profile-worktree-ids.test.ts b/src/main/window/history-gc-profile-worktree-ids.test.ts index b3f0960eab9..058b84effed 100644 --- a/src/main/window/history-gc-profile-worktree-ids.test.ts +++ b/src/main/window/history-gc-profile-worktree-ids.test.ts @@ -75,6 +75,30 @@ describe('getOtherProfileWorktreeIdsForHistoryGc', () => { ) }) + // The serializer omits any `worktreeMeta` row the identity map can rebuild, so on a projected + // file the alias keys are the only place those ids appear — and this reader is what stands + // between them and a delete of shell history the workspace is still using. + it('recovers the ids of locator rows the serializer projected onto the identity map', () => { + const root = userDataWithProfiles('active', [ + { id: 'active', state: { worktreeMeta: {} } }, + { + id: 'other', + state: { + // Exactly what a projected save writes: the twin row left the locator map entirely. + worktreeMeta: { 'repo::/kept': {} }, + worktreeMetaByIdentity: { 'wt2:local:inst-1': { hostId: 'local', instanceId: 'inst-1' } }, + worktreeIdentityAliases: { 'local|repo::/projected': ['wt2:local:inst-1'] }, + worktreeMetaAliasesWithoutLegacyRow: [] + } + } + ]) + + const result = getOtherProfileWorktreeIdsForHistoryGc(root) + + expect(result.unreadableProfiles).toBe(0) + expect(result.ids).toEqual(new Set(['repo::/kept', 'repo::/projected'])) + }) + // The active profile's ids come from the live Store, which is authoritative; // re-reading its file would only race a write in progress. it('skips the active profile', () => {