diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index c9703c516..02784cbef 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -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 ) diff --git a/src/main/ipc/worktree-branch-name.ts b/src/main/ipc/worktree-branch-name.ts index 186de8e9b..ae9d79242 100644 --- a/src/main/ipc/worktree-branch-name.ts +++ b/src/main/ipc/worktree-branch-name.ts @@ -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}` +} diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index a01fac836..d7ce72a06 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -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', () => { diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 16937b6b8..d040dec5d 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -8,7 +8,11 @@ import { getWslHome, parseWslPath } from '../wsl' type WorktreePathSettings = Pick type WorktreeBasePathRepo = Pick -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' diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 1497163d5..7a9b2d386 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -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 { 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 { if (!branchNameOverride) { - return computeBranchName(sanitizedName, settings, username) + return computeValidatedBranchName(sanitizedName, settings, username) } if (branchNameOverride.startsWith('-')) { throw new Error('Branch name must not start with "-"') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 2e5251bc2..b1c247372 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 { 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 "-"') diff --git a/src/renderer/src/components/settings/BranchPrefixFeedback.test.tsx b/src/renderer/src/components/settings/BranchPrefixFeedback.test.tsx new file mode 100644 index 000000000..e2730cd54 --- /dev/null +++ b/src/renderer/src/components/settings/BranchPrefixFeedback.test.tsx @@ -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') + }) +}) diff --git a/src/renderer/src/components/settings/BranchPrefixFeedback.tsx b/src/renderer/src/components/settings/BranchPrefixFeedback.tsx new file mode 100644 index 000000000..2f892dba0 --- /dev/null +++ b/src/renderer/src/components/settings/BranchPrefixFeedback.tsx @@ -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 = ( + + {translate( + 'auto.components.settings.BranchPrefixFeedback.6c40c0908f', + 'Prefix cannot contain spaces or special characters like ~ ^ : ? * [ \\' + )} + + ) + } else if (normalized) { + message = ( + + {translate( + 'auto.components.settings.BranchPrefixFeedback.64d70b156a', + 'Branches will be named {{example}}', + { example: `${normalized}/feature` } + )} + + ) + } else if (rawPrefix.trim()) { + message = ( + + {translate( + 'auto.components.settings.BranchPrefixFeedback.808f9a726e', + 'No prefix will be applied' + )} + + ) + } + + // Reserve a line of height so the message swapping in/out as the user types + // does not reflow the settings list below it. + return

{message}

+} diff --git a/src/renderer/src/components/settings/GitPane.test.tsx b/src/renderer/src/components/settings/GitPane.test.tsx new file mode 100644 index 000000000..6edf003c3 --- /dev/null +++ b/src/renderer/src/components/settings/GitPane.test.tsx @@ -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') + }) +}) diff --git a/src/renderer/src/components/settings/GitPane.tsx b/src/renderer/src/components/settings/GitPane.tsx index bdfff4a69..da1a34c3f 100644 --- a/src/renderer/src/components/settings/GitPane.tsx +++ b/src/renderer/src/components/settings/GitPane.tsx @@ -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({ ))} - {(settings.branchPrefix === 'custom' || settings.branchPrefix === 'git-username') && ( + {isBranchPrefixInputMode && ( 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 && } ) : null, matchesSettingsSearch(searchQuery, { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8d623b7bb..d8eb1b14a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 2ddddbc99..8699c7e0b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 7f9a27556..774849356 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5719,6 +5719,11 @@ "YamlThemeImportButton": { "label": "YAML からインポート" }, + "BranchPrefixFeedback": { + "6c40c0908f": "プレフィックスにスペースや ~ ^ : ? * [ \\ などの特殊文字は使用できません", + "64d70b156a": "ブランチ名は {{example}} になります", + "808f9a726e": "プレフィックスは適用されません" + }, "GitPane": { "d2eede4c54": "Orca の帰属を commits、PR、Issue に追加します。", "e02ea23a32": "Orca の帰属", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 4e4b41a89..50b56ecfc 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5682,6 +5682,11 @@ "273e7e81fe": "구성", "1f744a72f4": "구성" }, + "BranchPrefixFeedback": { + "6c40c0908f": "접두사에는 공백이나 ~ ^ : ? * [ \\ 같은 특수 문자를 사용할 수 없습니다", + "64d70b156a": "브랜치 이름은 {{example}}(으)로 지정됩니다", + "808f9a726e": "접두사가 적용되지 않습니다" + }, "GitPane": { "d2eede4c54": "commits, PR 및 이슈에 Orca 속성을 추가합니다.", "e02ea23a32": "Orca 표기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index ce35d4bc5..9e57f41ae 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5682,6 +5682,11 @@ "273e7e81fe": "配置", "1f744a72f4": "配置" }, + "BranchPrefixFeedback": { + "6c40c0908f": "前缀不能包含空格或 ~ ^ : ? * [ \\ 等特殊字符", + "64d70b156a": "分支将命名为 {{example}}", + "808f9a726e": "不会应用前缀" + }, "GitPane": { "d2eede4c54": "将 Orca 署名添加到 commits、PR 和议题。", "e02ea23a32": "Orca 署名", diff --git a/src/shared/branch-prefix.test.ts b/src/shared/branch-prefix.test.ts new file mode 100644 index 000000000..270a1fbfd --- /dev/null +++ b/src/shared/branch-prefix.test.ts @@ -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' + ) + }) +}) diff --git a/src/shared/branch-prefix.ts b/src/shared/branch-prefix.ts new file mode 100644 index 000000000..18a5fbe0b --- /dev/null +++ b/src/shared/branch-prefix.ts @@ -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` + ) + } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 32f3f4731..dcd040821 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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'