feat: allow custom worktree branch names (#6454)

* feat: allow custom worktree branch names

* refactor(ui): render custom branch name field unconditionally under advanced container

* fix(composer): hide manual branch field when a work-item source drives the branch

A tracked PR/issue/MR/Linear source derives the branch itself, and a linked
GitHub PR re-resolves the branch name at submit — so an override typed in the
Advanced branch field was silently ignored. Only render the field for the
typed-name and base-branch flows, where the manual override is honored.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Wolfie
2026-07-03 17:21:50 -07:00
committed by GitHub
co-authored by Orca Wolfgang Schoenberger Neil
parent 9f31826e79
commit 8796261eed
14 changed files with 271 additions and 17 deletions
@@ -16,7 +16,7 @@ describe('classifyWorkspaceCreateError', () => {
})
it('buckets a branch-already-exists throw as path_collision', () => {
const err = new Error('Branch "feature/foo" already exists. Pick a different worktree name.')
const err = new Error('Branch "feature/foo" already exists. Pick a different branch name.')
expect(classifyWorkspaceCreateError(err)).toBe('path_collision')
})
@@ -29,15 +29,13 @@ describe('classifyWorkspaceCreateError', () => {
it('buckets a branch-already-exists-locally throw as path_collision', () => {
const err = new Error(
'Branch "feature/foo" already exists locally. Pick a different worktree name.'
'Branch "feature/foo" already exists locally. Pick a different branch name.'
)
expect(classifyWorkspaceCreateError(err)).toBe('path_collision')
})
it('buckets an existing-PR collision throw as path_collision', () => {
const err = new Error(
'Branch "feature/foo" already has PR #42. Pick a different worktree name.'
)
const err = new Error('Branch "feature/foo" already has PR #42. Pick a different branch name.')
expect(classifyWorkspaceCreateError(err)).toBe('path_collision')
})
+5 -3
View File
@@ -1452,6 +1452,7 @@ export async function createRemoteWorktree(
// commit author identity rather than hosted-account usernames.
const username = await getSshGitUsername(provider, repo.path)
const branchConflictSubject = args.branchNameOverride ? 'branch name' : 'worktree name'
// Determine base branch
// Why: previously fell back to a hardcoded 'origin/main' when
// symbolic-ref failed. That silently handed addWorktree a ref that may
@@ -1531,7 +1532,7 @@ export async function createRemoteWorktree(
if (!remotePathResolved) {
if (lastBranchConflictKind) {
throw new Error(
`Branch "${branchName}" already exists ${lastBranchConflictKind === 'local' ? 'locally' : 'on a remote'}. Pick a different worktree name.`
`Branch "${branchName}" already exists ${lastBranchConflictKind === 'local' ? 'locally' : 'on a remote'}. Pick a different ${branchConflictSubject}.`
)
}
throw new Error(
@@ -1989,6 +1990,7 @@ export async function createLocalWorktree(
let branchName = ''
let worktreePath = ''
const branchConflictSubject = args.branchNameOverride ? 'branch name' : 'worktree name'
let resolved = false
let checkoutExistingBranch = false
let selectedExistingLocalBranchName: string | null = null
@@ -2111,12 +2113,12 @@ export async function createLocalWorktree(
// failed instead of a generic error or (worse) an infinite spinner.
if (lastExistingReviewNumber !== null) {
throw new Error(
`Branch "${branchName}" already has PR #${lastExistingReviewNumber}. Pick a different worktree name.`
`Branch "${branchName}" already has PR #${lastExistingReviewNumber}. Pick a different ${branchConflictSubject}.`
)
}
if (lastBranchConflictKind) {
throw new Error(
`Branch "${branchName}" already exists ${lastBranchConflictKind === 'local' ? 'locally' : 'on a remote'}. Pick a different worktree name.`
`Branch "${branchName}" already exists ${lastBranchConflictKind === 'local' ? 'locally' : 'on a remote'}. Pick a different ${branchConflictSubject}.`
)
}
throw new Error(
@@ -135,6 +135,8 @@ function renderCard(
canReuseSelectedBranch={false}
reuseSelectedBranch={false}
onReuseSelectedBranchChange={() => {}}
branchNameOverride=""
onBranchNameOverrideChange={() => {}}
forkPushWarning={null}
detectedAgentIds={null}
onOpenAgentSettings={() => {}}
@@ -174,6 +176,25 @@ function renderCard(
return { container, root }
}
function findInputByLabel(container: HTMLElement, labelText: string): HTMLInputElement | null {
const label = [...container.querySelectorAll('label')].find(
(candidate) => candidate.textContent?.trim() === labelText
)
const labelledId = label?.getAttribute('for')
if (labelledId) {
return document.getElementById(labelledId) as HTMLInputElement | null
}
return label?.parentElement?.querySelector<HTMLInputElement>('input') ?? null
}
function changeInputValue(input: HTMLInputElement, value: string): void {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
act(() => {
valueSetter?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
}
let current: { container: HTMLDivElement; root: Root } | null = null
describe('NewWorkspaceComposerCard folder task source mode', () => {
@@ -320,6 +341,64 @@ describe('NewWorkspaceComposerCard folder task source mode', () => {
expect(changes).toEqual(['wait-for-setup'])
})
it('shows a git-only branch name field in Advanced and emits manual edits', () => {
const changes: (string | undefined)[] = []
current = renderCard({
advancedOpen: false,
branchesEnabled: true,
branchNameOverride: 'feature/initial',
onBranchNameOverrideChange: (next) => changes.push(next)
})
const branchInput = findInputByLabel(current.container, 'Branch name')
expect(branchInput).toBeTruthy()
expect(branchInput?.value).toBe('feature/initial')
changeInputValue(branchInput as HTMLInputElement, 'feature/manual')
expect(changes).toEqual(['feature/manual'])
})
it('omits the branch name field for non-git projects', () => {
current = renderCard({
advancedOpen: true,
branchesEnabled: true,
selectedRepoIsGit: false,
branchNameOverride: 'feature/manual',
onBranchNameOverrideChange: vi.fn()
})
expect(findInputByLabel(current.container, 'Branch name')).toBeNull()
})
it('omits the branch name field when a tracked work item is the source', () => {
// Why: a PR/issue/MR/Linear source derives the branch itself (and a linked
// GitHub PR re-resolves it at submit), so a manual override would be a
// silently ignored control — the field is only for typed-name/base-branch.
current = renderCard({
advancedOpen: true,
branchesEnabled: true,
branchNameOverride: 'feature/manual',
smartNameSelection: { kind: 'github-pr', label: '#42 Fix', url: 'https://example.com/pr/42' },
onBranchNameOverrideChange: vi.fn()
})
expect(findInputByLabel(current.container, 'Branch name')).toBeNull()
})
it('keeps the branch name field when creating from a base branch', () => {
// Why: choosing a base branch still lets the user name their new branch.
current = renderCard({
advancedOpen: true,
branchesEnabled: true,
branchNameOverride: 'feature/manual',
smartNameSelection: { kind: 'branch', label: 'main' },
onBranchNameOverrideChange: vi.fn()
})
expect(findInputByLabel(current.container, 'Branch name')).toBeTruthy()
})
it('does not disable folder workspace creation when only source lookup needs SSH', () => {
current = renderCard({
eligibleRepos: [
@@ -102,6 +102,8 @@ type NewWorkspaceComposerCardProps = {
showAddProjectButton?: boolean
name: string
onNameValueChange: (value: string) => void
branchNameOverride: string | undefined
onBranchNameOverrideChange: (value: string | undefined) => void
onSmartGitHubItemSelect: (item: GitHubWorkItem) => void
onSmartGitLabItemSelect: (item: GitLabWorkItem) => void
onSmartBranchSelect: (refName: string, localBranchName: string) => void
@@ -564,6 +566,8 @@ export default function NewWorkspaceComposerCard({
showAddProjectButton = true,
name,
onNameValueChange,
branchNameOverride,
onBranchNameOverrideChange,
onSmartGitHubItemSelect,
onSmartGitLabItemSelect,
onSmartBranchSelect,
@@ -620,6 +624,7 @@ export default function NewWorkspaceComposerCard({
const disabledTuiAgents = useAppStore((s) => s.settings?.disabledTuiAgents ?? [])
const updateSettings = useAppStore((s) => s.updateSettings)
const nameInputFocusFrameRef = React.useRef<number | null>(null)
const branchNameInputId = React.useId()
const submitShortcutModifierLabel = getScreenSubmitModifierLabel()
const selectedRepoName = React.useMemo(() => {
const repo = eligibleRepos.find((candidate) => candidate.id === repoId)
@@ -1136,6 +1141,38 @@ export default function NewWorkspaceComposerCard({
</div>
) : null}
{/* Why: only offer a manual branch name when creating from a
typed name or a base branch. When a tracked work item (PR/
issue/MR/Linear) is the source, the branch is derived from
that item — a linked GitHub PR even re-resolves it at submit —
so an override typed here would be silently ignored. */}
{selectedRepoIsGit &&
branchesEnabled &&
(!smartNameSelection || smartNameSelection.kind === 'branch') ? (
<div className="space-y-1">
<label
htmlFor={branchNameInputId}
className="text-xs font-medium text-muted-foreground"
>
{translate(
'auto.components.NewWorkspaceComposerCard.branchName',
'Branch name'
)}
</label>
<input
id={branchNameInputId}
type="text"
value={branchNameOverride ?? ''}
onChange={(event) => onBranchNameOverrideChange(event.target.value)}
placeholder={translate(
'auto.components.NewWorkspaceComposerCard.branchNamePlaceholder',
'feature/my-branch'
)}
className="w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1.5 text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
</div>
) : null}
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
{translate('auto.components.NewWorkspaceComposerCard.f8728aa4f9', 'Note')}
@@ -4,6 +4,7 @@ import {
resolveComposerBranchNameOverrideForCreate,
resolveComposerBranchReuse,
resolveComposerBranchSelection,
resolveComposerManualBranchNameChange,
resolveComposerReuseOverride
} from './composer-branch-selection'
@@ -57,15 +58,28 @@ describe('resolveComposerBranchSelection', () => {
})
})
it('keeps resolver-provided PR branch overrides when the workspace name changes', () => {
it('keeps manual branch-name overrides when preserving workspace edits', () => {
expect(
resolveComposerBranchNameOverrideForCreate({
branchNameOverride: 'feature/fix',
branchAutoName: '',
branchNameOverride: 'feature/manual',
branchAutoName: 'feature/auto',
workspaceName: 'edited display name',
preserveWorkspaceNameEdits: true
})
).toBe('feature/fix')
).toBe('feature/manual')
})
it('drops empty or missing branch-name overrides even when preserving workspace edits', () => {
for (const branchNameOverride of [undefined, '']) {
expect(
resolveComposerBranchNameOverrideForCreate({
branchNameOverride,
branchAutoName: 'feature/fix',
workspaceName: 'edited display name',
preserveWorkspaceNameEdits: true
})
).toBeUndefined()
}
})
it('keeps existing branch picker override behavior tied to the auto-name', () => {
@@ -80,6 +94,58 @@ describe('resolveComposerBranchSelection', () => {
})
})
describe('resolveComposerManualBranchNameChange', () => {
const forkPushTarget = {
remoteName: 'contributor',
branchName: 'feature/from-pr',
remoteUrl: 'https://example.com/contributor/repo.git'
}
it('clears a PR-derived push target when the manual branch changes to a different branch', () => {
expect(
resolveComposerManualBranchNameChange({
value: 'feature/manual',
pushTarget: forkPushTarget,
forkPushWarning: 'Cannot push to fork'
})
).toEqual({
branchNameOverride: 'feature/manual',
pushTarget: undefined,
forkPushWarning: null
})
})
it('clears a PR-derived push target when manual branch input is empty or whitespace', () => {
for (const value of ['', ' ']) {
expect(
resolveComposerManualBranchNameChange({
value,
pushTarget: forkPushTarget,
forkPushWarning: 'Cannot push to fork'
})
).toEqual({
branchNameOverride: undefined,
pushTarget: undefined,
forkPushWarning: null
})
}
})
it('preserves a PR-derived push target when the manual branch exactly matches its branch', () => {
expect(
resolveComposerManualBranchNameChange({
value: 'feature/from-pr',
pushTarget: forkPushTarget,
forkPushWarning: 'Cannot push to fork'
})
).toEqual({
branchNameOverride: 'feature/from-pr',
pushTarget: forkPushTarget,
forkPushWarning: 'Cannot push to fork'
})
})
})
describe('isBranchCheckedOutInWorktrees', () => {
it('matches a branch against both refs/heads-qualified and short worktree refs', () => {
expect(
@@ -3,6 +3,7 @@ export {
resolveComposerBranchNameOverrideForCreate,
resolveComposerBranchReuse,
resolveComposerBranchSelection,
resolveComposerManualBranchNameChange,
resolveComposerReuseOverride,
type ComposerBranchSelection
} from '../../../shared/composer-branch-selection'
@@ -339,6 +339,19 @@ describe('useComposerState host-context boundaries', () => {
expect(section).not.toContain('folderSourceRequiresConnection')
})
it('clears branch reuse state when manually editing the branch name', () => {
const section = sourceBetween(
HOOK_SOURCE,
'const handleBranchNameOverrideChange = useCallback',
'const addComposerAttachments = useCallback'
)
expect(section).toContain('resolveComposerManualBranchNameChange')
expect(section).toContain('setReuseEligibleBranch(null)')
expect(section).toContain('setReuseSelectedBranch(false)')
expect(section).toContain("branchAutoNameRef.current = ''")
})
it('forces repo-scoped source reset when returning from folder target to a repo with the same id', () => {
const handleRepoChange = sourceBetween(
HOOK_SOURCE,
@@ -163,6 +163,7 @@ import {
resolveComposerBranchNameOverrideForCreate,
resolveComposerBranchReuse,
resolveComposerBranchSelection,
resolveComposerManualBranchNameChange,
resolveComposerReuseOverride
} from './composer-branch-selection'
import { isCurrentComposerDropOwner } from './composer-drop-owner'
@@ -255,6 +256,8 @@ export type ComposerCardProps = {
smartNameRepoSwitchTarget?: 'project' | 'task-source'
name: string
onNameValueChange: (value: string) => void
branchNameOverride: string | undefined
onBranchNameOverrideChange: (value: string | undefined) => void
onSmartGitHubItemSelect: (item: GitHubWorkItem) => void
onSmartGitLabItemSelect: (item: GitLabWorkItem) => void
onSmartBranchSelect: (refName: string, localBranchName: string) => void
@@ -2275,6 +2278,23 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
},
[branchNameOverride, branchNameOverridePreservesNameEdits, name]
)
const handleBranchNameOverrideChange = useCallback(
(value: string | undefined): void => {
const next = resolveComposerManualBranchNameChange({
value,
pushTarget,
forkPushWarning
})
setBranchNameOverride(next.branchNameOverride)
setBranchNameOverridePreservesNameEdits(Boolean(next.branchNameOverride))
setPushTarget(next.pushTarget)
setForkPushWarning(next.forkPushWarning)
setReuseEligibleBranch(null)
setReuseSelectedBranch(false)
branchAutoNameRef.current = ''
},
[forkPushWarning, pushTarget]
)
const addComposerAttachments = useCallback((paths: string[]): void => {
if (paths.length === 0) {
@@ -4166,6 +4186,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
smartNameRepoSwitchTarget: isProjectGroupTarget ? 'task-source' : 'project',
name,
onNameValueChange: handleNameValueChange,
branchNameOverride: isProjectGroupTarget ? undefined : branchNameOverride,
onBranchNameOverrideChange: isProjectGroupTarget ? () => {} : handleBranchNameOverrideChange,
onSmartGitHubItemSelect: handleSmartGitHubItemSelect,
onSmartGitLabItemSelect: handleSmartGitLabItemSelect,
onSmartBranchSelect: isProjectGroupTarget ? () => {} : handleSmartBranchSelect,
+3 -1
View File
@@ -1170,7 +1170,9 @@
"ephemeralVm": "Per-Workspace Environment",
"chooseRunTarget": "Choose target",
"noRunTargets": "No run targets are ready for this project.",
"perWorkspaceEnvHint": "Provision an on-demand environment from a recipe"
"perWorkspaceEnvHint": "Provision an on-demand environment from a recipe",
"branchName": "Branch name",
"branchNamePlaceholder": "feature/my-branch"
},
"NewWorkspaceComposerModal": {
"createWorktree": "Create worktree",
+3 -1
View File
@@ -1170,7 +1170,9 @@
"ephemeralVm": "Entorno por espacio de trabajo",
"chooseRunTarget": "Elige objetivo",
"noRunTargets": "No hay objetivos de ejecución listos para este proyecto.",
"perWorkspaceEnvHint": "Proporcione un entorno bajo demanda a partir de una receta"
"perWorkspaceEnvHint": "Proporcione un entorno bajo demanda a partir de una receta",
"branchName": "Nombre de rama",
"branchNamePlaceholder": "feature/my-branch"
},
"NewWorkspaceComposerModal": {
"createWorktree": "Crear árbol de trabajo",
+3 -1
View File
@@ -1170,7 +1170,9 @@
"ephemeralVm": "ワークスペースごとの環境",
"chooseRunTarget": "ターゲットを選択",
"noRunTargets": "このプロジェクトには実行ターゲットが準備されていません。",
"perWorkspaceEnvHint": "レシピからオンデマンド環境をプロビジョニングする"
"perWorkspaceEnvHint": "レシピからオンデマンド環境をプロビジョニングする",
"branchName": "ブランチ名",
"branchNamePlaceholder": "feature/my-branch"
},
"NewWorkspaceComposerModal": {
"createWorktree": "ワークツリーを作成する",
+3 -1
View File
@@ -1170,7 +1170,9 @@
"ephemeralVm": "워크스페이스별 환경",
"chooseRunTarget": "대상 선택",
"noRunTargets": "이 프로젝트에는 실행 대상이 준비되어 있지 않습니다.",
"perWorkspaceEnvHint": "레시피에서 온디맨드 환경 프로비저닝"
"perWorkspaceEnvHint": "레시피에서 온디맨드 환경 프로비저닝",
"branchName": "브랜치 이름",
"branchNamePlaceholder": "feature/my-branch"
},
"NewWorkspaceComposerModal": {
"createWorktree": "작업 트리 만들기",
+3 -1
View File
@@ -1170,7 +1170,9 @@
"ephemeralVm": "工作区专属环境",
"chooseRunTarget": "选择目标",
"noRunTargets": "此项目还没有可用的运行目标。",
"perWorkspaceEnvHint": "根据配方按需创建环境"
"perWorkspaceEnvHint": "根据配方按需创建环境",
"branchName": "分支名称",
"branchNamePlaceholder": "feature/my-branch"
},
"NewWorkspaceComposerModal": {
"createWorktree": "创建工作树",
+26
View File
@@ -1,3 +1,5 @@
import type { GitPushTarget } from './types'
export type ComposerBranchSelection = {
baseBranch: string
branchNameOverride: string | undefined
@@ -111,3 +113,27 @@ export function resolveComposerBranchNameOverrideForCreate(args: {
}
return args.workspaceName === args.branchAutoName ? args.branchNameOverride : undefined
}
export function resolveComposerManualBranchNameChange(args: {
value: string | undefined
pushTarget: GitPushTarget | undefined
forkPushWarning: string | null
}): {
branchNameOverride: string | undefined
pushTarget: GitPushTarget | undefined
forkPushWarning: string | null
} {
const branchNameOverride = args.value?.trim() || undefined
if (args.pushTarget && args.pushTarget.branchName !== branchNameOverride) {
return {
branchNameOverride,
pushTarget: undefined,
forkPushWarning: null
}
}
return {
branchNameOverride,
pushTarget: args.pushTarget,
forkPushWarning: args.forkPushWarning
}
}