Files
orca/src/shared/branch-prefix.test.ts
YoelandCursor d1ccfcff40 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>
2026-07-24 00:39:32 -07:00

146 lines
4.7 KiB
TypeScript

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'
)
})
})