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:
parent
662d23f9b3
commit
d1ccfcff40
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "-"')
|
||||
|
|
|
|||
|
|
@ -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 "-"')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -5719,6 +5719,11 @@
|
|||
"YamlThemeImportButton": {
|
||||
"label": "YAML からインポート"
|
||||
},
|
||||
"BranchPrefixFeedback": {
|
||||
"6c40c0908f": "プレフィックスにスペースや ~ ^ : ? * [ \\ などの特殊文字は使用できません",
|
||||
"64d70b156a": "ブランチ名は {{example}} になります",
|
||||
"808f9a726e": "プレフィックスは適用されません"
|
||||
},
|
||||
"GitPane": {
|
||||
"d2eede4c54": "Orca の帰属を commits、PR、Issue に追加します。",
|
||||
"e02ea23a32": "Orca の帰属",
|
||||
|
|
|
|||
|
|
@ -5682,6 +5682,11 @@
|
|||
"273e7e81fe": "구성",
|
||||
"1f744a72f4": "구성"
|
||||
},
|
||||
"BranchPrefixFeedback": {
|
||||
"6c40c0908f": "접두사에는 공백이나 ~ ^ : ? * [ \\ 같은 특수 문자를 사용할 수 없습니다",
|
||||
"64d70b156a": "브랜치 이름은 {{example}}(으)로 지정됩니다",
|
||||
"808f9a726e": "접두사가 적용되지 않습니다"
|
||||
},
|
||||
"GitPane": {
|
||||
"d2eede4c54": "commits, PR 및 이슈에 Orca 속성을 추가합니다.",
|
||||
"e02ea23a32": "Orca 표기",
|
||||
|
|
|
|||
|
|
@ -5682,6 +5682,11 @@
|
|||
"273e7e81fe": "配置",
|
||||
"1f744a72f4": "配置"
|
||||
},
|
||||
"BranchPrefixFeedback": {
|
||||
"6c40c0908f": "前缀不能包含空格或 ~ ^ : ? * [ \\ 等特殊字符",
|
||||
"64d70b156a": "分支将命名为 {{example}}",
|
||||
"808f9a726e": "不会应用前缀"
|
||||
},
|
||||
"GitPane": {
|
||||
"d2eede4c54": "将 Orca 署名添加到 commits、PR 和议题。",
|
||||
"e02ea23a32": "Orca 署名",
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in New Issue