mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 08:02:51 +00:00
fix(worktree): close retirement consistency gaps
This commit is contained in:
@@ -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
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user