Files
orca/src/main/persistence-worktree-visibility.test.ts
T
Jinjing 8b04e060fa refactor(persistence): extract modules to half persistence.ts (#14252)
* refactor(persistence): extract modules to half persistence.ts

* refactor(persistence): tighten the extracted operations seam

Review follow-ups on the module extraction, all behavior-neutral.

The extracted operations read and mutate the Store's state object in place, but
every seam typed it as a bare PersistedState, so nothing at the boundary said a
caller must pass the live reference — a future caller handing over a clone would
have its writes silently dropped. Name that contract: StoreOwnedPersistedState
carries it to every operations interface and every mutating free function.
normalizePersistedPaneIdentityState and backfillFolderScopeConnectionIds stay on
PersistedState; they build a fresh state rather than mutating the Store's.

The six *PersistenceOperations wrappers were constructed per delegate call. They
are stateless today, so this was inert, but any future instance state would be
lost between calls. Memoize them, and mark state and gitUsernameCache readonly
so the compiler enforces the single-assignment invariant memoizing them relies
on.

Also: restore flushSshPtyConsumerRecovery, whose inlining left its rationale
duplicated at both call sites; document that migrateWorktreeIdentity's boolean
gates the caller's save, since the extracted function kept no docs of its own;
and merge a duplicate shared/types import that was failing lint under
--deny-warnings.

* delete plan doc

* refactor(persistence): add error recovery and improve field cleanup

- Rollback failed migrations to prevent corrupted state that blocks retry
- Gracefully skip malformed entries in normalization instead of aborting
- Strip retired fields to prevent orphaned state and sync issues

* refactor(persistence): drop the redundant persistence- filename prefix

The extracted modules already live in src/main/persistence/, so name
them after the domain they own. Point leftover shared/types imports
at the real type modules while touching those files.

* refactor(persistence): optimize lookups and fix unsanitized updates

- Use Maps instead of repeated array searches for O(1) lookups
- Apply sanitized updates instead of raw input in ui-state-update
- Compare fields directly rather than JSON strings to avoid false dirty states from persisted key ordering differences

* refactor(persistence): group modules into lifecycle folders

Move the 42 flat persistence modules into six folders named for what the
module does, and lift the Store class out of the barrel so persistence.ts
becomes an 8-line public surface.

Bodies are unchanged: every moved file diffs clean against HEAD once import
blocks are excluded. Only import specifiers were rewritten, by resolving each
one to an absolute path and mapping it through the move map.

Store keeps its existing max-lines suppression; its baseline entry is repathed
rather than re-added. Its 119-method public API sets a ~525-line floor, so it
cannot meet the 400-line cap without breaking the API for 153 importers.

* Sanitize worktree visibility sources and preferences on hydration

Ensure invalid or corrupted data from disk (untracked whitespace,
relative paths, bogus preference values) is cleaned during load
rather than corrupting the in-memory store.
2026-08-15 21:41:01 -07:00

385 lines
12 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { rmSync, mkdtempSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { GlobalSettings } from '../shared/global-settings-types'
import type { ExternalWorktreeVisibility } from '../shared/repo-types'
import { getDefaultPersistedState } from '../shared/constants'
import { testState, createStore, writeDataFile, makeRepo } from './persistence-test-harness'
// Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config.
const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({
loadUserSshConfigMock: vi.fn(),
sshConfigHostsToTargetsMock: vi.fn()
}))
vi.mock('./ssh/ssh-config-parser', () => ({
loadUserSshConfig: loadUserSshConfigMock,
sshConfigHostsToTargets: sshConfigHostsToTargetsMock
}))
const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({
trackMock: vi.fn(),
getCohortAtEmitMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {
getPath: () => testState.dir
},
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'),
decryptString: (ciphertext: Buffer) => {
const decoded = ciphertext.toString('utf-8')
if (!decoded.startsWith('encrypted:')) {
throw new Error('invalid ciphertext')
}
return decoded.slice('encrypted:'.length)
}
}
}))
vi.mock('./telemetry/client', () => ({
track: trackMock
}))
vi.mock('./telemetry/cohort-classifier', () => ({
getCohortAtEmit: getCohortAtEmitMock
}))
describe('Store', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-'))
trackMock.mockReset()
getCohortAtEmitMock.mockReset()
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
})
afterEach(() => {
rmSync(testState.dir, { recursive: true, force: true })
})
it('updateRepo stamps legacy external-worktree visibility before changing old repos', async () => {
const store = await createStore()
store.addRepo(
makeRepo({
addedAt: Date.UTC(2026, 4, 24),
externalWorktreeVisibility: undefined,
externalWorktreeVisibilityLegacy: undefined
})
)
const updated = store.updateRepo('r1', { externalWorktreeVisibility: 'hide' })
expect(updated!.externalWorktreeVisibility).toBe('hide')
expect(updated!.externalWorktreeVisibilityLegacy).toBe(true)
})
it('migrates implicit legacy visibility to an explicit override before defaulting global hide', async () => {
const persisted = getDefaultPersistedState(testState.dir)
delete persisted.settings.worktreeVisibilityDefaults
persisted.repos = [
makeRepo({
id: 'legacy',
kind: 'git',
externalWorktreeVisibility: undefined,
externalWorktreeVisibilityLegacy: undefined
}),
makeRepo({ id: 'explicit', externalWorktreeVisibility: 'hide' }),
makeRepo({
id: 'inherited',
externalWorktreeVisibility: undefined,
externalWorktreeVisibilityLegacy: false
})
]
writeDataFile(persisted)
const store = await createStore()
expect(store.getSettings().worktreeVisibilityDefaults).toEqual({ external: 'hide' })
expect(store.getRepo('legacy')).toMatchObject({
externalWorktreeVisibility: 'show',
externalWorktreeVisibilityLegacy: true
})
expect(store.getRepo('explicit')?.externalWorktreeVisibility).toBe('hide')
expect(store.getRepo('inherited')?.externalWorktreeVisibility).toBeUndefined()
store.flush()
const reloaded = await createStore()
expect(reloaded.getRepo('legacy')).toMatchObject({
externalWorktreeVisibility: 'show',
externalWorktreeVisibilityLegacy: true
})
expect(reloaded.getRepo('inherited')?.externalWorktreeVisibility).toBeUndefined()
})
it('merges visibility-default patches so future source defaults survive older controls', async () => {
const store = await createStore()
store.updateSettings({
worktreeVisibilityDefaults: {
external: 'show',
futureSource: 'hide'
} as GlobalSettings['worktreeVisibilityDefaults']
})
store.updateSettings({ worktreeVisibilityDefaults: { external: 'hide' } })
expect(store.getSettings().worktreeVisibilityDefaults).toEqual({
external: 'hide',
futureSource: 'hide'
})
})
it('normalizes and persists global worktree source defaults', async () => {
const store = await createStore()
store.updateSettings({
worktreeVisibilityDefaults: {
external: 'show',
customSources: [
{ id: 'team', rootPath: ' /srv/team-worktrees ' },
{ id: 'invalid', rootPath: '../relative' }
],
sourcePreferences: {
builtIn: { claude: 'show', gsd: 'hide' },
custom: { team: 'show' }
}
}
})
expect(store.getSettings().worktreeVisibilityDefaults).toEqual({
external: 'show',
customSources: [{ id: 'team', rootPath: '/srv/team-worktrees' }],
sourcePreferences: {
builtIn: { claude: 'show', gsd: 'hide' },
custom: { team: 'show' }
}
})
store.flush()
const reloaded = await createStore()
expect(reloaded.getSettings().worktreeVisibilityDefaults).toEqual(
store.getSettings().worktreeVisibilityDefaults
)
})
it('persists agent worktree visibility independently from external visibility', async () => {
const store = await createStore()
store.addRepo(makeRepo({ externalWorktreeVisibility: 'hide' }))
const updated = store.updateRepo('r1', { agentWorktreeVisibility: 'show' })
expect(updated).toMatchObject({
externalWorktreeVisibility: 'hide',
agentWorktreeVisibility: 'show',
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show', gsd: 'show' }
}
})
store.flush()
const reloaded = await createStore()
expect(reloaded.getRepo('r1')).toMatchObject({
externalWorktreeVisibility: 'hide',
agentWorktreeVisibility: 'show',
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show', gsd: 'show' }
}
})
})
it('persists bounded custom worktree sources separately from their preferences', async () => {
const store = await createStore()
store.addRepo(makeRepo())
const updated = store.updateRepo('r1', {
customWorktreeVisibilitySources: [
{ id: 'team', rootPath: ' /srv/team-worktrees ' },
{ id: 'invalid', rootPath: '../relative' }
],
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show', gsd: 'hide' },
custom: { team: 'show' }
}
})
expect(updated).toMatchObject({
customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team-worktrees' }],
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show', gsd: 'hide' },
custom: { team: 'show' }
}
})
store.flush()
const reloaded = await createStore()
expect(reloaded.getRepo('r1')?.customWorktreeVisibilitySources).toEqual([
{ id: 'team', rootPath: '/srv/team-worktrees' }
])
})
it('clears an explicit external-worktree override without stamping an inherited default', async () => {
const store = await createStore()
store.addRepo(
makeRepo({
externalWorktreeVisibility: 'show',
externalWorktreeVisibilityLegacy: true
})
)
const updated = store.updateRepo('r1', { externalWorktreeVisibility: null })
expect(updated?.externalWorktreeVisibility).toBeUndefined()
expect(updated?.externalWorktreeVisibilityLegacy).toBe(false)
store.flush()
const reloaded = await createStore()
expect(reloaded.getRepo('r1')?.externalWorktreeVisibility).toBeUndefined()
expect(reloaded.getRepo('r1')?.externalWorktreeVisibilityLegacy).toBe(false)
})
it('sanitizes raw custom worktree visibility sources on the read path', async () => {
const persisted = getDefaultPersistedState(testState.dir)
persisted.repos = [
makeRepo({
id: 'r1',
customWorktreeVisibilitySources: [
{ id: 'team', rootPath: ' /srv/team-worktrees ' },
{ id: 'invalid', rootPath: '../relative' }
],
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show', gsd: 'show' },
custom: { team: 'show', missing: 'bogus' as unknown as ExternalWorktreeVisibility }
}
})
]
writeDataFile(persisted)
const store = await createStore()
expect(store.getRepo('r1')).toMatchObject({
customWorktreeVisibilitySources: [{ id: 'team', rootPath: '/srv/team-worktrees' }],
worktreeVisibilitySourcePreferences: {
builtIn: { claude: 'show', gsd: 'show' },
custom: { team: 'show' }
}
})
})
it('updateRepo clears source-control AI overrides independently from other clearable fields', async () => {
const store = await createStore()
store.addRepo(
makeRepo({
issueSourcePreference: 'origin',
sourceControlAi: {
instructionsByOperation: { commitMessage: 'Repo style' },
prCreationDefaults: { draft: true }
}
})
)
store.updateRepo('r1', {
issueSourcePreference: undefined,
sourceControlAi: undefined
})
expect(store.getRepo('r1')!.issueSourcePreference).toBeUndefined()
expect(store.getRepo('r1')!.sourceControlAi).toBeUndefined()
store.flush()
const reloaded = await createStore()
expect(reloaded.getRepo('r1')!.issueSourcePreference).toBeUndefined()
expect(reloaded.getRepo('r1')!.sourceControlAi).toBeUndefined()
})
it('updateRepo treats source-control AI null as a transport clear sentinel', async () => {
const store = await createStore()
store.addRepo(
makeRepo({
sourceControlAi: {
enabled: true,
customAgentCommand: 'repo-agent {prompt}'
}
})
)
store.updateRepo('r1', {
sourceControlAi: null
})
expect(store.getRepo('r1')!.sourceControlAi).toBeUndefined()
store.flush()
const reloaded = await createStore()
expect(reloaded.getRepo('r1')!.sourceControlAi).toBeUndefined()
})
it('updateRepo normalizes source-control AI overrides before storing', async () => {
const store = await createStore()
store.addRepo(makeRepo())
const updated = store.updateRepo('r1', {
sourceControlAi: {
instructionsByOperation: {
commitMessage: 'Repo style',
pullRequest: 42,
unknown: 'ignored'
},
prCreationDefaults: {
draft: true,
useTemplate: null,
openAfterCreate: 'yes'
},
modelOverridesByOperation: {
commitMessage: {
selectedModelByAgent: { codex: 'gpt-5.4', claude: false },
selectedThinkingByModel: { 'gpt-5.4': 'high', bad: true }
},
unknown: {
selectedModelByAgent: { codex: 'ignored' }
}
}
} as never
})
expect(updated!.sourceControlAi).toEqual({
instructionsByOperation: {
commitMessage: 'Repo style'
},
actionOverrides: {
commitMessage: {
commandInputTemplate: '{basePrompt}\n\nRepo style'
}
},
prCreationDefaults: {
draft: true,
useTemplate: null
},
modelOverridesByOperation: {
commitMessage: {
selectedModelByAgent: { codex: 'gpt-5.4' },
selectedThinkingByModel: { 'gpt-5.4': 'high' }
}
}
})
})
it('updateRepo ignores malformed source-control AI overrides without clearing existing overrides', async () => {
const store = await createStore()
store.addRepo(
makeRepo({
sourceControlAi: {
instructionsByOperation: { commitMessage: 'Keep me' }
}
})
)
const updated = store.updateRepo('r1', { sourceControlAi: 'bad' as never })
expect(updated!.sourceControlAi).toEqual({
instructionsByOperation: { commitMessage: 'Keep me' },
actionOverrides: {
commitMessage: {
commandInputTemplate: '{basePrompt}\n\nKeep me'
}
}
})
})
})