diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index cfb5320e96b..0d1f827556a 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -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 diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index f3f1a353e67..740bb078fac 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -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 + 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' + }) + } + ) +}) diff --git a/src/main/ipc/worktrees-test-module-mocks.ts b/src/main/ipc/worktrees-test-module-mocks.ts index 6c0d93a0fa2..03964646e2a 100644 --- a/src/main/ipc/worktrees-test-module-mocks.ts +++ b/src/main/ipc/worktrees-test-module-mocks.ts @@ -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() export const parseWorktreeListMock: Mock<(output: string) => ParsedWorktreeRow[]> = vi.fn( (output: string) => output diff --git a/src/main/runtime/runtime-local-worktree-create-candidate.ts b/src/main/runtime/runtime-local-worktree-create-candidate.ts index d8f68b149b7..9772caa474c 100644 --- a/src/main/runtime/runtime-local-worktree-create-candidate.ts +++ b/src/main/runtime/runtime-local-worktree-create-candidate.ts @@ -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 & { branchPrefix: string branchPrefixCustom?: string + autoRenameBranchFromWork?: boolean } worktreePathSettings: ReturnType workspaceRoot: string @@ -61,6 +63,10 @@ export async function resolveRuntimeLocalWorktreeCreateCandidate(args: { localWorktreeGitOptions: { wslDistro?: string } hostedReviewExecutionContext?: HostedReviewExecutionOptions }): Promise { + 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 diff --git a/src/main/runtime/runtime-local-worktree-create.test.ts b/src/main/runtime/runtime-local-worktree-create.test.ts index 856519135de..65209c2ffa8 100644 --- a/src/main/runtime/runtime-local-worktree-create.test.ts +++ b/src/main/runtime/runtime-local-worktree-create.test.ts @@ -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 = {}, rearm: PreparationRearmHolder = { fire: () => {} }, timing: WorktreeCreateTimingRecorder = createWorktreeCreateTimingRecorder(), - repoOverrides: Partial = {} + repoOverrides: Partial = {}, + autoRenameBranchFromWork = false ) { const store = { getSettings: () => ({ workspaceDir: '/worktrees', nestWorkspaces: false, refreshLocalBaseRefOnWorktreeCreate: false, - branchPrefix: '' + branchPrefix: '', + autoRenameBranchFromWork }), setWorktreeMeta: (_target: HostWorktreeTarget, updates: Partial) => { 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( + './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' + }) + } + ) +}) diff --git a/src/main/worktree-create-branch-seed.ts b/src/main/worktree-create-branch-seed.ts new file mode 100644 index 00000000000..dd4ebc91ed0 --- /dev/null +++ b/src/main/worktree-create-branch-seed.ts @@ -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 +} diff --git a/src/main/worktree-create-first-work-test-harness.ts b/src/main/worktree-create-first-work-test-harness.ts new file mode 100644 index 00000000000..78dae1a7c1c --- /dev/null +++ b/src/main/worktree-create-first-work-test-harness.ts @@ -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, + gitMock: Mock +): Promise { + 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) +}