From 8796261eedd7e042be8c4c7d68d1653909fed7e0 Mon Sep 17 00:00:00 2001 From: Wolfie Date: Fri, 3 Jul 2026 17:21:50 -0700 Subject: [PATCH] feat: allow custom worktree branch names (#6454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --------- 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 --- .../workspace-create-error-classifier.test.ts | 8 +- src/main/ipc/worktree-remote.ts | 8 +- .../NewWorkspaceComposerCard.test.tsx | 79 +++++++++++++++++++ .../components/NewWorkspaceComposerCard.tsx | 37 +++++++++ .../hooks/composer-branch-selection.test.ts | 74 ++++++++++++++++- .../src/hooks/composer-branch-selection.ts | 1 + ...poserState-host-context-boundaries.test.ts | 13 +++ src/renderer/src/hooks/useComposerState.ts | 22 ++++++ src/renderer/src/i18n/locales/en.json | 4 +- src/renderer/src/i18n/locales/es.json | 4 +- src/renderer/src/i18n/locales/ja.json | 4 +- src/renderer/src/i18n/locales/ko.json | 4 +- src/renderer/src/i18n/locales/zh.json | 4 +- src/shared/composer-branch-selection.ts | 26 ++++++ 14 files changed, 271 insertions(+), 17 deletions(-) diff --git a/src/main/ipc/workspace-create-error-classifier.test.ts b/src/main/ipc/workspace-create-error-classifier.test.ts index b4cc075d103..9013cf3065d 100644 --- a/src/main/ipc/workspace-create-error-classifier.test.ts +++ b/src/main/ipc/workspace-create-error-classifier.test.ts @@ -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') }) diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 5412db212a0..86d18129826 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -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( diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index fc7c1a0ce36..2883fdc5cad 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -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('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: [ diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 3ac6f38f55e..9128d8be889 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -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(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({ ) : 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') ? ( +
+ + 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" + /> +
+ ) : null} +