mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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'
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user