fix(worktree): close retirement consistency gaps

This commit is contained in:
Brennan Benson
2026-08-13 15:06:10 -07:00
parent 59f1105239
commit 21a117aa34
10 changed files with 331 additions and 67 deletions
+4 -2
View File
@@ -189,7 +189,8 @@ function NewWorktreeModalContent({
const [selectedRepo, setSelectedRepo] = useState<Repo | null>(null)
// Why: a deleted workspace's directory can still hold agent conversation state keyed by cwd, so
// its name must never be suggested again. Fetched per selected repo while the sheet is open.
const retiredWorktreeNames = useRetiredWorktreeNames(client, selectedRepo?.id)
const { names: retiredWorktreeNames, loading: retiredWorktreeNamesLoading } =
useRetiredWorktreeNames(client, selectedRepo?.id)
const { drawerView, formSheetVisible, formSheetInteractive, transitionDrawer, openSourceDrawer } =
useNewWorktreeDrawerNavigation(visible)
const createInFlightRef = useRef(false)
@@ -564,7 +565,7 @@ function NewWorktreeModalContent({
}
async function handleCreate(options: CreateOptions = {}) {
if (!client || !selectedRepo || createInFlightRef.current) {
if (!client || !selectedRepo || retiredWorktreeNamesLoading || createInFlightRef.current) {
return
}
createInFlightRef.current = true
@@ -684,6 +685,7 @@ function NewWorktreeModalContent({
const canCreate =
selectedRepo != null &&
!creating &&
!retiredWorktreeNamesLoading &&
!sshGate.requiresConnection &&
(!needsSetupChoice || setupDecisionChoice != null)
const visibleAgentOptions =
@@ -12,12 +12,15 @@ import type { RpcClient } from '../transport/rpc-client'
export function useRetiredWorktreeNames(
client: RpcClient | null | undefined,
repoId: string | null | undefined
): readonly string[] {
const [retiredNames, setRetiredNames] = useState<readonly string[]>([])
): { names: readonly string[]; loading: boolean } {
const [loaded, setLoaded] = useState<{
repoId: string
names: readonly string[]
} | null>(null)
useEffect(() => {
if (!client || !repoId) {
setRetiredNames([])
setLoaded(null)
return
}
let cancelled = false
@@ -32,11 +35,11 @@ export function useRetiredWorktreeNames(
| { retiredNamesByRepo?: Record<string, string[]> }
| undefined
const names = result?.retiredNamesByRepo?.[repoId]
setRetiredNames(Array.isArray(names) ? names : [])
setLoaded({ repoId, names: Array.isArray(names) ? names : [] })
})
.catch(() => {
if (!cancelled) {
setRetiredNames([])
setLoaded({ repoId, names: [] })
}
})
return () => {
@@ -44,5 +47,8 @@ export function useRetiredWorktreeNames(
}
}, [client, repoId])
return retiredNames
return {
names: loaded && loaded.repoId === repoId ? loaded.names : [],
loading: Boolean(client && repoId) && loaded?.repoId !== repoId
}
}
+11 -27
View File
@@ -108,10 +108,12 @@ import {
areWorktreePathsEqual,
formatWorktreeRemovalError,
isOrphanCompatiblePreflightError,
isOrphanedWorktreeError,
computeWorktreePath
isOrphanedWorktreeError
} from './worktree-logic'
import { discoverRetiredWorktreeNames, retirableLeafName } from '../worktree-name-retirement'
import {
ensureRetiredWorktreeNamesBackfilled,
getRetiredWorktreeNamesForRepo
} from '../worktree-name-retirement'
import { dedupeWorktreesByPath } from './worktree-path-comparison'
import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths'
import {
@@ -604,9 +606,6 @@ function getPreservedBranchCleanupTarget(
}
const loggedUnavailableSshGitProviders = new Set<string>()
/** Repos whose retirement registry has been seeded this run; the scan is idempotent, so a
* per-run guard is enough to keep repeated suggestion reads from re-scanning the filesystem. */
const backfilledRetirementRepoIds = new Set<string>()
const loggedWorktreeListFailures = new Set<string>()
const loggedMalformedWorktreeMetaKeys = new Set<string>()
export const DETECTED_WORKTREE_PROVIDER_TIMEOUT_MS = 30_000
@@ -1917,28 +1916,13 @@ export function registerWorktreeHandlers(
if (!repo) {
return []
}
// Why: seed once per app run rather than at startup — the scan is two directory reads, and
// repeating it per run also picks up workspaces deleted outside Orca since the last launch.
if (!backfilledRetirementRepoIds.has(args.repoId)) {
backfilledRetirementRepoIds.add(args.repoId)
try {
const probePath = computeWorktreePath(
'orca-retirement-probe',
repo.path,
store.getSettings()
)
const worktreeParent = probePath.slice(
0,
probePath.length - retirableLeafName(probePath).length - 1
)
const discovered = await discoverRetiredWorktreeNames({ workspaceRoots: [worktreeParent] })
store.mergeRetiredWorktreeNames(args.repoId, discovered)
} catch (err) {
// Best effort: without the seed we only under-retire, which is the pre-existing behavior.
console.warn(`[worktrees] retirement backfill failed for repo ${args.repoId}:`, err)
}
try {
await ensureRetiredWorktreeNamesBackfilled(store, repo, store.getSettings())
} catch (err) {
// Best effort: without the seed we only under-retire, which is the pre-existing behavior.
console.warn(`[worktrees] retirement backfill failed for repo ${args.repoId}:`, err)
}
return store.getRetiredWorktreeNames(args.repoId)
return getRetiredWorktreeNamesForRepo(store, repo, store.getRepos(), store.getSettings())
})
ipcMain.handle('worktrees:list', async (_event, args: { repoId: string }) => {
@@ -64,6 +64,13 @@ describe('worktree name retirement registry', () => {
expect(store.getRetiredWorktreeNames(REPO)).toEqual([])
})
it('does not persist arbitrary user and issue-title names', async () => {
const store = await createStore()
store.addRetiredWorktreeName(REPO, 'fix-login-redirect')
store.addRetiredWorktreeName(REPO, 'STA-4189-duplicate-name')
expect(store.getRetiredWorktreeNames(REPO)).toEqual([])
})
it('returns a copy so callers cannot mutate the registry in place', async () => {
const store = await createStore()
store.addRetiredWorktreeName(REPO, 'nautilus')
@@ -103,4 +110,11 @@ describe('worktree name retirement registry', () => {
})
expect(store.getRetiredWorktreeNames(REPO).sort()).toEqual(['nautilus', 'seahorse'])
})
it('drops legacy arbitrary names while loading the persisted map', async () => {
const store = await createStore({
retiredWorktreeNamesByRepo: { [REPO]: ['fix-login', 'Nautilus'] }
})
expect(store.getRetiredWorktreeNames(REPO)).toEqual(['nautilus'])
})
})
+7 -6
View File
@@ -170,6 +170,7 @@ import {
pruneAutomationRuns
} from '../shared/automation-run-retention'
import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history'
import { normalizeRetirableGeneratedName } from './worktree-name-retirement'
import {
FOLDER_WORKSPACE_INSTANCE_SEPARATOR,
getRepoIdFromWorktreeId,
@@ -2350,8 +2351,8 @@ function normalizeRetiredWorktreeNamesByRepo(value: unknown): Record<string, str
if (typeof entry !== 'string') {
continue
}
const normalized = entry.trim().toLowerCase()
if (normalized.length > 0 && normalized.length <= 256) {
const normalized = normalizeRetirableGeneratedName(entry)
if (normalized) {
seen.add(normalized)
}
}
@@ -7221,8 +7222,8 @@ export class Store {
/** Records a generated workspace name as spent for this repo. Called with the name main actually
* used, not the one the renderer proposed — the create path can advance past it on collision. */
addRetiredWorktreeName(repoId: string, name: string): void {
const normalized = name.trim().toLowerCase()
if (!repoId || normalized.length === 0) {
const normalized = normalizeRetirableGeneratedName(name)
if (!repoId || !normalized) {
return
}
this.state.retiredWorktreeNamesByRepo ??= {}
@@ -7244,8 +7245,8 @@ export class Store {
}
const incoming = new Set<string>()
for (const name of names) {
const normalized = name.trim().toLowerCase()
if (normalized.length > 0) {
const normalized = normalizeRetirableGeneratedName(name)
if (normalized) {
incoming.add(normalized)
}
}
+26 -10
View File
@@ -933,7 +933,11 @@ import {
getWorktreeCreateCandidate,
WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS
} from '../worktree-create-candidates'
import { retirableLeafName } from '../worktree-name-retirement'
import {
ensureRetiredWorktreeNamesBackfilled,
getRetiredWorktreeNamesByRepo,
retirableLeafName
} from '../worktree-name-retirement'
import { normalizeSparseDirectories } from '../ipc/sparse-checkout-directories'
import type { PtyBindingSourceExpectation, Store } from '../persistence'
import type { StatsCollector } from '../stats/collector'
@@ -1114,6 +1118,7 @@ type RuntimeStore = {
* retirement when absent, which only means a name stays issuable. */
addRetiredWorktreeName?: Store['addRetiredWorktreeName']
getRetiredWorktreeNames?: Store['getRetiredWorktreeNames']
mergeRetiredWorktreeNames?: Store['mergeRetiredWorktreeNames']
addRepo: Store['addRepo']
updateRepo: Store['updateRepo']
getProjects?: Store['getProjects']
@@ -21041,8 +21046,24 @@ export class OrcaRuntimeService {
if (!Number.isInteger(limit) || limit <= 0) {
throw new Error('invalid_limit')
}
const selectedRepo = repoSelector ? await this.resolveRepoSelector(repoSelector) : null
if (
selectedRepo &&
this.store?.getRetiredWorktreeNames &&
this.store.mergeRetiredWorktreeNames
) {
try {
await ensureRetiredWorktreeNamesBackfilled(
this.store,
selectedRepo,
this.store.getSettings()
)
} catch (error) {
console.warn(`[runtime] retirement backfill failed for repo ${selectedRepo.id}:`, error)
}
}
const resolved = await this.listResolvedWorktrees()
const repoId = repoSelector ? (await this.resolveRepoSelector(repoSelector)).id : null
const repoId = selectedRepo?.id ?? null
const visibilitySourceMatchersByRepoId =
this.buildRuntimeVisibilitySourceMatchersByRepoId(resolved)
const worktrees = resolved.filter((worktree) => {
@@ -21077,14 +21098,9 @@ export class OrcaRuntimeService {
if (!store?.getRetiredWorktreeNames) {
return {}
}
const byRepo: Record<string, string[]> = {}
for (const repoId of repoIds) {
const names = store.getRetiredWorktreeNames(repoId)
if (names.length > 0) {
byRepo[repoId] = names
}
}
return byRepo
const repos = store.getRepos()
const settings = store.getSettings()
return getRetiredWorktreeNamesByRepo(store, repoIds, repos, settings)
}
async listDetectedManagedWorktrees(
+74 -1
View File
@@ -1,6 +1,16 @@
import { describe, expect, it } from 'vitest'
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { MARINE_CREATURES } from '../shared/marine-creatures'
import { collectRetiredNamesFromPaths, extractCandidateLeafNames } from './worktree-name-retirement'
import type { GlobalSettings, Repo } from '../shared/types'
import {
collectRetiredNamesFromPaths,
ensureRetiredWorktreeNamesBackfilled,
extractCandidateLeafNames,
getRetiredWorktreeNamesForRepo,
normalizeRetirableGeneratedName
} from './worktree-name-retirement'
const FIRST = MARINE_CREATURES[0].toLowerCase()
const SECOND = MARINE_CREATURES[1].toLowerCase()
@@ -66,3 +76,66 @@ describe('collectRetiredNamesFromPaths', () => {
expect(collectRetiredNamesFromPaths(paths)).toEqual(new Set([FIRST]))
})
})
describe('normalizeRetirableGeneratedName', () => {
it('accepts only generated pool names and their numbered variants', () => {
expect(normalizeRetirableGeneratedName(` ${FIRST} `)).toBe(FIRST)
expect(normalizeRetirableGeneratedName(`${FIRST}-2`)).toBe(`${FIRST}-2`)
expect(normalizeRetirableGeneratedName('fix-login')).toBeNull()
expect(normalizeRetirableGeneratedName(`${FIRST}-1`)).toBeNull()
})
})
describe('getRetiredWorktreeNamesForRepo', () => {
const makeRepo = (id: string, path: string): Repo =>
({ id, path, displayName: id, badgeColor: '', addedAt: 0 }) as Repo
const store = {
getRetiredWorktreeNames: (repoId: string) => (repoId === 'repo-a' ? [FIRST] : [SECOND])
}
it('shares retirements when non-nested repos create into the same cwd namespace', () => {
const repos = [makeRepo('repo-a', '/repos/a'), makeRepo('repo-b', '/repos/b')]
const settings = { workspaceDir: '/workspaces', nestWorkspaces: false } as GlobalSettings
expect(getRetiredWorktreeNamesForRepo(store, repos[1], repos, settings).sort()).toEqual(
[FIRST, SECOND].sort()
)
})
it('keeps independent nested repo paths in separate retirement domains', () => {
const repos = [makeRepo('repo-a', '/repos/a'), makeRepo('repo-b', '/repos/b')]
const settings = { workspaceDir: '/workspaces', nestWorkspaces: true } as GlobalSettings
expect(getRetiredWorktreeNamesForRepo(store, repos[1], repos, settings)).toEqual([SECOND])
})
})
describe('ensureRetiredWorktreeNamesBackfilled', () => {
it('awaits the historical workspace scan before returning names to a client', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-retirement-backfill-'))
const workspaceRoot = join(root, 'workspaces')
await mkdir(join(workspaceRoot, FIRST), { recursive: true })
const merged: string[] = []
const store = {
mergeRetiredWorktreeNames: (_repoId: string, names: Iterable<string>) => {
merged.push(...names)
return true
}
}
const repo = {
id: 'repo-a',
path: join(root, 'repos', 'a'),
displayName: 'repo-a',
badgeColor: '',
addedAt: 0
} as Repo
try {
await ensureRetiredWorktreeNamesBackfilled(store, repo, {
workspaceDir: workspaceRoot,
nestWorkspaces: false
})
expect(merged).toContain(FIRST)
} finally {
await rm(root, { force: true, recursive: true })
}
})
})
+145 -5
View File
@@ -2,6 +2,16 @@ import { readdir } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { MARINE_CREATURES } from '../shared/marine-creatures'
import { getRepoExecutionHostId } from '../shared/execution-host'
import { isFolderRepo } from '../shared/repo-kind'
import type { GlobalSettings, Repo } from '../shared/types'
import {
computeRemoteWorktreePath,
computeWorktreePath,
getWorktreePathSettings,
hasRepoWorktreeBasePath
} from './ipc/worktree-logic'
import { worktreePathComparisonKey } from './ipc/worktree-path-comparison'
/** Agent CLIs bucket their per-conversation state under a directory derived from the workspace
* cwd. A bucket surviving its workspace is exactly the evidence we need: the name was used, the
@@ -9,6 +19,23 @@ import { MARINE_CREATURES } from '../shared/marine-creatures'
const AGENT_TRANSCRIPT_ROOTS = ['.claude/projects', '.codex/sessions'] as const
const POOL_NAMES: ReadonlySet<string> = new Set(MARINE_CREATURES.map((name) => name.toLowerCase()))
const RETIREMENT_PROBE_NAME = 'orca-retirement-probe'
const backfillsByStore = new WeakMap<object, Map<string, Promise<void>>>()
type RetirementStore = {
getRetiredWorktreeNames?(repoId: string): string[]
mergeRetiredWorktreeNames?(repoId: string, names: Iterable<string>): boolean
}
type RetirementPathSettings = Pick<GlobalSettings, 'nestWorkspaces' | 'workspaceDir'>
export function normalizeRetirableGeneratedName(name: string): string | null {
const normalized = name.trim().toLowerCase()
if (normalized.length > 256) {
return null
}
const match = normalized.match(/^(.+?)(?:-([2-9]\d*))?$/)
return match && POOL_NAMES.has(match[1]) ? normalized : null
}
/** Why: worktree paths reaching retirement can be native, WSL, or SSH-host paths, so the leaf is
* taken separator-agnostically rather than through a platform-bound basename. */
@@ -45,18 +72,131 @@ export function collectRetiredNamesFromPaths(paths: Iterable<string>): Set<strin
continue
}
for (const candidate of extractCandidateLeafNames(path)) {
const base = candidate.replace(/-\d+$/, '')
if (POOL_NAMES.has(base)) {
retired.add(candidate)
const normalized = normalizeRetirableGeneratedName(candidate)
if (normalized) {
retired.add(normalized)
}
}
}
return retired
}
function getRetirementProbePath(repo: Repo, settings: RetirementPathSettings): string {
const pathSettings = getWorktreePathSettings(repo, settings)
return repo.connectionId
? computeRemoteWorktreePath(RETIREMENT_PROBE_NAME, repo.path, pathSettings, {
useConfiguredAbsolutePath: hasRepoWorktreeBasePath(repo)
})
: computeWorktreePath(RETIREMENT_PROBE_NAME, repo.path, pathSettings)
}
function getRetirementCollisionKey(repo: Repo, settings: RetirementPathSettings): string {
return `${getRepoExecutionHostId(repo)}:${worktreePathComparisonKey(
getRetirementProbePath(repo, settings)
)}`
}
export function getRetiredWorktreeNamesForRepo(
store: RetirementStore,
repo: Repo,
repos: readonly Repo[],
settings: RetirementPathSettings
): string[] {
const getRetiredNames = store.getRetiredWorktreeNames?.bind(store)
if (!getRetiredNames) {
return []
}
const collisionKey = getRetirementCollisionKey(repo, settings)
const names = new Set<string>()
for (const candidate of repos) {
if (
isFolderRepo(candidate) ||
getRetirementCollisionKey(candidate, settings) !== collisionKey
) {
continue
}
for (const name of getRetiredNames(candidate.id)) {
names.add(name)
}
}
return [...names]
}
export function getRetiredWorktreeNamesByRepo(
store: RetirementStore,
repoIds: Iterable<string>,
repos: readonly Repo[],
settings: RetirementPathSettings
): Record<string, string[]> {
const getRetiredNames = store.getRetiredWorktreeNames?.bind(store)
if (!getRetiredNames) {
return {}
}
const namesByCollisionKey = new Map<string, Set<string>>()
for (const candidate of repos) {
if (isFolderRepo(candidate)) {
continue
}
const collisionKey = getRetirementCollisionKey(candidate, settings)
let names = namesByCollisionKey.get(collisionKey)
if (!names) {
names = new Set()
namesByCollisionKey.set(collisionKey, names)
}
for (const name of getRetiredNames(candidate.id)) {
names.add(name)
}
}
const requestedRepoIds = new Set(repoIds)
const byRepo: Record<string, string[]> = {}
for (const repo of repos) {
if (!requestedRepoIds.has(repo.id) || isFolderRepo(repo)) {
continue
}
const names = namesByCollisionKey.get(getRetirementCollisionKey(repo, settings))
if (names?.size) {
byRepo[repo.id] = [...names]
}
}
return byRepo
}
function parentPath(path: string): string {
const trimmed = path.replace(/[/\\]+$/, '')
const separatorIndex = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
return separatorIndex < 0 ? '' : trimmed.slice(0, separatorIndex)
}
export function ensureRetiredWorktreeNamesBackfilled(
store: RetirementStore,
repo: Repo,
settings: RetirementPathSettings
): Promise<void> {
const mergeRetiredNames = store.mergeRetiredWorktreeNames?.bind(store)
if (isFolderRepo(repo) || repo.connectionId || !mergeRetiredNames) {
return Promise.resolve()
}
let storeBackfills = backfillsByStore.get(store)
if (!storeBackfills) {
storeBackfills = new Map()
backfillsByStore.set(store, storeBackfills)
}
const collisionKey = getRetirementCollisionKey(repo, settings)
const existing = storeBackfills.get(collisionKey)
if (existing) {
return existing
}
const backfill = discoverRetiredWorktreeNames({
workspaceRoots: [parentPath(getRetirementProbePath(repo, settings))]
}).then((names) => {
mergeRetiredNames(repo.id, names)
})
storeBackfills.set(collisionKey, backfill)
return backfill
}
/** Transcript bucket names are the workspace cwd with every non-alphanumeric run flattened to a
* dash. Encoding a repo's worktree parent the same way lets us keep retirement per-repo: a name
* spent under one repo's root says nothing about the same name under another's. */
* dash. Encoding the worktree parent keeps retirement scoped to the cwd collision domain. */
export function encodePathForBucketMatch(path: string): string {
return path.replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase()
}
+17 -3
View File
@@ -1593,7 +1593,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
// Why: blank name with no other seed → globally-unique creature name so workspaces don't collide across repos or on a literal default.
// Retired names are excluded too, so a recreated workspace never reuses a deleted one's path.
const retiredWorktreeNames = useRetiredWorktreeNames(repoId)
const retiredNamesRefreshKey = useMemo(
() =>
(worktreesByRepo[repoId] ?? [])
.map((worktree) => worktree.path)
.sort()
.join('\0'),
[repoId, worktreesByRepo]
)
const { names: retiredWorktreeNames, loading: retiredWorktreeNamesLoading } =
useRetiredWorktreeNames(repoId, retiredNamesRefreshKey)
const fallbackCreatureName = useMemo(
() => getSuggestedCreatureName(worktreesByRepo, undefined, retiredWorktreeNames),
[worktreesByRepo, retiredWorktreeNames]
@@ -3571,6 +3580,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
if (
!workspaceSeedName ||
retiredWorktreeNamesLoading ||
selectedRepoRequiresConnection ||
shouldWaitForSetupCheck ||
shouldWaitForIssueAutomationCheck ||
@@ -4034,6 +4044,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
shouldWaitForIssueAutomationCheck,
shouldWaitForSetupCheck,
sourceIntentBlocksCreate,
retiredWorktreeNamesLoading,
taskSourceContext,
workspaceSeedName,
isProjectGroupTarget,
@@ -4084,6 +4095,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
if (
!workspaceNameSeed ||
retiredWorktreeNamesLoading ||
sourceIntentBlocksCreate ||
selectedRepoRequiresConnection ||
(requiresExplicitSetupChoice && !setupDecision) ||
@@ -4620,6 +4632,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
settings?.openAgentTabsInChatByDefault,
smartNameMode,
sourceIntentBlocksCreate,
retiredWorktreeNamesLoading,
disabledTuiAgents,
setupDecision,
sparseEnabled,
@@ -4653,9 +4666,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
sparseError
}
const repoCreateDisabled =
createGateMode === 'quick'
retiredWorktreeNamesLoading ||
(createGateMode === 'quick'
? getQuickComposerCreateDisabled(createGateInput)
: getFullComposerCreateDisabled(createGateInput)
: getFullComposerCreateDisabled(createGateInput))
const createDisabled = isProjectGroupTarget ? folderCreateDisabled : repoCreateDisabled
const cardProps: ComposerCardProps = {
eligibleRepos: isProjectGroupTarget ? folderSourceRepos : eligibleRepos,
@@ -8,12 +8,22 @@ import { useEffect, useState } from 'react'
*
* Failure returns an empty set, which degrades to the pre-existing behavior (a spent name can be
* suggested) rather than blocking workspace creation. */
export function useRetiredWorktreeNames(repoId: string | null | undefined): string[] {
const [retiredNames, setRetiredNames] = useState<string[]>([])
export function useRetiredWorktreeNames(
repoId: string | null | undefined,
refreshKey: unknown
): {
names: string[]
loading: boolean
} {
const [loaded, setLoaded] = useState<{
repoId: string
refreshKey: unknown
names: string[]
} | null>(null)
useEffect(() => {
if (!repoId) {
setRetiredNames([])
setLoaded(null)
return
}
let cancelled = false
@@ -21,19 +31,23 @@ export function useRetiredWorktreeNames(repoId: string | null | undefined): stri
.listRetiredNames({ repoId })
.then((names) => {
if (!cancelled) {
setRetiredNames(names)
setLoaded({ repoId, refreshKey, names })
}
})
.catch((err) => {
if (!cancelled) {
setRetiredNames([])
setLoaded({ repoId, refreshKey, names: [] })
}
console.warn(`Failed to load retired workspace names for repo ${repoId}:`, err)
})
return () => {
cancelled = true
}
}, [repoId])
}, [refreshKey, repoId])
return retiredNames
return {
names:
loaded && loaded.repoId === repoId && loaded.refreshKey === refreshKey ? loaded.names : [],
loading: Boolean(repoId) && (loaded?.repoId !== repoId || loaded?.refreshKey !== refreshKey)
}
}