fix(settings): normalize and validate branch prefixes (#7772)

* Normalize branch prefixes and flag invalid ones in settings

A custom branch prefix ending in a slash (e.g. "team/") produced a
double-slashed branch name like "team//feature" that git rejects, and
the raw check-ref-format error gave no hint that the prefix caused it.

- Normalize the configured prefix (trim whitespace, strip leading/
  trailing and duplicate slashes) in the shared branch-name builder so
  the common trailing-slash case just works, for local and SSH worktrees.
- Validate the prefix on the worktree-create path (computeValidatedBranchName)
  so a genuinely invalid prefix fails fast with a clear
  "update it in Settings -> Git" message instead of an opaque git error.
- Add a live BranchPrefixFeedback under the Branch Prefix setting: previews
  the resulting branch name, warns on invalid characters, and notes when a
  prefix collapses to none.
- Keep the background first-work rename on the non-throwing builder since the
  prefix is already validated at create time.

* Keep caret in place when editing the branch prefix

The custom branch prefix input was directly controlled by settings, but
updateSettings persists through an async IPC round-trip, so the value
updated a tick late and React re-assigned it, snapping the caret to the
end on mid-string edits. Drive the input from a local draft and only
adopt genuine external settings changes so the caret stays put (and fast
typing survives slow SSH round-trips).

Co-authored-by: Cursor <cursoragent@cursor.com>

* Return ReactNode from BranchPrefixFeedback

JSX.Element needlessly excludes null/string/number returns; ReactNode
keeps the component's return type from over-constraining future changes.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yoel 2026-07-24 01:39:32 -06:00 committed by GitHub
parent 662d23f9b3
commit d1ccfcff40
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 566 additions and 25 deletions

View File

@ -264,6 +264,9 @@ async function runAutoRename(
const newBranch = await resolveUniqueBranchName(
exec,
slug,
// Use the non-throwing builder here: the prefix was already validated at
// worktree-create time, and this best-effort background rename has its own
// retry/stop handling, so it must not throw on prefix issues.
(slugLeaf) => computeBranchName(slugLeaf, settings, username),
currentBranch
)

View File

@ -1,20 +1,26 @@
import {
assertBranchPrefixValid,
normalizeBranchPrefix,
selectBranchPrefixInput,
type BranchPrefixSettings
} from '../../shared/branch-prefix'
/**
* Resolve the branch prefix segment (the part before `/`) the configured
* strategy will prepend, or null when no prefix applies. Exposed so callers can
* detect a prefix the user already typed (or a generation model leaked) before
* it gets prepended a second time.
*
* The returned prefix is normalized (surrounding whitespace/slashes stripped) so
* a custom value like `team/` cannot produce a `team//name` branch that git
* check-ref-format rejects.
*/
export function getConfiguredBranchPrefix(
settings: { branchPrefix: string; branchPrefixCustom?: string },
settings: BranchPrefixSettings,
gitUsername: string | null
): string | null {
if (settings.branchPrefix === 'git-username') {
return gitUsername || null
}
if (settings.branchPrefix === 'custom' && settings.branchPrefixCustom) {
return settings.branchPrefixCustom
}
return null
const raw = selectBranchPrefixInput(settings, gitUsername)
return raw ? normalizeBranchPrefix(raw) || null : null
}
/**
@ -22,9 +28,27 @@ export function getConfiguredBranchPrefix(
*/
export function computeBranchName(
sanitizedName: string,
settings: { branchPrefix: string; branchPrefixCustom?: string },
settings: BranchPrefixSettings,
gitUsername: string | null
): string {
const prefix = getConfiguredBranchPrefix(settings, gitUsername)
return prefix ? `${prefix}/${sanitizedName}` : sanitizedName
}
/**
* Compute a branch name and fail fast when the configured prefix is invalid.
* Used on worktree-create paths so users get a clear settings hint instead of
* an opaque git check-ref-format failure.
*/
export function computeValidatedBranchName(
sanitizedName: string,
settings: BranchPrefixSettings,
gitUsername: string | null
): string {
const prefix = getConfiguredBranchPrefix(settings, gitUsername)
if (prefix === null) {
return sanitizedName
}
assertBranchPrefixValid(prefix)
return `${prefix}/${sanitizedName}`
}

View File

@ -6,6 +6,7 @@ import {
ensurePathWithinWorkspace,
computeBranchName,
getConfiguredBranchPrefix,
computeValidatedBranchName,
computeWorktreePath,
computeRemoteWorktreePath,
computeWorkspaceRoot,
@ -148,6 +149,18 @@ describe('computeBranchName', () => {
it('returns bare name when branchPrefix is none', () => {
expect(computeBranchName('feature', { branchPrefix: 'none' }, 'jdoe')).toBe('feature')
})
it('does not double the slash when a custom prefix ends in one', () => {
expect(
computeBranchName('feature', { branchPrefix: 'custom', branchPrefixCustom: 'team/' }, null)
).toBe('team/feature')
})
it('normalizes a trailing slash on a git username prefix', () => {
expect(computeBranchName('feature', { branchPrefix: 'git-username' }, 'jdoe/')).toBe(
'jdoe/feature'
)
})
})
describe('getConfiguredBranchPrefix', () => {
@ -174,6 +187,40 @@ describe('getConfiguredBranchPrefix', () => {
it('returns null when no prefix strategy applies', () => {
expect(getConfiguredBranchPrefix({ branchPrefix: 'none' }, 'jdoe')).toBeNull()
})
it('normalizes a trailing slash out of the custom prefix', () => {
expect(
getConfiguredBranchPrefix({ branchPrefix: 'custom', branchPrefixCustom: 'team/' }, null)
).toBe('team')
})
it('returns null when the custom prefix normalizes away to empty', () => {
expect(
getConfiguredBranchPrefix({ branchPrefix: 'custom', branchPrefixCustom: '/' }, null)
).toBeNull()
})
})
describe('computeValidatedBranchName', () => {
it('returns the computed branch name when the prefix is valid', () => {
expect(
computeValidatedBranchName(
'feature',
{ branchPrefix: 'custom', branchPrefixCustom: 'team' },
null
)
).toBe('team/feature')
})
it('throws when the configured prefix is invalid', () => {
expect(() =>
computeValidatedBranchName(
'feature',
{ branchPrefix: 'custom', branchPrefixCustom: 'team x' },
null
)
).toThrow('contains characters git rejects')
})
})
describe('computeWorktreePath', () => {

View File

@ -8,7 +8,11 @@ import { getWslHome, parseWslPath } from '../wsl'
type WorktreePathSettings = Pick<GlobalSettings, 'nestWorkspaces' | 'workspaceDir'>
type WorktreeBasePathRepo = Pick<Repo, 'path' | 'worktreeBasePath'>
export { computeBranchName, getConfiguredBranchPrefix } from './worktree-branch-name'
export {
computeBranchName,
getConfiguredBranchPrefix,
computeValidatedBranchName
} from './worktree-branch-name'
export { mergeWorktree } from './worktree-metadata-merge'
export { areWorktreePathsEqual } from './worktree-path-comparison'

View File

@ -70,7 +70,7 @@ type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & {
import {
sanitizeWorktreeName,
sanitizeWorktreeDisplayName,
computeBranchName,
computeValidatedBranchName,
computeWorktreePath,
computeRemoteWorktreePath,
computeWorkspaceRoot,
@ -82,6 +82,7 @@ import {
mergeWorktree
} from './worktree-logic'
import { findCreatedWorktree } from './created-worktree-reconciliation'
import type { BranchPrefixSettings } from '../../shared/branch-prefix'
import { getRepoIdFromWorktreeId } from '../../shared/worktree-id'
import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope'
import {
@ -518,12 +519,12 @@ async function resolveCreateBranchName(
repoPath: string,
branchNameOverride: string | undefined,
sanitizedName: string,
settings: { branchPrefix: string; branchPrefixCustom?: string },
settings: BranchPrefixSettings,
username: string | null,
gitOptions: { wslDistro?: string } = {}
): Promise<string> {
if (!branchNameOverride) {
return computeBranchName(sanitizedName, settings, username)
return computeValidatedBranchName(sanitizedName, settings, username)
}
if (branchNameOverride.startsWith('-')) {
throw new Error('Branch name must not start with "-"')
@ -540,11 +541,11 @@ async function resolveCreateBranchNameSsh(
repoPath: string,
branchNameOverride: string | undefined,
sanitizedName: string,
settings: { branchPrefix: string; branchPrefixCustom?: string },
settings: BranchPrefixSettings,
username: string | null
): Promise<string> {
if (!branchNameOverride) {
return computeBranchName(sanitizedName, settings, username)
return computeValidatedBranchName(sanitizedName, settings, username)
}
if (branchNameOverride.startsWith('-')) {
throw new Error('Branch name must not start with "-"')

View File

@ -112,6 +112,7 @@ import type {
ForceDeleteWorktreeBranchResult,
GitHubPrStartPoint,
GitPushTarget,
BranchPrefixStrategy,
GitWorktreeInfo,
GitHubCreateIssueFields,
GitHubOwnerRepo,
@ -796,7 +797,7 @@ import type { Store } from '../persistence'
import type { StatsCollector } from '../stats/collector'
import { AgentDetector } from '../stats/agent-detector'
import {
computeBranchName,
computeValidatedBranchName,
computeWorktreePath,
computeWorkspaceRoot,
ensurePathWithinWorkspace,
@ -1886,7 +1887,13 @@ async function resolveCreateBranchName(
gitOptions: { wslDistro?: string } = {}
): Promise<string> {
if (!branchNameOverride) {
return computeBranchName(sanitizedName, settings, username)
// The runtime store's getSettings() types branchPrefix loosely as string;
// it is always one of the BranchPrefixStrategy literals at runtime.
return computeValidatedBranchName(
sanitizedName,
{ ...settings, branchPrefix: settings.branchPrefix as BranchPrefixStrategy },
username
)
}
if (branchNameOverride.startsWith('-')) {
throw new Error('Branch name must not start with "-"')

View File

@ -0,0 +1,35 @@
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { BranchPrefixFeedback } from './BranchPrefixFeedback'
function render(rawPrefix: string): string {
return renderToStaticMarkup(React.createElement(BranchPrefixFeedback, { rawPrefix }))
}
describe('BranchPrefixFeedback', () => {
it('previews the resulting branch name and drops a redundant trailing slash', () => {
const html = render('team/')
expect(html).toContain('team/feature')
expect(html).not.toContain('team//feature')
expect(html).toContain('text-muted-foreground')
})
it('warns when the prefix contains invalid characters', () => {
const html = render('team x')
expect(html).toContain('Prefix cannot contain spaces')
expect(html).toContain('text-destructive')
})
it('reports when a slashes-only prefix collapses to no prefix', () => {
const html = render('///')
expect(html).toContain('No prefix will be applied')
})
it('renders no message for an empty prefix', () => {
const html = render('')
expect(html).not.toContain('feature')
expect(html).not.toContain('Prefix cannot contain spaces')
expect(html).not.toContain('No prefix will be applied')
})
})

View File

@ -0,0 +1,47 @@
import type { ReactNode } from 'react'
import { getBranchPrefixIssue, normalizeBranchPrefix } from '../../../../shared/branch-prefix'
import { translate } from '@/i18n/i18n'
type BranchPrefixFeedbackProps = {
rawPrefix: string
}
export function BranchPrefixFeedback({ rawPrefix }: BranchPrefixFeedbackProps): ReactNode {
const issue = getBranchPrefixIssue(rawPrefix)
const normalized = normalizeBranchPrefix(rawPrefix)
let message: ReactNode = null
if (issue) {
message = (
<span className="text-destructive">
{translate(
'auto.components.settings.BranchPrefixFeedback.6c40c0908f',
'Prefix cannot contain spaces or special characters like ~ ^ : ? * [ \\'
)}
</span>
)
} else if (normalized) {
message = (
<span className="text-muted-foreground">
{translate(
'auto.components.settings.BranchPrefixFeedback.64d70b156a',
'Branches will be named {{example}}',
{ example: `${normalized}/feature` }
)}
</span>
)
} else if (rawPrefix.trim()) {
message = (
<span className="text-muted-foreground">
{translate(
'auto.components.settings.BranchPrefixFeedback.808f9a726e',
'No prefix will be applied'
)}
</span>
)
}
// Reserve a line of height so the message swapping in/out as the user types
// does not reflow the settings list below it.
return <p className="min-h-4 text-xs">{message}</p>
}

View File

@ -0,0 +1,74 @@
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import type { GlobalSettings } from '../../../../shared/types'
import { getDefaultSettings } from '../../../../shared/constants'
import { TooltipProvider } from '../ui/tooltip'
import { GitPane } from './GitPane'
function renderGitPane(settings: GlobalSettings, displayedGitUsername = 'jdoe'): string {
return renderToStaticMarkup(
React.createElement(
TooltipProvider,
null,
React.createElement(GitPane, {
settings,
updateSettings: () => {},
writeSourceControlAiSettings: async () => {},
displayedGitUsername
})
)
)
}
function customPrefixSettings(branchPrefixCustom: string): GlobalSettings {
return {
...getDefaultSettings('/home/test'),
branchPrefix: 'custom',
branchPrefixCustom
}
}
function gitUsernamePrefixSettings(): GlobalSettings {
return {
...getDefaultSettings('/home/test'),
branchPrefix: 'git-username',
branchPrefixCustom: ''
}
}
describe('GitPane branch prefix feedback', () => {
it('previews the resulting branch name and drops a redundant trailing slash', () => {
const html = renderGitPane(customPrefixSettings('team/'))
expect(html).toContain('team/feature')
expect(html).not.toContain('team//feature')
})
it('warns when the custom prefix contains invalid characters', () => {
const html = renderGitPane(customPrefixSettings('team x'))
expect(html).toContain('Prefix cannot contain spaces')
})
it('shows neither preview nor warning when no custom prefix is set', () => {
const html = renderGitPane(customPrefixSettings(''))
expect(html).not.toContain('/feature')
expect(html).not.toContain('Prefix cannot contain spaces')
expect(html).not.toContain('No prefix will be applied')
})
it('explains when a custom prefix normalizes away to empty', () => {
const html = renderGitPane(customPrefixSettings('/'))
expect(html).toContain('No prefix will be applied')
expect(html).not.toContain('/feature')
})
it('warns in git-username mode when the displayed username is invalid', () => {
const html = renderGitPane(gitUsernamePrefixSettings(), 'team x')
expect(html).toContain('Prefix cannot contain spaces')
})
it('previews in git-username mode when the displayed username is valid', () => {
const html = renderGitPane(gitUsernamePrefixSettings(), 'jdoe/')
expect(html).toContain('jdoe/feature')
})
})

View File

@ -1,3 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import type { GlobalSettings, SourceControlGroupOrder } from '../../../../shared/types'
import type { SourceControlAiSettingsPatch } from '../../../../shared/source-control-ai-types'
import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from '../../../../shared/source-control-group-order'
@ -6,6 +7,7 @@ import { Label } from '../ui/label'
import { useAppStore } from '../../store'
import { getGitPaneSearchEntries } from './git-search'
import { SearchableSetting } from './SearchableSetting'
import { BranchPrefixFeedback } from './BranchPrefixFeedback'
import { matchesSettingsSearch } from './settings-search'
import { AutoRenameBranchFromWorkSetting } from './AutoRenameBranchFromWorkSetting'
import {
@ -144,6 +146,24 @@ export function GitPane({
const searchQuery = settingsSearchQuery ?? storeSearchQuery
const keepLocalMainUpToDateTitle = getKeepLocalMainUpToDateTitle()
const isBranchPrefixInputMode = settings.branchPrefix !== 'none'
// Local draft for the editable custom prefix: updateSettings persists through
// an async IPC round-trip, so a directly-controlled value would only reflect
// the edit a tick later and React would then re-assign it, snapping the caret
// to the end. The draft keeps the caret put; the ref guard adopts only genuine
// external changes (settings reloaded/reset), not the async echo of our own
// keystrokes, which would clobber fast typing on slow (SSH) round-trips.
const [customPrefixDraft, setCustomPrefixDraft] = useState(settings.branchPrefixCustom)
const lastCommittedPrefixRef = useRef(settings.branchPrefixCustom)
useEffect(() => {
if (settings.branchPrefixCustom !== lastCommittedPrefixRef.current) {
lastCommittedPrefixRef.current = settings.branchPrefixCustom
setCustomPrefixDraft(settings.branchPrefixCustom)
}
}, [settings.branchPrefixCustom])
const branchPrefixInputValue =
settings.branchPrefix === 'git-username' ? displayedGitUsername : customPrefixDraft
const visibleSections = [
matchesSettingsSearch(searchQuery, {
title: translate('auto.components.settings.GitPane.330f584b50', 'Branch Prefix'),
@ -195,14 +215,15 @@ export function GitPane({
</button>
))}
</div>
{(settings.branchPrefix === 'custom' || settings.branchPrefix === 'git-username') && (
{isBranchPrefixInputMode && (
<Input
value={
settings.branchPrefix === 'git-username'
? displayedGitUsername
: settings.branchPrefixCustom
}
onChange={(e) => updateSettings({ branchPrefixCustom: e.target.value })}
value={branchPrefixInputValue}
onChange={(e) => {
const next = e.target.value
lastCommittedPrefixRef.current = next
setCustomPrefixDraft(next)
updateSettings({ branchPrefixCustom: next })
}}
placeholder={
settings.branchPrefix === 'git-username'
? translate(
@ -215,6 +236,7 @@ export function GitPane({
readOnly={settings.branchPrefix === 'git-username'}
/>
)}
{isBranchPrefixInputMode && <BranchPrefixFeedback rawPrefix={branchPrefixInputValue} />}
</SearchableSetting>
) : null,
matchesSettingsSearch(searchQuery, {

View File

@ -5757,6 +5757,11 @@
"YamlThemeImportButton": {
"label": "Import from YAML"
},
"BranchPrefixFeedback": {
"6c40c0908f": "Prefix cannot contain spaces or special characters like ~ ^ : ? * [ \\",
"64d70b156a": "Branches will be named {{example}}",
"808f9a726e": "No prefix will be applied"
},
"GitPane": {
"d2eede4c54": "Add Orca attribution to commits, PRs, and issues.",
"e02ea23a32": "Orca Attribution",

View File

@ -5697,6 +5697,11 @@
"273e7e81fe": "Configuraciones",
"1f744a72f4": "configuración"
},
"BranchPrefixFeedback": {
"6c40c0908f": "El prefijo no puede contener espacios ni caracteres especiales como ~ ^ : ? * [ \\",
"64d70b156a": "Las ramas se llamarán {{example}}",
"808f9a726e": "No se aplicará ningún prefijo"
},
"GitPane": {
"d2eede4c54": "Agrega la atribución de Orca a commits, PR e issues.",
"e02ea23a32": "Atribución de Orca",

View File

@ -5719,6 +5719,11 @@
"YamlThemeImportButton": {
"label": "YAML からインポート"
},
"BranchPrefixFeedback": {
"6c40c0908f": "プレフィックスにスペースや ~ ^ : ? * [ \\ などの特殊文字は使用できません",
"64d70b156a": "ブランチ名は {{example}} になります",
"808f9a726e": "プレフィックスは適用されません"
},
"GitPane": {
"d2eede4c54": "Orca の帰属を commits、PR、Issue に追加します。",
"e02ea23a32": "Orca の帰属",

View File

@ -5682,6 +5682,11 @@
"273e7e81fe": "구성",
"1f744a72f4": "구성"
},
"BranchPrefixFeedback": {
"6c40c0908f": "접두사에는 공백이나 ~ ^ : ? * [ \\ 같은 특수 문자를 사용할 수 없습니다",
"64d70b156a": "브랜치 이름은 {{example}}(으)로 지정됩니다",
"808f9a726e": "접두사가 적용되지 않습니다"
},
"GitPane": {
"d2eede4c54": "commits, PR 및 이슈에 Orca 속성을 추가합니다.",
"e02ea23a32": "Orca 표기",

View File

@ -5682,6 +5682,11 @@
"273e7e81fe": "配置",
"1f744a72f4": "配置"
},
"BranchPrefixFeedback": {
"6c40c0908f": "前缀不能包含空格或 ~ ^ : ? * [ \\ 等特殊字符",
"64d70b156a": "分支将命名为 {{example}}",
"808f9a726e": "不会应用前缀"
},
"GitPane": {
"d2eede4c54": "将 Orca 署名添加到 commits、PR 和议题。",
"e02ea23a32": "Orca 署名",

View File

@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest'
import {
assertBranchPrefixValid,
getBranchPrefixIssue,
normalizeBranchPrefix,
selectBranchPrefixInput
} from './branch-prefix'
describe('normalizeBranchPrefix', () => {
it('strips a trailing slash so the join does not double it', () => {
expect(normalizeBranchPrefix('team/')).toBe('team')
})
it('strips a leading slash', () => {
expect(normalizeBranchPrefix('/team')).toBe('team')
})
it('collapses internal double slashes', () => {
expect(normalizeBranchPrefix('team//frontend')).toBe('team/frontend')
})
it('trims surrounding whitespace', () => {
expect(normalizeBranchPrefix(' team ')).toBe('team')
})
it('preserves a legitimate multi-segment prefix', () => {
expect(normalizeBranchPrefix('team/frontend')).toBe('team/frontend')
})
it('returns empty when the value is only slashes/whitespace', () => {
expect(normalizeBranchPrefix(' // ')).toBe('')
})
it('leaves a plain prefix untouched', () => {
expect(normalizeBranchPrefix('feature')).toBe('feature')
})
})
describe('getBranchPrefixIssue', () => {
it('accepts a normal prefix', () => {
expect(getBranchPrefixIssue('team')).toBeNull()
})
it('accepts a prefix that only needs trailing-slash normalization', () => {
expect(getBranchPrefixIssue('team/')).toBeNull()
})
it('accepts a hyphenated prefix', () => {
expect(getBranchPrefixIssue('feat-x')).toBeNull()
})
it('accepts a multi-segment prefix', () => {
expect(getBranchPrefixIssue('team/frontend')).toBeNull()
})
it('accepts a mid-ref segment that ends with a dot (git allows it)', () => {
expect(getBranchPrefixIssue('team./frontend')).toBeNull()
})
it('accepts a non-leading segment that starts with a dash (git allows it)', () => {
expect(getBranchPrefixIssue('team/-frontend')).toBeNull()
})
it('treats an empty prefix as valid (no prefix)', () => {
expect(getBranchPrefixIssue('')).toBeNull()
})
it('flags whitespace inside the prefix', () => {
expect(getBranchPrefixIssue('team x')).toBe('invalid-characters')
})
it('flags git ref-reserved characters', () => {
expect(getBranchPrefixIssue('team~')).toBe('invalid-characters')
expect(getBranchPrefixIssue('team:x')).toBe('invalid-characters')
expect(getBranchPrefixIssue('team[')).toBe('invalid-characters')
expect(getBranchPrefixIssue('team\\')).toBe('invalid-characters')
})
it('flags ASCII control characters', () => {
expect(getBranchPrefixIssue('team\x01')).toBe('invalid-characters')
})
it('flags a `..` sequence', () => {
expect(getBranchPrefixIssue('team..x')).toBe('invalid-characters')
})
it('flags a `@{` sequence', () => {
expect(getBranchPrefixIssue('team@{x')).toBe('invalid-characters')
})
it('flags a leading dash on the whole prefix', () => {
expect(getBranchPrefixIssue('-team')).toBe('invalid-characters')
})
it('flags a segment starting with a dot', () => {
expect(getBranchPrefixIssue('.team')).toBe('invalid-characters')
expect(getBranchPrefixIssue('team/.frontend')).toBe('invalid-characters')
})
it('flags the whole prefix ending with a dot', () => {
expect(getBranchPrefixIssue('team.')).toBe('invalid-characters')
expect(getBranchPrefixIssue('team/frontend.')).toBe('invalid-characters')
})
it('flags a `.lock` suffix on any segment', () => {
expect(getBranchPrefixIssue('team.lock')).toBe('invalid-characters')
expect(getBranchPrefixIssue('team.lock/x')).toBe('invalid-characters')
})
})
describe('selectBranchPrefixInput', () => {
it('returns the git username for the git-username strategy', () => {
expect(selectBranchPrefixInput({ branchPrefix: 'git-username' }, 'jdoe')).toBe('jdoe')
})
it('returns null for git-username when no username is available', () => {
expect(selectBranchPrefixInput({ branchPrefix: 'git-username' }, null)).toBeNull()
})
it('returns the raw custom value for the custom strategy', () => {
expect(
selectBranchPrefixInput({ branchPrefix: 'custom', branchPrefixCustom: 'team/' }, null)
).toBe('team/')
})
it('returns null for custom when no value is set', () => {
expect(selectBranchPrefixInput({ branchPrefix: 'custom' }, null)).toBeNull()
})
it('returns null for the none strategy', () => {
expect(selectBranchPrefixInput({ branchPrefix: 'none' }, 'jdoe')).toBeNull()
})
})
describe('assertBranchPrefixValid', () => {
it('does not throw for a valid prefix', () => {
expect(() => assertBranchPrefixValid('team')).not.toThrow()
})
it('throws with a settings hint for an invalid prefix', () => {
expect(() => assertBranchPrefixValid('team x')).toThrow(
'Branch prefix "team x" contains characters git rejects — update it in Settings → Git'
)
})
})

104
src/shared/branch-prefix.ts Normal file
View File

@ -0,0 +1,104 @@
import type { BranchPrefixStrategy } from './types'
/** The branch-prefix settings slice the prefix helpers read. */
export type BranchPrefixSettings = {
branchPrefix: BranchPrefixStrategy
branchPrefixCustom?: string
}
/**
* Pick the raw, un-normalized value the configured strategy contributes, or
* null when no prefix applies. Shared so the main-process branch builder and
* the renderer's live settings feedback agree on which field each strategy uses.
*/
export function selectBranchPrefixInput(
settings: BranchPrefixSettings,
gitUsername: string | null
): string | null {
switch (settings.branchPrefix) {
case 'git-username':
return gitUsername
case 'custom':
return settings.branchPrefixCustom ?? null
case 'none':
return null
}
}
/**
* Normalize a configured branch prefix into the segment that gets prepended
* before the `/` separator when building a branch name.
*
* Why: the branch-name join (`${prefix}/${leaf}`) already inserts a single `/`,
* so a user-typed prefix like `team/` would otherwise yield `team//name`, which
* git check-ref-format rejects. Strip surrounding whitespace and slashes and
* collapse internal runs so the join always produces exactly one separator.
* Legitimate multi-segment prefixes (e.g. `team/frontend`) are preserved.
*/
export function normalizeBranchPrefix(rawPrefix: string): string {
return rawPrefix
.trim()
.replace(/^\/+|\/+$/g, '')
.replace(/\/{2,}/g, '/')
}
// Ref-reserved characters git check-ref-format rejects inside a branch name.
const INVALID_BRANCH_PREFIX_CHARS = /[~^:?*[\\]/
/**
* Whether the value contains an ASCII control character or space, both of which
* git rejects. Checked by code point (like `sanitizeWorktreeDisplayName`) to
* avoid a control-character regex that the linter forbids.
*/
function hasControlOrSpace(value: string): boolean {
return [...value].some((char) => {
const code = char.charCodeAt(0)
return code <= 0x20 || code === 0x7f
})
}
/**
* Detect whether a branch prefix, after normalization, still contains characters
* git check-ref-format rejects. Returns a reason code (not a UI string, so the
* renderer owns translation) or null when the prefix is usable.
*
* This is a lightweight mirror of the relevant check-ref-format rules for live
* settings feedback; git remains the source of truth at worktree-create time.
*/
export function getBranchPrefixIssue(rawPrefix: string): 'invalid-characters' | null {
const normalized = normalizeBranchPrefix(rawPrefix)
if (!normalized) {
// Empty after normalization means "no prefix" — valid.
return null
}
// Mirror git check-ref-format exactly so we don't reject prefixes git accepts:
// control chars/space, the ref-reserved set, `..`, and `@{` are forbidden
// anywhere; the whole ref may not start with `-` (arg-injection / leading-dash)
// nor end with `.`; and each `/`-segment may not start with `.` nor end with
// `.lock`. Hyphens and mid-segment dots elsewhere are fine (e.g. `team./x`).
if (
hasControlOrSpace(normalized) ||
INVALID_BRANCH_PREFIX_CHARS.test(normalized) ||
normalized.includes('..') ||
normalized.includes('@{') ||
normalized.startsWith('-') ||
normalized.endsWith('.') ||
normalized.split('/').some((seg) => seg.startsWith('.') || seg.endsWith('.lock'))
) {
return 'invalid-characters'
}
return null
}
/**
* Fail fast when a configured prefix would produce a branch name git rejects.
* Used on the main-process worktree-create path so users get a clear error
* instead of an opaque check-ref-format failure later.
*/
export function assertBranchPrefixValid(prefix: string): void {
if (getBranchPrefixIssue(prefix) !== null) {
throw new Error(
`Branch prefix "${prefix}" contains characters git rejects — update it in Settings → Git`
)
}
}

View File

@ -2577,6 +2577,9 @@ export type SourceControlGroupOrder = 'changes-first' | 'staged-first' | 'untrac
export type LeftSidebarAppearanceMode = 'default' | 'match-terminal' | 'tinted'
/** Strategy for the prefix prepended to worktree branch names. */
export type BranchPrefixStrategy = 'git-username' | 'custom' | 'none'
export type FloatingTerminalCwdRequest = {
path?: string
requireTrusted?: boolean
@ -2612,7 +2615,7 @@ export type GlobalSettings = {
/** One-shot migration guard for the default-on rollout. Existing profiles
* without the guard are flipped on once; later explicit opt-outs stick. */
autoRenameBranchFromWorkDefaultedOn?: boolean
branchPrefix: 'git-username' | 'custom' | 'none'
branchPrefix: BranchPrefixStrategy
branchPrefixCustom: string
enableGitHubAttribution: boolean
theme: 'system' | 'dark' | 'light'