mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Preserve user-set workspace names across branch changes (#17448)
* fix(worktrees): preserve user workspace names across branch changes * test(worktrees): cover pinned rename metadata * fix(workspaces): address display-name review edge cases * fix(workspaces): keep automatic names fresh across refreshes * fix(workspaces): preserve legacy CLI labels * fix(workspaces): preserve display-name provenance across hosts * fix(workspaces): honor legacy display-name provenance * fix(workspaces): fence display-name refresh races * fix(workspaces): accept peer renames from provenance-less hosts The old-host preserve fence kept a pinned local label on every refresh, which also suppressed a legitimate rename another client persisted through the same host until app restart. Narrow it to labels the host re-derived itself (branch short name, or path basename when detached); any other changed label in a mode-less response is explicit meta a peer wrote there. Stale prior-label responses stay covered by the downstream staleness fence, in-flight writes by the pending fence. * refactor(workspaces): unify display-name pin derivation Three call sites (renderer optimistic update, local IPC updateMeta handler, remote worktree.set handler) each restated the same formula; a future edit to one would silently skew provenance between paths.
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
"../src/main/gitlab/mappers.ts",
|
||||
"../src/main/ipc/worktree-branch-name.ts",
|
||||
"../src/main/ipc/worktree-logic.ts",
|
||||
"../src/main/ipc/worktree-display-name.ts",
|
||||
"../src/main/ipc/worktree-linked-work-item-metadata.ts",
|
||||
"../src/main/ipc/worktree-metadata-merge.ts",
|
||||
"../src/main/ipc/worktree-path-comparison.ts",
|
||||
|
||||
@@ -28,7 +28,7 @@ function fakeClient(script: (method: string, call: number) => unknown, calls: Ca
|
||||
}
|
||||
|
||||
describe('createBlankWorkspace', () => {
|
||||
it('sends no agent-launch fields for a blank workspace', async () => {
|
||||
it('pins a manually entered blank-workspace name and sends no agent-launch fields', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = fakeClient(() => ({ worktree: { id: 'wt-1' } }), calls)
|
||||
|
||||
@@ -51,6 +51,8 @@ describe('createBlankWorkspace', () => {
|
||||
repo: 'id:repo-1',
|
||||
setupDecision: 'inherit',
|
||||
name: 'octopus',
|
||||
displayName: 'octopus',
|
||||
displayNameKind: 'user',
|
||||
// Idempotency key so a create interrupted by a connection migration can be
|
||||
// safely retried without the host spawning a duplicate worktree.
|
||||
clientMutationId: expect.any(String)
|
||||
|
||||
@@ -32,6 +32,9 @@ export async function createBlankWorkspace(args: {
|
||||
repo: `id:${args.repoId}`,
|
||||
setupDecision: args.setupDecision,
|
||||
name,
|
||||
...(args.nameWasGenerated
|
||||
? { displayNameKind: 'generated' as const }
|
||||
: { displayName: args.baseName, displayNameKind: 'user' as const }),
|
||||
...(args.nameWasGenerated ? { nameWasGenerated: true } : {}),
|
||||
...agentLaunchCreateFields(args.createdWithAgentId)
|
||||
}
|
||||
|
||||
@@ -16,11 +16,11 @@ const hash = (parts: string[] | string): string =>
|
||||
.update(Array.isArray(parts) ? parts.join('\n') : parts)
|
||||
.digest('hex')
|
||||
|
||||
const PRE_REFACTOR_SCREEN_HOOKS = '1d8e1b69c4ac80e5e035cc2ece62a88a73bb35b0b60af74f3e75e54db5df3f75'
|
||||
const PRE_REFACTOR_SCREEN_HOOKS = '42174315a76c475d09dcb7209af4481f01258c4c9dc012127ff07a893d8cd291'
|
||||
const PRE_REFACTOR_DIFF_HOOKS = '93c7189b32bed8456cc51814fffa8ce80cf62011ef968a9d53ddec2b9686f58f'
|
||||
const PRE_REFACTOR_STATEMENTS = 'ef0e5bd607a96ff11fb60cff285bc01dfcfeb804cd29a896cca107b09994b610'
|
||||
const PRE_REFACTOR_STATEMENTS = '9323fbee7c3806f37de42578ba73ce659c786c0ed5f8b6bcbc321b201ca50a73'
|
||||
const PRE_REFACTOR_DECLARATIONS = 'cff54172af17a877789be1479c2eb6ca97d83c3e31dd831cd59395962f2b4c4a'
|
||||
const PRE_REFACTOR_SEMANTICS = '60be4eee5513751530f98925828dc59e9775838842dafb5e5c7e9f25bf8a016d'
|
||||
const PRE_REFACTOR_SEMANTICS = '5219d210d6f274e9ce2716a37c4c6fc4860a736a80f059ab6e89da6123043263'
|
||||
const PRE_REFACTOR_STYLES = '1db6af69c791d9963928541ad5310942fcbda6d984b422c90b6eb92b6816579a'
|
||||
const PRE_REFACTOR_RENDER_TREE = '2111145136b1e4fbca150d4792d735a90e992488e9934cfc1a8b8f3be981f39f'
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('Mobile Tasks refactor parity', () => {
|
||||
|
||||
it('preserves RPC calls, runtime strings, and JSX host signatures', () => {
|
||||
const semantics = readMobileTasksSemanticSource()
|
||||
expect(semantics.split('\n')).toHaveLength(3_498)
|
||||
expect(semantics.split('\n')).toHaveLength(3_499)
|
||||
expect(hash(semantics)).toBe(PRE_REFACTOR_SEMANTICS)
|
||||
})
|
||||
|
||||
|
||||
@@ -174,7 +174,62 @@ describe('createWorkspaceFromComposerSource', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('suppresses displayName when the name is user-edited (not auto-managed)', async () => {
|
||||
it('does not pin an automatically managed branch selection without a custom label', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = fakeClient(() => ({ worktree: { id: 'wt-auto-branch' } }), calls)
|
||||
const selection: MobileComposerCreateSelection = {
|
||||
kind: 'branch',
|
||||
baseBranch: 'main',
|
||||
refName: 'main',
|
||||
localBranchName: 'topic',
|
||||
reuse: false,
|
||||
branchNameOverride: 'topic'
|
||||
}
|
||||
await createWorkspaceFromComposerSource({ client, selection, ...baseArgs })
|
||||
expect(calls[0]!.params).not.toHaveProperty('displayName')
|
||||
expect(calls[0]!.params).not.toHaveProperty('displayNameKind')
|
||||
})
|
||||
|
||||
it('does not pin an auto-derived branch label even when the draft is populated', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = fakeClient(() => ({ worktree: { id: 'wt-auto-branch-draft' } }), calls)
|
||||
const selection: MobileComposerCreateSelection = {
|
||||
kind: 'new-branch',
|
||||
branchName: 'topic'
|
||||
}
|
||||
|
||||
await createWorkspaceFromComposerSource({
|
||||
client,
|
||||
selection,
|
||||
...baseArgs,
|
||||
workspaceName: 'topic',
|
||||
nameIsAutoManaged: true
|
||||
})
|
||||
|
||||
expect(calls[0]!.params).not.toHaveProperty('displayName')
|
||||
expect(calls[0]!.params).not.toHaveProperty('displayNameKind')
|
||||
})
|
||||
|
||||
it('pins a custom label for a new branch selection', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = fakeClient(() => ({ worktree: { id: 'wt-labeled-branch' } }), calls)
|
||||
const selection: MobileComposerCreateSelection = {
|
||||
kind: 'new-branch',
|
||||
branchName: 'feature/login'
|
||||
}
|
||||
await createWorkspaceFromComposerSource({
|
||||
client,
|
||||
selection,
|
||||
...baseArgs,
|
||||
workspaceName: ' Login work '
|
||||
})
|
||||
expect(calls[0]!.params).toMatchObject({
|
||||
displayName: 'Login work',
|
||||
displayNameKind: 'user'
|
||||
})
|
||||
})
|
||||
|
||||
it('pins displayName when the name is user-edited (not auto-managed)', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = fakeClient(() => ({ worktree: { id: 'wt-dn' } }), calls)
|
||||
const selection: MobileComposerCreateSelection = {
|
||||
@@ -195,8 +250,12 @@ describe('createWorkspaceFromComposerSource', () => {
|
||||
workspaceName: 'my-name',
|
||||
nameIsAutoManaged: false
|
||||
})
|
||||
expect(calls[0]!.params.displayName).toBeUndefined()
|
||||
expect(calls[0]!.params).toMatchObject({ name: 'my-name', linkedIssue: 7 })
|
||||
expect(calls[0]!.params).toMatchObject({
|
||||
name: 'my-name',
|
||||
displayName: 'my-name',
|
||||
displayNameKind: 'user',
|
||||
linkedIssue: 7
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a new branch off a ref, bumping the branch on collision', async () => {
|
||||
|
||||
@@ -28,8 +28,8 @@ export type CreateWorkspaceFromComposerArgs = {
|
||||
setupDecision: WorkspaceCreateSetupDecision
|
||||
agent: WorkspaceCreateAgentBundle
|
||||
workspaceName: string | undefined
|
||||
note: string | undefined
|
||||
nameIsAutoManaged?: boolean
|
||||
note: string | undefined
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
|
||||
}
|
||||
|
||||
@@ -91,8 +91,8 @@ async function createWorkItemWorkspace(args: {
|
||||
setupDecision: WorkspaceCreateSetupDecision
|
||||
agent: WorkspaceCreateAgentBundle
|
||||
workspaceName: string | undefined
|
||||
note: string | undefined
|
||||
nameIsAutoManaged?: boolean
|
||||
note: string | undefined
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
|
||||
@@ -150,12 +150,23 @@ async function createBranchWorkspace(args: {
|
||||
setupDecision: WorkspaceCreateSetupDecision
|
||||
agent: WorkspaceCreateAgentBundle
|
||||
workspaceName: string | undefined
|
||||
nameIsAutoManaged?: boolean
|
||||
note: string | undefined
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
|
||||
const {
|
||||
client,
|
||||
selection,
|
||||
targetRepoId,
|
||||
setupDecision,
|
||||
agent,
|
||||
workspaceName,
|
||||
nameIsAutoManaged,
|
||||
note
|
||||
} = args
|
||||
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
|
||||
const comment = note?.trim()
|
||||
const manualDisplayName = nameIsAutoManaged === true ? undefined : workspaceName?.trim()
|
||||
const applyCommon = (params: Record<string, unknown>): Record<string, unknown> => {
|
||||
Object.assign(params, agentLaunchCreateFields(createdWithAgentId))
|
||||
if (comment) {
|
||||
@@ -181,6 +192,9 @@ async function createBranchWorkspace(args: {
|
||||
applyCommon({
|
||||
repo: `id:${targetRepoId}`,
|
||||
name,
|
||||
...(manualDisplayName
|
||||
? { displayName: manualDisplayName, displayNameKind: 'user' as const }
|
||||
: {}),
|
||||
setupDecision,
|
||||
baseBranch: selection.refName,
|
||||
branchNameOverride: selection.localBranchName
|
||||
@@ -203,7 +217,10 @@ async function createBranchWorkspace(args: {
|
||||
repo: `id:${targetRepoId}`,
|
||||
name: candidate,
|
||||
setupDecision,
|
||||
baseBranch: selection.baseBranch
|
||||
baseBranch: selection.baseBranch,
|
||||
...(manualDisplayName
|
||||
? { displayName: manualDisplayName, displayNameKind: 'user' as const }
|
||||
: {})
|
||||
}
|
||||
if (selection.branchNameOverride) {
|
||||
params.branchNameOverride = candidate
|
||||
@@ -220,11 +237,22 @@ async function createNewBranchWorkspace(args: {
|
||||
setupDecision: WorkspaceCreateSetupDecision
|
||||
agent: WorkspaceCreateAgentBundle
|
||||
workspaceName: string | undefined
|
||||
nameIsAutoManaged?: boolean
|
||||
note: string | undefined
|
||||
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
const { client, selection, targetRepoId, setupDecision, agent, note } = args
|
||||
const {
|
||||
client,
|
||||
selection,
|
||||
targetRepoId,
|
||||
setupDecision,
|
||||
agent,
|
||||
workspaceName,
|
||||
nameIsAutoManaged,
|
||||
note
|
||||
} = args
|
||||
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
|
||||
const manualDisplayName = nameIsAutoManaged === true ? undefined : workspaceName?.trim()
|
||||
const comment = note?.trim()
|
||||
// A brand-new branch off the repo's default base. The typed name is kept as the
|
||||
// git branch (via branchNameOverride) so a slash like `feature/login` survives;
|
||||
@@ -240,6 +268,9 @@ async function createNewBranchWorkspace(args: {
|
||||
name: candidate,
|
||||
setupDecision,
|
||||
branchNameOverride: candidate,
|
||||
...(manualDisplayName
|
||||
? { displayName: manualDisplayName, displayNameKind: 'user' as const }
|
||||
: {}),
|
||||
...agentLaunchCreateFields(createdWithAgentId)
|
||||
}
|
||||
if (comment) {
|
||||
|
||||
@@ -39,7 +39,8 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod
|
||||
taskStateHydrated,
|
||||
tasksSupported,
|
||||
trustedOrcaHooks,
|
||||
workspaceDetectedAgentIds
|
||||
workspaceDetectedAgentIds,
|
||||
workspaceLastAutoName
|
||||
} = model
|
||||
const createWorkspace = useCallback(
|
||||
async (
|
||||
@@ -149,6 +150,9 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod
|
||||
})
|
||||
return
|
||||
}
|
||||
const trimmedWorkspaceName = workspaceNameOverride?.trim() ?? ''
|
||||
const nameIsAutoManaged =
|
||||
!trimmedWorkspaceName || trimmedWorkspaceName === workspaceLastAutoName
|
||||
let params: Record<string, unknown>
|
||||
if (item.provider === 'github') {
|
||||
const source = item.source
|
||||
@@ -192,7 +196,8 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod
|
||||
baseBranch: baseBranchOverride,
|
||||
branchNameOverride,
|
||||
sparseCheckout: sparseCheckoutOverride,
|
||||
hostedStartPoint: prStartPoint
|
||||
hostedStartPoint: prStartPoint,
|
||||
nameIsAutoManaged
|
||||
})
|
||||
} else if (item.provider === 'gitlab') {
|
||||
const source = item.source
|
||||
@@ -236,7 +241,8 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod
|
||||
baseBranch: baseBranchOverride,
|
||||
branchNameOverride,
|
||||
sparseCheckout: sparseCheckoutOverride,
|
||||
hostedStartPoint: mrStartPoint
|
||||
hostedStartPoint: mrStartPoint,
|
||||
nameIsAutoManaged
|
||||
})
|
||||
} else {
|
||||
params = buildTaskWorkspaceCreateParams({
|
||||
@@ -248,7 +254,8 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod
|
||||
note: comment,
|
||||
baseBranch: baseBranchOverride,
|
||||
branchNameOverride,
|
||||
sparseCheckout: sparseCheckoutOverride
|
||||
sparseCheckout: sparseCheckoutOverride,
|
||||
nameIsAutoManaged
|
||||
})
|
||||
}
|
||||
const response = await client.sendRequest('worktree.create', params, {
|
||||
@@ -289,7 +296,8 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod
|
||||
taskStateHydrated,
|
||||
tasksSupported,
|
||||
trustedOrcaHooks,
|
||||
workspaceDetectedAgentIds
|
||||
workspaceDetectedAgentIds,
|
||||
workspaceLastAutoName
|
||||
]
|
||||
)
|
||||
return Object.assign(model, { createWorkspace })
|
||||
|
||||
@@ -41,6 +41,7 @@ describe('task workspace create params', () => {
|
||||
repo: 'id:repo-1',
|
||||
name: 'mobile-tasks',
|
||||
displayName: 'Fix mobile tasks',
|
||||
displayNameKind: 'generated',
|
||||
setupDecision: 'run',
|
||||
activate: true,
|
||||
startupDraft: 'https://github.com/acme/app/pull/123',
|
||||
@@ -72,6 +73,7 @@ describe('task workspace create params', () => {
|
||||
repo: 'id:repo-1',
|
||||
name: 'issue-88',
|
||||
displayName: 'Investigate login',
|
||||
displayNameKind: 'generated',
|
||||
setupDecision: 'skip',
|
||||
activate: true,
|
||||
linkedIssue: 88
|
||||
@@ -80,6 +82,31 @@ describe('task workspace create params', () => {
|
||||
expect(params).not.toHaveProperty('createdWithAgent')
|
||||
})
|
||||
|
||||
it('marks an edited task label as user-owned', () => {
|
||||
const params = buildTaskWorkspaceCreateParams({
|
||||
item: {
|
||||
provider: 'github',
|
||||
source: {
|
||||
type: 'issue',
|
||||
repoId: 'repo-1',
|
||||
number: 88,
|
||||
title: 'Investigate login',
|
||||
url: 'https://github.com/acme/app/issues/88'
|
||||
}
|
||||
},
|
||||
targetRepoId: 'ignored-for-github',
|
||||
setupDecision: 'skip',
|
||||
workspaceName: 'My workspace',
|
||||
nameIsAutoManaged: false
|
||||
})
|
||||
|
||||
expect(params).toMatchObject({
|
||||
name: 'My workspace',
|
||||
displayName: 'My workspace',
|
||||
displayNameKind: 'user'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the startup draft when no agent was provided so the host can auto-pick', () => {
|
||||
const params = buildTaskWorkspaceCreateParams({
|
||||
item: {
|
||||
|
||||
@@ -108,8 +108,7 @@ export function buildTaskWorkspaceCreateParams(args: {
|
||||
const comment = note?.trim()
|
||||
const selectedBaseBranch = baseBranch || hostedStartPoint?.baseBranch
|
||||
const selectedPushTarget = pushTarget ?? hostedStartPoint?.pushTarget
|
||||
// Why: desktop only sends displayName while the name is still auto-derived; a
|
||||
// user-edited name suppresses it so the runtime keeps the user's chosen name.
|
||||
// Preserve provenance so the host can distinguish an intentional label from a generated title.
|
||||
const sourceName =
|
||||
item.provider === 'linear'
|
||||
? getWorkspaceSourceName({
|
||||
@@ -121,7 +120,11 @@ export function buildTaskWorkspaceCreateParams(args: {
|
||||
linearIdentifier: item.source.identifier
|
||||
})
|
||||
: getWorkspaceSourceName({ provider: item.provider, ...item.source })
|
||||
const displayName = nameIsAutoManaged ? { displayName: sourceName.displayName } : {}
|
||||
const displayName = nameIsAutoManaged
|
||||
? { displayName: sourceName.displayName, displayNameKind: 'generated' as const }
|
||||
: workspaceName?.trim()
|
||||
? { displayName: workspaceName, displayNameKind: 'user' as const }
|
||||
: {}
|
||||
const common = {
|
||||
setupDecision,
|
||||
activate: true,
|
||||
|
||||
@@ -55,7 +55,7 @@ async function flush(): Promise<void> {
|
||||
// cutover). Records every call so tests can assert on the clientMutationId.
|
||||
function scriptedClient(
|
||||
outcomes: Array<
|
||||
| { id: string }
|
||||
| { id: string; displayName?: string }
|
||||
| { errorMessage: string }
|
||||
// takesMs models how long the ambiguity took to SURFACE — a clean close is
|
||||
// instant, a half-open socket waits out the liveness watchdog or the timeout.
|
||||
@@ -103,7 +103,12 @@ function scriptedClient(
|
||||
return {
|
||||
id: '1',
|
||||
ok: true,
|
||||
result: { worktree: { id: outcome.id } },
|
||||
result: {
|
||||
worktree: {
|
||||
id: outcome.id,
|
||||
...(outcome.displayName !== undefined ? { displayName: outcome.displayName } : {})
|
||||
}
|
||||
},
|
||||
_meta: { runtimeId: 'r' }
|
||||
}
|
||||
}
|
||||
@@ -227,6 +232,40 @@ describe('createWorktreeWithNameRetry', () => {
|
||||
expect(attempts[1]!.params.name).toBe('topic-2')
|
||||
})
|
||||
|
||||
it('uses the host-selected display name after a collision retry', async () => {
|
||||
const attempts: Attempt[] = []
|
||||
const client = scriptedClient(
|
||||
[{ errorMessage: 'already exists locally' }, { id: 'wt-host-name', displayName: 'topic-3' }],
|
||||
attempts
|
||||
)
|
||||
|
||||
await expect(
|
||||
createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName: 'topic',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
worktreeCreateIdempotency: false
|
||||
})
|
||||
).resolves.toEqual({ worktreeId: 'wt-host-name', name: 'topic-3' })
|
||||
})
|
||||
|
||||
it('falls back to the client candidate when an older host omits displayName', async () => {
|
||||
const attempts: Attempt[] = []
|
||||
const client = scriptedClient(
|
||||
[{ errorMessage: 'already exists locally' }, { id: 'wt-legacy' }],
|
||||
attempts
|
||||
)
|
||||
|
||||
await expect(
|
||||
createWorktreeWithNameRetry({
|
||||
client,
|
||||
baseName: 'topic',
|
||||
buildParams: (name) => ({ repo: 'id:r', name }),
|
||||
worktreeCreateIdempotency: false
|
||||
})
|
||||
).resolves.toEqual({ worktreeId: 'wt-legacy', name: 'topic-2' })
|
||||
})
|
||||
|
||||
it('advances generated retries without nesting suffixes', async () => {
|
||||
const attempts: Attempt[] = []
|
||||
const client = scriptedClient(
|
||||
|
||||
@@ -82,8 +82,17 @@ export async function createWorktreeWithNameRetry(
|
||||
: candidateParams
|
||||
const response = await sendWorktreeCreateResilient(client, params, worktreeCreateIdempotency)
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as { worktree: { id: string } }
|
||||
return { worktreeId: result.worktree.id, name: candidateName }
|
||||
const result = (response as RpcSuccess).result as {
|
||||
worktree: { id: string; displayName?: string }
|
||||
}
|
||||
const authoritativeName = result.worktree.displayName
|
||||
return {
|
||||
worktreeId: result.worktree.id,
|
||||
name:
|
||||
typeof authoritativeName === 'string' && authoritativeName.trim()
|
||||
? authoritativeName
|
||||
: candidateName
|
||||
}
|
||||
}
|
||||
lastError = response.error.message
|
||||
if (!isRetryableWorktreeCreateConflict(lastError ?? '')) {
|
||||
|
||||
@@ -230,9 +230,12 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
|
||||
}
|
||||
const linearIssueLink = getOptionalLinearIssueLinkFlag(flags, 'linear-issue')
|
||||
const activate = flags.get('activate') === true || flags.get('run-hooks') === true
|
||||
const name = getRequiredStringFlag(flags, 'name')
|
||||
const result = await client.call<RuntimeWorktreeCreateResult>('worktree.create', {
|
||||
repo: await getCreateRepoSelector(flags, cwdParentWorktree, client),
|
||||
name: getRequiredStringFlag(flags, 'name'),
|
||||
name,
|
||||
displayName: name,
|
||||
displayNameKind: 'user',
|
||||
baseBranch: getOptionalStringFlag(flags, 'base-branch'),
|
||||
linkedIssue: getOptionalNumberFlag(flags, 'issue'),
|
||||
...linearIssueLink,
|
||||
|
||||
@@ -72,6 +72,8 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
|
||||
repo: 'id:repo-1',
|
||||
name: 'feature',
|
||||
displayName: 'feature',
|
||||
displayNameKind: 'user',
|
||||
baseBranch: undefined,
|
||||
linkedIssue: undefined,
|
||||
comment: undefined,
|
||||
@@ -122,6 +124,8 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
|
||||
repo: 'id:repo-1',
|
||||
name: 'agent-task',
|
||||
displayName: 'agent-task',
|
||||
displayNameKind: 'user',
|
||||
baseBranch: undefined,
|
||||
linkedIssue: undefined,
|
||||
comment: undefined,
|
||||
@@ -169,6 +173,8 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
|
||||
repo: 'id:repo-1',
|
||||
name: 'agent-task',
|
||||
displayName: 'agent-task',
|
||||
displayNameKind: 'user',
|
||||
baseBranch: undefined,
|
||||
linkedIssue: undefined,
|
||||
comment: undefined,
|
||||
|
||||
@@ -87,6 +87,8 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
|
||||
repo: 'id:repo-1',
|
||||
name: 'feature',
|
||||
displayName: 'feature',
|
||||
displayNameKind: 'user',
|
||||
baseBranch: undefined,
|
||||
linkedIssue: undefined,
|
||||
linkedLinearIssue: 'STA-335',
|
||||
@@ -137,6 +139,8 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(callMock).toHaveBeenCalledWith('worktree.create', {
|
||||
repo: 'id:repo-1',
|
||||
name: 'feature',
|
||||
displayName: 'feature',
|
||||
displayNameKind: 'user',
|
||||
baseBranch: undefined,
|
||||
linkedIssue: undefined,
|
||||
linkedLinearIssue: 'STA-335',
|
||||
|
||||
@@ -106,6 +106,8 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(callMock).toHaveBeenCalledWith('worktree.create', {
|
||||
repo: 'id:repo-1',
|
||||
name: 'child',
|
||||
displayName: 'child',
|
||||
displayNameKind: 'user',
|
||||
baseBranch: undefined,
|
||||
linkedIssue: undefined,
|
||||
comment: undefined,
|
||||
@@ -152,6 +154,8 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(callMock).toHaveBeenCalledWith('worktree.create', {
|
||||
repo: 'id:repo-1',
|
||||
name: 'child',
|
||||
displayName: 'child',
|
||||
displayNameKind: 'user',
|
||||
baseBranch: undefined,
|
||||
linkedIssue: undefined,
|
||||
comment: undefined,
|
||||
@@ -219,6 +223,8 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(callMock).toHaveBeenCalledWith('worktree.create', {
|
||||
repo: 'id:repo-1',
|
||||
name: 'child',
|
||||
displayName: 'child',
|
||||
displayNameKind: 'user',
|
||||
baseBranch: undefined,
|
||||
linkedIssue: undefined,
|
||||
comment: undefined,
|
||||
@@ -263,6 +269,8 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
|
||||
repo: 'id:repo-1',
|
||||
name: 'child',
|
||||
displayName: 'child',
|
||||
displayNameKind: 'user',
|
||||
baseBranch: undefined,
|
||||
linkedIssue: undefined,
|
||||
comment: undefined,
|
||||
@@ -308,6 +316,8 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
|
||||
repo: 'id:repo-1',
|
||||
name: 'child',
|
||||
displayName: 'child',
|
||||
displayNameKind: 'user',
|
||||
baseBranch: undefined,
|
||||
linkedIssue: undefined,
|
||||
comment: undefined,
|
||||
@@ -369,6 +379,8 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
|
||||
repo: 'id:repo-1',
|
||||
name: 'child',
|
||||
displayName: 'child',
|
||||
displayNameKind: 'user',
|
||||
baseBranch: undefined,
|
||||
linkedIssue: undefined,
|
||||
comment: undefined,
|
||||
@@ -517,6 +529,8 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(callMock).toHaveBeenCalledWith('worktree.create', {
|
||||
repo: 'id:repo-1',
|
||||
name: 'child',
|
||||
displayName: 'child',
|
||||
displayNameKind: 'user',
|
||||
baseBranch: undefined,
|
||||
linkedIssue: undefined,
|
||||
comment: undefined,
|
||||
@@ -559,6 +573,8 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(callMock).toHaveBeenCalledWith('worktree.create', {
|
||||
repo: 'id:repo-1',
|
||||
name: 'child',
|
||||
displayName: 'child',
|
||||
displayNameKind: 'user',
|
||||
baseBranch: undefined,
|
||||
linkedIssue: undefined,
|
||||
comment: undefined,
|
||||
|
||||
@@ -72,6 +72,8 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
|
||||
repo: 'id:repo-1',
|
||||
name: 'feature',
|
||||
displayName: 'feature',
|
||||
displayNameKind: 'user',
|
||||
baseBranch: undefined,
|
||||
linkedIssue: undefined,
|
||||
comment: undefined,
|
||||
@@ -147,6 +149,8 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
|
||||
repo: 'id:repo-gpu',
|
||||
name: 'feature',
|
||||
displayName: 'feature',
|
||||
displayNameKind: 'user',
|
||||
baseBranch: undefined,
|
||||
linkedIssue: undefined,
|
||||
comment: undefined,
|
||||
@@ -259,6 +263,8 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
|
||||
repo: 'id:repo-1',
|
||||
name: 'child',
|
||||
displayName: 'child',
|
||||
displayNameKind: 'user',
|
||||
baseBranch: undefined,
|
||||
linkedIssue: undefined,
|
||||
comment: undefined,
|
||||
|
||||
@@ -668,6 +668,9 @@ function maybeAutoRenameBranchOnFirstWorkFromHook(event: {
|
||||
}
|
||||
currentStore.setWorktreeMeta(worktreeId, {
|
||||
displayName,
|
||||
// The first-agent title is an intentional user-facing label; keep it stable after the
|
||||
// generated branch is renamed and across subsequent catalog refreshes.
|
||||
displayNameIsPinned: true,
|
||||
pendingFirstAgentMessageRename: false,
|
||||
// Success clears the failure badge (redundant with the explicit setRenameError(null)).
|
||||
firstAgentMessageRenameError: null
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { CreateWorktreeArgs } from '../../shared/worktree/create-types'
|
||||
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
|
||||
|
||||
type DisplayNameKind = CreateWorktreeArgs['displayNameKind']
|
||||
|
||||
export function sanitizeWorktreeDisplayName(input: string): string | undefined {
|
||||
const withoutControls = Array.from(input, (char) => {
|
||||
const code = char.charCodeAt(0)
|
||||
return code <= 0x1f || (code >= 0x7f && code <= 0x9f) ? ' ' : char
|
||||
}).join('')
|
||||
const sanitized = withoutControls
|
||||
// Why: titles come from external systems; bidi overrides could visually reorder sidebar text.
|
||||
.replace(/[\u202a-\u202e\u2066-\u2069]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 120)
|
||||
.trim()
|
||||
|
||||
return sanitized || undefined
|
||||
}
|
||||
|
||||
export function resolveWorktreeCreateDisplayName(
|
||||
input: string | undefined,
|
||||
kind: DisplayNameKind
|
||||
): string | undefined {
|
||||
if (!input) {
|
||||
return undefined
|
||||
}
|
||||
if (kind !== 'user') {
|
||||
return sanitizeWorktreeDisplayName(input)
|
||||
}
|
||||
const safe = Array.from(input, (char) => {
|
||||
const code = char.charCodeAt(0)
|
||||
return code <= 0x1f || (code >= 0x7f && code <= 0x9f) ? ' ' : char
|
||||
})
|
||||
.join('')
|
||||
.replace(/[\u202a-\u202e\u2066-\u2069]/g, '')
|
||||
.trim()
|
||||
return safe || undefined
|
||||
}
|
||||
|
||||
/** Resolve the create label, including the pre-provenance CLI contract. */
|
||||
export function resolveWorktreeCreateDisplayNameRequest(
|
||||
input: string | undefined,
|
||||
kind: DisplayNameKind,
|
||||
fallbackName: string,
|
||||
cliCreated: boolean,
|
||||
nameWasGenerated = false
|
||||
): { value: string | undefined; kind: DisplayNameKind } {
|
||||
// The CLI name is always an explicit command argument; its marker wins over a
|
||||
// missing or malformed kind so a future client cannot make it auto-managed.
|
||||
// Legacy clients omitted displayNameKind: an explicit displayName was the artifact-title
|
||||
// contract, while a name-only request was user-entered unless marked as generated.
|
||||
const effectiveKind = cliCreated
|
||||
? 'user'
|
||||
: (kind ?? (input !== undefined || nameWasGenerated ? 'generated' : 'user'))
|
||||
const effectiveInput = input ?? (effectiveKind === 'user' ? fallbackName : undefined)
|
||||
return {
|
||||
value: resolveWorktreeCreateDisplayName(effectiveInput, effectiveKind),
|
||||
kind: effectiveKind
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWorktreeCreateDisplayNameMeta(
|
||||
requestedDisplayName: string | undefined,
|
||||
branchName: string,
|
||||
kind: DisplayNameKind,
|
||||
fallback: { requestedName: string; sanitizedName: string }
|
||||
): Partial<Pick<WorktreeMeta, 'displayName' | 'displayNameIsPinned'>> {
|
||||
if (requestedDisplayName !== undefined) {
|
||||
// Generated labels equal to their branch stay automatic; user labels remain fixed even when equal.
|
||||
if (kind !== 'user' && requestedDisplayName === branchName) {
|
||||
return {}
|
||||
}
|
||||
return { displayName: requestedDisplayName, displayNameIsPinned: true }
|
||||
}
|
||||
// A user label that sanitizes away is an empty label, so keep the generated fallback automatic.
|
||||
if (kind === 'user') {
|
||||
return { displayNameIsPinned: false }
|
||||
}
|
||||
if (fallback.requestedName === branchName) {
|
||||
return { displayName: fallback.requestedName, displayNameIsPinned: false }
|
||||
}
|
||||
return shouldSetDisplayName(fallback.requestedName, branchName, fallback.sanitizedName)
|
||||
? { displayName: fallback.requestedName, displayNameIsPinned: true }
|
||||
: {}
|
||||
}
|
||||
|
||||
export function shouldSetDisplayName(
|
||||
requestedName: string,
|
||||
branchName: string,
|
||||
sanitizedName: string
|
||||
): boolean {
|
||||
return !(branchName === requestedName && sanitizedName === requestedName)
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
sanitizeWorktreeName,
|
||||
sanitizeWorktreeDisplayName,
|
||||
resolveWorktreeCreateDisplayName,
|
||||
resolveWorktreeCreateDisplayNameRequest,
|
||||
resolveWorktreeCreateDisplayNameMeta,
|
||||
ensurePathWithinWorkspace,
|
||||
computeBranchName,
|
||||
getConfiguredBranchPrefix,
|
||||
@@ -128,6 +131,101 @@ describe('sanitizeWorktreeDisplayName', () => {
|
||||
it('returns undefined when nothing displayable remains', () => {
|
||||
expect(sanitizeWorktreeDisplayName('\u0000\n\t')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for an unusable user label', () => {
|
||||
expect(resolveWorktreeCreateDisplayName('\u0000\u202e', 'user')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('worktree create display-name provenance', () => {
|
||||
it('recovers the name-only contract from an older CLI request', () => {
|
||||
expect(resolveWorktreeCreateDisplayNameRequest(undefined, undefined, 'feature', true)).toEqual({
|
||||
value: 'feature',
|
||||
kind: 'user'
|
||||
})
|
||||
})
|
||||
|
||||
it('recovers a legacy name-only user create without CLI provenance', () => {
|
||||
expect(resolveWorktreeCreateDisplayNameRequest(undefined, undefined, 'feature', false)).toEqual(
|
||||
{
|
||||
value: 'feature',
|
||||
kind: 'user'
|
||||
}
|
||||
)
|
||||
expect(
|
||||
resolveWorktreeCreateDisplayNameMeta('feature', 'feature', 'user', {
|
||||
requestedName: 'feature',
|
||||
sanitizedName: 'feature'
|
||||
})
|
||||
).toEqual({ displayName: 'feature', displayNameIsPinned: true })
|
||||
})
|
||||
|
||||
it('keeps a legacy generated name automatic when nameWasGenerated is set', () => {
|
||||
expect(
|
||||
resolveWorktreeCreateDisplayNameRequest(undefined, undefined, 'nautilus', false, true)
|
||||
).toEqual({
|
||||
value: undefined,
|
||||
kind: 'generated'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a legacy artifact display name generated when its kind is absent', () => {
|
||||
expect(
|
||||
resolveWorktreeCreateDisplayNameRequest('Issue title', undefined, 'feature', false)
|
||||
).toEqual({ value: 'Issue title', kind: 'generated' })
|
||||
})
|
||||
|
||||
it('treats a CLI name as intentional even if a caller supplies generated provenance', () => {
|
||||
expect(
|
||||
resolveWorktreeCreateDisplayNameRequest('Agent label', 'generated', 'feature', true)
|
||||
).toEqual({ value: 'Agent label', kind: 'user' })
|
||||
})
|
||||
|
||||
it('preserves exact user text apart from edge whitespace and controls', () => {
|
||||
expect(resolveWorktreeCreateDisplayName(' My Label\n', 'user')).toBe('My Label')
|
||||
})
|
||||
|
||||
it('pins user labels without adding collision suffixes to visible text', () => {
|
||||
expect(
|
||||
resolveWorktreeCreateDisplayNameMeta('My Label', 'my-label-2', 'user', {
|
||||
requestedName: 'My Label',
|
||||
sanitizedName: 'my-label-2'
|
||||
})
|
||||
).toEqual({ displayName: 'My Label', displayNameIsPinned: true })
|
||||
})
|
||||
|
||||
it('keeps generated labels automatic only when they equal the branch', () => {
|
||||
expect(
|
||||
resolveWorktreeCreateDisplayNameMeta('Issue title', 'feature-2', 'generated', {
|
||||
requestedName: 'feature-2',
|
||||
sanitizedName: 'feature-2'
|
||||
})
|
||||
).toEqual({ displayName: 'Issue title', displayNameIsPinned: true })
|
||||
expect(
|
||||
resolveWorktreeCreateDisplayNameMeta('feature-2', 'feature-2', 'generated', {
|
||||
requestedName: 'feature-2',
|
||||
sanitizedName: 'feature-2'
|
||||
})
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
it('keeps a slashy branch label automatic when only its folder is sanitized', () => {
|
||||
expect(
|
||||
resolveWorktreeCreateDisplayNameMeta(undefined, 'feature/login', undefined, {
|
||||
requestedName: 'feature/login',
|
||||
sanitizedName: 'feature-login'
|
||||
})
|
||||
).toEqual({ displayName: 'feature/login', displayNameIsPinned: false })
|
||||
})
|
||||
|
||||
it('keeps a user label that sanitizes away automatic', () => {
|
||||
expect(
|
||||
resolveWorktreeCreateDisplayNameMeta(undefined, 'feature-2', 'user', {
|
||||
requestedName: 'feature',
|
||||
sanitizedName: 'feature-2'
|
||||
})
|
||||
).toEqual({ displayNameIsPinned: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensurePathWithinWorkspace', () => {
|
||||
@@ -459,6 +557,7 @@ describe('mergeWorktree', () => {
|
||||
it('merges with full metadata', () => {
|
||||
const meta = {
|
||||
displayName: 'My Feature',
|
||||
displayNameIsPinned: true,
|
||||
comment: 'WIP',
|
||||
linkedIssue: 42,
|
||||
linkedPR: 10,
|
||||
@@ -500,6 +599,7 @@ describe('mergeWorktree', () => {
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: 'My Feature',
|
||||
displayNameMode: 'fixed',
|
||||
comment: 'WIP',
|
||||
linkedIssue: 42,
|
||||
linkedPR: 10,
|
||||
|
||||
@@ -61,22 +61,13 @@ function containsEmoji(input: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
export function sanitizeWorktreeDisplayName(input: string): string | undefined {
|
||||
const withoutControls = Array.from(input, (char) => {
|
||||
const code = char.charCodeAt(0)
|
||||
return code <= 0x1f || (code >= 0x7f && code <= 0x9f) ? ' ' : char
|
||||
}).join('')
|
||||
const sanitized = withoutControls
|
||||
// Why: titles come from external systems. Strip bidi override controls so a
|
||||
// malicious title cannot visually reorder adjacent sidebar text.
|
||||
.replace(/[\u202a-\u202e\u2066-\u2069]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 120)
|
||||
.trim()
|
||||
|
||||
return sanitized || undefined
|
||||
}
|
||||
export {
|
||||
resolveWorktreeCreateDisplayName,
|
||||
resolveWorktreeCreateDisplayNameRequest,
|
||||
resolveWorktreeCreateDisplayNameMeta,
|
||||
sanitizeWorktreeDisplayName,
|
||||
shouldSetDisplayName
|
||||
} from './worktree-display-name'
|
||||
|
||||
/**
|
||||
* Ensure a target path is within the workspace directory (prevent path traversal).
|
||||
@@ -283,14 +274,6 @@ function shouldMirrorWorkspaceDirInsideWsl(repoPath: string, workspaceDir: strin
|
||||
* A display name is set only when the user's requested name differs from
|
||||
* both the branch name and the sanitized name (i.e. it was modified).
|
||||
*/
|
||||
export function shouldSetDisplayName(
|
||||
requestedName: string,
|
||||
branchName: string,
|
||||
sanitizedName: string
|
||||
): boolean {
|
||||
return !(branchName === requestedName && sanitizedName === requestedName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a composite worktreeId ("repoId::worktreePath") into its parts.
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,47 @@ const git: GitWorktreeInfo = {
|
||||
}
|
||||
|
||||
describe('mergeWorktree identity projection', () => {
|
||||
it('re-derives an automatic display name from the current branch', () => {
|
||||
const worktree = mergeWorktree(
|
||||
'repo-1',
|
||||
{ ...git, branch: 'refs/heads/main' },
|
||||
{
|
||||
displayName: 'feature',
|
||||
displayNameIsPinned: false,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0
|
||||
}
|
||||
)
|
||||
|
||||
expect(worktree.displayName).toBe('main')
|
||||
expect(worktree.displayNameMode).toBe('automatic')
|
||||
})
|
||||
|
||||
it('treats legacy CLI labels as fixed display names', () => {
|
||||
const worktree = mergeWorktree('repo-1', git, {
|
||||
displayName: 'feature',
|
||||
cliProvenance: { kind: 'created-by-cli', createdAt: 1 },
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0
|
||||
})
|
||||
|
||||
expect(worktree.displayNameMode).toBe('fixed')
|
||||
})
|
||||
|
||||
it('publishes canonical identity when host and instance metadata are known', () => {
|
||||
const worktree = mergeWorktree('repo-1', git, {
|
||||
instanceId: '11111111-1111-4111-8111-111111111111',
|
||||
|
||||
@@ -18,6 +18,10 @@ export function mergeWorktree(
|
||||
const branchShort = git.branch.replace(/^refs\/heads\//, '')
|
||||
const creatorProvenance = normalizeWorkspaceCreatorProvenance(meta?.creatorProvenance)
|
||||
const worktreeId = `${repoId}::${git.path}`
|
||||
const automaticDisplayName = branchShort || defaultDisplayName || basename(git.path)
|
||||
// CLI-created labels predate displayNameIsPinned but are still explicit names.
|
||||
const legacyCliDisplayNameIsPinned =
|
||||
meta?.displayNameIsPinned === undefined && meta?.cliProvenance?.kind === 'created-by-cli'
|
||||
return {
|
||||
id: worktreeId,
|
||||
...(meta?.instanceId && meta.hostId
|
||||
@@ -46,7 +50,19 @@ export function mergeWorktree(
|
||||
isBare: git.isBare,
|
||||
...(git.isSparse === true ? { isSparse: true } : {}),
|
||||
isMainWorktree: git.isMainWorktree,
|
||||
displayName: meta?.displayName || branchShort || defaultDisplayName || basename(git.path),
|
||||
// Automatic labels follow the live branch; persisted values are only authoritative when pinned.
|
||||
displayName:
|
||||
meta?.displayNameIsPinned === false
|
||||
? automaticDisplayName
|
||||
: meta?.displayName || automaticDisplayName,
|
||||
displayNameMode:
|
||||
meta?.displayNameIsPinned === true || legacyCliDisplayNameIsPinned
|
||||
? 'fixed'
|
||||
: meta?.displayNameIsPinned === false
|
||||
? 'automatic'
|
||||
: meta?.displayName && meta.displayName.trim() !== branchShort
|
||||
? 'fixed'
|
||||
: 'automatic',
|
||||
comment: meta?.comment || '',
|
||||
linkedIssue: meta?.linkedIssue ?? null,
|
||||
linkedPR: meta?.linkedPR ?? null,
|
||||
|
||||
@@ -78,7 +78,8 @@ type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & {
|
||||
}
|
||||
import {
|
||||
sanitizeWorktreeName,
|
||||
sanitizeWorktreeDisplayName,
|
||||
resolveWorktreeCreateDisplayNameRequest,
|
||||
resolveWorktreeCreateDisplayNameMeta,
|
||||
computeValidatedBranchName,
|
||||
computeWorktreePath,
|
||||
computeRemoteWorktreePath,
|
||||
@@ -87,7 +88,6 @@ import {
|
||||
getWorktreeCreationLayout,
|
||||
getWorktreePathSettings,
|
||||
hasRepoWorktreeBasePath,
|
||||
shouldSetDisplayName,
|
||||
mergeWorktree
|
||||
} from './worktree-logic'
|
||||
import { findCreatedWorktree, resolveCreatedWorktree } from './created-worktree-reconciliation'
|
||||
@@ -1546,9 +1546,14 @@ export async function createRemoteWorktree(
|
||||
let effectiveRequestedName = args.name
|
||||
const sanitizedName = sanitizeWorktreeName(args.name)
|
||||
let effectiveSanitizedName = sanitizedName
|
||||
const requestedDisplayName = args.displayName
|
||||
? sanitizeWorktreeDisplayName(args.displayName)
|
||||
: undefined
|
||||
const displayNameRequest = resolveWorktreeCreateDisplayNameRequest(
|
||||
args.displayName,
|
||||
args.displayNameKind,
|
||||
args.name,
|
||||
args.cliProvenance?.kind === 'created-by-cli',
|
||||
args.nameWasGenerated === true
|
||||
)
|
||||
const requestedDisplayName = displayNameRequest.value
|
||||
|
||||
// Why: base resolution probes refs via generic git.exec; register the repo root first so relays don't report a valid base as stale.
|
||||
await registerRequiredSshWorktreeCreateRoots(repo.connectionId!, [repo.path])
|
||||
@@ -1878,11 +1883,12 @@ export async function createRemoteWorktree(
|
||||
baseRef: metadataBaseRef,
|
||||
...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}),
|
||||
...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}),
|
||||
...(requestedDisplayName
|
||||
? { displayName: requestedDisplayName }
|
||||
: shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName)
|
||||
? { displayName: effectiveRequestedName }
|
||||
: {}),
|
||||
...resolveWorktreeCreateDisplayNameMeta(
|
||||
requestedDisplayName,
|
||||
branchName,
|
||||
displayNameRequest.kind,
|
||||
{ requestedName: effectiveRequestedName, sanitizedName: effectiveSanitizedName }
|
||||
),
|
||||
...(isTuiAgent(args.createdWithAgent) ? { createdWithAgent: args.createdWithAgent } : {}),
|
||||
...(args.pendingFirstAgentMessageRename === true && isTuiAgent(args.createdWithAgent)
|
||||
? { pendingFirstAgentMessageRename: true }
|
||||
@@ -2021,9 +2027,14 @@ export async function createLocalWorktree(
|
||||
|
||||
const requestedName = args.name
|
||||
const sanitizedName = sanitizeWorktreeName(args.name)
|
||||
const requestedDisplayName = args.displayName
|
||||
? sanitizeWorktreeDisplayName(args.displayName)
|
||||
: undefined
|
||||
const displayNameRequest = resolveWorktreeCreateDisplayNameRequest(
|
||||
args.displayName,
|
||||
args.displayNameKind,
|
||||
args.name,
|
||||
args.cliProvenance?.kind === 'created-by-cli',
|
||||
args.nameWasGenerated === true
|
||||
)
|
||||
const requestedDisplayName = displayNameRequest.value
|
||||
// Why: explicit branches and non-username prefix modes never consume this; skipping the probe preserves the exact generated branch name.
|
||||
// Username and base resolution are independent read-only probes. Starting
|
||||
// both before awaiting removes one serial git/config round trip from create.
|
||||
@@ -2551,11 +2562,12 @@ export async function createLocalWorktree(
|
||||
baseRef: metadataBaseRef,
|
||||
...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}),
|
||||
...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}),
|
||||
...(requestedDisplayName
|
||||
? { displayName: requestedDisplayName }
|
||||
: shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName)
|
||||
? { displayName: effectiveRequestedName }
|
||||
: {}),
|
||||
...resolveWorktreeCreateDisplayNameMeta(
|
||||
requestedDisplayName,
|
||||
branchName,
|
||||
displayNameRequest.kind,
|
||||
{ requestedName: effectiveRequestedName, sanitizedName: effectiveSanitizedName }
|
||||
),
|
||||
...(sparseDirectories.length > 0
|
||||
? {
|
||||
sparseDirectories,
|
||||
|
||||
@@ -253,6 +253,20 @@ describe('registerWorktreeHandlers', () => {
|
||||
expect(runtimeStub.notifyWorktreesChangedForRemoteClients).toHaveBeenCalledWith('repo-1')
|
||||
})
|
||||
|
||||
it('persists display-name provenance at the host boundary', () => {
|
||||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||||
|
||||
handlers['worktrees:updateMeta'](null, {
|
||||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||||
updates: { displayName: 'Agent label' }
|
||||
})
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/feature-wt',
|
||||
expect.objectContaining({ displayName: 'Agent label', displayNameIsPinned: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not trust renderer-authored automation provenance during local create', async () => {
|
||||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
@@ -319,6 +333,29 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('pins a legacy name-only user create when the branch matches', async () => {
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/feature',
|
||||
head: 'abc123',
|
||||
branch: 'feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||||
|
||||
await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'feature'
|
||||
})
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/feature',
|
||||
expect.objectContaining({ displayName: 'feature', displayNameIsPinned: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('persists linked issue and PR metadata during local create', async () => {
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { CreateWorktreeResult } from '../../../../shared/worktree/create-ty
|
||||
import type { Store } from '../../../persistence/loading-store/store'
|
||||
import type { CreateWorktreeArgsWithSystemProvenance } from '../ipc-context-schemas'
|
||||
import { getFolderWorkspaceInstanceId, mergeFolderWorkspace } from '../folder-workspace-model'
|
||||
import { resolveWorktreeCreateDisplayNameRequest } from '../../worktree-logic'
|
||||
|
||||
export function createFolderWorkspace(
|
||||
args: CreateWorktreeArgsWithSystemProvenance,
|
||||
@@ -14,12 +15,22 @@ export function createFolderWorkspace(
|
||||
const now = Date.now()
|
||||
const instanceId = randomUUID()
|
||||
const worktreeId = getFolderWorkspaceInstanceId(repo, instanceId)
|
||||
const displayNameRequest = resolveWorktreeCreateDisplayNameRequest(
|
||||
args.displayName,
|
||||
args.displayNameKind,
|
||||
args.name,
|
||||
args.cliProvenance?.kind === 'created-by-cli',
|
||||
args.nameWasGenerated === true
|
||||
)
|
||||
const meta = store.setWorktreeMeta(worktreeId, {
|
||||
instanceId,
|
||||
...(store.getProjectHostSetups
|
||||
? getProjectHostSetupWorktreeMeta(store.getProjectHostSetups(), repo)
|
||||
: {}),
|
||||
displayName: args.displayName || args.name,
|
||||
displayName: displayNameRequest.value || args.name,
|
||||
...(displayNameRequest.kind === 'user' && displayNameRequest.value
|
||||
? { displayNameIsPinned: true }
|
||||
: {}),
|
||||
lastActivityAt: now,
|
||||
createdAt: now,
|
||||
orcaCreatedAt: now,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import type { WorktreeMeta } from '../../../../shared/worktree/meta-types'
|
||||
import { displayNameUpdatePinsLabel } from '../../../../shared/worktree/display-name-provenance'
|
||||
import { parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { stripOrcaProvenanceMetaUpdates } from '../../../worktree-removal-safety'
|
||||
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
|
||||
@@ -39,6 +40,8 @@ export function registerWorktreeMetadataHandlers(context: WorktreeIpcContext): v
|
||||
validatedUpdates.displayName !== undefined
|
||||
? {
|
||||
...validatedUpdates,
|
||||
// The host persists provenance; do not rely on renderer-authored metadata.
|
||||
displayNameIsPinned: displayNameUpdatePinsLabel(validatedUpdates.displayName),
|
||||
pendingFirstAgentMessageRename: false,
|
||||
firstAgentMessageRenameError: null
|
||||
}
|
||||
|
||||
@@ -73,6 +73,33 @@ describe('adoptProvisionedRootSshCheckout', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('pins an explicit label even when it equals the adopted branch', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
registerSshGitProvider(connectionId, {
|
||||
listWorktrees: vi.fn().mockResolvedValue([gitWorktree(projectRoot)]),
|
||||
exec: sparseCheckoutProbe(false)
|
||||
} as never)
|
||||
const { store, setWorktreeMeta } = makeStore()
|
||||
|
||||
const result = await adoptProvisionedRootSshCheckout({
|
||||
userDataPath,
|
||||
request: {
|
||||
...request(projectRoot),
|
||||
displayName: 'fix-sandbox',
|
||||
displayNameKind: 'user'
|
||||
},
|
||||
repo: repo(projectRoot),
|
||||
store,
|
||||
isRepoCurrent: () => true
|
||||
})
|
||||
|
||||
expect(result.worktree.displayName).toBe('fix-sandbox')
|
||||
expect(setWorktreeMeta).toHaveBeenCalledWith(
|
||||
`repo-1::${projectRoot}`,
|
||||
expect.objectContaining({ displayName: 'fix-sandbox', displayNameIsPinned: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a recipe checkout on a branch Orca did not request', async () => {
|
||||
seedRuntime(userDataPath, projectRoot)
|
||||
registerSshGitProvider(connectionId, {
|
||||
|
||||
@@ -23,7 +23,12 @@ import {
|
||||
isCurrentSshProviderAuthority
|
||||
} from './ssh/ssh-provider-authority'
|
||||
import { attachEphemeralVmRuntimeToWorkspace } from './ephemeral-vm-runtime-attachment'
|
||||
import { getWorktreeCreationLayout, mergeWorktree } from './ipc/worktree-logic'
|
||||
import {
|
||||
getWorktreeCreationLayout,
|
||||
mergeWorktree,
|
||||
resolveWorktreeCreateDisplayNameMeta,
|
||||
resolveWorktreeCreateDisplayNameRequest
|
||||
} from './ipc/worktree-logic'
|
||||
|
||||
type AdoptionArgs = AdoptProvisionedRootArgs & {
|
||||
automationProvenance?: AutomationWorkspaceProvenance
|
||||
@@ -110,7 +115,13 @@ export async function adoptProvisionedRootSshCheckout(args: {
|
||||
const now = Date.now()
|
||||
const meta = store.setWorktreeMeta(
|
||||
worktreeId,
|
||||
buildProvisionedRootMeta(store, repo, request, now)
|
||||
buildProvisionedRootMeta(
|
||||
store,
|
||||
repo,
|
||||
request,
|
||||
gitWorktree.branch.replace(/^refs\/heads\//, ''),
|
||||
now
|
||||
)
|
||||
)
|
||||
return { worktree: mergeWorktree(repo.id, gitWorktree, meta) }
|
||||
}
|
||||
@@ -155,8 +166,22 @@ function buildProvisionedRootMeta(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
args: AdoptionArgs,
|
||||
branchName: string,
|
||||
now: number
|
||||
): Partial<WorktreeMeta> {
|
||||
const displayNameRequest = resolveWorktreeCreateDisplayNameRequest(
|
||||
args.displayName,
|
||||
args.displayNameKind,
|
||||
args.name,
|
||||
false,
|
||||
args.nameWasGenerated === true
|
||||
)
|
||||
const displayNameMeta = resolveWorktreeCreateDisplayNameMeta(
|
||||
displayNameRequest.value,
|
||||
branchName,
|
||||
displayNameRequest.kind,
|
||||
{ requestedName: args.name, sanitizedName: args.name }
|
||||
)
|
||||
return {
|
||||
instanceId: randomUUID(),
|
||||
...(store.getProjectHostSetups
|
||||
@@ -164,7 +189,8 @@ function buildProvisionedRootMeta(
|
||||
: {}),
|
||||
hostId: args.executionHostId,
|
||||
ephemeralVmCheckoutMode: 'provisioned-root',
|
||||
displayName: args.displayName || args.name,
|
||||
displayName: displayNameMeta.displayName ?? args.name,
|
||||
...displayNameMeta,
|
||||
lastActivityAt: now,
|
||||
createdAt: now,
|
||||
orcaCreatedAt: now,
|
||||
|
||||
@@ -5320,6 +5320,8 @@ describe('OrcaRuntimeService', () => {
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:folder-repo',
|
||||
name: 'folder-session',
|
||||
displayName: '\u0000\u202e',
|
||||
displayNameKind: 'user',
|
||||
createdWithAgent: 'codex',
|
||||
startup: { command: 'codex', viewMode: 'chat' }
|
||||
})
|
||||
@@ -5345,6 +5347,7 @@ describe('OrcaRuntimeService', () => {
|
||||
orcaCreationSource: 'runtime',
|
||||
createdWithAgent: 'codex'
|
||||
})
|
||||
expect(metaById[result.worktree.id]).not.toHaveProperty('displayNameIsPinned')
|
||||
await expect(runtime.showManagedWorktree(`id:${result.worktree.id}`)).resolves.toMatchObject({
|
||||
id: result.worktree.id,
|
||||
repoId: 'folder-repo',
|
||||
|
||||
@@ -413,6 +413,7 @@ import type {
|
||||
WorktreeRemoteBranchConflictEvent
|
||||
} from '../../shared/worktree/base-ref-drift-types'
|
||||
import type {
|
||||
CreateWorktreeArgs,
|
||||
CreateWorktreeResult,
|
||||
ForceDeleteWorktreeBranchResult,
|
||||
RemoveWorktreeResult
|
||||
@@ -1248,7 +1249,8 @@ import {
|
||||
isOrphanedWorktreeError,
|
||||
mergeWorktree,
|
||||
sanitizeWorktreeName,
|
||||
shouldSetDisplayName,
|
||||
resolveWorktreeCreateDisplayNameRequest,
|
||||
resolveWorktreeCreateDisplayNameMeta,
|
||||
areWorktreePathsEqual
|
||||
} from '../ipc/worktree-logic'
|
||||
import { resolveCreatedWorktree } from '../ipc/created-worktree-reconciliation'
|
||||
@@ -26993,6 +26995,7 @@ export class OrcaRuntimeService {
|
||||
linkedTaskSourceContext?: TaskSourceContext | null
|
||||
comment?: string
|
||||
displayName?: string
|
||||
displayNameKind?: CreateWorktreeArgs['displayNameKind']
|
||||
telemetrySource?: WorkspaceCreateTelemetrySource
|
||||
workspaceStatus?: string
|
||||
manualOrder?: number
|
||||
@@ -27066,10 +27069,21 @@ export class OrcaRuntimeService {
|
||||
const settings = createSettings
|
||||
const instanceId = randomUUID()
|
||||
const worktreeId = getRuntimeFolderWorkspaceInstanceId(repo, instanceId)
|
||||
const displayNameRequest = resolveWorktreeCreateDisplayNameRequest(
|
||||
args.displayName,
|
||||
args.displayNameKind,
|
||||
args.name,
|
||||
args.cliProvenance?.kind === 'created-by-cli',
|
||||
args.nameWasGenerated === true
|
||||
)
|
||||
const resolvedFolderDisplayName = displayNameRequest.value
|
||||
const meta = this.store.setWorktreeMeta(worktreeId, {
|
||||
instanceId,
|
||||
...getProjectHostSetupWorktreeMeta(this.store.getProjectHostSetups?.() ?? [], repo),
|
||||
displayName: args.displayName?.trim() || args.name,
|
||||
displayName: resolvedFolderDisplayName ?? args.name,
|
||||
...(displayNameRequest.kind === 'user' && resolvedFolderDisplayName
|
||||
? { displayNameIsPinned: true }
|
||||
: {}),
|
||||
lastActivityAt: now,
|
||||
createdAt: now,
|
||||
orcaCreatedAt: now,
|
||||
@@ -27261,7 +27275,14 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
const hostedReviewExecutionContext = this.getHostedReviewExecutionOptions(repo)
|
||||
let effectiveRequestedName = args.name
|
||||
const requestedDisplayName = args.displayName?.trim() || undefined
|
||||
const displayNameRequest = resolveWorktreeCreateDisplayNameRequest(
|
||||
args.displayName,
|
||||
args.displayNameKind,
|
||||
args.name,
|
||||
args.cliProvenance?.kind === 'created-by-cli',
|
||||
args.nameWasGenerated === true
|
||||
)
|
||||
const requestedDisplayName = displayNameRequest.value
|
||||
const sanitizedName = sanitizeWorktreeName(args.name)
|
||||
let effectiveSanitizedName = sanitizedName
|
||||
// Username and base resolution are independent read-only probes. Starting
|
||||
@@ -27704,11 +27725,12 @@ export class OrcaRuntimeService {
|
||||
// Why: PR/MR-created worktrees can start from a head ref/SHA while Source
|
||||
// Control must compare against the review target branch.
|
||||
const metadataBaseRef = args.compareBaseRef ?? remoteTrackingBase?.ref ?? baseBranch
|
||||
const displayNameMeta = requestedDisplayName
|
||||
? { displayName: requestedDisplayName }
|
||||
: shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName)
|
||||
? { displayName: effectiveRequestedName }
|
||||
: {}
|
||||
const displayNameMeta = resolveWorktreeCreateDisplayNameMeta(
|
||||
requestedDisplayName,
|
||||
branchName,
|
||||
displayNameRequest.kind,
|
||||
{ requestedName: effectiveRequestedName, sanitizedName: effectiveSanitizedName }
|
||||
)
|
||||
const meta = this.store.setWorktreeMeta(worktreeId, {
|
||||
// Why: worktree IDs are path-derived. If a path is deleted outside Orca
|
||||
// and later recreated, creation must mint a fresh instance identity so
|
||||
@@ -28158,6 +28180,7 @@ export class OrcaRuntimeService {
|
||||
linkedTaskSourceContext?: TaskSourceContext | null
|
||||
comment?: string
|
||||
displayName?: string
|
||||
displayNameKind?: CreateWorktreeArgs['displayNameKind']
|
||||
workspaceStatus?: string
|
||||
manualOrder?: number
|
||||
sparseCheckout?: { directories: string[]; presetId?: string }
|
||||
@@ -28195,6 +28218,7 @@ export class OrcaRuntimeService {
|
||||
name: args.name,
|
||||
...(args.nameWasGenerated === true ? { nameWasGenerated: true } : {}),
|
||||
...(args.displayName ? { displayName: args.displayName } : {}),
|
||||
...(args.displayNameKind ? { displayNameKind: args.displayNameKind } : {}),
|
||||
...(args.baseBranch ? { baseBranch: args.baseBranch } : {}),
|
||||
...(args.compareBaseRef ? { compareBaseRef: args.compareBaseRef } : {}),
|
||||
...(args.branchNameOverride ? { branchNameOverride: args.branchNameOverride } : {}),
|
||||
|
||||
@@ -160,6 +160,7 @@ export async function startFederatedWorker(args: {
|
||||
repo: params.repo,
|
||||
baseBranch: params.baseBranch,
|
||||
displayName: params.displayName,
|
||||
...(params.displayName !== undefined ? { displayNameKind: 'user' as const } : {}),
|
||||
comment: params.comment,
|
||||
setup: createsWorktree ? (params.setup ?? 'run') : undefined,
|
||||
setupSource: createsWorktree
|
||||
|
||||
@@ -14,6 +14,7 @@ export const FederationAttachStartParams = z.object({
|
||||
repo: OptionalString,
|
||||
baseBranch: OptionalString,
|
||||
displayName: OptionalString,
|
||||
displayNameKind: z.enum(['generated', 'user']).optional(),
|
||||
comment: OptionalString,
|
||||
setup: z.enum(['run', 'skip', 'inherit']).optional(),
|
||||
setupSource: z.enum(['explicit_request', 'orchestration_default']).optional(),
|
||||
|
||||
@@ -211,6 +211,19 @@ describe('orchestration federation', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('carries an explicit worker label as user display-name provenance', async () => {
|
||||
const task = createHomeTask()
|
||||
|
||||
await homeDispatcher.dispatch(startRequest(task.id, { displayName: 'Windows release audit' }))
|
||||
|
||||
expect(workerRuntime.createManagedWorktree).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
displayName: 'Windows release audit',
|
||||
displayNameKind: 'user'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not report remotely rejected preferences as effective', async () => {
|
||||
const task = createHomeTask()
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [
|
||||
name: params.name as string,
|
||||
baseBranch: params.baseBranch,
|
||||
displayName: params.displayName,
|
||||
displayNameKind: params.displayNameKind,
|
||||
comment: params.comment,
|
||||
// setupDecision runs setup without the legacy runHooks activation side effect.
|
||||
runHooks: false,
|
||||
|
||||
@@ -135,6 +135,7 @@ export async function createWorkerWorktree(args: {
|
||||
name: params.name as string,
|
||||
baseBranch: params.baseBranch,
|
||||
displayName: params.displayName,
|
||||
...(params.displayName !== undefined ? { displayNameKind: 'user' as const } : {}),
|
||||
comment: params.comment,
|
||||
// setupDecision runs setup without the legacy runHooks activation side effect.
|
||||
runHooks: false,
|
||||
|
||||
@@ -252,6 +252,7 @@ describe('orchestration new-worktree workers', () => {
|
||||
repoSelector: 'id:repo-explicit',
|
||||
baseBranch: 'origin/release',
|
||||
displayName: 'Windows release audit',
|
||||
displayNameKind: 'user',
|
||||
comment: 'Created for a supervised audit',
|
||||
setupDecision: 'skip',
|
||||
runHooks: false,
|
||||
|
||||
@@ -27,6 +27,19 @@ describe('buildManagedWorktreeCreateArgs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the legacy CLI marker on a name-only create request', () => {
|
||||
const args = buildManagedWorktreeCreateArgs(
|
||||
WorktreeCreate.parse({ repo: 'id:repo-1', name: 'feature' }),
|
||||
{ ...PROVENANCE, cliProvenance: { kind: 'created-by-cli', createdAt: 1 } }
|
||||
)
|
||||
|
||||
expect(args).toMatchObject({
|
||||
name: 'feature',
|
||||
cliProvenance: { kind: 'created-by-cli', createdAt: 1 }
|
||||
})
|
||||
expect(args.displayName).toBeUndefined()
|
||||
})
|
||||
|
||||
it('carries the parent-pick provenance only when the client marked it manual', () => {
|
||||
// Why: older clients never send it, and those creates really are CLI-flag equivalents.
|
||||
expect(
|
||||
|
||||
@@ -39,6 +39,7 @@ export function buildManagedWorktreeCreateArgs(
|
||||
linkedTaskSourceContext: params.linkedTaskSourceContext,
|
||||
comment: params.comment,
|
||||
displayName: params.displayName,
|
||||
displayNameKind: params.displayNameKind,
|
||||
telemetrySource: params.telemetrySource,
|
||||
workspaceStatus: params.workspaceStatus,
|
||||
manualOrder: params.manualOrder,
|
||||
|
||||
@@ -45,6 +45,7 @@ export const WorktreeCreate = z
|
||||
linkedTaskSourceContext: TaskSourceContextSchema.nullable().optional(),
|
||||
comment: OptionalString,
|
||||
displayName: OptionalString,
|
||||
displayNameKind: z.enum(['generated', 'user']).optional(),
|
||||
telemetrySource: z
|
||||
.unknown()
|
||||
.transform((value) => {
|
||||
|
||||
@@ -3,6 +3,15 @@ import { WorktreeCreate } from './worktree-create-schemas'
|
||||
import { WorktreeActivate, WorktreeSet } from './worktree-schemas'
|
||||
|
||||
describe('worktree RPC schemas', () => {
|
||||
it('accepts optional display-name provenance values', () => {
|
||||
expect(
|
||||
WorktreeCreate.parse({ repo: 'repo-1', name: 'feature', displayNameKind: 'user' })
|
||||
).toMatchObject({ displayNameKind: 'user' })
|
||||
expect(
|
||||
WorktreeCreate.parse({ repo: 'repo-1', name: 'feature', displayNameKind: 'generated' })
|
||||
).toMatchObject({ displayNameKind: 'generated' })
|
||||
})
|
||||
|
||||
it('validates additive navigation intent', () => {
|
||||
expect(WorktreeActivate.parse({ worktree: 'id:wt-1', navigation: 'clients' }).navigation).toBe(
|
||||
'clients'
|
||||
|
||||
@@ -147,6 +147,33 @@ describe('worktree RPC methods', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the legacy CLI name-only create shape explicit at the host boundary', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
dedupeWorktreeCreate: passthroughDedupe,
|
||||
showRepo: vi.fn().mockResolvedValue(repo),
|
||||
createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-cli' } })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
|
||||
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('worktree.create', {
|
||||
repo: 'repo-1',
|
||||
name: 'feature',
|
||||
cliProvenanceRequest: {}
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.createManagedWorktree).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'feature',
|
||||
displayName: undefined,
|
||||
displayNameKind: undefined,
|
||||
cliProvenance: expect.objectContaining({ kind: 'created-by-cli' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('mints automation provenance from a valid dispatch request on worktree creation', async () => {
|
||||
const dispatchToken = createAutomationDispatchToken('automation-1', 'run-1')
|
||||
const runtime = {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
resolveAutomationWorkspaceProvenance
|
||||
} from '../../../automations/workspace-provenance'
|
||||
import { buildCliWorkspaceProvenance } from '../../../../shared/cli-workspace-provenance'
|
||||
import { displayNameUpdatePinsLabel } from '../../../../shared/worktree/display-name-provenance'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { buildManagedWorktreeCreateArgs } from './worktree-create-args'
|
||||
import { resolvePairedCallerHostId } from './paired-caller-host-id'
|
||||
@@ -133,6 +134,9 @@ export const WORKTREE_METHODS: RpcMethod[] = [
|
||||
handler: async (params, { runtime }) => ({
|
||||
worktree: await runtime.updateManagedWorktreeMeta(params.worktree, {
|
||||
displayName: params.displayName,
|
||||
...(params.displayName !== undefined
|
||||
? { displayNameIsPinned: displayNameUpdatePinsLabel(params.displayName) }
|
||||
: {}),
|
||||
linkedIssue: params.linkedIssue,
|
||||
linkedPR: params.linkedPR,
|
||||
suppressedGitHubPR: params.suppressedGitHubPR,
|
||||
|
||||
@@ -78,6 +78,7 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) {
|
||||
submitLinkedPR,
|
||||
workspaceName,
|
||||
nameWasGenerated,
|
||||
nameIsAutoManaged,
|
||||
submitBaseBranch,
|
||||
submitCompareBaseRef,
|
||||
submitPushTarget,
|
||||
@@ -155,6 +156,9 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) {
|
||||
linkedWorkItem: toFolderWorkspaceLinkedTask(submitLinkedWorkItem),
|
||||
linkedTaskSourceContext: taskSourceContext,
|
||||
nameWasGenerated,
|
||||
...(createDisplayName
|
||||
? { displayNameKind: nameIsAutoManaged ? ('generated' as const) : ('user' as const) }
|
||||
: {}),
|
||||
...(!backendStartup && startupPlan?.draftPrompt
|
||||
? { startupDraft: startupPlan.draftPrompt }
|
||||
: {}),
|
||||
|
||||
@@ -163,11 +163,10 @@ export function useFullSubmitPreparation(input: FullSubmitPreparationInput) {
|
||||
smartGitHubResolution.kind === 'none' && smartNameMode === 'branches'
|
||||
})
|
||||
|
||||
const createDisplayName =
|
||||
smartGitHubResolution.kind === 'none'
|
||||
? nameIsAutoManaged
|
||||
? submitTitleName?.displayName
|
||||
: undefined
|
||||
const createDisplayName = !nameIsAutoManaged
|
||||
? workspaceName
|
||||
: smartGitHubResolution.kind === 'none'
|
||||
? submitTitleName?.displayName
|
||||
: smartGitHubCreateNames.displayName
|
||||
|
||||
// Why: the first-work hook only renames blank, auto-generated git workspaces that launch an agent; persist that pending state for the card.
|
||||
|
||||
@@ -105,6 +105,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) {
|
||||
submitLinkedPR,
|
||||
workspaceName,
|
||||
nameWasGenerated,
|
||||
nameIsAutoManaged,
|
||||
submitCompareBaseRef,
|
||||
submitPushTarget,
|
||||
effectiveSetupDecision,
|
||||
@@ -204,6 +205,7 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) {
|
||||
workspaceName,
|
||||
nameWasGenerated,
|
||||
displayName: createDisplayName,
|
||||
displayNameKind: createDisplayName ? (nameIsAutoManaged ? 'generated' : 'user') : undefined,
|
||||
selectedRepoIsGit,
|
||||
baseBranch: submitBaseBranch,
|
||||
compareBaseRef: submitCompareBaseRef,
|
||||
|
||||
@@ -18,6 +18,7 @@ export type QuickCreationRequestInput = {
|
||||
workspaceName: string
|
||||
nameWasGenerated: boolean
|
||||
displayName: string | undefined
|
||||
displayNameKind?: 'generated' | 'user'
|
||||
selectedRepoIsGit: boolean
|
||||
baseBranch: string | undefined
|
||||
compareBaseRef: string | undefined
|
||||
@@ -63,6 +64,7 @@ export function buildQuickCreationRequest(
|
||||
name: input.workspaceName,
|
||||
...(input.nameWasGenerated ? { nameWasGenerated: true } : {}),
|
||||
...(input.displayName ? { displayName: input.displayName } : {}),
|
||||
...(input.displayNameKind ? { displayNameKind: input.displayNameKind } : {}),
|
||||
...(input.selectedRepoIsGit && input.baseBranch ? { baseBranch: input.baseBranch } : {}),
|
||||
...(input.selectedRepoIsGit && input.compareBaseRef
|
||||
? { compareBaseRef: input.compareBaseRef }
|
||||
|
||||
@@ -230,11 +230,10 @@ export function useQuickSubmitPreparation(input: QuickSubmitPreparationInput) {
|
||||
|
||||
const submitBaseBranch = baseBranchSettlement.value
|
||||
|
||||
const createDisplayName =
|
||||
smartGitHubResolution.kind === 'none'
|
||||
? nameIsAutoManaged
|
||||
? submitTitleName?.displayName
|
||||
: undefined
|
||||
const createDisplayName = !nameIsAutoManaged
|
||||
? workspaceName
|
||||
: smartGitHubResolution.kind === 'none'
|
||||
? submitTitleName?.displayName
|
||||
: smartGitHubCreateNames.displayName
|
||||
|
||||
// Why: quick create shares the blank-name flow; the card needs an explicit marker, not a guess from the title.
|
||||
|
||||
@@ -66,6 +66,7 @@ export type WorktreeCreationRequest = {
|
||||
/** True only when `name` came from the creature-name generator; gates host-side retirement. */
|
||||
nameWasGenerated?: boolean
|
||||
displayName?: string
|
||||
displayNameKind?: 'generated' | 'user'
|
||||
baseBranch?: string
|
||||
compareBaseRef?: string
|
||||
setupDecision: SetupDecision
|
||||
|
||||
@@ -99,6 +99,9 @@ export async function executeWorktreeCreation(
|
||||
preparedRequest.compareBaseRef,
|
||||
{
|
||||
...(preparedRequest.nameWasGenerated ? { nameWasGenerated: true } : {}),
|
||||
...(preparedRequest.displayNameKind
|
||||
? { displayNameKind: preparedRequest.displayNameKind }
|
||||
: {}),
|
||||
...(preparedRequest.linkedWorkItem !== undefined
|
||||
? { linkedWorkItem: preparedRequest.linkedWorkItem }
|
||||
: {}),
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { applyWorktreeUpdates } from './worktree-meta-update-application'
|
||||
|
||||
describe('applyWorktreeUpdates display-name provenance', () => {
|
||||
it('returns to the current branch immediately when a label is cleared', () => {
|
||||
const worktree = {
|
||||
id: 'repo-1::/workspace/feature',
|
||||
repoId: 'repo-1',
|
||||
path: '/workspace/feature',
|
||||
branch: 'refs/heads/main',
|
||||
displayName: 'Agent workspace'
|
||||
}
|
||||
|
||||
const next = applyWorktreeUpdates({ 'repo-1': [worktree as never] }, worktree.id, {
|
||||
displayName: '',
|
||||
displayNameIsPinned: false
|
||||
})
|
||||
|
||||
expect(next['repo-1']?.[0]).toMatchObject({
|
||||
displayName: 'main',
|
||||
displayNameMode: 'automatic'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps text until the host resolves a detached fallback', () => {
|
||||
const worktree = {
|
||||
id: 'repo-1::/workspace/feature',
|
||||
repoId: 'repo-1',
|
||||
path: '/workspace/feature',
|
||||
branch: '',
|
||||
displayName: 'Agent workspace'
|
||||
}
|
||||
|
||||
const next = applyWorktreeUpdates({ 'repo-1': [worktree as never] }, worktree.id, {
|
||||
displayName: '',
|
||||
displayNameIsPinned: false
|
||||
})
|
||||
|
||||
expect(next['repo-1']?.[0]).toMatchObject({
|
||||
displayName: 'Agent workspace',
|
||||
displayNameMode: 'automatic'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { worktreeRowMatchesMetaHost } from './worktrees/listing/worktree-meta-host-match'
|
||||
import { branchName } from '@/lib/git-utils'
|
||||
|
||||
type RequiredKey<T> = { [K in keyof T]-?: undefined extends T[K] ? never : K }[keyof T]
|
||||
|
||||
@@ -59,7 +60,17 @@ export function applyWorktreeUpdates(
|
||||
}
|
||||
|
||||
changed = true
|
||||
return { ...worktree, ...updates }
|
||||
const next = { ...worktree, ...updates }
|
||||
if (updates.displayNameIsPinned !== undefined) {
|
||||
next.displayNameMode = updates.displayNameIsPinned ? 'fixed' : 'automatic'
|
||||
if (updates.displayNameIsPinned === false && !updates.displayName?.trim()) {
|
||||
const automaticName = branchName(next.branch)
|
||||
// A detached worktree has no branch-derived label; keep the old text until the host
|
||||
// projection supplies its repo/path fallback instead of flashing an empty sidebar row.
|
||||
next.displayName = automaticName || worktree.displayName
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
if (!changed) {
|
||||
return worktreesByRepo
|
||||
|
||||
@@ -301,6 +301,236 @@ describe('fetchWorktrees', () => {
|
||||
expect(store.getState().worktreesByRepo.repo1[0]?.head).toBe('def456')
|
||||
})
|
||||
|
||||
it('does not merge a stale display name over a rename completed during refresh', async () => {
|
||||
const store = createTestStore()
|
||||
const worktreeId = 'repo1::/path/wt1'
|
||||
const requestStarted = makeWorktree({
|
||||
id: worktreeId,
|
||||
repoId: 'repo1',
|
||||
path: '/path/wt1',
|
||||
displayName: 'old label',
|
||||
displayNameMode: 'fixed'
|
||||
})
|
||||
const staleResponse = makeWorktree({
|
||||
...requestStarted,
|
||||
head: 'stale-head'
|
||||
})
|
||||
let resolveListing!: (worktrees: Worktree[]) => void
|
||||
const listing = new Promise<Worktree[]>((resolve) => {
|
||||
resolveListing = resolve
|
||||
})
|
||||
worktreeListMock.mockReturnValueOnce(listing)
|
||||
store.setState({ worktreesByRepo: { repo1: [requestStarted] } } as Partial<AppState>)
|
||||
|
||||
const refresh = store.getState().fetchWorktrees('repo1')
|
||||
await vi.waitFor(() => expect(worktreeListMock).toHaveBeenCalledTimes(1))
|
||||
await store.getState().updateWorktreeMeta(worktreeId, { displayName: 'new label' })
|
||||
resolveListing([staleResponse])
|
||||
|
||||
await refresh
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
|
||||
head: 'stale-head',
|
||||
displayName: 'new label',
|
||||
displayNameMode: 'fixed'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a rename when an older host omits display-name mode', async () => {
|
||||
const store = createTestStore()
|
||||
const worktreeId = 'repo1::/path/wt1'
|
||||
const requestStarted = makeWorktree({
|
||||
id: worktreeId,
|
||||
repoId: 'repo1',
|
||||
path: '/path/wt1',
|
||||
displayName: 'old label',
|
||||
displayNameMode: 'automatic'
|
||||
})
|
||||
const staleResponse = { ...requestStarted, displayNameMode: undefined }
|
||||
let resolveListing!: (worktrees: Worktree[]) => void
|
||||
worktreeListMock.mockReturnValueOnce(
|
||||
new Promise<Worktree[]>((resolve) => {
|
||||
resolveListing = resolve
|
||||
})
|
||||
)
|
||||
store.setState({ worktreesByRepo: { repo1: [requestStarted] } } as Partial<AppState>)
|
||||
|
||||
const refresh = store.getState().fetchWorktrees('repo1')
|
||||
await vi.waitFor(() => expect(worktreeListMock).toHaveBeenCalledTimes(1))
|
||||
await store.getState().updateWorktreeMeta(worktreeId, { displayName: 'new label' })
|
||||
resolveListing([staleResponse])
|
||||
|
||||
await refresh
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
|
||||
displayName: 'new label',
|
||||
displayNameMode: 'fixed'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a rename when an old-host refresh is projected with a newer mode', async () => {
|
||||
const store = createTestStore()
|
||||
const worktreeId = 'repo1::/path/wt1'
|
||||
const requestStarted = makeWorktree({
|
||||
id: worktreeId,
|
||||
repoId: 'repo1',
|
||||
path: '/path/wt1',
|
||||
displayName: 'old label',
|
||||
displayNameMode: undefined
|
||||
})
|
||||
const staleResponse = { ...requestStarted, displayNameMode: 'fixed' as const }
|
||||
let resolveListing!: (worktrees: Worktree[]) => void
|
||||
worktreeListMock.mockReturnValueOnce(
|
||||
new Promise<Worktree[]>((resolve) => {
|
||||
resolveListing = resolve
|
||||
})
|
||||
)
|
||||
store.setState({ worktreesByRepo: { repo1: [requestStarted] } } as Partial<AppState>)
|
||||
|
||||
const refresh = store.getState().fetchWorktrees('repo1')
|
||||
await vi.waitFor(() => expect(worktreeListMock).toHaveBeenCalledTimes(1))
|
||||
await store.getState().updateWorktreeMeta(worktreeId, { displayName: 'new label' })
|
||||
resolveListing([staleResponse])
|
||||
|
||||
await refresh
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
|
||||
displayName: 'new label',
|
||||
displayNameMode: 'fixed'
|
||||
})
|
||||
})
|
||||
|
||||
it('retains an existing pinned mode when an older host omits it', async () => {
|
||||
const store = createTestStore()
|
||||
const existing = makeWorktree({
|
||||
id: 'repo1::/path/wt1',
|
||||
repoId: 'repo1',
|
||||
path: '/path/wt1',
|
||||
branch: 'refs/heads/feature',
|
||||
displayName: 'feature',
|
||||
displayNameMode: 'fixed'
|
||||
})
|
||||
const staleResponse = { ...existing, displayNameMode: undefined }
|
||||
|
||||
mockApi.worktrees.list.mockResolvedValueOnce([staleResponse])
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
|
||||
await store.getState().fetchWorktrees('repo1')
|
||||
store.getState().updateWorktreeGitIdentity(existing.id, { branch: 'refs/heads/next' })
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
|
||||
displayName: 'feature',
|
||||
displayNameMode: 'fixed'
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a peer rename from an older host over a pinned label', async () => {
|
||||
const store = createTestStore()
|
||||
const existing = makeWorktree({
|
||||
id: 'repo1::/path/wt1',
|
||||
repoId: 'repo1',
|
||||
path: '/path/wt1',
|
||||
branch: 'refs/heads/feature',
|
||||
displayName: 'my label',
|
||||
displayNameMode: 'fixed'
|
||||
})
|
||||
// A changed non-branch label from a mode-less host is explicit meta a peer wrote there.
|
||||
const peerRenamed = { ...existing, displayName: 'peer label', displayNameMode: undefined }
|
||||
|
||||
mockApi.worktrees.list.mockResolvedValueOnce([peerRenamed])
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
|
||||
await store.getState().fetchWorktrees('repo1')
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0].displayName).toBe('peer label')
|
||||
})
|
||||
|
||||
it('suppresses an older host branch-derived relabel of a pinned name', async () => {
|
||||
const store = createTestStore()
|
||||
const existing = makeWorktree({
|
||||
id: 'repo1::/path/wt1',
|
||||
repoId: 'repo1',
|
||||
path: '/path/wt1',
|
||||
branch: 'refs/heads/feature',
|
||||
displayName: 'my label',
|
||||
displayNameMode: 'fixed'
|
||||
})
|
||||
const rederived = {
|
||||
...existing,
|
||||
branch: 'refs/heads/next',
|
||||
displayName: 'next',
|
||||
displayNameMode: undefined
|
||||
}
|
||||
|
||||
mockApi.worktrees.list.mockResolvedValueOnce([rederived])
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
|
||||
await store.getState().fetchWorktrees('repo1')
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
|
||||
branch: 'refs/heads/next',
|
||||
displayName: 'my label',
|
||||
displayNameMode: 'fixed'
|
||||
})
|
||||
})
|
||||
|
||||
it('suppresses an older host detached-HEAD path relabel of a pinned name', async () => {
|
||||
const store = createTestStore()
|
||||
const existing = makeWorktree({
|
||||
id: 'repo1::/path/wt1',
|
||||
repoId: 'repo1',
|
||||
path: '/path/wt1',
|
||||
branch: 'refs/heads/feature',
|
||||
displayName: 'my label',
|
||||
displayNameMode: 'fixed'
|
||||
})
|
||||
const rederived = { ...existing, branch: '', displayName: 'wt1', displayNameMode: undefined }
|
||||
|
||||
mockApi.worktrees.list.mockResolvedValueOnce([rederived])
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
|
||||
await store.getState().fetchWorktrees('repo1')
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
|
||||
branch: '',
|
||||
displayName: 'my label',
|
||||
displayNameMode: 'fixed'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not merge a host response captured before an optimistic rename settles', async () => {
|
||||
const store = createTestStore()
|
||||
const existing = makeWorktree({
|
||||
id: 'repo1::/path/wt1',
|
||||
repoId: 'repo1',
|
||||
path: '/path/wt1',
|
||||
displayName: 'old label',
|
||||
displayNameMode: 'automatic'
|
||||
})
|
||||
const staleResponse = { ...existing }
|
||||
let resolvePersist!: () => void
|
||||
mockApi.worktrees.updateMeta.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
resolvePersist = resolve
|
||||
})
|
||||
)
|
||||
mockApi.worktrees.list.mockResolvedValueOnce([staleResponse])
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
|
||||
const rename = store.getState().updateWorktreeMeta(existing.id, { displayName: 'new label' })
|
||||
await vi.waitFor(() => expect(mockApi.worktrees.updateMeta).toHaveBeenCalledTimes(1))
|
||||
const refresh = store.getState().fetchWorktrees('repo1')
|
||||
|
||||
await refresh
|
||||
expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({
|
||||
displayName: 'new label',
|
||||
displayNameMode: 'fixed'
|
||||
})
|
||||
|
||||
resolvePersist()
|
||||
await rename
|
||||
})
|
||||
|
||||
it('updates the repo entry when only the persisted base ref changes', async () => {
|
||||
const store = createTestStore()
|
||||
const existing = makeWorktree({
|
||||
|
||||
@@ -211,6 +211,44 @@ describe('updateWorktreeGitIdentity', () => {
|
||||
expect(store.getState().worktreesByRepo.repo1[0].displayName).toBe('My Cool Work')
|
||||
})
|
||||
|
||||
it('preserves a pinned user title even when it equals the old branch', () => {
|
||||
const store = createTestStore()
|
||||
const existing = makeWorktree({
|
||||
id: 'repo1::/path/wt1',
|
||||
repoId: 'repo1',
|
||||
path: '/path/wt1',
|
||||
branch: 'refs/heads/feature',
|
||||
displayName: 'feature',
|
||||
displayNameMode: 'fixed'
|
||||
})
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
branch: 'refs/heads/main'
|
||||
})
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0].displayName).toBe('feature')
|
||||
})
|
||||
|
||||
it('preserves a legacy CLI title even without projected display-name mode', () => {
|
||||
const store = createTestStore()
|
||||
const existing = makeWorktree({
|
||||
id: 'repo1::/path/wt1',
|
||||
repoId: 'repo1',
|
||||
path: '/path/wt1',
|
||||
branch: 'refs/heads/feature',
|
||||
displayName: 'feature',
|
||||
cliProvenance: { kind: 'created-by-cli', createdAt: 1 }
|
||||
})
|
||||
|
||||
store.setState({ worktreesByRepo: { repo1: [existing] } } as Partial<AppState>)
|
||||
store.getState().updateWorktreeGitIdentity('repo1::/path/wt1', {
|
||||
branch: 'refs/heads/main'
|
||||
})
|
||||
|
||||
expect(store.getState().worktreesByRepo.repo1[0].displayName).toBe('feature')
|
||||
})
|
||||
|
||||
it('clears stale branch identity for detached HEAD updates', () => {
|
||||
const store = createTestStore()
|
||||
const existing = makeWorktree({
|
||||
|
||||
@@ -204,6 +204,7 @@ describe('worktree remote runtime mutations', () => {
|
||||
executionHostId: 'local',
|
||||
updates: {
|
||||
displayName: 'Fix auth',
|
||||
displayNameIsPinned: true,
|
||||
pendingFirstAgentMessageRename: false,
|
||||
firstAgentMessageRenameError: null
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export type CreateWorktreeCallOptions = {
|
||||
startupDraft?: string
|
||||
/** True only when `name` came from the creature-name generator; gates host-side retirement. */
|
||||
nameWasGenerated?: boolean
|
||||
displayNameKind?: CreateWorktreeArgs['displayNameKind']
|
||||
/** Parent picked in the composer. Sets sidebar nesting only; ignored if it no longer exists. */
|
||||
parentWorktreeId?: string
|
||||
provisionedRoot?: {
|
||||
@@ -51,6 +52,9 @@ function sharedCreateFields(
|
||||
setupDecision: request.setupDecision,
|
||||
sparseCheckout: request.sparseCheckout,
|
||||
...(request.displayName ? { displayName: request.displayName } : {}),
|
||||
...((request.displayNameKind ?? options?.displayNameKind)
|
||||
? { displayNameKind: request.displayNameKind ?? options?.displayNameKind }
|
||||
: {}),
|
||||
...(request.telemetrySource ? { telemetrySource: request.telemetrySource } : {}),
|
||||
...(request.linkedIssue !== undefined ? { linkedIssue: request.linkedIssue } : {}),
|
||||
...(request.linkedPR !== undefined ? { linkedPR: request.linkedPR } : {}),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { DetectedWorktreeListResult } from '../../../../../../shared/worktree/types'
|
||||
import { applyDetectedWorktreeUpdates } from './detected-worktree-meta'
|
||||
|
||||
describe('applyDetectedWorktreeUpdates display-name provenance', () => {
|
||||
it('projects pinning changes into detected rows', () => {
|
||||
const detected = {
|
||||
id: 'repo-1::/workspace/feature',
|
||||
displayName: 'feature',
|
||||
displayNameMode: 'automatic',
|
||||
repoId: 'repo-1',
|
||||
branch: 'refs/heads/feature'
|
||||
}
|
||||
const state = {
|
||||
'repo-1': {
|
||||
repoId: 'repo-1',
|
||||
authoritative: true,
|
||||
source: 'git',
|
||||
worktrees: [detected]
|
||||
}
|
||||
} as unknown as Record<string, DetectedWorktreeListResult>
|
||||
|
||||
const fixed = applyDetectedWorktreeUpdates(state, detected.id, {
|
||||
displayName: 'Agent workspace',
|
||||
displayNameIsPinned: true
|
||||
})
|
||||
expect(fixed['repo-1']?.worktrees[0]).toMatchObject({
|
||||
displayName: 'Agent workspace',
|
||||
displayNameMode: 'fixed'
|
||||
})
|
||||
|
||||
const automatic = applyDetectedWorktreeUpdates(state, detected.id, {
|
||||
displayName: '',
|
||||
displayNameIsPinned: false
|
||||
})
|
||||
expect(automatic['repo-1']?.worktrees[0]).toMatchObject({
|
||||
displayName: 'feature',
|
||||
displayNameMode: 'automatic'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps text until the host resolves a detached fallback', () => {
|
||||
const detached = {
|
||||
id: 'repo-1::/workspace/feature',
|
||||
displayName: 'Agent workspace',
|
||||
displayNameMode: 'fixed' as const,
|
||||
repoId: 'repo-1',
|
||||
branch: ''
|
||||
}
|
||||
const detachedState = {
|
||||
'repo-1': {
|
||||
repoId: 'repo-1',
|
||||
authoritative: true,
|
||||
source: 'git',
|
||||
worktrees: [detached]
|
||||
}
|
||||
} as unknown as Record<string, DetectedWorktreeListResult>
|
||||
|
||||
const automatic = applyDetectedWorktreeUpdates(detachedState, detached.id, {
|
||||
displayName: '',
|
||||
displayNameIsPinned: false
|
||||
})
|
||||
|
||||
expect(automatic['repo-1']?.worktrees[0]).toMatchObject({
|
||||
displayName: 'Agent workspace',
|
||||
displayNameMode: 'automatic'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,7 @@ import { worktreeMatchesHost } from './worktree-host-ownership'
|
||||
const folderWorkspaceWorktreeCache = new WeakMap<FolderWorkspace, Worktree>()
|
||||
|
||||
import { worktreeRowMatchesMetaHost } from './worktree-meta-host-match'
|
||||
import { branchName } from '@/lib/git-utils'
|
||||
|
||||
export function applyDetectedWorktreeUpdates(
|
||||
detectedWorktreesByRepo: AppState['detectedWorktreesByRepo'],
|
||||
@@ -37,7 +38,15 @@ export function applyDetectedWorktreeUpdates(
|
||||
}
|
||||
repoChanged = true
|
||||
changed = true
|
||||
return { ...worktree, ...updates }
|
||||
const next = { ...worktree, ...updates }
|
||||
if (updates.displayNameIsPinned !== undefined) {
|
||||
next.displayNameMode = updates.displayNameIsPinned ? 'fixed' : 'automatic'
|
||||
if (updates.displayNameIsPinned === false && !updates.displayName?.trim()) {
|
||||
const automaticName = branchName(next.branch)
|
||||
next.displayName = automaticName || worktree.displayName
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
nextByRepo[repoId] = repoChanged ? { ...result, worktrees: nextWorktrees } : result
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
} from '../metadata/hosted-review-link-mutation'
|
||||
import { isCurrentDetectedWorktreeRefresh } from './detected-worktree-refresh-admission'
|
||||
import { buildWorktreePurgeState } from '../teardown/worktree-purge-state'
|
||||
import { isDisplayNamePersistencePending } from '../metadata/worktree-meta-persist'
|
||||
import { branchName } from '@/lib/git-utils'
|
||||
import {
|
||||
forgetAuthoritativelyRemovedWorktrees,
|
||||
forgetPersistedWorktreeMetaForRemovals,
|
||||
@@ -52,6 +54,79 @@ export function preserveConcurrentManualOrder<T extends Worktree>(
|
||||
})
|
||||
}
|
||||
|
||||
export function preserveConcurrentDisplayName<T extends Worktree>(
|
||||
incoming: readonly T[],
|
||||
requestStarted: readonly Worktree[] | undefined,
|
||||
current: readonly Worktree[] | undefined,
|
||||
matchesRefreshHost: (worktree: Worktree) => boolean
|
||||
): T[] {
|
||||
if (!requestStarted || !current) {
|
||||
return [...incoming]
|
||||
}
|
||||
const startedById = new Map(
|
||||
requestStarted.filter(matchesRefreshHost).map((worktree) => [worktree.id, worktree])
|
||||
)
|
||||
const currentById = new Map(
|
||||
current.filter(matchesRefreshHost).map((worktree) => [worktree.id, worktree])
|
||||
)
|
||||
return incoming.map((worktree) => {
|
||||
const started = startedById.get(worktree.id)
|
||||
const latest = currentById.get(worktree.id)
|
||||
if (!started || !latest) {
|
||||
return worktree
|
||||
}
|
||||
if (isDisplayNamePersistencePending(worktree.id, latest.hostId)) {
|
||||
return {
|
||||
...worktree,
|
||||
displayName: latest.displayName,
|
||||
...(latest.displayNameMode !== undefined
|
||||
? { displayNameMode: latest.displayNameMode }
|
||||
: { displayNameMode: undefined })
|
||||
}
|
||||
}
|
||||
const latestChanged =
|
||||
latest.displayName !== started.displayName ||
|
||||
latest.displayNameMode !== started.displayNameMode
|
||||
// The label is the stable stale-response marker; mode may be absent on an
|
||||
// older host or newly projected by a newer one.
|
||||
const incomingIsStale = worktree.displayName === started.displayName
|
||||
const latestDisplayNameIsPinned =
|
||||
latest.displayNameMode === 'fixed' ||
|
||||
(latest.displayNameMode === undefined && latest.cliProvenance?.kind === 'created-by-cli')
|
||||
const incomingBranchShort = branchName(worktree.branch)
|
||||
// Old hosts re-derive automatic labels from branch (or path basename when detached);
|
||||
// any other label in their response is explicit meta a peer wrote there.
|
||||
const incomingLooksAutomatic =
|
||||
worktree.displayName === incomingBranchShort ||
|
||||
(incomingBranchShort === '' &&
|
||||
worktree.displayName === (worktree.path.split(/[\\/]/).pop() ?? ''))
|
||||
if (
|
||||
worktree.displayNameMode === undefined &&
|
||||
latestDisplayNameIsPinned &&
|
||||
incomingLooksAutomatic
|
||||
) {
|
||||
// Older hosts omit provenance; never let their re-derived label replace a pinned one.
|
||||
return {
|
||||
...worktree,
|
||||
displayName: latest.displayName,
|
||||
...(latest.displayNameMode !== undefined
|
||||
? { displayNameMode: latest.displayNameMode }
|
||||
: { displayNameMode: undefined })
|
||||
}
|
||||
}
|
||||
if (!latestChanged || !incomingIsStale) {
|
||||
return worktree
|
||||
}
|
||||
return {
|
||||
...worktree,
|
||||
displayName: latest.displayName,
|
||||
...(latest.displayNameMode !== undefined
|
||||
? { displayNameMode: latest.displayNameMode }
|
||||
: { displayNameMode: undefined })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function mergeFetchedWorktrees(
|
||||
set: Parameters<StateCreator<AppState, [], [], WorktreeSlice>>[0],
|
||||
args: FencedWorktreeMergeArgs
|
||||
@@ -77,8 +152,13 @@ export function mergeFetchedWorktrees(
|
||||
const currentWorktrees = s.worktreesByRepo[args.repoId]
|
||||
const refreshResult = {
|
||||
...args.refresh.result,
|
||||
worktrees: preserveConcurrentManualOrder(
|
||||
args.refresh.result.worktrees,
|
||||
worktrees: preserveConcurrentDisplayName(
|
||||
preserveConcurrentManualOrder(
|
||||
args.refresh.result.worktrees,
|
||||
args.requestStartedWorktrees,
|
||||
currentWorktrees,
|
||||
(worktree) => worktreeMatchesHost(worktree, args.hostId, matchOptions)
|
||||
),
|
||||
args.requestStartedWorktrees,
|
||||
currentWorktrees,
|
||||
(worktree) => worktreeMatchesHost(worktree, args.hostId, matchOptions)
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { WorktreeSlice } from '../../worktree-helpers'
|
||||
import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { isPositiveHostedReviewNumber } from '../../../../../../shared/hosted-review'
|
||||
import { displayNameUpdatePinsLabel } from '../../../../../../shared/worktree/display-name-provenance'
|
||||
import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope'
|
||||
import { applyWorktreeUpdates, getRepoIdFromWorktreeId } from '../../worktree-helpers'
|
||||
import { getHostedReviewCacheKey } from '../../hosted-review-cache-identity'
|
||||
@@ -122,11 +123,15 @@ export function createUpdateWorktreeMeta(
|
||||
const reviewBranch = worktreeForUpdate?.branch.replace(/^refs\/heads\//, '')
|
||||
|
||||
// Why: bump lastActivityAt on comment edits so the time-decay sort doesn't drop a just-touched worktree.
|
||||
const displayNameProvenance =
|
||||
'displayName' in normalizedUpdates
|
||||
? { displayNameIsPinned: displayNameUpdatePinsLabel(normalizedUpdates.displayName) }
|
||||
: {}
|
||||
const targetEnriched = resolvedPushTarget
|
||||
? { ...normalizedUpdates, pushTarget: resolvedPushTarget }
|
||||
? { ...normalizedUpdates, ...displayNameProvenance, pushTarget: resolvedPushTarget }
|
||||
: shouldClearStaleHostedReviewPushTarget
|
||||
? { ...normalizedUpdates, pushTarget: undefined }
|
||||
: normalizedUpdates
|
||||
? { ...normalizedUpdates, ...displayNameProvenance, pushTarget: undefined }
|
||||
: { ...normalizedUpdates, ...displayNameProvenance }
|
||||
const renameCleared =
|
||||
'displayName' in targetEnriched
|
||||
? {
|
||||
|
||||
@@ -85,7 +85,11 @@ export function createUpdateWorktreeGitIdentity(
|
||||
}
|
||||
// Why: terminal branch switches only patch branch/head here; re-derive auto titles like full listing does.
|
||||
const currentBranchName = branchName(worktree.branch)
|
||||
const wasAutoDerived = worktree.displayName === currentBranchName
|
||||
const wasAutoDerived =
|
||||
worktree.displayNameMode === 'automatic' ||
|
||||
(worktree.displayNameMode === undefined &&
|
||||
worktree.cliProvenance?.kind !== 'created-by-cli' &&
|
||||
worktree.displayName === currentBranchName)
|
||||
const wasDetachedAutoDerived =
|
||||
worktree.branch === '' &&
|
||||
nextBranch !== '' &&
|
||||
|
||||
@@ -15,7 +15,69 @@ import type { AppState } from '../../../types'
|
||||
import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types'
|
||||
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
import { encodePushTargetClearForRuntimeRpc } from './hosted-review-link-mutation'
|
||||
export async function persistWorktreeMeta(
|
||||
|
||||
type PendingDisplayNameWrite = {
|
||||
worktreeId: string
|
||||
executionHostId?: ExecutionHostId
|
||||
}
|
||||
|
||||
const pendingDisplayNameWrites = new Set<PendingDisplayNameWrite>()
|
||||
|
||||
function pendingDisplayNameWriteMatches(
|
||||
write: PendingDisplayNameWrite,
|
||||
worktreeId: string,
|
||||
executionHostId?: ExecutionHostId
|
||||
): boolean {
|
||||
return (
|
||||
write.worktreeId === worktreeId &&
|
||||
(write.executionHostId === undefined ||
|
||||
executionHostId === undefined ||
|
||||
write.executionHostId === executionHostId)
|
||||
)
|
||||
}
|
||||
|
||||
export function isDisplayNamePersistencePending(
|
||||
worktreeId: string,
|
||||
executionHostId?: ExecutionHostId
|
||||
): boolean {
|
||||
for (const write of pendingDisplayNameWrites) {
|
||||
if (pendingDisplayNameWriteMatches(write, worktreeId, executionHostId)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function persistWorktreeMeta(
|
||||
settings: AppState['settings'],
|
||||
worktreeId: string,
|
||||
updates: Partial<WorktreeMeta>,
|
||||
executionHostId?: ExecutionHostId,
|
||||
identityKey?: string
|
||||
): Promise<void> {
|
||||
const operation = persistWorktreeMetaUntracked(
|
||||
settings,
|
||||
worktreeId,
|
||||
updates,
|
||||
executionHostId,
|
||||
identityKey
|
||||
)
|
||||
if (!('displayName' in updates)) {
|
||||
return operation
|
||||
}
|
||||
const write: PendingDisplayNameWrite = {
|
||||
worktreeId,
|
||||
executionHostId
|
||||
}
|
||||
pendingDisplayNameWrites.add(write)
|
||||
void operation.then(
|
||||
() => pendingDisplayNameWrites.delete(write),
|
||||
() => pendingDisplayNameWrites.delete(write)
|
||||
)
|
||||
return operation
|
||||
}
|
||||
|
||||
async function persistWorktreeMetaUntracked(
|
||||
settings: AppState['settings'],
|
||||
worktreeId: string,
|
||||
updates: Partial<WorktreeMeta>,
|
||||
|
||||
@@ -53,6 +53,7 @@ export function createWorktreesApi(): NonNullable<Partial<PreloadApi>['worktrees
|
||||
name: args.name,
|
||||
// Absent means user-typed, which is what the host must assume — so send it only when true.
|
||||
...(args.nameWasGenerated ? { nameWasGenerated: true } : {}),
|
||||
...(args.displayNameKind ? { displayNameKind: args.displayNameKind } : {}),
|
||||
baseBranch: args.baseBranch,
|
||||
compareBaseRef: args.compareBaseRef,
|
||||
branchNameOverride: args.branchNameOverride,
|
||||
|
||||
@@ -596,6 +596,8 @@ describe('web worktree preload API', () => {
|
||||
compareBaseRef: 'refs/remotes/origin/main',
|
||||
setupDecision: 'inherit',
|
||||
createdWithAgent: 'codex',
|
||||
displayName: 'Review label',
|
||||
displayNameKind: 'user',
|
||||
startup: {
|
||||
command: "codex 'summarize repo'",
|
||||
env: { ORCA_AGENT_MODE: 'direct' },
|
||||
@@ -636,6 +638,8 @@ describe('web worktree preload API', () => {
|
||||
baseBranch: TEST_COMMIT_OID,
|
||||
compareBaseRef: 'refs/remotes/origin/main',
|
||||
createdWithAgent: 'codex',
|
||||
displayName: 'Review label',
|
||||
displayNameKind: 'user',
|
||||
startupCommand: "codex 'summarize repo'",
|
||||
startupEnv: { ORCA_AGENT_MODE: 'direct' },
|
||||
startupLaunchConfig: {
|
||||
|
||||
@@ -68,6 +68,8 @@ export type CreateWorktreeArgs = {
|
||||
* branch/path seed. Used when a workspace is created from a GitHub or
|
||||
* Linear artifact whose title should remain readable in the sidebar. */
|
||||
displayName?: string
|
||||
/** Distinguishes user labels from generated artifact titles at creation time. */
|
||||
displayNameKind?: 'generated' | 'user'
|
||||
baseBranch?: string
|
||||
/** Source Control compare target when it differs from the checkout start point. */
|
||||
compareBaseRef?: string
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** A rename with text pins the label; empty text returns it to automatic (branch-derived). */
|
||||
export function displayNameUpdatePinsLabel(displayName: string | undefined): boolean {
|
||||
return Boolean(displayName?.trim())
|
||||
}
|
||||
@@ -28,6 +28,8 @@ export type WorktreeMeta = {
|
||||
/** See Worktree.creatorProvenance. */
|
||||
creatorProvenance?: WorkspaceCreatorProvenance
|
||||
displayName: string
|
||||
/** True when a user-authored label must survive branch changes. */
|
||||
displayNameIsPinned?: boolean
|
||||
comment: string
|
||||
linkedIssue: number | null
|
||||
linkedPR: number | null
|
||||
|
||||
@@ -77,6 +77,8 @@ export type Worktree = {
|
||||
/** Checkout ownership for a recipe-provisioned main workspace. */
|
||||
ephemeralVmCheckoutMode?: EphemeralVmCheckoutMode
|
||||
displayName: string
|
||||
/** Projection of persisted display-name provenance. */
|
||||
displayNameMode?: 'fixed' | 'automatic'
|
||||
comment: string
|
||||
linkedIssue: number | null
|
||||
linkedPR: number | null
|
||||
|
||||
Reference in New Issue
Block a user