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).
This commit is contained in:
Neil
2026-09-03 21:01:47 -07:00
parent 59385b3e85
commit 6e42c141eb
4 changed files with 126 additions and 2 deletions
@@ -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({})
})
})
@@ -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(
@@ -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()
@@ -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', () => {