fix(naming): keep the built-in branch-name prompt general

The shipped auto-rename prompt no longer hard-codes style rules (word
count, kebab-case, no prefixes). Users can already override naming via
Source Control AI instructions and the branch-name command template; a
prescriptive default fought those overrides. Git-safe sanitization still
runs after generation.
This commit is contained in:
Brennan Benson
2026-07-16 16:47:47 -07:00
parent 8461df2139
commit 3874e62c34
5 changed files with 53 additions and 41 deletions
@@ -140,7 +140,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
})
it('strips a prefix the model leaked into the slug from both branch and display name', async () => {
// Model ignored "no prefixes" and echoed `you/worktree-spinner`, which the
// Model echoed `you/worktree-spinner`, which the
// sanitizer folds to `you-worktree-spinner`; without stripping it would
// double-prefix the branch (`you/you-...`) and show "You worktree spinner".
generateBranchNameMock.mockResolvedValue({ success: true, slug: 'you-worktree-spinner' })
@@ -283,9 +283,8 @@ async function runAutoRename(
const username = provider
? (await getSshGitUsername(provider, repo.path)) || null
: (await resolveLocalGitUsername(repo.path)) || null
// The model is told not to add a prefix, but sometimes echoes the configured
// one (e.g. `tmchow/...`); strip it so it doesn't double-prefix the branch or
// leak into the display name.
// The model sometimes echoes the configured prefix (e.g. `tmchow/...`); strip
// it so it doesn't double-prefix the branch or leak into the display name.
const slug = stripConfiguredBranchPrefix(
generated.slug,
getConfiguredBranchPrefix(settings, username)
@@ -1879,8 +1879,9 @@ describe('generateBranchNameFromContext', () => {
}
)
expect(prompt).toContain('Additional user prompt:')
expect(prompt.startsWith('Prefer auth terminology.')).toBe(true)
expect(prompt).toContain('Prefer auth terminology.')
expect(prompt).not.toContain('Additional user prompt:')
})
})
+15 -3
View File
@@ -100,6 +100,16 @@ describe('buildBranchNamePrompt', () => {
expect(prompt).not.toContain("Agent's initial response")
})
it('keeps the default prompt general without style rules', () => {
const prompt = buildBranchNamePrompt({ firstPrompt: 'Add a logout button' })
expect(prompt).toContain('Generate a short git branch name')
expect(prompt).toContain('Output ONLY the branch name on a single line')
expect(prompt).not.toContain('Rules:')
expect(prompt).not.toMatch(/kebab-case/i)
expect(prompt).not.toMatch(/between \d+ and \d+ words/i)
expect(prompt).not.toMatch(/no prefixes/i)
})
it('includes the assistant response when present', () => {
const prompt = buildBranchNamePrompt({
firstPrompt: 'Add a logout button',
@@ -109,12 +119,14 @@ describe('buildBranchNamePrompt', () => {
expect(prompt).toContain("I'll wire it into the header.")
})
it('appends a custom branch-name prompt when present', () => {
it('leads with a custom naming prompt so overrides can own style', () => {
const prompt = buildBranchNamePrompt(
{ firstPrompt: 'Add a logout button' },
'Prefer product nouns.'
)
expect(prompt).toContain('Additional user prompt:')
expect(prompt).toContain('Prefer product nouns.')
expect(prompt.startsWith('Prefer product nouns.')).toBe(true)
expect(prompt).not.toContain('Additional user prompt:')
expect(prompt).not.toContain('Rules:')
expect(prompt).toContain('Add a logout button')
})
})
+33 -33
View File
@@ -1,10 +1,9 @@
import { MARINE_CREATURES } from './marine-creatures'
// Why: the work-derived branch name stays short on purpose — long, sentence-like
// branches read worse than the creature name they replace. Two-to-four words is
// the sweet spot the feature targets.
// Why: post-generation sanitization still bounds the leaf so a long model dump
// cannot become an unreadable branch. The *prompt* stays general — users can
// override naming style via Source Control AI instructions / templates.
export const MAX_BRANCH_NAME_WORDS = 4
const MIN_BRANCH_NAME_WORDS = 2
const CREATURE_NAMES_LOWER = new Set(MARINE_CREATURES.map((name) => name.toLowerCase()))
@@ -46,16 +45,15 @@ export function sanitizeBranchSlug(raw: string, maxWords = MAX_BRANCH_NAME_WORDS
}
/**
* Drop a leading prefix segment the generation model prepended despite the
* "no prefixes" instruction — e.g. with a `tmchow/` branch prefix the model
* emits `tmchow/worktree-spinner`, which `sanitizeBranchSlug` folds to
* `tmchow-worktree-spinner`. Left alone, that leaks the prefix into both the
* branch leaf (yielding a doubled `tmchow/tmchow-...`) and the humanized
* display name. Strips only when the leading segment matches the *configured*
* prefix, so a work-derived name that merely starts with a real word survives.
* Prefix-only output (the model echoed just `tmchow`) yields an empty slug so
* the caller skips the rename — otherwise it would double-prefix to
* `tmchow/tmchow`.
* Drop a leading prefix segment the generation model sometimes prepends (e.g.
* with a `tmchow/` branch prefix it emits `tmchow/worktree-spinner`, which
* `sanitizeBranchSlug` folds to `tmchow-worktree-spinner`). Left alone, that
* leaks the prefix into both the branch leaf (yielding a doubled
* `tmchow/tmchow-...`) and the humanized display name. Strips only when the
* leading segment matches the *configured* prefix, so a work-derived name that
* merely starts with a real word survives. Prefix-only output (the model
* echoed just `tmchow`) yields an empty slug so the caller skips the rename —
* otherwise it would double-prefix to `tmchow/tmchow`.
*/
export function stripConfiguredBranchPrefix(
slug: string,
@@ -100,30 +98,32 @@ export type BranchNameWorkContext = {
}
/**
* Build the text-generation prompt that asks the configured agent to summarize
* the work into a branch name. Kept in shared so the prompt is identical across
* local and SSH generation targets.
* Build the text-generation prompt that asks the configured agent to name the
* work. Kept in shared so the prompt is identical across local and SSH targets.
*
* Why: the shipped default stays general. Naming style is left open so users
* who override via Source Control AI instructions / the branch-name command
* template are not fighting a rigid built-in rule list. Git-safety (kebab,
* length) is enforced after generation by sanitizeBranchSlug.
*/
export function buildBranchNamePrompt(context: BranchNameWorkContext, customPrompt = ''): string {
const sections = [
'Generate a git branch name that summarizes the coding task described below.',
'Rules:',
`- Use between ${MIN_BRANCH_NAME_WORDS} and ${MAX_BRANCH_NAME_WORDS} words.`,
'- Lowercase kebab-case only (words joined by single hyphens).',
'- No slashes, no prefixes, no quotes, no trailing punctuation.',
'- Describe the work itself, not the agent or the repository.',
'- Output ONLY the branch name on a single line, nothing else.',
'',
'User request:',
context.firstPrompt.trim()
]
const sections: string[] = []
const prompt = customPrompt.trim()
// Why: when the user supplies naming guidance, lead with it so their
// override owns style rather than sitting under a prescriptive default.
if (prompt) {
sections.push(prompt, '')
} else {
sections.push(
'Generate a short git branch name that summarizes the coding task described below.',
'Output ONLY the branch name on a single line, nothing else.',
''
)
}
sections.push('User request:', context.firstPrompt.trim())
const assistant = context.assistantMessage?.trim()
if (assistant) {
sections.push('', "Agent's initial response:", assistant)
}
const prompt = customPrompt.trim()
if (prompt) {
sections.push('', 'Additional user prompt:', prompt)
}
return sections.join('\n')
}