mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
fix(worktree): preserve generated name retirement across failures
This commit is contained in:
@@ -24,6 +24,7 @@ export async function createBlankWorkspace(args: {
|
||||
return createWorktreeWithNameRetry({
|
||||
client: args.client,
|
||||
baseName: args.baseName,
|
||||
nameWasGenerated: args.nameWasGenerated,
|
||||
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
|
||||
buildParams: (name) => {
|
||||
const params: Record<string, unknown> = {
|
||||
|
||||
@@ -154,6 +154,25 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
expect(attempts[1]!.params.name).toBe('topic-2')
|
||||
})
|
||||
|
||||
it('advances generated retries without nesting suffixes', async () => {
|
||||
const attempts: Attempt[] = []
|
||||
const client = scriptedClient(
|
||||
[{ errorMessage: 'already exists locally' }, { id: 'wt-generated' }],
|
||||
attempts
|
||||
)
|
||||
|
||||
const result = await createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName: 'nautilus-2',
|
||||
nameWasGenerated: true,
|
||||
buildParams: (name) => ({ repo: 'id:r', name, nameWasGenerated: true }),
|
||||
supportsIdempotentCutoverRetry: false
|
||||
})
|
||||
|
||||
expect(result).toEqual({ worktreeId: 'wt-generated', name: 'nautilus-3' })
|
||||
expect(attempts.map((attempt) => attempt.params.name)).toEqual(['nautilus-2', 'nautilus-3'])
|
||||
})
|
||||
|
||||
it('does not replay an ambiguous cutover when the host lacks idempotency support', async () => {
|
||||
const attempts: Attempt[] = []
|
||||
const client = scriptedClient([{ throws: new LogicalClientCutoverError() }], attempts)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-cli
|
||||
import {
|
||||
CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS,
|
||||
getClientWorktreeCreateCandidate,
|
||||
getGeneratedWorktreeCreateRetryCandidate,
|
||||
isRetryableWorktreeCreateConflict
|
||||
} from '../../../src/shared/new-workspace/worktree-create-retry-policy'
|
||||
import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout'
|
||||
@@ -26,6 +27,7 @@ const WORKTREE_CREATE_CUTOVER_MAX_RETRIES = 5
|
||||
export type CreateWorktreeWithNameRetryArgs = {
|
||||
client: RpcClient
|
||||
baseName: string
|
||||
nameWasGenerated?: boolean
|
||||
buildParams: (name: string) => Record<string, unknown>
|
||||
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
|
||||
maxAttempts?: number
|
||||
@@ -49,7 +51,9 @@ export async function createWorktreeWithNameRetry(
|
||||
const mintMutationId = args.mintMutationId ?? defaultWorktreeCreateMutationId
|
||||
let lastError: string | null = null
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
const candidateName = getClientWorktreeCreateCandidate(baseName, attempt)
|
||||
const candidateName = args.nameWasGenerated
|
||||
? getGeneratedWorktreeCreateRetryCandidate(baseName, attempt)
|
||||
: getClientWorktreeCreateCandidate(baseName, attempt)
|
||||
const candidateParams = buildParams(candidateName)
|
||||
// Why: older hosts strip unknown fields, so only stamp and replay when the
|
||||
// host advertises idempotency. One key per candidate makes cutover retries
|
||||
|
||||
@@ -1600,4 +1600,46 @@ branch refs/heads/main
|
||||
'git branch -D -- feature/test'
|
||||
)
|
||||
})
|
||||
|
||||
it('marks cleanup failed when sparse rollback cannot clear metadata or remove the worktree', async () => {
|
||||
mockGitCommands({
|
||||
'git config --get push.autoSetupRemote': {
|
||||
error: Object.assign(new Error('key unset'), { code: 1 })
|
||||
},
|
||||
'git sparse-checkout set -- packages/web': {
|
||||
error: new Error('sparse setup failed')
|
||||
},
|
||||
'git config --local --unset-all branch.feature/test.base': {
|
||||
error: new Error('metadata cleanup failed')
|
||||
},
|
||||
'git worktree list --porcelain': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
|
||||
worktree /repo-feature
|
||||
HEAD def456
|
||||
branch refs/heads/feature/test
|
||||
`
|
||||
},
|
||||
'git worktree remove --force /repo-feature': {
|
||||
error: new Error('worktree cleanup failed')
|
||||
}
|
||||
})
|
||||
|
||||
const error = await addSparseWorktree('/repo', '/repo-feature', 'feature/test', [
|
||||
'packages/web'
|
||||
]).catch((caught: unknown) => caught)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
cleanupFailed: true,
|
||||
message: expect.stringContaining('cleanup also failed')
|
||||
})
|
||||
const calls = getGitCalls()
|
||||
expectGitCallOrder(
|
||||
calls,
|
||||
'git config --local --unset-all branch.feature/test.base',
|
||||
'git worktree remove --force /repo-feature'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1067,7 +1067,14 @@ export async function addSparseWorktree(
|
||||
error instanceof Error ? (error as SparseWorktreeCreateError) : new Error(String(error))
|
||||
if (created) {
|
||||
if (!options.checkoutExistingBranch) {
|
||||
await unsetWorktreeCreationBase(worktreePath, branch, options)
|
||||
try {
|
||||
await unsetWorktreeCreationBase(worktreePath, branch, options)
|
||||
} catch (cleanupError) {
|
||||
console.warn(
|
||||
`addSparseWorktree: failed to clear creation base for ${worktreePath}`,
|
||||
cleanupError
|
||||
)
|
||||
}
|
||||
}
|
||||
try {
|
||||
await removeWorktree(repoPath, worktreePath, true, {
|
||||
|
||||
+142
-92
@@ -136,10 +136,16 @@ import {
|
||||
} from '../project-runtime-git-options'
|
||||
import {
|
||||
getBranchNameOverrideCandidate,
|
||||
getGeneratedWorktreeCreateCandidate,
|
||||
getWorktreeCreateCandidate,
|
||||
isGeneratedWorktreeCreateName,
|
||||
WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS
|
||||
} from '../worktree-create-candidates'
|
||||
import { getRetiredNameRegistryForRepo } from '../worktree-name-retirement'
|
||||
import {
|
||||
failedWorktreeCreationNeedsRetirement,
|
||||
getRetiredNameRegistryForRepo,
|
||||
retireGeneratedWorktreeName
|
||||
} from '../worktree-name-retirement'
|
||||
import { createRetiredNameLookup } from '../../shared/worktree/retired-name-registry'
|
||||
|
||||
const SSH_WORKTREE_CREATE_FETCH_FRESHNESS_MS = 30_000
|
||||
@@ -1556,20 +1562,30 @@ export async function createRemoteWorktree(
|
||||
let selectedExistingLocalBranchName: string | null = null
|
||||
let lastBranchConflictKind: 'local' | 'remote' | null = null
|
||||
let remotePathResolved = false
|
||||
const isRetiredName = args.nameWasGenerated
|
||||
? createRetiredNameLookup(
|
||||
getRetiredNameRegistryForRepo(store, repo, store.getRepos(), settings)
|
||||
)
|
||||
const shouldRetireGeneratedName =
|
||||
args.nameWasGenerated === true && isGeneratedWorktreeCreateName(sanitizedName)
|
||||
const retiredNameRegistry = shouldRetireGeneratedName
|
||||
? await getRetiredNameRegistryForRepo(store, repo, store.getRepos(), settings)
|
||||
: null
|
||||
const isRetiredName = retiredNameRegistry ? createRetiredNameLookup(retiredNameRegistry) : null
|
||||
// Why: duplicate PR/MR checkouts still need a workspace; suffix branch/path while preserving review metadata and push target.
|
||||
for (let suffix = 1; suffix <= WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS; suffix += 1) {
|
||||
effectiveSanitizedName = getWorktreeCreateCandidate(sanitizedName, suffix)
|
||||
effectiveRequestedName = args.name.trim()
|
||||
? getWorktreeCreateCandidate(args.name, suffix)
|
||||
: effectiveSanitizedName
|
||||
for (let suffix = 1, attempts = 0; attempts < WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS; suffix += 1) {
|
||||
effectiveSanitizedName = shouldRetireGeneratedName
|
||||
? getGeneratedWorktreeCreateCandidate(
|
||||
sanitizedName,
|
||||
suffix,
|
||||
retiredNameRegistry?.exhaustedTiers
|
||||
)
|
||||
: getWorktreeCreateCandidate(sanitizedName, suffix)
|
||||
effectiveRequestedName = shouldRetireGeneratedName
|
||||
? effectiveSanitizedName
|
||||
: args.name.trim()
|
||||
? getWorktreeCreateCandidate(args.name, suffix)
|
||||
: effectiveSanitizedName
|
||||
if (isRetiredName?.(effectiveSanitizedName)) {
|
||||
continue
|
||||
}
|
||||
attempts += 1
|
||||
branchName = await resolveCreateBranchNameSsh(
|
||||
provider,
|
||||
repo.path,
|
||||
@@ -1749,20 +1765,39 @@ export async function createRemoteWorktree(
|
||||
await provider.exec(['sparse-checkout', 'set', '--', ...sparseDirectories], remotePath)
|
||||
await provider.exec(['checkout', branchName], remotePath)
|
||||
} catch (err) {
|
||||
let rollbackSucceeded = false
|
||||
if (!checkoutExistingBranch) {
|
||||
await unsetRemoteWorktreeCreationBase(provider, remotePath, branchName)
|
||||
try {
|
||||
await unsetRemoteWorktreeCreationBase(provider, remotePath, branchName)
|
||||
} catch (cleanupError) {
|
||||
console.warn(
|
||||
'[worktree-create] Failed to clear remote sparse creation base:',
|
||||
cleanupError
|
||||
)
|
||||
}
|
||||
}
|
||||
await provider
|
||||
.removeWorktree(remotePath, true, {
|
||||
try {
|
||||
await provider.removeWorktree(remotePath, true, {
|
||||
deleteBranch: !checkoutExistingBranch,
|
||||
// Why: sparse setup failed before any work happened, so rollback removes the just-created remote branch.
|
||||
forceBranchDelete: !checkoutExistingBranch
|
||||
})
|
||||
.catch(() => undefined)
|
||||
rollbackSucceeded = true
|
||||
} catch (rollbackError) {
|
||||
console.warn('[worktree-create] Failed to roll back remote sparse worktree:', rollbackError)
|
||||
}
|
||||
if (!rollbackSucceeded && shouldRetireGeneratedName) {
|
||||
await retireGeneratedWorktreeName(store, repo, settings, effectiveSanitizedName)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Why: fallible metadata work after creation must not leave a real workspace name reusable.
|
||||
if (shouldRetireGeneratedName) {
|
||||
await retireGeneratedWorktreeName(store, repo, settings, effectiveSanitizedName)
|
||||
}
|
||||
|
||||
// Re-list to get the created worktree info
|
||||
const gitWorktrees = await timing.time('list_created_worktree', async () =>
|
||||
provider.listWorktrees(repo.path)
|
||||
@@ -1775,10 +1810,6 @@ export async function createRemoteWorktree(
|
||||
}
|
||||
|
||||
const worktreeId = `${repo.id}::${created.path}`
|
||||
// Why: repo-qualified layouts decorate the physical leaf; retire the name main actually used.
|
||||
if (args.nameWasGenerated) {
|
||||
store.addRetiredWorktreeName(repo.id, effectiveSanitizedName)
|
||||
}
|
||||
const now = Date.now()
|
||||
// Why: PR/MR worktrees start from a head ref/SHA but Source Control must compare against the review target branch.
|
||||
const metadataBaseRef = args.compareBaseRef ?? remoteTrackingBase?.ref ?? baseBranch
|
||||
@@ -2107,20 +2138,30 @@ export async function createLocalWorktree(
|
||||
let lastBranchConflictKind: 'local' | 'remote' | null = null
|
||||
let lastExistingPR: Awaited<ReturnType<typeof getPRForBranch>> | null = null
|
||||
let lastExistingReviewNumber: number | null = null
|
||||
const isRetiredName = args.nameWasGenerated
|
||||
? createRetiredNameLookup(
|
||||
getRetiredNameRegistryForRepo(store, repo, store.getRepos(), settings)
|
||||
)
|
||||
const shouldRetireGeneratedName =
|
||||
args.nameWasGenerated === true && isGeneratedWorktreeCreateName(sanitizedName)
|
||||
const retiredNameRegistry = shouldRetireGeneratedName
|
||||
? await getRetiredNameRegistryForRepo(store, repo, store.getRepos(), settings)
|
||||
: null
|
||||
const isRetiredName = retiredNameRegistry ? createRetiredNameLookup(retiredNameRegistry) : null
|
||||
// Why: a create-from-review branch override may already exist locally; suffix both branch and path instead of blocking the user.
|
||||
for (let suffix = 1; suffix <= WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS; suffix += 1) {
|
||||
effectiveSanitizedName = getWorktreeCreateCandidate(sanitizedName, suffix)
|
||||
effectiveRequestedName = requestedName.trim()
|
||||
? getWorktreeCreateCandidate(requestedName, suffix)
|
||||
: effectiveSanitizedName
|
||||
for (let suffix = 1, attempts = 0; attempts < WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS; suffix += 1) {
|
||||
effectiveSanitizedName = shouldRetireGeneratedName
|
||||
? getGeneratedWorktreeCreateCandidate(
|
||||
sanitizedName,
|
||||
suffix,
|
||||
retiredNameRegistry?.exhaustedTiers
|
||||
)
|
||||
: getWorktreeCreateCandidate(sanitizedName, suffix)
|
||||
effectiveRequestedName = shouldRetireGeneratedName
|
||||
? effectiveSanitizedName
|
||||
: requestedName.trim()
|
||||
? getWorktreeCreateCandidate(requestedName, suffix)
|
||||
: effectiveSanitizedName
|
||||
if (isRetiredName?.(effectiveSanitizedName)) {
|
||||
continue
|
||||
}
|
||||
attempts += 1
|
||||
lastExistingReviewNumber = null
|
||||
|
||||
branchName = await resolveCreateBranchName(
|
||||
@@ -2292,93 +2333,106 @@ export async function createLocalWorktree(
|
||||
...remoteTrackingBaseOption,
|
||||
...(suggestLocalBaseRefUpdate ? { suggestLocalBaseRefUpdate } : {})
|
||||
}
|
||||
const addResult: AddWorktreeResult =
|
||||
(await timing.time('git_worktree_add', async () => {
|
||||
if (sparseDirectories.length > 0) {
|
||||
if (checkoutExistingBranch) {
|
||||
return addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
addProjectGitOptions(existingBranchOption)
|
||||
)
|
||||
}
|
||||
if (suggestLocalBaseRefUpdate) {
|
||||
return addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate })
|
||||
)
|
||||
}
|
||||
const sparseOptions = addProjectGitOptions(remoteTrackingBaseOption)
|
||||
return sparseOptions
|
||||
? addSparseWorktree(
|
||||
let addResult: AddWorktreeResult
|
||||
try {
|
||||
addResult =
|
||||
(await timing.time('git_worktree_add', async () => {
|
||||
if (sparseDirectories.length > 0) {
|
||||
if (checkoutExistingBranch) {
|
||||
return addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
sparseOptions
|
||||
addProjectGitOptions(existingBranchOption)
|
||||
)
|
||||
: addSparseWorktree(
|
||||
}
|
||||
if (suggestLocalBaseRefUpdate) {
|
||||
return addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate })
|
||||
)
|
||||
}
|
||||
}
|
||||
const sparseOptions = addProjectGitOptions(remoteTrackingBaseOption)
|
||||
return sparseOptions
|
||||
? addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
sparseOptions
|
||||
)
|
||||
: addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||||
)
|
||||
}
|
||||
|
||||
if (checkoutExistingBranch) {
|
||||
return addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
false,
|
||||
addProjectGitOptions(existingBranchOption)
|
||||
)
|
||||
}
|
||||
if (suggestLocalBaseRefUpdate) {
|
||||
return addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
false,
|
||||
addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate })
|
||||
)
|
||||
}
|
||||
const worktreeOptions = addProjectGitOptions(remoteTrackingBaseOption)
|
||||
return worktreeOptions
|
||||
? addWorktree(
|
||||
if (checkoutExistingBranch) {
|
||||
return addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
false,
|
||||
worktreeOptions
|
||||
addProjectGitOptions(existingBranchOption)
|
||||
)
|
||||
: addWorktree(
|
||||
}
|
||||
if (suggestLocalBaseRefUpdate) {
|
||||
return addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
false,
|
||||
addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate })
|
||||
)
|
||||
})) ?? {}
|
||||
}
|
||||
const worktreeOptions = addProjectGitOptions(remoteTrackingBaseOption)
|
||||
return worktreeOptions
|
||||
? addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
false,
|
||||
worktreeOptions
|
||||
)
|
||||
: addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||||
)
|
||||
})) ?? {}
|
||||
} catch (error) {
|
||||
if (shouldRetireGeneratedName && failedWorktreeCreationNeedsRetirement(error)) {
|
||||
await retireGeneratedWorktreeName(store, repo, settings, effectiveSanitizedName)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// Why: fallible metadata work after creation must not leave a real workspace name reusable.
|
||||
if (shouldRetireGeneratedName) {
|
||||
await retireGeneratedWorktreeName(store, repo, settings, effectiveSanitizedName)
|
||||
}
|
||||
|
||||
let configuredPushTarget: GitPushTarget | undefined
|
||||
if (preparedPushTarget) {
|
||||
@@ -2404,10 +2458,6 @@ export async function createLocalWorktree(
|
||||
}
|
||||
|
||||
const worktreeId = `${repo.id}::${created.path}`
|
||||
// Why: repo-qualified layouts decorate the physical leaf; retire the name main actually used.
|
||||
if (args.nameWasGenerated) {
|
||||
store.addRetiredWorktreeName(repo.id, effectiveSanitizedName)
|
||||
}
|
||||
const now = Date.now()
|
||||
// Why: PR/MR worktrees start from a head ref/SHA but Source Control must compare against the review target branch.
|
||||
const metadataBaseRef = args.compareBaseRef ?? remoteTrackingBase?.ref ?? baseBranch
|
||||
|
||||
@@ -5750,7 +5750,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('unsets SSH branch base config before removing a sparse worktree after setup failure', async () => {
|
||||
it('attempts SSH base cleanup and still removes a sparse worktree when that cleanup fails', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
@@ -5769,6 +5769,9 @@ describe('registerWorktreeHandlers', () => {
|
||||
if (args[0] === 'sparse-checkout' && args[1] === 'init') {
|
||||
throw setupError
|
||||
}
|
||||
if (args[0] === 'config' && args[2] === '--unset-all') {
|
||||
throw new Error('metadata cleanup failed')
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
}),
|
||||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -8284,6 +8287,23 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('retires a generated sparse name when creation rollback also fails', async () => {
|
||||
addSparseWorktreeMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error('sparse setup failed'), { cleanupFailed: true })
|
||||
)
|
||||
|
||||
await expect(
|
||||
handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'nautilus',
|
||||
nameWasGenerated: true,
|
||||
sparseCheckout: { directories: ['packages/web'] }
|
||||
})
|
||||
).rejects.toThrow('sparse setup failed')
|
||||
|
||||
expect(store.addRetiredWorktreeName).toHaveBeenCalledWith('repo-1', 'nautilus')
|
||||
})
|
||||
|
||||
it('clears sparse preset attribution when the preset id does not belong to the repo', async () => {
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -121,10 +121,7 @@ import {
|
||||
isOrphanCompatiblePreflightError,
|
||||
isOrphanedWorktreeError
|
||||
} from './worktree-logic'
|
||||
import {
|
||||
ensureRetiredWorktreeNamesBackfilled,
|
||||
getRetiredNameRegistryForRepo
|
||||
} from '../worktree-name-retirement'
|
||||
import { getRetiredNameRegistryForRepo } from '../worktree-name-retirement'
|
||||
import { EMPTY_RETIRED_NAME_REGISTRY } from '../../shared/worktree/retired-name-registry'
|
||||
import { dedupeWorktreesByPath } from './worktree-path-comparison'
|
||||
import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths'
|
||||
@@ -1944,12 +1941,6 @@ export function registerWorktreeHandlers(
|
||||
if (!repo) {
|
||||
return EMPTY_RETIRED_NAME_REGISTRY
|
||||
}
|
||||
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 getRetiredNameRegistryForRepo(store, repo, store.getRepos(), store.getSettings())
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import {
|
||||
mergeRetiredNameRegistries,
|
||||
type RetiredNameRegistry
|
||||
} from '../../shared/worktree/retired-name-registry'
|
||||
import { getRemoteRetirementNamespaceKey } from '../worktree-name-retirement'
|
||||
import type { TransferProfileState } from './profile-project-state-file'
|
||||
|
||||
export function extractRetiredNameRegistriesByNamespace(
|
||||
sourceState: TransferProfileState,
|
||||
sourceRepo: Repo
|
||||
): Record<string, RetiredNameRegistry> {
|
||||
const namespaceKey = getRemoteRetirementNamespaceKey(sourceRepo, sourceState.settings)
|
||||
const registry = namespaceKey
|
||||
? sourceState.retiredWorktreeNamesByNamespace?.[namespaceKey]
|
||||
: undefined
|
||||
return namespaceKey && registry ? { [namespaceKey]: registry } : {}
|
||||
}
|
||||
|
||||
export function mergeRetiredNameRegistryMaps(
|
||||
base: Record<string, RetiredNameRegistry>,
|
||||
incoming: Record<string, RetiredNameRegistry>
|
||||
): Record<string, RetiredNameRegistry> {
|
||||
const merged = { ...base }
|
||||
for (const [key, registry] of Object.entries(incoming)) {
|
||||
merged[key] = merged[key] ? mergeRetiredNameRegistries(merged[key], registry) : registry
|
||||
}
|
||||
return merged
|
||||
}
|
||||
@@ -47,6 +47,9 @@ export function readProfileState(profileId: string, userDataPath: string): Trans
|
||||
retiredWorktreeNamesByRepo: recordOrEmpty<RetiredNameRegistry>(
|
||||
parsed.retiredWorktreeNamesByRepo
|
||||
),
|
||||
retiredWorktreeNamesByNamespace: recordOrEmpty<RetiredNameRegistry>(
|
||||
parsed.retiredWorktreeNamesByNamespace
|
||||
),
|
||||
worktreeMeta: recordOrEmpty(parsed.worktreeMeta),
|
||||
worktreeLineageById: recordOrEmpty(parsed.worktreeLineageById),
|
||||
workspaceLineageByChildKey: recordOrEmpty(parsed.workspaceLineageByChildKey),
|
||||
|
||||
@@ -7,7 +7,10 @@ import type { PersistedState } from '../../shared/persisted-state-types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { SparsePreset } from '../../shared/worktree/create-types'
|
||||
import type { RetiredNameRegistry } from '../../shared/worktree/retired-name-registry'
|
||||
import { parseWorkspaceKey } from '../../shared/workspace-scope'
|
||||
import {
|
||||
extractRetiredNameRegistriesByNamespace,
|
||||
mergeRetiredNameRegistryMaps
|
||||
} from './profile-project-retired-name-transfer'
|
||||
import type { TransferProfileState } from './profile-project-state-file'
|
||||
import { rebuildRepoBackedProjectState } from './profile-project-state-file'
|
||||
import { mergeHostWorkspaceSessions, mergeWorkspaceSessions } from './profile-project-session-state'
|
||||
@@ -15,11 +18,8 @@ import {
|
||||
extractHostSessionsForTransfer,
|
||||
extractSessionForTransfer
|
||||
} from './profile-project-session-transfer'
|
||||
import {
|
||||
isRepoWorktreeId,
|
||||
rekeyWorktreeId,
|
||||
rekeyWorkspaceKey
|
||||
} from './profile-project-worktree-identity'
|
||||
import { collectTransferWorktreeIds } from './profile-project-transfer-worktree-ids'
|
||||
import { rekeyWorktreeId, rekeyWorkspaceKey } from './profile-project-worktree-identity'
|
||||
|
||||
export type TransferPayload = {
|
||||
repo: Repo
|
||||
@@ -27,6 +27,7 @@ export type TransferPayload = {
|
||||
/** Generated names already spent for this repo, compacted. Carried so the destination profile
|
||||
* does not reissue a name whose old directory still holds agent state. */
|
||||
retiredNameRegistry: RetiredNameRegistry | undefined
|
||||
retiredNameRegistriesByNamespace: Record<string, RetiredNameRegistry>
|
||||
worktreeMeta: PersistedState['worktreeMeta']
|
||||
worktreeLineageById: PersistedState['worktreeLineageById']
|
||||
workspaceLineageByChildKey: PersistedState['workspaceLineageByChildKey']
|
||||
@@ -64,86 +65,6 @@ function createUniqueRepoId(state: TransferProfileState): string {
|
||||
return candidate
|
||||
}
|
||||
|
||||
function collectTransferWorktreeIds(state: TransferProfileState, repoId: string): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
const add = (value: string | null | undefined): void => {
|
||||
if (value && isRepoWorktreeId(repoId, value)) {
|
||||
ids.add(value)
|
||||
}
|
||||
}
|
||||
Object.keys(state.worktreeMeta).forEach(add)
|
||||
for (const lineage of Object.values(state.worktreeLineageById)) {
|
||||
add(lineage.worktreeId)
|
||||
add(lineage.parentWorktreeId)
|
||||
}
|
||||
for (const [key, lineage] of Object.entries(state.workspaceLineageByChildKey)) {
|
||||
const child = parseWorkspaceKey(key)
|
||||
const parent = parseWorkspaceKey(lineage.parentWorkspaceKey)
|
||||
if (child?.type === 'worktree') {
|
||||
add(child.worktreeId)
|
||||
}
|
||||
if (parent?.type === 'worktree') {
|
||||
add(parent.worktreeId)
|
||||
}
|
||||
}
|
||||
collectSessionWorktreeIds(state.workspaceSession, repoId, ids)
|
||||
for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) {
|
||||
collectSessionWorktreeIds(session, repoId, ids)
|
||||
}
|
||||
Object.keys(state.ui?.showDotfilesByWorktree ?? {}).forEach(add)
|
||||
return ids
|
||||
}
|
||||
|
||||
function collectSessionWorktreeIds(
|
||||
session: PersistedState['workspaceSession'] | undefined,
|
||||
repoId: string,
|
||||
ids: Set<string>
|
||||
): void {
|
||||
if (!session) {
|
||||
return
|
||||
}
|
||||
const add = (value: string | null | undefined): void => {
|
||||
if (value && isRepoWorktreeId(repoId, value)) {
|
||||
ids.add(value)
|
||||
}
|
||||
}
|
||||
const addOwnerKeys = (record: Record<string, unknown> | undefined): void => {
|
||||
for (const key of Object.keys(record ?? {})) {
|
||||
if (isRepoWorktreeId(repoId, key)) {
|
||||
ids.add(key)
|
||||
}
|
||||
const parsed = parseWorkspaceKey(key)
|
||||
if (parsed?.type === 'worktree' && isRepoWorktreeId(repoId, parsed.worktreeId)) {
|
||||
ids.add(parsed.worktreeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
addOwnerKeys(session.tabsByWorktree)
|
||||
addOwnerKeys(session.openFilesByWorktree)
|
||||
addOwnerKeys(session.browserTabsByWorktree)
|
||||
addOwnerKeys(session.activeBrowserTabIdByWorktree)
|
||||
addOwnerKeys(session.activeTabTypeByWorktree)
|
||||
addOwnerKeys(session.activeTabIdByWorktree)
|
||||
addOwnerKeys(session.unifiedTabs)
|
||||
addOwnerKeys(session.tabGroups)
|
||||
addOwnerKeys(session.tabGroupLayouts)
|
||||
addOwnerKeys(session.activeGroupIdByWorktree)
|
||||
addOwnerKeys(session.lastVisitedAtByWorktreeId)
|
||||
addOwnerKeys(session.defaultTerminalTabsAppliedByWorktreeId)
|
||||
addOwnerKeys(session.terminalTopologyRevisionByRepoId)
|
||||
addOwnerKeys(session.activeFileIdByWorktree)
|
||||
for (const tombstone of Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {})) {
|
||||
add(tombstone.worktreeId)
|
||||
}
|
||||
add(session.activeWorktreeId)
|
||||
const activeScope = session.activeWorkspaceKey
|
||||
? parseWorkspaceKey(session.activeWorkspaceKey)
|
||||
: null
|
||||
if (activeScope?.type === 'worktree') {
|
||||
add(activeScope.worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
function rekeyWorktreeIdRecord<T>(
|
||||
record: Record<string, T>,
|
||||
worktreeIds: ReadonlySet<string>,
|
||||
@@ -222,6 +143,10 @@ export function createTransferPayload(args: {
|
||||
repoId: newRepoId
|
||||
})),
|
||||
retiredNameRegistry: sourceState.retiredWorktreeNamesByRepo?.[oldRepoId],
|
||||
retiredNameRegistriesByNamespace: extractRetiredNameRegistriesByNamespace(
|
||||
sourceState,
|
||||
sourceRepo
|
||||
),
|
||||
worktreeMeta: rekeyWorktreeIdRecord(
|
||||
sourceState.worktreeMeta,
|
||||
worktreeIds,
|
||||
@@ -281,6 +206,10 @@ export function applyPayloadToTarget(
|
||||
...targetState.retiredWorktreeNamesByRepo,
|
||||
...(payload.retiredNameRegistry ? { [payload.repo.id]: payload.retiredNameRegistry } : {})
|
||||
},
|
||||
retiredWorktreeNamesByNamespace: mergeRetiredNameRegistryMaps(
|
||||
targetState.retiredWorktreeNamesByNamespace ?? {},
|
||||
payload.retiredNameRegistriesByNamespace
|
||||
),
|
||||
|
||||
worktreeMeta: { ...targetState.worktreeMeta, ...payload.worktreeMeta },
|
||||
worktreeLineageById: { ...targetState.worktreeLineageById, ...payload.worktreeLineageById },
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { PersistedState } from '../../shared/persisted-state-types'
|
||||
import { parseWorkspaceKey } from '../../shared/workspace-scope'
|
||||
import type { TransferProfileState } from './profile-project-state-file'
|
||||
import { isRepoWorktreeId } from './profile-project-worktree-identity'
|
||||
|
||||
export function collectTransferWorktreeIds(
|
||||
state: TransferProfileState,
|
||||
repoId: string
|
||||
): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
const add = (value: string | null | undefined): void => {
|
||||
if (value && isRepoWorktreeId(repoId, value)) {
|
||||
ids.add(value)
|
||||
}
|
||||
}
|
||||
Object.keys(state.worktreeMeta).forEach(add)
|
||||
for (const lineage of Object.values(state.worktreeLineageById)) {
|
||||
add(lineage.worktreeId)
|
||||
add(lineage.parentWorktreeId)
|
||||
}
|
||||
for (const [key, lineage] of Object.entries(state.workspaceLineageByChildKey)) {
|
||||
const child = parseWorkspaceKey(key)
|
||||
const parent = parseWorkspaceKey(lineage.parentWorkspaceKey)
|
||||
if (child?.type === 'worktree') {
|
||||
add(child.worktreeId)
|
||||
}
|
||||
if (parent?.type === 'worktree') {
|
||||
add(parent.worktreeId)
|
||||
}
|
||||
}
|
||||
collectSessionWorktreeIds(state.workspaceSession, repoId, ids)
|
||||
for (const session of Object.values(state.workspaceSessionsByHostId ?? {})) {
|
||||
collectSessionWorktreeIds(session, repoId, ids)
|
||||
}
|
||||
Object.keys(state.ui?.showDotfilesByWorktree ?? {}).forEach(add)
|
||||
return ids
|
||||
}
|
||||
|
||||
function collectSessionWorktreeIds(
|
||||
session: PersistedState['workspaceSession'] | undefined,
|
||||
repoId: string,
|
||||
ids: Set<string>
|
||||
): void {
|
||||
if (!session) {
|
||||
return
|
||||
}
|
||||
const add = (value: string | null | undefined): void => {
|
||||
if (value && isRepoWorktreeId(repoId, value)) {
|
||||
ids.add(value)
|
||||
}
|
||||
}
|
||||
const addOwnerKeys = (record: Record<string, unknown> | undefined): void => {
|
||||
for (const key of Object.keys(record ?? {})) {
|
||||
if (isRepoWorktreeId(repoId, key)) {
|
||||
ids.add(key)
|
||||
}
|
||||
const parsed = parseWorkspaceKey(key)
|
||||
if (parsed?.type === 'worktree' && isRepoWorktreeId(repoId, parsed.worktreeId)) {
|
||||
ids.add(parsed.worktreeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
addOwnerKeys(session.tabsByWorktree)
|
||||
addOwnerKeys(session.openFilesByWorktree)
|
||||
addOwnerKeys(session.browserTabsByWorktree)
|
||||
addOwnerKeys(session.activeBrowserTabIdByWorktree)
|
||||
addOwnerKeys(session.activeTabTypeByWorktree)
|
||||
addOwnerKeys(session.activeTabIdByWorktree)
|
||||
addOwnerKeys(session.unifiedTabs)
|
||||
addOwnerKeys(session.tabGroups)
|
||||
addOwnerKeys(session.tabGroupLayouts)
|
||||
addOwnerKeys(session.activeGroupIdByWorktree)
|
||||
addOwnerKeys(session.lastVisitedAtByWorktreeId)
|
||||
addOwnerKeys(session.defaultTerminalTabsAppliedByWorktreeId)
|
||||
addOwnerKeys(session.terminalTopologyRevisionByRepoId)
|
||||
addOwnerKeys(session.activeFileIdByWorktree)
|
||||
for (const tombstone of Object.values(session.terminalSurfaceTombstonesByPaneKey ?? {})) {
|
||||
add(tombstone.worktreeId)
|
||||
}
|
||||
add(session.activeWorktreeId)
|
||||
const activeScope = session.activeWorkspaceKey
|
||||
? parseWorkspaceKey(session.activeWorkspaceKey)
|
||||
: null
|
||||
if (activeScope?.type === 'worktree') {
|
||||
add(activeScope.worktreeId)
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,9 @@ describe('profile project transfer', () => {
|
||||
]
|
||||
},
|
||||
retiredWorktreeNamesByRepo: { 'repo-1': { exhaustedTiers: 0, names: ['nautilus'] } },
|
||||
retiredWorktreeNamesByNamespace: {
|
||||
'ssh:ssh-1:/workspace/orca-nautilus': { exhaustedTiers: 0, names: ['seahorse'] }
|
||||
},
|
||||
worktreeMeta: {
|
||||
[sourceWorktreeId]: makeWorktreeMeta({ projectHostSetupId: 'repo-1' })
|
||||
},
|
||||
@@ -186,6 +189,7 @@ describe('profile project transfer', () => {
|
||||
exhaustedTiers: 0,
|
||||
names: ['nautilus']
|
||||
})
|
||||
expect(target.retiredWorktreeNamesByNamespace).toEqual({})
|
||||
expect(target.workspaceSession.tabsByWorktree).toEqual({})
|
||||
expect(readProfileState('personal').repos.map((repo) => repo.id)).toEqual(['repo-1'])
|
||||
})
|
||||
@@ -211,6 +215,12 @@ describe('profile project transfer', () => {
|
||||
})
|
||||
],
|
||||
sshTargets: [sshTarget],
|
||||
retiredWorktreeNamesByNamespace: {
|
||||
'ssh:ssh-1:posix:/srv/orca-orca-retirement-probe': {
|
||||
exhaustedTiers: 0,
|
||||
names: ['seahorse']
|
||||
}
|
||||
},
|
||||
worktreeMeta: {
|
||||
[sourceWorktreeId]: makeWorktreeMeta({ projectHostSetupId: 'repo-ssh' })
|
||||
},
|
||||
@@ -266,6 +276,12 @@ describe('profile project transfer', () => {
|
||||
executionHostId: 'ssh:ssh-1'
|
||||
})
|
||||
expect(target.sshTargets).toEqual([sshTarget])
|
||||
expect(target.retiredWorktreeNamesByNamespace).toEqual({
|
||||
'ssh:ssh-1:posix:/srv/orca-orca-retirement-probe': {
|
||||
exhaustedTiers: 0,
|
||||
names: ['seahorse']
|
||||
}
|
||||
})
|
||||
expect(target.workspaceSession.browserTabsByWorktree?.[sourceWorktreeId]?.[0]).toMatchObject({
|
||||
worktreeId: sourceWorktreeId,
|
||||
sessionProfileId: null,
|
||||
|
||||
@@ -140,6 +140,32 @@ describe('worktree name retirement registry', () => {
|
||||
expect(store.getRetiredWorktreeNameRegistry(REPO).names).toEqual([])
|
||||
})
|
||||
|
||||
it('reassociates remote retirements after remove and re-add mints a new repo id', async () => {
|
||||
const oldRepo = {
|
||||
id: REPO,
|
||||
path: '/remote/repos/a',
|
||||
displayName: 'a',
|
||||
badgeColor: '',
|
||||
addedAt: 0,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
let store = await createStore()
|
||||
store.addRepo(oldRepo)
|
||||
const { getRetiredNameRegistryForRepo, retireGeneratedWorktreeName } =
|
||||
await import('./worktree-name-retirement')
|
||||
await retireGeneratedWorktreeName(store, oldRepo, store.getSettings(), 'nautilus')
|
||||
store.removeProject(REPO)
|
||||
store.flush()
|
||||
|
||||
store = await reloadStore()
|
||||
const newRepo = { ...oldRepo, id: OTHER_REPO }
|
||||
store.addRepo(newRepo)
|
||||
|
||||
await expect(
|
||||
getRetiredNameRegistryForRepo(store, newRepo, [newRepo], store.getSettings())
|
||||
).resolves.toEqual({ exhaustedTiers: 0, names: ['nautilus'] })
|
||||
})
|
||||
|
||||
it('keeps the registry when one host row is removed but the repo id survives elsewhere', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo({ id: REPO, path: '/repos/a', displayName: 'a', badgeColor: '', addedAt: 0 })
|
||||
|
||||
+36
-2
@@ -2358,7 +2358,7 @@ function normalizeRetiredNameRegistry(row: unknown): RetiredNameRegistry {
|
||||
|
||||
/** Why: a corrupt or hand-edited map must degrade to "nothing retired" rather than throw during
|
||||
* load — over-retiring costs one name from a 552-entry pool, but a load failure costs the app. */
|
||||
function normalizeRetiredWorktreeNamesByRepo(value: unknown): Record<string, RetiredNameRegistry> {
|
||||
function normalizeRetiredNameRegistryMap(value: unknown): Record<string, RetiredNameRegistry> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {}
|
||||
}
|
||||
@@ -3865,9 +3865,12 @@ export class Store {
|
||||
(alias): alias is string => typeof alias === 'string'
|
||||
)
|
||||
: [],
|
||||
retiredWorktreeNamesByRepo: normalizeRetiredWorktreeNamesByRepo(
|
||||
retiredWorktreeNamesByRepo: normalizeRetiredNameRegistryMap(
|
||||
parsed.retiredWorktreeNamesByRepo
|
||||
),
|
||||
retiredWorktreeNamesByNamespace: normalizeRetiredNameRegistryMap(
|
||||
parsed.retiredWorktreeNamesByNamespace
|
||||
),
|
||||
sshRemotePtyLeases: (parsed.sshRemotePtyLeases ?? [])
|
||||
.map(normalizeSshRemotePtyLease)
|
||||
.filter((lease): lease is SshRemotePtyLease => lease !== null),
|
||||
@@ -7265,6 +7268,13 @@ export class Store {
|
||||
: EMPTY_RETIRED_NAME_REGISTRY
|
||||
}
|
||||
|
||||
getRetiredWorktreeNameRegistryForNamespace(namespaceKey: string): RetiredNameRegistry {
|
||||
const stored = this.state.retiredWorktreeNamesByNamespace?.[namespaceKey]
|
||||
return stored
|
||||
? { exhaustedTiers: stored.exhaustedTiers, names: [...stored.names] }
|
||||
: EMPTY_RETIRED_NAME_REGISTRY
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
@@ -7291,6 +7301,30 @@ export class Store {
|
||||
return incoming.size > 0 && this.applyRetiredWorktreeNames(repoId, incoming)
|
||||
}
|
||||
|
||||
mergeRetiredWorktreeNamesForNamespace(namespaceKey: string, names: Iterable<string>): boolean {
|
||||
if (!namespaceKey) {
|
||||
return false
|
||||
}
|
||||
const normalized = new Set<string>()
|
||||
for (const name of names) {
|
||||
const candidate = normalizeRetirableGeneratedName(name)
|
||||
if (candidate) {
|
||||
normalized.add(candidate)
|
||||
}
|
||||
}
|
||||
const next = addRetiredNames(
|
||||
this.getRetiredWorktreeNameRegistryForNamespace(namespaceKey),
|
||||
normalized
|
||||
)
|
||||
if (!next) {
|
||||
return false
|
||||
}
|
||||
this.state.retiredWorktreeNamesByNamespace ??= {}
|
||||
this.state.retiredWorktreeNamesByNamespace[namespaceKey] = next
|
||||
this.scheduleSave()
|
||||
return true
|
||||
}
|
||||
|
||||
private applyRetiredWorktreeNames(repoId: string, names: Iterable<string>): boolean {
|
||||
const next = addRetiredNames(this.getRetiredWorktreeNameRegistry(repoId), names)
|
||||
if (!next) {
|
||||
|
||||
@@ -28,6 +28,7 @@ const ORIGIN_REMOTE_URL = 'git@example.com:group/repo.git'
|
||||
const ORIGIN_HEAD_COMPONENT = reviewHeadRemoteRefComponent('origin', ORIGIN_REMOTE_URL)
|
||||
import { detectAgentStatusFromTitle, MAX_OSC_TITLE_CHARS } from '../../shared/agent-detection'
|
||||
import {
|
||||
addSparseWorktree,
|
||||
addWorktree,
|
||||
assertWorktreeCleanForRemoval,
|
||||
listWorktrees,
|
||||
@@ -216,6 +217,7 @@ vi.mock('../ipc/filesystem-watcher', () => ({
|
||||
|
||||
const {
|
||||
MOCK_GIT_WORKTREES,
|
||||
addSparseWorktreeMock,
|
||||
addWorktreeMock,
|
||||
removeWorktreeMock,
|
||||
forceDeleteLocalBranchMock,
|
||||
@@ -312,6 +314,7 @@ const {
|
||||
isMainWorktree: false
|
||||
}
|
||||
],
|
||||
addSparseWorktreeMock: vi.fn(),
|
||||
addWorktreeMock: vi.fn(),
|
||||
removeWorktreeMock: vi.fn(),
|
||||
forceDeleteLocalBranchMock: vi.fn(),
|
||||
@@ -412,6 +415,7 @@ vi.mock('../git/worktree', () => ({
|
||||
listWorktrees: vi.fn().mockResolvedValue(MOCK_GIT_WORKTREES),
|
||||
listWorktreesStrict: vi.fn().mockResolvedValue(MOCK_GIT_WORKTREES),
|
||||
assertWorktreeCleanForRemoval: vi.fn().mockResolvedValue(undefined),
|
||||
addSparseWorktree: addSparseWorktreeMock,
|
||||
addWorktree: addWorktreeMock,
|
||||
removeWorktree: removeWorktreeMock,
|
||||
forceDeleteLocalBranch: forceDeleteLocalBranchMock
|
||||
@@ -677,6 +681,7 @@ function resetRuntimeTestMocks(): void {
|
||||
}
|
||||
})
|
||||
vi.mocked(addWorktree).mockReset()
|
||||
vi.mocked(addSparseWorktree).mockReset()
|
||||
vi.mocked(assertWorktreeCleanForRemoval).mockReset()
|
||||
vi.mocked(assertWorktreeCleanForRemoval).mockResolvedValue(undefined)
|
||||
vi.mocked(removeWorktree).mockReset()
|
||||
@@ -4297,13 +4302,18 @@ describe('OrcaRuntimeService', () => {
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
addRetiredWorktreeName,
|
||||
getRetiredWorktreeNameRegistry: () => ({ exhaustedTiers: 0, names: ['nautilus'] })
|
||||
getRetiredWorktreeNameRegistry: () => ({
|
||||
exhaustedTiers: 0,
|
||||
names: Array.from({ length: 100 }, (_unused, index) =>
|
||||
index === 0 ? 'nautilus' : `nautilus-${index + 1}`
|
||||
)
|
||||
})
|
||||
})
|
||||
const staleScan = deferred<typeof MOCK_GIT_WORKTREES>()
|
||||
const createdWorktree = {
|
||||
path: '/tmp/workspaces/repo-nautilus-2',
|
||||
path: '/tmp/workspaces/repo-nautilus-101',
|
||||
head: 'def',
|
||||
branch: 'nautilus-2',
|
||||
branch: 'nautilus-101',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
@@ -4334,7 +4344,47 @@ describe('OrcaRuntimeService', () => {
|
||||
worktrees: expect.arrayContaining([expect.objectContaining({ path: createdWorktree.path })])
|
||||
}
|
||||
)
|
||||
expect(addRetiredWorktreeName).toHaveBeenCalledWith(TEST_REPO_ID, 'nautilus-2')
|
||||
expect(addRetiredWorktreeName).toHaveBeenCalledWith(TEST_REPO_ID, 'nautilus-101')
|
||||
})
|
||||
|
||||
it('retires a generated name before a post-create listing failure', async () => {
|
||||
const addRetiredWorktreeName = vi.fn()
|
||||
const runtime = new OrcaRuntimeService({ ...store, addRetiredWorktreeName })
|
||||
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/nautilus')
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/nautilus')
|
||||
vi.mocked(listWorktrees).mockRejectedValueOnce(new Error('listing unavailable'))
|
||||
|
||||
await expect(
|
||||
runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'nautilus',
|
||||
nameWasGenerated: true
|
||||
})
|
||||
).rejects.toThrow('listing unavailable')
|
||||
|
||||
expect(addWorktree).toHaveBeenCalled()
|
||||
expect(addRetiredWorktreeName).toHaveBeenCalledWith(TEST_REPO_ID, 'nautilus')
|
||||
})
|
||||
|
||||
it('retires a generated sparse name when creation rollback also fails', async () => {
|
||||
const addRetiredWorktreeName = vi.fn()
|
||||
const runtime = new OrcaRuntimeService({ ...store, addRetiredWorktreeName })
|
||||
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/nautilus')
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/nautilus')
|
||||
vi.mocked(addSparseWorktree).mockRejectedValueOnce(
|
||||
Object.assign(new Error('sparse setup failed'), { cleanupFailed: true })
|
||||
)
|
||||
|
||||
await expect(
|
||||
runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'nautilus',
|
||||
nameWasGenerated: true,
|
||||
sparseCheckout: { directories: ['packages/web'] }
|
||||
})
|
||||
).rejects.toThrow('sparse setup failed')
|
||||
|
||||
expect(addRetiredWorktreeName).toHaveBeenCalledWith(TEST_REPO_ID, 'nautilus')
|
||||
})
|
||||
|
||||
it('neither skips nor retires a name the user typed', async () => {
|
||||
@@ -44878,12 +44928,12 @@ describe('OrcaRuntimeService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('detects agents on the SSH host before launching remote startup drafts', async () => {
|
||||
it('forwards generated-name provenance while launching SSH startup drafts', async () => {
|
||||
detectRemoteAgentsMock.mockResolvedValue(['claude'])
|
||||
const created = {
|
||||
path: '/remote/mobile-startup-draft',
|
||||
path: '/remote/repo-nautilus-2',
|
||||
head: 'def',
|
||||
branch: 'refs/heads/mobile-startup-draft',
|
||||
branch: 'refs/heads/nautilus-2',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
@@ -44913,6 +44963,7 @@ describe('OrcaRuntimeService', () => {
|
||||
defaultTuiAgent: null,
|
||||
agentCmdOverrides: {}
|
||||
}),
|
||||
getRetiredWorktreeNameRegistry: () => ({ exhaustedTiers: 0, names: ['nautilus'] }),
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
|
||||
@@ -44956,15 +45007,22 @@ describe('OrcaRuntimeService', () => {
|
||||
const draftUrl = 'https://github.com/stablyai/orca/pull/456'
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: TEST_REPO_ID,
|
||||
name: 'mobile-startup-draft',
|
||||
name: 'nautilus',
|
||||
nameWasGenerated: true,
|
||||
startupDraft: draftUrl
|
||||
})
|
||||
|
||||
expect(detectRemoteAgentsMock).toHaveBeenCalledWith({ connectionId: 'ssh-1' })
|
||||
expect(detectInstalledAgentsWithShellPathHydrationMock).not.toHaveBeenCalled()
|
||||
expect(provider.addWorktree).toHaveBeenCalledWith(
|
||||
'/remote/repo',
|
||||
'nautilus-2',
|
||||
'/remote/repo-nautilus-2',
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: '/remote/mobile-startup-draft',
|
||||
cwd: '/remote/repo-nautilus-2',
|
||||
command: `claude '--dangerously-skip-permissions' --prefill '${draftUrl}'`,
|
||||
connectionId: 'ssh-1',
|
||||
worktreeId: result.worktree.id
|
||||
|
||||
@@ -962,12 +962,15 @@ import {
|
||||
} from '../ipc/worktree-remote'
|
||||
import {
|
||||
getBranchNameOverrideCandidate,
|
||||
getGeneratedWorktreeCreateCandidate,
|
||||
getWorktreeCreateCandidate,
|
||||
isGeneratedWorktreeCreateName,
|
||||
WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS
|
||||
} from '../worktree-create-candidates'
|
||||
import {
|
||||
ensureRetiredWorktreeNamesBackfilled,
|
||||
getRetiredNameRegistryForRepo
|
||||
failedWorktreeCreationNeedsRetirement,
|
||||
getRetiredNameRegistryForRepo,
|
||||
retireGeneratedWorktreeName
|
||||
} from '../worktree-name-retirement'
|
||||
import {
|
||||
createRetiredNameLookup,
|
||||
@@ -21348,12 +21351,7 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
const repo = await this.resolveRepoSelector(repoSelector)
|
||||
const settings = store.getSettings()
|
||||
try {
|
||||
await ensureRetiredWorktreeNamesBackfilled(store, repo, settings)
|
||||
} catch (error) {
|
||||
console.warn(`[runtime] retirement backfill failed for repo ${repo.id}:`, error)
|
||||
}
|
||||
const registry: RetiredNameRegistry = getRetiredNameRegistryForRepo(
|
||||
const registry: RetiredNameRegistry = await getRetiredNameRegistryForRepo(
|
||||
store,
|
||||
repo,
|
||||
store.getRepos(),
|
||||
@@ -22570,21 +22568,35 @@ export class OrcaRuntimeService {
|
||||
let branchConflictKind: 'local' | 'remote' | null = null
|
||||
let worktreePath = ''
|
||||
let worktreePathResolved = false
|
||||
const isRetiredName = args.nameWasGenerated
|
||||
? createRetiredNameLookup(
|
||||
getRetiredNameRegistryForRepo(this.store, repo, this.store.getRepos(), settings)
|
||||
)
|
||||
const shouldRetireGeneratedName =
|
||||
args.nameWasGenerated === true && isGeneratedWorktreeCreateName(sanitizedName)
|
||||
const retiredNameRegistry = shouldRetireGeneratedName
|
||||
? await getRetiredNameRegistryForRepo(this.store, repo, this.store.getRepos(), settings)
|
||||
: null
|
||||
const isRetiredName = retiredNameRegistry ? createRetiredNameLookup(retiredNameRegistry) : null
|
||||
// Why: runtime/mobile create-from-review callers should get a new workspace
|
||||
// even when the PR branch or review branch name is already in use.
|
||||
for (let suffix = 1; suffix <= WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS; suffix += 1) {
|
||||
effectiveSanitizedName = getWorktreeCreateCandidate(sanitizedName, suffix)
|
||||
effectiveRequestedName = args.name.trim()
|
||||
? getWorktreeCreateCandidate(args.name, suffix)
|
||||
: effectiveSanitizedName
|
||||
for (
|
||||
let suffix = 1, attempts = 0;
|
||||
attempts < WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS;
|
||||
suffix += 1
|
||||
) {
|
||||
effectiveSanitizedName = shouldRetireGeneratedName
|
||||
? getGeneratedWorktreeCreateCandidate(
|
||||
sanitizedName,
|
||||
suffix,
|
||||
retiredNameRegistry?.exhaustedTiers
|
||||
)
|
||||
: getWorktreeCreateCandidate(sanitizedName, suffix)
|
||||
effectiveRequestedName = shouldRetireGeneratedName
|
||||
? effectiveSanitizedName
|
||||
: args.name.trim()
|
||||
? getWorktreeCreateCandidate(args.name, suffix)
|
||||
: effectiveSanitizedName
|
||||
if (isRetiredName?.(effectiveSanitizedName)) {
|
||||
continue
|
||||
}
|
||||
attempts += 1
|
||||
branchName = await resolveCreateBranchName(
|
||||
repo.path,
|
||||
selectedExistingLocalBranchName ??
|
||||
@@ -22786,19 +22798,11 @@ export class OrcaRuntimeService {
|
||||
...(suggestLocalBaseRefUpdate ? { suggestLocalBaseRefUpdate } : {})
|
||||
}
|
||||
const defaultAddWorktreeOption = addProjectGitOptions()
|
||||
const addResult: AddWorktreeResult =
|
||||
(await (sparseDirectories.length > 0
|
||||
? checkoutExistingBranch
|
||||
? addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
addProjectGitOptions(existingBranchOption)
|
||||
)
|
||||
: suggestLocalBaseRefUpdate
|
||||
let addResult: AddWorktreeResult
|
||||
try {
|
||||
addResult =
|
||||
(await (sparseDirectories.length > 0
|
||||
? checkoutExistingBranch
|
||||
? addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
@@ -22806,9 +22810,9 @@ export class OrcaRuntimeService {
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate })
|
||||
addProjectGitOptions(existingBranchOption)
|
||||
)
|
||||
: remoteTrackingBaseOption
|
||||
: suggestLocalBaseRefUpdate
|
||||
? addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
@@ -22816,9 +22820,9 @@ export class OrcaRuntimeService {
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
addProjectGitOptions(remoteTrackingBaseOption)
|
||||
addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate })
|
||||
)
|
||||
: defaultAddWorktreeOption
|
||||
: remoteTrackingBaseOption
|
||||
? addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
@@ -22826,27 +22830,27 @@ export class OrcaRuntimeService {
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
defaultAddWorktreeOption
|
||||
addProjectGitOptions(remoteTrackingBaseOption)
|
||||
)
|
||||
: addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||||
)
|
||||
: checkoutExistingBranch
|
||||
? addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
false,
|
||||
addProjectGitOptions(existingBranchOption)
|
||||
)
|
||||
: suggestLocalBaseRefUpdate
|
||||
: defaultAddWorktreeOption
|
||||
? addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
defaultAddWorktreeOption
|
||||
)
|
||||
: addSparseWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
sparseDirectories,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||||
)
|
||||
: checkoutExistingBranch
|
||||
? addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
@@ -22854,9 +22858,9 @@ export class OrcaRuntimeService {
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
false,
|
||||
addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate })
|
||||
addProjectGitOptions(existingBranchOption)
|
||||
)
|
||||
: remoteTrackingBaseOption
|
||||
: suggestLocalBaseRefUpdate
|
||||
? addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
@@ -22864,9 +22868,9 @@ export class OrcaRuntimeService {
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
false,
|
||||
addProjectGitOptions(remoteTrackingBaseOption)
|
||||
addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate })
|
||||
)
|
||||
: defaultAddWorktreeOption
|
||||
: remoteTrackingBaseOption
|
||||
? addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
@@ -22874,15 +22878,36 @@ export class OrcaRuntimeService {
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
false,
|
||||
defaultAddWorktreeOption
|
||||
addProjectGitOptions(remoteTrackingBaseOption)
|
||||
)
|
||||
: addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||||
))) ?? {}
|
||||
: defaultAddWorktreeOption
|
||||
? addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate,
|
||||
false,
|
||||
defaultAddWorktreeOption
|
||||
)
|
||||
: addWorktree(
|
||||
repo.path,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseBranch,
|
||||
settings.refreshLocalBaseRefOnWorktreeCreate
|
||||
))) ?? {}
|
||||
} catch (error) {
|
||||
if (shouldRetireGeneratedName && failedWorktreeCreationNeedsRetirement(error)) {
|
||||
await retireGeneratedWorktreeName(this.store, repo, settings, effectiveSanitizedName)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// Why: fallible metadata work after creation must not leave a real workspace name reusable.
|
||||
if (shouldRetireGeneratedName) {
|
||||
await retireGeneratedWorktreeName(this.store, repo, settings, effectiveSanitizedName)
|
||||
}
|
||||
|
||||
let configuredPushTarget: GitPushTarget | undefined
|
||||
if (preparedPushTarget) {
|
||||
@@ -22904,10 +22929,6 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
const worktreeId = `${repo.id}::${created.path}`
|
||||
// Why: repo-qualified layouts decorate the physical leaf; retire the name main actually used.
|
||||
if (args.nameWasGenerated) {
|
||||
this.store.addRetiredWorktreeName(repo.id, effectiveSanitizedName)
|
||||
}
|
||||
const now = Date.now()
|
||||
// Why: PR/MR-created worktrees can start from a head ref/SHA while Source
|
||||
// Control must compare against the review target branch.
|
||||
@@ -23355,6 +23376,7 @@ export class OrcaRuntimeService {
|
||||
repo: Repo,
|
||||
args: {
|
||||
name: string
|
||||
nameWasGenerated?: boolean
|
||||
baseBranch?: string
|
||||
compareBaseRef?: string
|
||||
branchNameOverride?: string
|
||||
@@ -23406,6 +23428,7 @@ export class OrcaRuntimeService {
|
||||
{
|
||||
repoId: repo.id,
|
||||
name: args.name,
|
||||
...(args.nameWasGenerated === true ? { nameWasGenerated: true } : {}),
|
||||
...(args.displayName ? { displayName: args.displayName } : {}),
|
||||
...(args.baseBranch ? { baseBranch: args.baseBranch } : {}),
|
||||
...(args.compareBaseRef ? { compareBaseRef: args.compareBaseRef } : {}),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getGeneratedWorktreeCreateCandidate,
|
||||
isGeneratedWorktreeCreateName
|
||||
} from './worktree-create-candidates'
|
||||
|
||||
describe('generated worktree create candidates', () => {
|
||||
it('advances canonical tiers instead of nesting collision suffixes', () => {
|
||||
expect(getGeneratedWorktreeCreateCandidate('nautilus-2', 1)).toBe('nautilus-2')
|
||||
expect(getGeneratedWorktreeCreateCandidate('nautilus-2', 2)).toBe('nautilus-3')
|
||||
expect(getGeneratedWorktreeCreateCandidate('nautilus-2', 99)).toBe('nautilus-100')
|
||||
expect(getGeneratedWorktreeCreateCandidate('nautilus', 1, 100)).toBe('nautilus-101')
|
||||
})
|
||||
|
||||
it('canonicalizes legacy nested generated suffixes', () => {
|
||||
expect(isGeneratedWorktreeCreateName('nautilus')).toBe(true)
|
||||
expect(isGeneratedWorktreeCreateName('nautilus-2')).toBe(true)
|
||||
expect(isGeneratedWorktreeCreateName('nautilus-2-3')).toBe(true)
|
||||
expect(getGeneratedWorktreeCreateCandidate('nautilus-2-3', 1)).toBe('nautilus-4')
|
||||
expect(isGeneratedWorktreeCreateName('fix-login')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,24 @@
|
||||
import {
|
||||
getGeneratedWorktreeCreateRetryCandidate,
|
||||
isGeneratedWorktreeCreateName
|
||||
} from '../shared/new-workspace/worktree-create-retry-policy'
|
||||
|
||||
export { isGeneratedWorktreeCreateName }
|
||||
|
||||
export const WORKTREE_CREATE_MAX_SUFFIX_ATTEMPTS = 100
|
||||
|
||||
export function getWorktreeCreateCandidate(value: string, suffix: number): string {
|
||||
return suffix === 1 ? value : `${value}-${suffix}`
|
||||
}
|
||||
|
||||
export function getGeneratedWorktreeCreateCandidate(
|
||||
value: string,
|
||||
suffix: number,
|
||||
exhaustedTiers = 0
|
||||
): string {
|
||||
return getGeneratedWorktreeCreateRetryCandidate(value, suffix - 1, exhaustedTiers + 1)
|
||||
}
|
||||
|
||||
export function getBranchNameOverrideCandidate(
|
||||
branchNameOverride: string | undefined,
|
||||
suffix: number
|
||||
|
||||
@@ -35,12 +35,9 @@ describe('normalizeRetirableGeneratedName', () => {
|
||||
expect(normalizeRetirableGeneratedName(`${FIRST}-100`)).toBe(`${FIRST}-100`)
|
||||
})
|
||||
|
||||
it('retires a suffixed name that itself took a collision suffix', () => {
|
||||
// Once the pool is spent the suggester emits `<name>-2`; a collision on that yields
|
||||
// `<name>-2-3`. Stripping only one suffix leaves `<name>-2`, which is not a pool name, so
|
||||
// retirement would no-op at exactly the tier where every base name is already gone.
|
||||
expect(normalizeRetirableGeneratedName(`${FIRST}-2-3`)).toBe(`${FIRST}-2-3`)
|
||||
expect(normalizeRetirableGeneratedName(`${FIRST}-2-3-4`)).toBe(`${FIRST}-2-3-4`)
|
||||
it('rejects legacy repeated suffixes that the canonical collision sequence never emits', () => {
|
||||
expect(normalizeRetirableGeneratedName(`${FIRST}-2-3`)).toBeNull()
|
||||
expect(normalizeRetirableGeneratedName(`${FIRST}-2-3-4`)).toBeNull()
|
||||
expect(normalizeRetirableGeneratedName('fix-login-2-3')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -122,6 +119,18 @@ describe('discoverRetiredWorktreeNames', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('matches an NFC bucket for an NFD workspace root', async () => {
|
||||
const nfdRoot = '/Users/ada/cafe\u0301'
|
||||
await withFakeHome([`-Users-ada-caf--${FIRST}`], async (home) => {
|
||||
const retired = await discoverRetiredWorktreeNames({
|
||||
workspaceRoots: [nfdRoot],
|
||||
home,
|
||||
env: {}
|
||||
})
|
||||
expect(retired).toEqual(new Set([FIRST]))
|
||||
})
|
||||
})
|
||||
|
||||
it('matches a dot-directory root, where the separator run encodes to two dashes', async () => {
|
||||
await withFakeHome([`-Users-ada--orca-worktrees-${FIRST}`], async (home) => {
|
||||
const retired = await discoverRetiredWorktreeNames({
|
||||
@@ -214,6 +223,7 @@ describe('getRetiredNameRegistryForRepo', () => {
|
||||
const calls: string[] = []
|
||||
return {
|
||||
calls,
|
||||
mergeRetiredWorktreeNames: () => false,
|
||||
getRetiredWorktreeNameRegistry: (repoId: string) => {
|
||||
calls.push(repoId)
|
||||
return { exhaustedTiers: 0, names: byRepo[repoId] ?? [] }
|
||||
@@ -221,28 +231,28 @@ describe('getRetiredNameRegistryForRepo', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it('shares retirements when two repos create into the same cwd namespace', () => {
|
||||
it('shares retirements when two repos create into the same cwd namespace', async () => {
|
||||
const store = storeOf({ 'repo-a': [FIRST], 'repo-b': [SECOND] })
|
||||
const repos = [makeRepo('repo-a', '/repos/a'), makeRepo('repo-b', '/repos/b')]
|
||||
|
||||
expect(
|
||||
[...getRetiredNameRegistryForRepo(store, repos[1], repos, settingsFor(false)).names].sort()
|
||||
[
|
||||
...(await getRetiredNameRegistryForRepo(store, repos[1], repos, settingsFor(false))).names
|
||||
].sort()
|
||||
).toEqual([FIRST, SECOND].sort())
|
||||
})
|
||||
|
||||
it('keeps independent nested repo paths in separate retirement domains', () => {
|
||||
it('keeps independent nested repo paths in separate retirement domains', async () => {
|
||||
const store = storeOf({ 'repo-a': [FIRST], 'repo-b': [SECOND] })
|
||||
const repos = [makeRepo('repo-a', '/repos/a'), makeRepo('repo-b', '/repos/b')]
|
||||
|
||||
expect(getRetiredNameRegistryForRepo(store, repos[1], repos, settingsFor(true))).toEqual({
|
||||
expect(await getRetiredNameRegistryForRepo(store, repos[1], repos, settingsFor(true))).toEqual({
|
||||
exhaustedTiers: 0,
|
||||
names: [SECOND]
|
||||
})
|
||||
})
|
||||
|
||||
it('never probes a path for peers that hold no retirements', () => {
|
||||
// Why: deriving a peer's namespace runs computeWorktreePath, which for a WSL repo is a blocking
|
||||
// wsl.exe call. Reading `path` is exactly what a probe does, so count that.
|
||||
it('never probes a path for peers that hold no retirements', async () => {
|
||||
const pathReads: string[] = []
|
||||
const spyRepo = (id: string, path: string): Repo => {
|
||||
const repo = makeRepo(id, path)
|
||||
@@ -259,19 +269,22 @@ describe('getRetiredNameRegistryForRepo', () => {
|
||||
...Array.from({ length: 20 }, (_unused, index) => spyRepo(`peer-${index}`, `/repos/${index}`))
|
||||
]
|
||||
|
||||
expect(getRetiredNameRegistryForRepo(store, repos[0], repos, settingsFor(false))).toEqual({
|
||||
exhaustedTiers: 0,
|
||||
names: [FIRST]
|
||||
})
|
||||
expect(await getRetiredNameRegistryForRepo(store, repos[0], repos, settingsFor(false))).toEqual(
|
||||
{
|
||||
exhaustedTiers: 0,
|
||||
names: [FIRST]
|
||||
}
|
||||
)
|
||||
expect(pathReads.filter((id) => id.startsWith('peer-'))).toEqual([])
|
||||
})
|
||||
|
||||
it('reports nothing for a folder workspace, which has no generated worktree names', () => {
|
||||
it('reports nothing for a folder workspace, which has no generated worktree names', async () => {
|
||||
const store = storeOf({ folder: [FIRST] })
|
||||
const folderRepo = { ...makeRepo('folder', '/repos/folder'), kind: 'folder' as const }
|
||||
|
||||
expect(
|
||||
getRetiredNameRegistryForRepo(store, folderRepo, [folderRepo], settingsFor(false)).names
|
||||
(await getRetiredNameRegistryForRepo(store, folderRepo, [folderRepo], settingsFor(false)))
|
||||
.names
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { getRepoExecutionHostId } from '../shared/execution-host'
|
||||
import {
|
||||
CREATURE_POOL_NAMES,
|
||||
creatureNameTier,
|
||||
EMPTY_RETIRED_NAME_REGISTRY,
|
||||
isEmptyRetiredNameRegistry,
|
||||
mergeRetiredNameRegistries,
|
||||
@@ -13,12 +13,11 @@ import { isFolderRepo } from '../shared/repo-kind'
|
||||
import type { GlobalSettings } from '../shared/global-settings-types'
|
||||
import type { Repo } from '../shared/repo-types'
|
||||
import {
|
||||
encodeClaudeProjectPath,
|
||||
encodeClaudeProjectPaths,
|
||||
isClaudeProjectDirInScope
|
||||
} from './ai-vault/claude-project-dir-encoding'
|
||||
import {
|
||||
computeRemoteWorktreePath,
|
||||
computeWorktreePath,
|
||||
computeWorktreePathAsync,
|
||||
getWorktreePathSettings,
|
||||
hasRepoWorktreeBasePath
|
||||
@@ -31,29 +30,28 @@ const scansByStore = new WeakMap<object, Map<string, Promise<Set<string>>>>()
|
||||
|
||||
type RetirementReadStore = {
|
||||
getRetiredWorktreeNameRegistry(repoId: string): RetiredNameRegistry
|
||||
getRetiredWorktreeNameRegistryForNamespace?(namespaceKey: string): RetiredNameRegistry
|
||||
}
|
||||
type RetirementBackfillStore = {
|
||||
mergeRetiredWorktreeNames(repoId: string, names: Iterable<string>): boolean
|
||||
}
|
||||
type RetirementWriteStore = {
|
||||
addRetiredWorktreeName(repoId: string, name: string): void
|
||||
mergeRetiredWorktreeNamesForNamespace?(namespaceKey: string, names: Iterable<string>): boolean
|
||||
}
|
||||
type RetirementPathSettings = Pick<GlobalSettings, 'nestWorkspaces' | 'workspaceDir'>
|
||||
|
||||
/** Strips EVERY trailing numeric suffix, not just one. Once the pool is spent the suggester emits
|
||||
* `nautilus-2`, and a collision on that yields `nautilus-2-3` — which a single strip leaves as
|
||||
* `nautilus-2`, not a pool name, so retirement would silently no-op on exactly the tier where the
|
||||
* pool is already exhausted. Deliberately not shared with `isAutoGeneratedCreatureBranchName`:
|
||||
* auto-rename eligibility is a different question and its single-strip behavior is intended. */
|
||||
/** Only canonical generator output is persisted. Collision retries advance canonical tiers, so a
|
||||
* repeat-suffixed path can never be generated again and needs no permanent registry entry. */
|
||||
export function normalizeRetirableGeneratedName(name: string): string | null {
|
||||
const normalized = name.trim().toLowerCase()
|
||||
if (normalized.length > 256) {
|
||||
return null
|
||||
}
|
||||
let base = normalized
|
||||
let stripped = base.replace(/-\d+$/, '')
|
||||
while (stripped !== base) {
|
||||
base = stripped
|
||||
stripped = base.replace(/-\d+$/, '')
|
||||
}
|
||||
return base && CREATURE_POOL_NAMES.has(base) ? normalized : null
|
||||
return normalized.length <= 256 && creatureNameTier(normalized) !== null ? normalized : null
|
||||
}
|
||||
|
||||
/** A sparse create error carries this marker only when its rollback also failed, leaving the path
|
||||
* occupied even though creation rejected. */
|
||||
export function failedWorktreeCreationNeedsRetirement(error: unknown): boolean {
|
||||
return typeof error === 'object' && error !== null && Reflect.get(error, 'cleanupFailed') === true
|
||||
}
|
||||
|
||||
/** Why: over-retiring costs one name from a 552-entry pool; under-retiring reissues a path whose
|
||||
@@ -95,17 +93,33 @@ export function extractBucketLeafCandidates(
|
||||
return []
|
||||
}
|
||||
|
||||
function getRetirementProbePath(repo: Repo, settings: RetirementPathSettings): string {
|
||||
async function getRetirementProbePath(
|
||||
repo: Repo,
|
||||
settings: RetirementPathSettings
|
||||
): Promise<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)
|
||||
: computeWorktreePathAsync(RETIREMENT_PROBE_NAME, repo.path, pathSettings)
|
||||
}
|
||||
|
||||
// Why: for a WSL repo the probe path costs a blocking `wsl.exe` call, and the create path plus
|
||||
// every suggestion refresh asks for the same key. Inputs are all the key derives from.
|
||||
export function getRemoteRetirementNamespaceKey(
|
||||
repo: Repo,
|
||||
settings: RetirementPathSettings
|
||||
): string | null {
|
||||
if (!repo.connectionId) {
|
||||
return null
|
||||
}
|
||||
const pathSettings = getWorktreePathSettings(repo, settings)
|
||||
const probePath = computeRemoteWorktreePath(RETIREMENT_PROBE_NAME, repo.path, pathSettings, {
|
||||
useConfiguredAbsolutePath: hasRepoWorktreeBasePath(repo)
|
||||
})
|
||||
return `${getRepoExecutionHostId(repo)}:${worktreePathComparisonKey(probePath)}`
|
||||
}
|
||||
|
||||
// Why: the create path and every suggestion refresh ask for the same namespace identity.
|
||||
const COLLISION_KEY_CACHE_MAX = 512
|
||||
const collisionKeyCache = new Map<string, string>()
|
||||
|
||||
@@ -115,7 +129,10 @@ export function resetRetirementCollisionKeyCacheForTests(): void {
|
||||
|
||||
/** Identifies the cwd namespace a repo creates workspaces into. Two repos that would place a
|
||||
* workspace of the same name at the same path share retirements; independent paths do not. */
|
||||
export function getRetirementCollisionKey(repo: Repo, settings: RetirementPathSettings): string {
|
||||
async function getRetirementCollisionKey(
|
||||
repo: Repo,
|
||||
settings: RetirementPathSettings
|
||||
): Promise<string> {
|
||||
const hostId = getRepoExecutionHostId(repo)
|
||||
const cacheKey = [
|
||||
hostId,
|
||||
@@ -128,7 +145,9 @@ export function getRetirementCollisionKey(repo: Repo, settings: RetirementPathSe
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
const key = `${hostId}:${worktreePathComparisonKey(getRetirementProbePath(repo, settings))}`
|
||||
const key =
|
||||
getRemoteRetirementNamespaceKey(repo, settings) ??
|
||||
`${hostId}:${worktreePathComparisonKey(await getRetirementProbePath(repo, settings))}`
|
||||
// Why not always cache: only the WSL home *success* path is cached upstream, so a stopped distro
|
||||
// yields a fallback namespace. Memoizing that would strand the repo there for the whole session.
|
||||
const wsl = parseWslPath(repo.path)
|
||||
@@ -146,19 +165,30 @@ export function getRetirementCollisionKey(repo: Repo, settings: RetirementPathSe
|
||||
* namespace — that is where a reissued name would actually collide.
|
||||
*
|
||||
* The peer scan is deliberately lazy: a repo's own retirements never touch a path, and a peer's
|
||||
* namespace is derived only once that peer is known to hold retirements. Deriving one costs a
|
||||
* blocking `wsl.exe` call for a WSL repo, so it must not be paid per repo on every create. */
|
||||
export function getRetiredNameRegistryForRepo(
|
||||
store: RetirementReadStore,
|
||||
* namespace is derived only once that peer is known to hold retirements. */
|
||||
export async function getRetiredNameRegistryForRepo(
|
||||
store: RetirementReadStore & RetirementBackfillStore,
|
||||
repo: Repo,
|
||||
repos: readonly Repo[],
|
||||
settings: RetirementPathSettings
|
||||
): RetiredNameRegistry {
|
||||
): Promise<RetiredNameRegistry> {
|
||||
if (isFolderRepo(repo)) {
|
||||
return EMPTY_RETIRED_NAME_REGISTRY
|
||||
}
|
||||
let registry = store.getRetiredWorktreeNameRegistry(repo.id)
|
||||
let collisionKey: string | null = null
|
||||
try {
|
||||
collisionKey = await ensureRetiredWorktreeNamesBackfilled(store, repo, settings)
|
||||
} catch (error) {
|
||||
console.warn(`[worktrees] retirement backfill failed for repo ${repo.id}:`, error)
|
||||
}
|
||||
let registry = store.getRetiredWorktreeNameRegistry(repo.id)
|
||||
if (repo.connectionId && store.getRetiredWorktreeNameRegistryForNamespace) {
|
||||
collisionKey ??= await getRetirementCollisionKey(repo, settings)
|
||||
registry = mergeRetiredNameRegistries(
|
||||
registry,
|
||||
store.getRetiredWorktreeNameRegistryForNamespace(collisionKey)
|
||||
)
|
||||
}
|
||||
for (const candidate of repos) {
|
||||
if (candidate.id === repo.id || isFolderRepo(candidate)) {
|
||||
continue
|
||||
@@ -167,8 +197,8 @@ export function getRetiredNameRegistryForRepo(
|
||||
if (isEmptyRetiredNameRegistry(candidateRegistry)) {
|
||||
continue
|
||||
}
|
||||
collisionKey ??= getRetirementCollisionKey(repo, settings)
|
||||
if (getRetirementCollisionKey(candidate, settings) !== collisionKey) {
|
||||
collisionKey ??= await getRetirementCollisionKey(repo, settings)
|
||||
if ((await getRetirementCollisionKey(candidate, settings)) !== collisionKey) {
|
||||
continue
|
||||
}
|
||||
registry = mergeRetiredNameRegistries(registry, candidateRegistry)
|
||||
@@ -176,6 +206,24 @@ export function getRetiredNameRegistryForRepo(
|
||||
return registry
|
||||
}
|
||||
|
||||
export async function retireGeneratedWorktreeName(
|
||||
store: RetirementWriteStore,
|
||||
repo: Repo,
|
||||
settings: RetirementPathSettings,
|
||||
name: string
|
||||
): Promise<void> {
|
||||
store.addRetiredWorktreeName(repo.id, name)
|
||||
if (!repo.connectionId || !store.mergeRetiredWorktreeNamesForNamespace) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const namespaceKey = await getRetirementCollisionKey(repo, settings)
|
||||
store.mergeRetiredWorktreeNamesForNamespace(namespaceKey, [name])
|
||||
} catch (error) {
|
||||
console.warn(`[worktrees] failed to persist remote retirement namespace for ${repo.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
function parentPath(path: string): string {
|
||||
const trimmed = path.replace(/[/\\]+$/, '')
|
||||
const separatorIndex = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
||||
@@ -189,11 +237,11 @@ export async function ensureRetiredWorktreeNamesBackfilled(
|
||||
store: RetirementBackfillStore,
|
||||
repo: Repo,
|
||||
settings: RetirementPathSettings
|
||||
): Promise<void> {
|
||||
): Promise<string | null> {
|
||||
// Remote workspaces keep their agent state on the execution host, which this scan cannot see, so
|
||||
// a re-added SSH repo does not recover its retirements the way a local one does.
|
||||
if (isFolderRepo(repo) || repo.connectionId) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
const probePath = await computeWorktreePathAsync(
|
||||
RETIREMENT_PROBE_NAME,
|
||||
@@ -214,6 +262,7 @@ export async function ensureRetiredWorktreeNamesBackfilled(
|
||||
// Why the merge sits outside the cached scan: the scan is per cwd namespace but the registry is
|
||||
// per repo, so every repo that asks must receive it — not only the one that triggered it.
|
||||
store.mergeRetiredWorktreeNames(repo.id, await scan)
|
||||
return scanKey
|
||||
}
|
||||
|
||||
async function readDirectoryNames(path: string): Promise<string[]> {
|
||||
@@ -257,7 +306,7 @@ export async function discoverRetiredWorktreeNames(args: {
|
||||
// filesystems, and a missed bucket reissues a live path while a spurious one costs one name.
|
||||
const encodedParents = args.workspaceRoots
|
||||
.filter((root) => root.length > 0)
|
||||
.map((root) => encodeClaudeProjectPath(root).toLowerCase())
|
||||
.flatMap((root) => encodeClaudeProjectPaths(root).map((path) => path.toLowerCase()))
|
||||
if (encodedParents.length > 0) {
|
||||
const projectsDir = getClaudeProjectsDir(args.home ?? homedir(), args.env ?? process.env)
|
||||
for (const bucket of await readDirectoryNames(projectsDir)) {
|
||||
|
||||
@@ -6244,6 +6244,33 @@ describe('worktree remote runtime mutations', () => {
|
||||
expect(store.getState().worktreesByRepo.repo1).toEqual([wt])
|
||||
})
|
||||
|
||||
it('forwards generated-name provenance through paired-runtime create', async () => {
|
||||
const store = createTestStore()
|
||||
const wt = makeWorktree({ id: 'repo1::/path/nautilus', repoId: 'repo1' })
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-create',
|
||||
ok: true,
|
||||
result: { worktree: wt },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
store.setState({
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
|
||||
worktreesByRepo: { repo1: [] }
|
||||
} as Partial<AppState>)
|
||||
const createWorktree = store.getState().createWorktree
|
||||
const args: Parameters<typeof createWorktree> = ['repo1', 'nautilus']
|
||||
args[25] = { nameWasGenerated: true }
|
||||
|
||||
await createWorktree(...args)
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'worktree.create',
|
||||
params: expect.objectContaining({ nameWasGenerated: true })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('persists Jira item and source context through paired-runtime create', async () => {
|
||||
const store = createTestStore()
|
||||
const wt = makeWorktree({
|
||||
|
||||
@@ -109,6 +109,7 @@ import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-w
|
||||
import {
|
||||
CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS,
|
||||
getClientWorktreeCreateCandidate,
|
||||
getGeneratedWorktreeCreateRetryCandidate,
|
||||
isRetryableWorktreeCreateConflict
|
||||
} from '../../../../shared/new-workspace/worktree-create-retry-policy'
|
||||
import {
|
||||
@@ -3941,7 +3942,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
const provisionedRoot = options?.provisionedRoot
|
||||
try {
|
||||
for (let attempt = 0; attempt < CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS; attempt += 1) {
|
||||
const candidateName = getClientWorktreeCreateCandidate(name, attempt)
|
||||
const candidateName = options?.nameWasGenerated
|
||||
? getGeneratedWorktreeCreateRetryCandidate(name, attempt)
|
||||
: getClientWorktreeCreateCandidate(name, attempt)
|
||||
// Why: older runtimes reject exact PR branch overrides on collision, so retry both branch and worktree names.
|
||||
const candidateBranchNameOverride = branchNameOverride
|
||||
? getClientWorktreeCreateCandidate(branchNameOverride, attempt)
|
||||
@@ -4020,6 +4023,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
{
|
||||
repo: repoId,
|
||||
name: candidateName,
|
||||
...(options?.nameWasGenerated ? { nameWasGenerated: true } : {}),
|
||||
baseBranch,
|
||||
...(compareBaseRef ? { compareBaseRef } : {}),
|
||||
...(candidateBranchNameOverride
|
||||
|
||||
@@ -430,6 +430,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
|
||||
folderWorkspaces: [],
|
||||
sparsePresetsByRepo: {},
|
||||
retiredWorktreeNamesByRepo: {},
|
||||
retiredWorktreeNamesByNamespace: {},
|
||||
worktreeMeta: {},
|
||||
worktreeLineageById: {},
|
||||
workspaceLineageByChildKey: {},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getClientWorktreeCreateCandidate,
|
||||
getGeneratedWorktreeCreateRetryCandidate,
|
||||
isRetryableWorktreeCreateConflict
|
||||
} from './worktree-create-retry-policy'
|
||||
|
||||
@@ -10,6 +11,12 @@ describe('client worktree create retry policy', () => {
|
||||
expect(getClientWorktreeCreateCandidate('feature', 1)).toBe('feature-2')
|
||||
})
|
||||
|
||||
it('keeps generated retries on canonical tiers', () => {
|
||||
expect(getGeneratedWorktreeCreateRetryCandidate('nautilus-2', 0)).toBe('nautilus-2')
|
||||
expect(getGeneratedWorktreeCreateRetryCandidate('nautilus-2', 1)).toBe('nautilus-3')
|
||||
expect(getGeneratedWorktreeCreateRetryCandidate('nautilus-2-3', 0)).toBe('nautilus-4')
|
||||
})
|
||||
|
||||
it('retries only known branch and review conflicts', () => {
|
||||
expect(isRetryableWorktreeCreateConflict('Branch already exists locally')).toBe(true)
|
||||
expect(isRetryableWorktreeCreateConflict('Branch "x" already exists.')).toBe(true)
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
CREATURE_POOL_NAMES,
|
||||
creatureNameAtTier,
|
||||
creatureNameTier
|
||||
} from '../worktree/retired-name-registry'
|
||||
|
||||
export const CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS = 25
|
||||
|
||||
// Why: mixed-version runtimes can still return these legacy conflicts instead
|
||||
@@ -13,6 +19,49 @@ export function getClientWorktreeCreateCandidate(value: string, attempt: number)
|
||||
return attempt === 0 ? value : `${value}-${attempt + 1}`
|
||||
}
|
||||
|
||||
function generatedNameIdentity(value: string): { poolName: string; tier: number } | null {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
const canonicalTier = creatureNameTier(normalized)
|
||||
if (canonicalTier !== null) {
|
||||
const poolName =
|
||||
canonicalTier === 1 ? normalized : normalized.slice(0, normalized.lastIndexOf('-'))
|
||||
return { poolName, tier: canonicalTier }
|
||||
}
|
||||
let poolName = normalized
|
||||
let tier = 1
|
||||
let suffixCount = 0
|
||||
for (;;) {
|
||||
const match = /-(\d{1,6})$/.exec(poolName)
|
||||
if (!match) {
|
||||
break
|
||||
}
|
||||
const suffix = Number(match[1])
|
||||
if (suffix < 2 || tier + suffix - 1 > 999_999) {
|
||||
return null
|
||||
}
|
||||
tier += suffix - 1
|
||||
suffixCount += 1
|
||||
poolName = poolName.slice(0, match.index)
|
||||
}
|
||||
return suffixCount > 1 && CREATURE_POOL_NAMES.has(poolName) ? { poolName, tier } : null
|
||||
}
|
||||
|
||||
export function isGeneratedWorktreeCreateName(value: string): boolean {
|
||||
return generatedNameIdentity(value) !== null
|
||||
}
|
||||
|
||||
export function getGeneratedWorktreeCreateRetryCandidate(
|
||||
value: string,
|
||||
attempt: number,
|
||||
minimumTier = 1
|
||||
): string {
|
||||
const identity = generatedNameIdentity(value)
|
||||
if (!identity) {
|
||||
return getClientWorktreeCreateCandidate(value, attempt)
|
||||
}
|
||||
return creatureNameAtTier(identity.poolName, Math.max(identity.tier, minimumTier) + attempt)
|
||||
}
|
||||
|
||||
export function isRetryableWorktreeCreateConflict(message: string): boolean {
|
||||
return RETRYABLE_WORKTREE_CREATE_CONFLICT_PATTERNS.some((pattern) => pattern.test(message))
|
||||
}
|
||||
|
||||
@@ -67,12 +67,13 @@ export type PersistedState = {
|
||||
/** Generated workspace names already issued, keyed by repoId. Suppresses reissuing a name so a
|
||||
* recreated workspace never lands on a prior occupant's path and inherits agent state keyed to
|
||||
* that cwd. Grows monotonically within a repo — entries are never removed on workspace deletion
|
||||
* — and is dropped wholesale when the repo is removed. Readers union across every repo that
|
||||
* creates into the same cwd namespace, so the storage key stays the stable repo id while the
|
||||
* guarantee follows the path.
|
||||
* — and the repo-keyed copy is dropped when the repo is removed. Readers union across repos
|
||||
* that create into the same cwd namespace; remote namespaces also retain a compact tombstone.
|
||||
*
|
||||
* Stored compacted, not as a flat list: a fully spent tier collapses into the row's watermark. */
|
||||
retiredWorktreeNamesByRepo?: Record<string, RetiredNameRegistry>
|
||||
/** Compacted remote cwd tombstones outlive repo ids, so remove/re-add cannot reissue a path. */
|
||||
retiredWorktreeNamesByNamespace?: Record<string, RetiredNameRegistry>
|
||||
/** Per paired device last tab selection by worktree; keeps mobile navigation across host restarts. */
|
||||
mobileClientTabSelectionsByDeviceId?: PersistedMobileClientTabSelections
|
||||
worktreeMeta: Record<string, WorktreeMeta>
|
||||
|
||||
Reference in New Issue
Block a user