Keep workspace labels independent of automatic branch names (#32919)

* Fix automatic branch naming for workspace labels in every language

* Use creature branch seeds for typed workspace labels

* Keep first-work generator mocks in creator test suites

* Type the created worktree test mock from its production signature

* Assert created worktree results before exercising first work

* Match the missing-folder update contract in first-work fixtures
This commit is contained in:
Brennan Benson
2026-10-26 13:14:16 -07:00
committed by GitHub
parent fdcf193cd4
commit 9608420517
7 changed files with 334 additions and 5 deletions
+3 -1
View File
@@ -1,5 +1,6 @@
/* eslint-disable max-lines */
import { selectWorktreeCreateBranchSeed } from '../worktree-create-branch-seed'
import { worktreeCreateGit } from '../git/worktree-create-git-executor'
import { getStoredRepoExecutionHostId } from '../repo-execution-host'
import type { BrowserWindow } from 'electron'
@@ -998,6 +999,7 @@ async function performLocalWorktreeCreate(
: {})
})
const branchSeed = selectWorktreeCreateBranchSeed(args, settings.autoRenameBranchFromWork)
const requestedName = args.name
const sanitizedName = sanitizeWorktreeName(args.name)
const displayNameRequest = resolveWorktreeCreateDisplayNameRequest(
@@ -1224,7 +1226,7 @@ async function performLocalWorktreeCreate(
selectedExistingLocalBranchName
? selectedExistingLocalBranchName
: getBranchNameOverrideCandidate(args.branchNameOverride, suffix),
effectiveSanitizedName,
branchSeed ? getWorktreeCreateCandidate(branchSeed, suffix) : effectiveSanitizedName,
settings,
username,
localWorktreeGitOptions
@@ -1,9 +1,16 @@
import {
labelOnlyCreateRequests,
renameCreatedWorktreeOnFirstWork
} from '../worktree-create-first-work-test-harness'
import { isAutoGeneratedCreatureBranchName } from '../../shared/branch-name-from-work'
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { REVIEW_HEAD_FETCH_TIMEOUT_MS } from '../../shared/review-head-tracking-ref'
import {
handleMock,
removeHandlerMock,
listWorktreesMock,
describeCreatedWorktreeMock,
getPullRequestPushTargetMock,
gitExecFileAsyncMock
} from './worktrees-test-module-mocks'
@@ -52,6 +59,17 @@ const WORKTREE_HANDLER_CHANNELS = [
'hooks:writeIssueCommand'
] as const
vi.mock('../text-generation/commit-message-text-generation', () => ({
resolveTextGenerationParams: () => ({
ok: true,
params: { agentId: 'claude', model: 'fixture' }
}),
generateBranchNameFromContext: async () => ({ success: true, slug: 'fix-auth' })
}))
vi.mock('../text-generation/commit-message-agent-environment', () => ({
prepareLocalCommitMessageAgentEnv: async () => ({ ok: true })
}))
vi.mock('electron', async () =>
(await import('./worktrees-test-module-mocks')).electronModuleMock()
)
@@ -776,3 +794,110 @@ describe('registerWorktreeHandlers', () => {
})
})
})
describe('desktop label-only creation through first work', () => {
function expectCreatedWorktree(result: unknown): asserts result is CreateWorktreeResult {
expect(result).toEqual(
expect.objectContaining({
worktree: expect.objectContaining({
id: expect.any(String),
repoId: 'repo-1',
path: expect.any(String),
head: expect.any(String),
branch: expect.stringMatching(/^refs\/heads\//),
isBare: false,
isMainWorktree: false,
displayName: expect.any(String),
displayNameMode: expect.stringMatching(/^(fixed|automatic)$/),
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: expect.any(Number),
lastActivityAt: expect.any(Number)
})
})
)
}
let savedMeta: Partial<WorktreeMeta>
beforeEach(() => {
setupWorktreeHandlers()
savedMeta = {}
store.getSettings.mockReturnValue({
branchPrefix: 'none',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
workspaceDir: '/workspace',
autoRenameBranchFromWork: true
})
store.setWorktreeMeta.mockImplementation((_target, updates) => {
savedMeta = { ...savedMeta, ...updates }
return savedMeta
})
describeCreatedWorktreeMock.mockImplementation(async (_repoPath, path, branch) => ({
path,
branch: `refs/heads/${branch}`,
head: 'abc123',
isBare: false,
isMainWorktree: false
}))
})
it.each(labelOnlyCreateRequests)(
'creates and renames a creature branch for $name while projecting its label',
async (request) => {
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-1',
...request
})
expectCreatedWorktree(result)
const { worktree } = result
const branch = worktree.branch.replace(/^refs\/heads\//, '')
expect(isAutoGeneratedCreatureBranchName(branch)).toBe(true)
expect(worktree.displayName).toBe(request.name)
expect(worktree.displayNameMode).toBe(request.nameWasGenerated ? 'automatic' : 'fixed')
const renamed = await renameCreatedWorktreeOnFirstWork(
worktree,
savedMeta,
gitExecFileAsyncMock
)
expect(renamed).toMatchObject({
branch: 'refs/heads/fix-auth',
displayName: request.nameWasGenerated ? 'Fix auth' : request.name,
displayNameMode: 'fixed'
})
}
)
it.each([
{ branchNameOverride: 'feature/exact', enabled: true, branch: 'feature/exact' },
{ branchNameOverride: undefined, enabled: false, branch: 'typed-label' }
])(
'preserves branch choice $branch (automatic=$enabled)',
async ({ branchNameOverride, enabled, branch }) => {
store.getSettings.mockReturnValue({
branchPrefix: 'none',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
workspaceDir: '/workspace',
autoRenameBranchFromWork: enabled
})
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'typed-label',
branchNameOverride
})
expectCreatedWorktree(result)
const { worktree } = result
expect(worktree).toMatchObject({
branch: `refs/heads/${branch}`,
displayName: 'typed-label',
displayNameMode: 'fixed'
})
}
)
})
+2 -1
View File
@@ -1,4 +1,5 @@
import { type Mock, vi } from 'vitest'
import type { describeCreatedWorktree } from '../git/worktree'
import type { computeWorktreePath } from './worktree-logic'
import type { HandlerMap } from './worktrees-test-ipc-surface'
@@ -30,7 +31,7 @@ export const findExistingWorktreeSymlinkPathsMock: ModuleMock = vi.fn()
export const handleMock: Mock<(channel: string, handler: HandlerMap[string]) => void> = vi.fn()
export const removeHandlerMock: ModuleMock = vi.fn()
export const listWorktreesMock: StringArgMock = vi.fn()
export const describeCreatedWorktreeMock: ModuleMock = vi.fn()
export const describeCreatedWorktreeMock = vi.fn<typeof describeCreatedWorktree>()
export const parseWorktreeListMock: Mock<(output: string) => ParsedWorktreeRow[]> = vi.fn(
(output: string) =>
output
@@ -1,3 +1,4 @@
import { selectWorktreeCreateBranchSeed } from '../worktree-create-branch-seed'
import type { Repo } from '../../shared/repo-types'
import { WorktreeCreateCollisionError } from '../../shared/new-workspace/worktree-create-collision'
import type { CreateWorktreeArgs } from '../../shared/worktree/create-types'
@@ -52,6 +53,7 @@ export async function resolveRuntimeLocalWorktreeCreateCandidate(args: {
settings: ReturnType<typeof getWorktreePathSettings> & {
branchPrefix: string
branchPrefixCustom?: string
autoRenameBranchFromWork?: boolean
}
worktreePathSettings: ReturnType<typeof getWorktreePathSettings>
workspaceRoot: string
@@ -61,6 +63,10 @@ export async function resolveRuntimeLocalWorktreeCreateCandidate(args: {
localWorktreeGitOptions: { wslDistro?: string }
hostedReviewExecutionContext?: HostedReviewExecutionOptions
}): Promise<RuntimeLocalWorktreeCreateCandidate> {
const branchSeed = selectWorktreeCreateBranchSeed(
args.request,
args.settings.autoRenameBranchFromWork
)
const sanitizedName = sanitizeWorktreeName(args.request.name)
let effectiveRequestedName = args.request.name
let effectiveSanitizedName = sanitizedName
@@ -149,7 +155,7 @@ export async function resolveRuntimeLocalWorktreeCreateCandidate(args: {
args.repo.path,
selectedExistingLocalBranchName ??
getBranchNameOverrideCandidate(args.request.branchNameOverride, suffix),
effectiveSanitizedName,
branchSeed ? getWorktreeCreateCandidate(branchSeed, suffix) : effectiveSanitizedName,
args.settings,
args.username,
args.localWorktreeGitOptions
@@ -1,3 +1,9 @@
import type * as RuntimeWorktreeCreateGit from './runtime-worktree-create-git'
import {
labelOnlyCreateRequests,
renameCreatedWorktreeOnFirstWork
} from '../worktree-create-first-work-test-harness'
import { isAutoGeneratedCreatureBranchName } from '../../shared/branch-name-from-work'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { resolve } from 'node:path'
import type { Store } from '../persistence'
@@ -15,6 +21,7 @@ import {
import { resolveGitAdmissionTier } from '../git/command-runner/git-operation-executor'
const mocks = vi.hoisted(() => ({
git: vi.fn(),
rearm: vi.fn(),
routing: vi.fn<() => { wslDistro?: string }>(),
defaultBase: vi.fn(),
@@ -46,6 +53,18 @@ const mocks = vi.hoisted(() => ({
}
}))
vi.mock('../text-generation/commit-message-text-generation', () => ({
resolveTextGenerationParams: () => ({
ok: true,
params: { agentId: 'claude', model: 'fixture' }
}),
generateBranchNameFromContext: async () => ({ success: true, slug: 'fix-auth' })
}))
vi.mock('../text-generation/commit-message-agent-environment', () => ({
prepareLocalCommitMessageAgentEnv: async () => ({ ok: true })
}))
vi.mock('../git/runner', () => ({ gitExecFileAsync: mocks.git }))
vi.mock('../project-runtime-git-options', () => ({
getLocalProjectGitExecOptions: () => ({ cwd: '/repo', ...mocks.routing() }),
getLocalProjectWorktreeGitOptions: mocks.routing,
@@ -119,14 +138,16 @@ function createWorktree(
request: Partial<RuntimeManagedWorktreeCreateArgs> = {},
rearm: PreparationRearmHolder = { fire: () => {} },
timing: WorktreeCreateTimingRecorder = createWorktreeCreateTimingRecorder(),
repoOverrides: Partial<Repo> = {}
repoOverrides: Partial<Repo> = {},
autoRenameBranchFromWork = false
) {
const store = {
getSettings: () => ({
workspaceDir: '/worktrees',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
branchPrefix: ''
branchPrefix: '',
autoRenameBranchFromWork
}),
setWorktreeMeta: (_target: HostWorktreeTarget, updates: Partial<WorktreeMeta>) => {
writtenMeta.push(updates)
@@ -160,6 +181,9 @@ function createWorktree(
beforeEach(() => {
vi.resetAllMocks()
writtenMeta.length = 0
mocks.created.branch = 'app'
mocks.git.mockResolvedValue({ stdout: '', stderr: '' })
mocks.created.path = worktreePath
mocks.routing.mockReturnValue({})
mocks.defaultBase.mockImplementation(async () => {
@@ -866,3 +890,61 @@ describe('runtime create setup decision', () => {
expect(mocks.consume).toHaveBeenCalledOnce()
})
})
describe('runtime label-only creation through first work', () => {
async function useRealBranchSelection() {
const actual = await vi.importActual<typeof RuntimeWorktreeCreateGit>(
'./runtime-worktree-create-git'
)
mocks.branchName.mockImplementation(actual.resolveCreateBranchName)
mocks.listing.mockImplementation(async (_repoPath, path, branch) => ({
created: { ...mocks.created, path, branch: `refs/heads/${branch}` }
}))
}
it.each(labelOnlyCreateRequests)(
'creates and renames a creature branch for $name while projecting its label',
async (request) => {
await useRealBranchSelection()
const { worktree } = await createWorktree(request, undefined, undefined, {}, true)
const branch = worktree.branch.replace(/^refs\/heads\//, '')
expect(isAutoGeneratedCreatureBranchName(branch)).toBe(true)
expect(worktree.displayName).toBe(request.name)
expect(worktree.displayNameMode).toBe(request.nameWasGenerated ? 'automatic' : 'fixed')
const renamed = await renameCreatedWorktreeOnFirstWork(worktree, writtenMeta[0], mocks.git)
expect(renamed).toMatchObject({
branch: 'refs/heads/fix-auth',
displayName: request.nameWasGenerated ? 'Fix auth' : request.name,
displayNameMode: 'fixed'
})
}
)
it.each([
{
request: { name: 'typed-label', branchNameOverride: 'feature/exact' },
enabled: true,
branch: 'feature/exact'
},
{
request: {
name: 'cli-exact',
cliProvenance: { kind: 'created-by-cli' as const, createdAt: 1 }
},
enabled: true,
branch: 'cli-exact'
},
{ request: { name: 'typed-label' }, enabled: false, branch: 'typed-label' }
])(
'preserves branch choice $branch (automatic=$enabled)',
async ({ request, enabled, branch }) => {
await useRealBranchSelection()
const { worktree } = await createWorktree(request, undefined, undefined, {}, enabled)
expect(worktree).toMatchObject({
branch: `refs/heads/${branch}`,
displayName: request.name,
displayNameMode: 'fixed'
})
}
)
})
+29
View File
@@ -0,0 +1,29 @@
import type { CreateWorktreeArgs } from '../shared/worktree/create-types'
import type { CliWorkspaceProvenance } from '../shared/worktree/types'
import { selectSuggestedCreatureName } from '../shared/worktree-name-suggestion'
import { resolveWorktreeCreateDisplayNameRequest } from './ipc/worktree-display-name'
export function selectWorktreeCreateBranchSeed(
request: Pick<
CreateWorktreeArgs,
'name' | 'displayName' | 'displayNameKind' | 'nameWasGenerated' | 'branchNameOverride'
> & { cliProvenance?: CliWorkspaceProvenance },
autoRenameBranchFromWork: boolean | undefined
): string | undefined {
if (
!autoRenameBranchFromWork ||
request.branchNameOverride ||
request.cliProvenance?.kind === 'created-by-cli'
) {
return undefined
}
const label = resolveWorktreeCreateDisplayNameRequest(
request.displayName,
request.displayNameKind,
request.name,
false,
request.nameWasGenerated === true
)
// Typed labels stay independent of the placeholder consumed by first-work naming.
return label.kind === 'user' && label.value ? selectSuggestedCreatureName([]) : undefined
}
@@ -0,0 +1,84 @@
import { expect, vi, type Mock } from 'vitest'
import type { CreateWorktreeArgs } from '../shared/worktree/create-types'
import type { Worktree } from '../shared/worktree/types'
import type { WorktreeMeta } from '../shared/worktree/meta-types'
import { getDefaultSettings } from '../shared/constants'
import { firstWorkRenameDeps } from './agent-hooks/first-work-rename-runtime'
import {
maybeAutoRenameBranchOnFirstWork,
resetFirstWorkBranchRenameState
} from './agent-hooks/first-work-branch-rename'
import { gitResponder, workingEvent } from './agent-hooks/first-work-branch-rename-test-harness'
import { mergeWorktree } from './ipc/worktree-metadata-merge'
export const labelOnlyCreateRequests: Pick<
CreateWorktreeArgs,
'name' | 'displayName' | 'displayNameKind' | 'nameWasGenerated'
>[] = [
{ name: '認証を修正', displayName: '認証を修正', displayNameKind: 'user' },
{ name: 'fix-authentication', displayNameKind: 'user' },
{ name: 'nautilus', nameWasGenerated: true }
]
export async function renameCreatedWorktreeOnFirstWork(
worktree: Worktree,
initialMeta: Partial<WorktreeMeta>,
gitMock: Mock
): Promise<Worktree> {
resetFirstWorkBranchRenameState()
let meta: WorktreeMeta = {
displayName: '',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
...initialMeta
}
let branch = worktree.branch.replace(/^refs\/heads\//, '')
const respond = gitResponder({ currentBranch: branch, hasUpstream: false })
gitMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'branch' && args[1] === '-m') {
branch = args[2]
}
return respond(args)
})
const repo = {
id: worktree.repoId,
path: '/repo',
displayName: 'Repo',
badgeColor: '',
addedAt: 0
}
const notifyBranchRenamed = vi.fn()
const deps = firstWorkRenameDeps(
{
getSettings: () => ({
...getDefaultSettings('/tmp/pr-32919-fixture'),
autoRenameBranchFromWork: true,
branchPrefix: 'none'
}),
getRepo: () => repo,
getRepos: () => [repo],
getWorktreeMeta: () => meta,
setWorktreeMeta: (_target, updates) => (meta = { ...meta, ...updates }),
getFolderWorkspace: () => undefined,
updateFolderWorkspace: () => null,
getWorktreeIdForTab: () => worktree.id
},
{
getCommitMessageAgentEnvironmentResolvers: () => undefined,
notifyFolderWorkspaceChanged: vi.fn(),
notifyBranchRenamed,
notifyWorktreeFolderRenamed: vi.fn()
}
)
await maybeAutoRenameBranchOnFirstWork(workingEvent({ worktreeId: worktree.id }), deps)
expect(notifyBranchRenamed).toHaveBeenCalledWith(worktree.repoId)
expect(branch).toBe('fix-auth')
return mergeWorktree(worktree.repoId, { ...worktree, branch: `refs/heads/${branch}` }, meta)
}