From f7a4100bc7531c0fea5c30dd277ff0be0bd0d199 Mon Sep 17 00:00:00 2001 From: mehmet turac Date: Sat, 4 Jul 2026 03:33:22 +0300 Subject: [PATCH] Preserve slash branch names from branch composer (#6748) Co-authored-by: Orca Co-authored-by: Neil --- .../components/NewWorkspaceComposerCard.tsx | 4 ++ .../hooks/composer-branch-selection.test.ts | 52 +++++++++++++++++++ src/renderer/src/hooks/useComposerState.ts | 14 ++++- src/shared/composer-branch-selection.ts | 17 +++++- 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 9128d8be8..c7d96c14f 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -51,6 +51,7 @@ import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPreset import SmartWorkspaceNameField, { type SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' +import type { SmartNameMode } from '@/components/new-workspace/smart-workspace-source-results' import ProjectCombobox from '@/components/new-workspace/ProjectCombobox' import type { SetupConfig } from '@/lib/new-workspace' import type { NewWorkspaceProjectOption } from '@/lib/new-workspace-project-options' @@ -107,6 +108,7 @@ type NewWorkspaceComposerCardProps = { onSmartGitHubItemSelect: (item: GitHubWorkItem) => void onSmartGitLabItemSelect: (item: GitLabWorkItem) => void onSmartBranchSelect: (refName: string, localBranchName: string) => void + onSmartNameModeChange?: (mode: SmartNameMode) => void onSmartLinearIssueSelect: (issue: LinearIssue) => void smartNameSelection: SmartWorkspaceNameSelection | null onClearSmartNameSelection: () => void @@ -571,6 +573,7 @@ export default function NewWorkspaceComposerCard({ onSmartGitHubItemSelect, onSmartGitLabItemSelect, onSmartBranchSelect, + onSmartNameModeChange, onSmartLinearIssueSelect, smartNameSelection, onClearSmartNameSelection, @@ -956,6 +959,7 @@ export default function NewWorkspaceComposerCard({ repoBackedSearchRepos={repoBackedSearchRepos} allowCrossRepoProjectAdd={allowSmartNameAddProject} crossRepoSwitchTarget={smartNameRepoSwitchTarget} + onActiveSourceModeChange={onSmartNameModeChange} onPlainEnter={() => { // Why: Enter on the workspace name advances focus to the next // field (Agent combobox) rather than submitting, letting the user diff --git a/src/renderer/src/hooks/composer-branch-selection.test.ts b/src/renderer/src/hooks/composer-branch-selection.test.ts index 6b4bf8f43..cf4e8fb7c 100644 --- a/src/renderer/src/hooks/composer-branch-selection.test.ts +++ b/src/renderer/src/hooks/composer-branch-selection.test.ts @@ -92,6 +92,58 @@ describe('resolveComposerBranchSelection', () => { }) ).toBeUndefined() }) + + it('uses a slash-containing typed branch name as the create override in branch mode', () => { + expect( + resolveComposerBranchNameOverrideForCreate({ + branchNameOverride: undefined, + branchAutoName: '', + workspaceName: 'feature/user-profile', + preserveWorkspaceNameEdits: false, + createBranchFromWorkspaceName: true + }) + ).toBe('feature/user-profile') + }) + + it('keeps plain typed branch names on the existing sanitized-name path', () => { + expect( + resolveComposerBranchNameOverrideForCreate({ + branchNameOverride: undefined, + branchAutoName: '', + workspaceName: 'feature-user-profile', + preserveWorkspaceNameEdits: false, + createBranchFromWorkspaceName: true + }) + ).toBeUndefined() + }) + + it('does not preserve a slash typed name outside branch mode (gate off)', () => { + // Why: only branch mode opts into slash preservation; every other mode keeps + // deriving the branch from the sanitized workspace name. + expect( + resolveComposerBranchNameOverrideForCreate({ + branchNameOverride: undefined, + branchAutoName: '', + workspaceName: 'feature/user-profile', + preserveWorkspaceNameEdits: false, + createBranchFromWorkspaceName: false + }) + ).toBeUndefined() + }) + + it('keeps a resolver-provided override even in branch mode with a slash name', () => { + // Why: a picked branch (override set) wins over the typed slash name so the + // branch-mode gate never hijacks an explicit branch selection. + expect( + resolveComposerBranchNameOverrideForCreate({ + branchNameOverride: 'feature/picked', + branchAutoName: 'feature/picked', + workspaceName: 'feature/user-profile', + preserveWorkspaceNameEdits: true, + createBranchFromWorkspaceName: true + }) + ).toBe('feature/picked') + }) }) describe('resolveComposerManualBranchNameChange', () => { diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 048c8f7fd..202563742 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -141,6 +141,7 @@ import { queueNewWorkspaceTerminalFocus } from '@/lib/new-workspace-terminal-foc import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions' import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField' +import type { SmartNameMode } from '@/components/new-workspace/smart-workspace-source-results' import { getForkPushWarning } from './fork-push-warning' import { CONTEXTUAL_TOUR_ENABLE_AUTO_WORKSPACE_NAME_EVENT } from '@/components/contextual-tours/contextual-tour-composer-events' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' @@ -261,6 +262,7 @@ export type ComposerCardProps = { onSmartGitHubItemSelect: (item: GitHubWorkItem) => void onSmartGitLabItemSelect: (item: GitLabWorkItem) => void onSmartBranchSelect: (refName: string, localBranchName: string) => void + onSmartNameModeChange?: (mode: SmartNameMode) => void onSmartLinearIssueSelect: (issue: LinearIssue) => void smartNameGitHubSourceContext?: TaskSourceContext | null /** GitLab parallel of onBaseBranchPrSelect. */ @@ -1024,6 +1026,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const [branchNameOverride, setBranchNameOverride] = useState(undefined) const [branchNameOverridePreservesNameEdits, setBranchNameOverridePreservesNameEdits] = useState(false) + const [smartNameMode, setSmartNameMode] = useState('smart') // Why (#5181): when the user picks an existing LOCAL branch, let them reuse it // (check it out) instead of creating a new branch from it. `reuseEligibleBranch` // is the local branch name eligible for reuse (null = not a reusable local @@ -3476,7 +3479,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS branchAutoName: branchAutoNameRef.current, workspaceName, preserveWorkspaceNameEdits: - smartGitHubResolution.kind === 'pr-start-point' || branchNameOverridePreservesNameEdits + smartGitHubResolution.kind === 'pr-start-point' || branchNameOverridePreservesNameEdits, + createBranchFromWorkspaceName: + smartGitHubResolution.kind === 'none' && smartNameMode === 'branches' }) const createDisplayName = smartGitHubResolution.kind === 'none' @@ -3678,6 +3683,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS settings?.agentDefaultArgs, settings?.agentDefaultEnv, settings?.autoRenameBranchFromWork, + smartNameMode, setSidebarOpen, setupDecision, sparseEnabled, @@ -3875,7 +3881,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS branchAutoName: branchAutoNameRef.current, workspaceName, preserveWorkspaceNameEdits: - smartGitHubResolution.kind === 'pr-start-point' || branchNameOverridePreservesNameEdits + smartGitHubResolution.kind === 'pr-start-point' || branchNameOverridePreservesNameEdits, + createBranchFromWorkspaceName: + smartGitHubResolution.kind === 'none' && smartNameMode === 'branches' }) const submitBaseBranch = selectedRepoIsGit ? await resolveWorktreeCreateBaseBranch({ @@ -4128,6 +4136,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS settings?.agentDefaultArgs, settings?.agentDefaultEnv, settings?.autoRenameBranchFromWork, + smartNameMode, disabledTuiAgents, setupDecision, sparseEnabled, @@ -4191,6 +4200,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS onSmartGitHubItemSelect: handleSmartGitHubItemSelect, onSmartGitLabItemSelect: handleSmartGitLabItemSelect, onSmartBranchSelect: isProjectGroupTarget ? () => {} : handleSmartBranchSelect, + onSmartNameModeChange: setSmartNameMode, onSmartLinearIssueSelect: handleSmartLinearIssueSelect, smartNameGitHubSourceContext: selectedRepoGitHubSourceContext, smartNameSelection, diff --git a/src/shared/composer-branch-selection.ts b/src/shared/composer-branch-selection.ts index 094b74e65..de2750ad6 100644 --- a/src/shared/composer-branch-selection.ts +++ b/src/shared/composer-branch-selection.ts @@ -99,14 +99,29 @@ export function resolveComposerReuseOverride(args: { return args.branchNameOverride } +/** + * The branch-name override to apply when creating a worktree from the composer. + * + * With no resolver-provided override, branch mode (#6721) keeps a + * slash-containing typed name as the git branch — validated downstream by + * `git check-ref-format` — while the worktree folder name is sanitized + * separately; every other mode leaves the branch to be derived from the + * sanitized name. With an override, keep it verbatim when the workspace name is + * user-edited (`preserveWorkspaceNameEdits`) or still matches the auto-name. + */ export function resolveComposerBranchNameOverrideForCreate(args: { branchNameOverride: string | undefined branchAutoName: string workspaceName: string preserveWorkspaceNameEdits: boolean + createBranchFromWorkspaceName?: boolean }): string | undefined { if (!args.branchNameOverride) { - return undefined + // Why: branch mode keeps slash-containing git branch names while the + // workspace folder name may still be sanitized separately. + return args.createBranchFromWorkspaceName && args.workspaceName.includes('/') + ? args.workspaceName + : undefined } if (args.preserveWorkspaceNameEdits) { return args.branchNameOverride