refactor(renderer): make the store repos array readonly at the type level

Preserving the repos array identity means handing callers the same array that
is live store state, which previously relied on `as Repo[]` casts to launder
readonly inputs back into a mutable field. A cast is a footgun: the next person
to add a .push or .sort corrupts store state with no type error.

Widen RepoSlice['repos'] to readonly Repo[] and propagate honestly. Consumers
that only read take readonly Repo[]; genuine local accumulators are annotated
Repo[] and built from copies.

Removes all four pre-existing `as Repo[]` casts in the reconcile chain
(repo-identity-reconcile, superseded-ssh-repo-rows, manual-repo-order x2) —
this lands with fewer casts than main has today.

Type-only change; no runtime behavior differs.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-10 22:29:55 -07:00
co-authored by Orca
parent eb703b8c26
commit 3f86147839
34 changed files with 134 additions and 79 deletions
+1 -1
View File
@@ -149,7 +149,7 @@ function PreflightBanner({
repos
}: {
issues: PreflightIssue[]
repos: Repo[]
repos: readonly Repo[]
}): React.JSX.Element | null {
// Why: keying the seed on the current GitHub project set means adding a new
// GitHub project (which changes the key) re-evaluates dismissals, so a lapsed
@@ -84,7 +84,7 @@ type NewWorkspaceComposerCardProps = {
nameInputRef?: React.RefObject<HTMLInputElement | null>
quickAgent: TuiAgent | null
onQuickAgentChange: (agent: TuiAgent | null) => void
eligibleRepos: RepoOption[]
eligibleRepos: readonly RepoOption[]
repoId: string
projectOptions?: NewWorkspaceProjectOption[]
selectedProjectId?: string | null
@@ -98,7 +98,7 @@ type NewWorkspaceComposerCardProps = {
selectedEphemeralVmRecipeId?: string | null
onEphemeralVmRecipeChange?: (recipeId: string | null) => void
ephemeralVmRecipeError?: string | null
repoBackedSearchRepos?: RepoOption[]
repoBackedSearchRepos?: readonly RepoOption[]
repoBackedSourcesDisabled?: boolean
allowSmartNameAddProject?: boolean
smartNameRepoSwitchTarget?: 'project' | 'task-source'
@@ -61,7 +61,7 @@ type AutomationEditorDialogProps = {
isSaving: boolean
canSave: boolean
createTarget: AutomationCreateTarget
repos: Repo[]
repos: readonly Repo[]
projectHostSetups: ProjectHostSetup[]
automationYamlHooksByRepoKey: Record<string, OrcaHooks | null>
getAutomationHooksCacheKey: (repoId: string) => string
@@ -31,7 +31,7 @@ type AutomationEditorDialogFooterProps = {
isHermesCreate: boolean
isSaving: boolean
canSave: boolean
repos: Repo[]
repos: readonly Repo[]
projectHostSetups: ProjectHostSetup[]
automationYamlHooksByRepoKey: Record<string, OrcaHooks | null>
getAutomationHooksCacheKey: (repoId: string) => string
@@ -19,7 +19,7 @@ import {
} from './automation-project-groups'
type AutomationProjectComboboxProps = {
repos: Repo[]
repos: readonly Repo[]
value: string
onValueChange: (repoId: string) => void
placeholder?: string
@@ -94,8 +94,22 @@ describe('buildDashboardWorktreeLaunchOptions', () => {
const options = buildDashboardWorktreeLaunchOptions(
state({
repos: [
{ id: 'repo-ssh', connectionId: 'ssh-1' },
{ id: 'repo-runtime', executionHostId: 'runtime:hub-1' }
{
id: 'repo-ssh',
path: '/repo-ssh',
displayName: 'repo-ssh',
badgeColor: 'blue',
addedAt: 1,
connectionId: 'ssh-1'
},
{
id: 'repo-runtime',
path: '/repo-runtime',
displayName: 'repo-runtime',
badgeColor: 'blue',
addedAt: 1,
executionHostId: 'runtime:hub-1'
}
] as LaunchState['repos'],
worktreesByRepo: {
'repo-ssh': [{ id: 'ssh-worktree', repoId: 'repo-ssh' }],
@@ -22,7 +22,7 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id'
type RetainedAgentSnapshot = Map<string, { row: DashboardAgentRow; worktreeId: string }>
type RetainedAgentsSyncInputs = {
repos: Repo[]
repos: readonly Repo[]
worktreesByRepo: Record<string, Worktree[]>
folderWorkspaces: FolderWorkspace[]
tabsByWorktree: Record<string, TerminalTab[]>
@@ -38,7 +38,7 @@ function paneKeyTabId(paneKey: string): string | null {
}
function buildLiveTabIndex(args: {
repos: Repo[]
repos: readonly Repo[]
worktreesByRepo: Record<string, Worktree[]>
folderWorkspaces: FolderWorkspace[]
tabsByWorktree: Record<string, TerminalTab[]>
@@ -23,7 +23,7 @@ function storageKey(issueId: string): string {
/** GitHub-backed project identity keys for the current repo set, de-duped so
* the same GitHub project added twice doesn't read as two distinct projects. */
export function githubProjectKeys(repos: Repo[]): string[] {
export function githubProjectKeys(repos: readonly Repo[]): string[] {
const keys = repos
.filter((repo) => isGitHubBackedRepo(repo))
.map((repo) => getProjectIdentityKey(repo))
@@ -45,7 +45,7 @@ function readRecord(issueId: string): DismissalRecord | null {
/** True when the issue was dismissed and no new GitHub project has appeared
* since. A GitHub key present now but absent from the snapshot re-surfaces it. */
export function isPreflightIssueDismissed(issueId: string, repos: Repo[]): boolean {
export function isPreflightIssueDismissed(issueId: string, repos: readonly Repo[]): boolean {
const record = readRecord(issueId)
if (!record) {
return false
@@ -55,7 +55,7 @@ export function isPreflightIssueDismissed(issueId: string, repos: Repo[]): boole
return !hasNewGithubProject
}
export function dismissPreflightIssue(issueId: string, repos: Repo[]): void {
export function dismissPreflightIssue(issueId: string, repos: readonly Repo[]): void {
try {
const record: DismissalRecord = { githubKeys: githubProjectKeys(repos) }
localStorage.setItem(storageKey(issueId), JSON.stringify(record))
@@ -114,7 +114,7 @@ type RepoOption = ReturnType<typeof useAppStore.getState>['repos'][number]
const EMPTY_REPO_SEARCH_REPOS: readonly RepoOption[] = []
type SmartWorkspaceNameFieldProps = {
repos: RepoOption[]
repos: readonly RepoOption[]
repoId: string
onRepoChange: (repoId: string) => void
value: string
@@ -19,7 +19,7 @@ import RepoBadgeLabel from './RepoBadgeLabel'
import { translate } from '@/i18n/i18n'
type RepoComboboxProps = {
repos: Repo[]
repos: readonly Repo[]
value: string
onValueChange: (repoId: string) => void
onValueSelected?: (repoId: string) => void
@@ -217,7 +217,7 @@ describe('useGitStatusPolling rerender stability', () => {
useAppStore.setState({
repos: [{ ...repo, connectionId: 'ssh-1' }],
sshConnectionStates: new Map([
['ssh-1', { status: 'disconnected', error: null, reconnectAttempt: 0 }]
['ssh-1', { targetId: 'ssh-1', status: 'disconnected', error: null, reconnectAttempt: 0 }]
])
} as Partial<AppState>)
await renderHook()
@@ -21,7 +21,7 @@ function ScopeTriggerLabel({
}: {
showAll: boolean
effectiveSelection: ReadonlySet<string>
repos: Repo[]
repos: readonly Repo[]
}): React.JSX.Element {
if (showAll) {
return (
@@ -57,7 +57,7 @@ export function QuickCommandsScopeFilter({
handleSelectAll,
toggleScope
}: {
repos: Repo[]
repos: readonly Repo[]
effectiveSelection: ReadonlySet<string>
showAll: boolean
scopePopoverOpen: boolean
@@ -117,7 +117,16 @@ describe('resource usage open slices', () => {
})
it('gates repo and worktree slices only while closed', () => {
const repos = [{ id: 'repo-1', path: '/repo', kind: 'git' }] as AppState['repos']
const repos = [
{
id: 'repo-1',
path: '/repo',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1,
kind: 'git'
}
] as AppState['repos']
const row = worktree()
const worktreesByRepo = {
'repo-1': [row]
@@ -11,7 +11,7 @@ import { TerminalQuickCommandScopeField } from './TerminalQuickCommandScopeField
type TerminalQuickCommandAdvancedSectionProps = {
draft: TerminalQuickCommand
repos: Pick<Repo, 'id' | 'displayName' | 'path' | 'badgeColor'>[]
repos: readonly Pick<Repo, 'id' | 'displayName' | 'path' | 'badgeColor'>[]
advancedOpen: boolean
selectedScope: ReturnType<typeof getTerminalQuickCommandScope>
selectedRepoId: string
@@ -39,7 +39,7 @@ type TerminalQuickCommandDialogProps = {
open: boolean
mode: TerminalQuickCommandDialogMode
command: TerminalQuickCommand
repos?: Pick<Repo, 'id' | 'displayName' | 'path' | 'badgeColor'>[]
repos?: readonly Pick<Repo, 'id' | 'displayName' | 'path' | 'badgeColor'>[]
onOpenChange: (open: boolean) => void
onSave: (command: TerminalQuickCommand) => void
}
@@ -18,7 +18,7 @@ import { QUICK_COMMAND_TOGGLE_ITEM_CLASS } from './terminal-quick-command-toggle
import { translate } from '@/i18n/i18n'
type TerminalQuickCommandScopeFieldProps = {
repos: Pick<Repo, 'id' | 'displayName' | 'path' | 'badgeColor'>[]
repos: readonly Pick<Repo, 'id' | 'displayName' | 'path' | 'badgeColor'>[]
selectedScope: TerminalQuickCommandScope
selectedRepoId: string
selectedRepoMissing: boolean
@@ -32,7 +32,7 @@ function getRepoLabel(repo: Pick<Repo, 'displayName' | 'path'>): string {
}
export function getQuickCommandProjectScopeRepoId(
repos: Pick<Repo, 'id'>[],
repos: readonly Pick<Repo, 'id'>[],
lastRepoScopeId: string | null
): string | null {
return lastRepoScopeId ?? repos[0]?.id ?? null
@@ -16,7 +16,7 @@ import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
import { translate } from '@/i18n/i18n'
type RepoMultiComboboxProps = {
repos: Repo[]
repos: readonly Repo[]
/** Currently selected repo ids. The component enforces `selected.size >= 1`
* by disabling the last-selected checkbox. */
selected: ReadonlySet<string>
@@ -32,7 +32,10 @@ type RepoMultiComboboxProps = {
triggerClassName?: string
}
function renderTriggerLabel(repos: Repo[], selected: ReadonlySet<string>): React.JSX.Element {
function renderTriggerLabel(
repos: readonly Repo[],
selected: ReadonlySet<string>
): React.JSX.Element {
if (repos.length === 0) {
return (
<span className="text-muted-foreground">
@@ -1,16 +1,20 @@
import { describe, expect, it } from 'vitest'
import type { Repo } from '../../../shared/types'
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
import { getAgentDetectionTargetKeyForWorktree } from './useAgentDetectionTarget'
describe('getAgentDetectionTargetKeyForWorktree', () => {
it('uses an explicit runtime owner without scanning ambiguous child SSH repos', () => {
let projectGroupReads = 0
const repos = Array.from({ length: 100 }, (_, index) => {
const repo = {
const repos: readonly Repo[] = Array.from({ length: 100 }, (_, index) => {
const repo: Repo = {
id: `repo-${index}`,
connectionId: `ssh-${index}`,
executionHostId: `ssh:ssh-${index}`,
path: `/workspace/repo-${index}`
path: `/workspace/repo-${index}`,
displayName: `repo-${index}`,
badgeColor: 'blue',
addedAt: 1
}
Object.defineProperty(repo, 'projectGroupId', {
enumerable: true,
@@ -291,7 +291,10 @@ describe('ensureHooksConfirmed', () => {
repos: [
{
id: 'repo-1',
path: '/repo-1',
displayName: 'Repo One',
badgeColor: 'blue',
addedAt: 1,
hookSettings: {
mode: 'auto',
commandSourcePolicy: 'local-only',
@@ -318,7 +321,10 @@ describe('ensureHooksConfirmed', () => {
repos: [
{
id: 'repo-1',
path: '/repo-1',
displayName: 'Repo One',
badgeColor: 'blue',
addedAt: 1,
hookSettings: {
mode: 'auto',
scripts: { setup: 'echo local', archive: '' }
@@ -6,7 +6,7 @@ import { parseExecutionHostId } from '../../../shared/execution-host'
export type FolderWorkspaceConnectionState = {
folderWorkspaces: FolderWorkspace[]
projectGroups: ProjectGroup[]
repos: Repo[]
repos: readonly Repo[]
}
function getFolderScopeCandidateRepos(args: {
+1 -1
View File
@@ -35,7 +35,7 @@ type StoreAccessor = () => {
> | null
setActiveWorktree: (worktreeId: string) => void
createBrowserTab: (worktreeId: string, url: string, opts: { activate: boolean }) => unknown
repos?: LocalhostLinkRepo[]
repos?: readonly LocalhostLinkRepo[]
projects?: LocalhostLinkProject[]
worktreesByRepo?: Record<string, LocalhostLinkWorktree[]>
allWorktrees?: () => LocalhostLinkWorktree[]
@@ -16,11 +16,15 @@ afterEach(() => {
resetLocalPreflightContextCachesForTests()
})
function makeRepos(id: string, path: string): AppState['repos'] {
return [{ id, path, displayName: id, badgeColor: 'blue', addedAt: 1 }]
}
function makeWslState(distro: string): AppState {
return {
activeRepoId: 'repo-1',
activeWorktreeId: null,
repos: [{ id: 'repo-1', path: `\\\\wsl.localhost\\${distro}\\home\\alice\\repo` }],
repos: makeRepos('repo-1', `\\\\wsl.localhost\\${distro}\\home\\alice\\repo`),
worktreesByRepo: {}
} as AppState
}
@@ -29,7 +33,7 @@ function makeWindowsProjectState(projectId: string): AppState {
return {
activeRepoId: projectId,
activeWorktreeId: null,
repos: [{ id: projectId, path: `C:\\Users\\alice\\${projectId}` }],
repos: makeRepos(projectId, `C:\\Users\\alice\\${projectId}`),
settings: {},
worktreesByRepo: {}
} as AppState
@@ -11,26 +11,30 @@ import {
} from './local-preflight-context'
function makeState(args: {
repoPath?: string | null
repoPath?: string
worktreePath?: string | null
repo?: Partial<Repo>
worktree?: Partial<Worktree>
}): AppState {
const repoId = 'repo-1'
const worktreeId = `${repoId}::worktree-1`
const repos: AppState['repos'] =
args.repoPath === undefined
? []
: [
{
id: repoId,
path: args.repoPath,
displayName: repoId,
badgeColor: 'blue',
addedAt: 1,
...args.repo
}
]
return {
activeRepoId: repoId,
activeWorktreeId: args.worktreePath === undefined ? null : worktreeId,
repos:
args.repoPath === undefined
? []
: [
{
id: repoId,
path: args.repoPath,
...args.repo
}
],
repos,
worktreesByRepo:
args.worktreePath === undefined
? {}
+1 -1
View File
@@ -38,7 +38,7 @@ function matchScore(repo: Repo, query: string): number | null {
return null
}
export function searchRepos(repos: Repo[], rawQuery: string): Repo[] {
export function searchRepos(repos: readonly Repo[], rawQuery: string): readonly Repo[] {
if (isRepoSearchQueryTooLarge(rawQuery)) {
return []
}
+1 -1
View File
@@ -126,7 +126,7 @@ async function resolveRepoSlug(
}
async function buildIndex(
repos: Repo[],
repos: readonly Repo[],
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
): Promise<{ index: SlugIndex; upstreamIndex: SlugIndex; retryDelayMs: number | null }> {
// Why: evict cached entries for repos that no longer exist in state so
@@ -9,8 +9,8 @@ import { localhostWorktreeLabelRouteForPort } from './workspace-port-localhost-l
// this is the single source for both reactive and imperative call sites.
type LocalhostLabelLookupState = {
settings?: Pick<GlobalSettings, 'localhostWorktreeLabelsEnabled'> | null
repos?: Repo[]
projects?: Project[]
repos?: readonly Repo[]
projects?: readonly Project[]
getKnownWorktreeById?: (worktreeId: string) => { projectId?: string | null } | null | undefined
}
@@ -327,6 +327,10 @@ describe('createDetectedAgentsSlice WSL context', () => {
projects: [
{
id: 'repo-1',
displayName: 'repo-1',
badgeColor: 'blue',
createdAt: 1,
updatedAt: 1,
sourceRepoIds: ['repo-1'],
localWindowsRuntimePreference: { kind: 'windows-host' }
}
+20 -18
View File
@@ -6408,17 +6408,18 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
_meta: { runtimeId: 'remote-runtime' }
})
const store = createTestStore()
const repos: AppState['repos'] = [
{
id: 'runtime-repo-id',
path: '/server/repo',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1
}
]
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' },
repos: [
{
id: 'runtime-repo-id',
path: '/server/repo',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1
}
]
repos
} as Partial<AppState>)
await store.getState().fetchWorkItems('caller-repo-id', '/server/repo', 24, 'is:open', {
@@ -6508,17 +6509,18 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
_meta: { runtimeId: 'source-runtime' }
})
const store = createTestStore()
const repos: AppState['repos'] = [
{
id: 'local-repo-id',
path: '/server/repo',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1
}
]
store.setState({
settings: { activeRuntimeEnvironmentId: 'focused-runtime' },
repos: [
{
id: 'local-repo-id',
path: '/server/repo',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1
}
]
repos
} as Partial<AppState>)
const sourceContext = {
@@ -265,11 +265,12 @@ describe('createPreflightSlice', () => {
new Error('Project runtime requires repair before preflight: wsl-distro-required')
)
const store = createTestStore()
const repos: AppState['repos'] = [makeRepo({ id: 'repo-1', path: 'C:\\repo' })]
store.setState({
settings: {
localWindowsRuntimeDefault: { kind: 'wsl', distro: null }
},
repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })],
repos,
worktreesByRepo: {},
activeRepoId: 'repo-1',
activeWorktreeId: null
@@ -27,7 +27,10 @@ function areReposEqual(a: Repo, b: Repo): boolean {
return true
}
export function reconcileFetchedRepos(previous: readonly Repo[], next: Repo[]): Repo[] {
export function reconcileFetchedRepos(
previous: readonly Repo[],
next: readonly Repo[]
): readonly Repo[] {
const previousById = new Map(previous.map((repo) => [getRepoHostIdentity(repo), repo]))
let identical = next.length === previous.length
const reconciled = next.map((repo, index) => {
@@ -41,5 +44,5 @@ export function reconcileFetchedRepos(previous: readonly Repo[], next: Repo[]):
identical = false
return repo
})
return identical ? (previous as Repo[]) : reconciled
return identical ? previous : reconciled
}
+5 -5
View File
@@ -880,9 +880,9 @@ function mergeByIdentity<T>(
function mergeFetchedReposForHost(
previous: readonly Repo[],
fetched: Repo[],
fetched: readonly Repo[],
hostId: string
): Repo[] {
): readonly Repo[] {
const fetchedWithProjectGroups = applyInheritedProjectGroups(previous, fetched)
const fetchedIdentities = new Set(fetchedWithProjectGroups.map(getRepoHostIdentity))
const preserved = previous.filter((repo) => {
@@ -1049,7 +1049,7 @@ function mergeFetchedFolderWorkspacesForHost({
}
type FetchedRepoCatalog = {
repos: Repo[]
repos: readonly Repo[]
projectHostSetupCompatibility: ProjectHostSetupProjection
hostId: ReturnType<typeof getRuntimeTargetHostId>
}
@@ -1110,7 +1110,7 @@ function mergeFetchedRepoCatalog(
catalog: FetchedRepoCatalog,
currentRepos: readonly Repo[]
): {
repos: Repo[]
repos: readonly Repo[]
projectHostSetupCompatibility: ProjectHostSetupProjection
hostId: ReturnType<typeof getRuntimeTargetHostId>
} {
@@ -1619,7 +1619,7 @@ function getFolderWorkspacePathStatusRequestSnapshotForRead(
}
export type RepoSlice = {
repos: Repo[]
repos: readonly Repo[]
projects: Project[]
projectHostSetups: ProjectHostSetup[]
projectGroups: ProjectGroup[]
@@ -3,7 +3,7 @@ import type { Repo } from '../../../../shared/types'
import { getRepoExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host'
export type SshRepoReconciliation = {
repos: Repo[]
repos: readonly Repo[]
pendingReadoptions: SshRepoReadoption[]
}
@@ -56,7 +56,7 @@ export function reconcileReadoptedSshRepoRows(
// Why: pruning nothing must hand back the input array, or the copy alone would defeat the
// referential stability reconcileFetchedRepos just established upstream.
if (prunedOwners.size === 0) {
return { repos: repos as Repo[], pendingReadoptions }
return { repos, pendingReadoptions }
}
return {
repos: repos.filter(
+2 -1
View File
@@ -14,6 +14,7 @@ import type {
WorktreeLineage,
WorkspaceLineage,
ProjectHostSetup,
Repo,
WorktreeMeta
} from '../../../../shared/types'
import type { RuntimeWorktreeListResult } from '../../../../shared/runtime-types'
@@ -6065,7 +6066,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
const repoIdsWithRemovedOwners = new Set<string>()
const survivingRepoIds = new Set<string>()
const repoIdsWithSurvivingOwners = new Set<string>()
const survivingRepos: AppState['repos'] = []
const survivingRepos: Repo[] = []
for (const repo of s.repos) {
if (isRemovedRuntimeHostId(getRepoExecutionHostId(repo), removed)) {
repoIdsWithRemovedOwners.add(repo.id)
+3 -3
View File
@@ -41,12 +41,12 @@ export function getManualRepoOrder(repos: readonly Repo[]): ManualRepoOrderEntry
export function applyManualRepoOrder(
repos: readonly Repo[],
order: readonly ManualRepoOrderEntry[] | null | undefined
): Repo[] {
): readonly Repo[] {
const normalized = normalizeManualRepoOrder(order)
// Why: results flow straight back into `repos`, so a reorder that moves nothing must return
// the input — a fresh array would invalidate every identity-keyed repo memo downstream.
if (normalized.length === 0) {
return repos as Repo[]
return repos
}
const rankByKey = new Map(normalized.map((entry, index) => [getEntryKey(entry), index]))
const ordered = repos
@@ -68,5 +68,5 @@ export function applyManualRepoOrder(
return a.rank - b.rank || a.index - b.index
})
.map(({ repo }) => repo)
return ordered.every((repo, index) => repo === repos[index]) ? (repos as Repo[]) : ordered
return ordered.every((repo, index) => repo === repos[index]) ? repos : ordered
}