diff --git a/src/generator.ts b/src/generator.ts index 8286417..810572a 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -61,6 +61,30 @@ function formatChangeSummaries(summaries: ChangeSummary[]): string { .join('\n') } +/** Normalize HEAD name for prompts; undefined when detached/unnamed. */ +export function normalizeBranchName( + branchName?: string | null, +): string | undefined { + const trimmed = branchName?.trim() + return trimmed ? trimmed : undefined +} + +export function buildCommitUserContent( + summaries: ChangeSummary[], + branchName?: string | null, +): string { + const wrappedSummaries = wrapUntrustedContent( + 'summaries', + formatChangeSummaries(summaries), + ) + const normalizedBranch = normalizeBranchName(branchName) + const branchSection = normalizedBranch + ? `Current git branch (optional context only; may inform ticket or feature naming but must not invent changes):\n${wrapUntrustedContent('branch', normalizedBranch)}\n\n` + : 'Current git branch: detached HEAD or unnamed (optional context only).\n\n' + + return `${branchSection}Staged change summaries:\n${wrappedSummaries}` +} + function buildStructuredPrompt(options: { typeRules: string commitMessageRules: string @@ -96,6 +120,8 @@ function buildStructuredPrompt(options: { - Describe only what is present in the change summaries - Never say the commit is empty, has no changes, or that input is missing - Do not invent files, features, or changes that are not in the summaries + - You may use the current branch name as optional context (for example ticket IDs) + - Never invent work from the branch name alone; ground the message in the change summaries ${useDescription ? descriptionPrompt : ''} Respond using JSON` @@ -109,7 +135,10 @@ function buildStructuredPrompt(options: { async function requestStructuredCommit( summaries: ChangeSummary[], - extraInstruction?: string, + options?: { + extraInstruction?: string + branchName?: string | null + }, ): Promise { const { promptTemperature, @@ -150,13 +179,9 @@ async function requestStructuredCommit( useDescription, descriptionPrompt, customPrompt, - extraInstruction, + extraInstruction: options?.extraInstruction, }) - const wrappedSummaries = wrapUntrustedContent( - 'summaries', - formatChangeSummaries(summaries), - ) const outputSchema = buildCommitSchema(useDescription, language) const result = await ai.chat({ @@ -165,7 +190,7 @@ async function requestStructuredCommit( messages: [ { role: 'user', - content: `Staged change summaries:\n${wrappedSummaries}`, + content: buildCommitUserContent(summaries, options?.branchName), }, ], outputSchema, @@ -183,15 +208,17 @@ async function requestStructuredCommit( export async function generateStructuredCommit( summaries: ChangeSummary[], + branchName?: string | null, ): Promise { try { - let commit = await requestStructuredCommit(summaries) + let commit = await requestStructuredCommit(summaries, { branchName }) if (isLowQualityCommit(commit.message, commit.type)) { - commit = await requestStructuredCommit( - summaries, - 'The previous response was invalid because it did not describe the staged changes. Use the summaries exactly and describe the real code changes.', - ) + commit = await requestStructuredCommit(summaries, { + branchName, + extraInstruction: + 'The previous response was invalid because it did not describe the staged changes. Use the summaries exactly and describe the real code changes.', + }) } if (isLowQualityCommit(commit.message, commit.type)) { @@ -235,7 +262,10 @@ export async function generateStructuredCommit( } } -export async function getCommitMessage(summaries: ChangeSummary[]) { +export async function getCommitMessage( + summaries: ChangeSummary[], + branchName?: string | null, +) { const { useDescription, useEmojis, @@ -244,7 +274,10 @@ export async function getCommitMessage(summaries: ChangeSummary[]) { commitTemplate, } = config.inference - const structuredCommit = await generateStructuredCommit(summaries) + const structuredCommit = await generateStructuredCommit( + summaries, + branchName, + ) const { type, message, summary } = structuredCommit diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index f741aa3..7874c32 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -5,7 +5,12 @@ import * as ai from '../ai' import { defaultConfig } from '../config' import * as extension from '../extension' import type { ChangeSummary } from '../generator' -import { generateStructuredCommit, getCommitMessage } from '../generator' +import { + buildCommitUserContent, + generateStructuredCommit, + getCommitMessage, + normalizeBranchName, +} from '../generator' import { getConfig, getGitExtension, setConfig } from '../utils' suite('Extension Test Suite', () => { @@ -120,6 +125,49 @@ suite('generateStructuredCommit Tests', () => { /Switch Model/, ) }) + + test('Should include current branch name in the chat user content', async () => { + await generateStructuredCommit(summariesSample, 'feature/ABC-123-login') + + const chatArgs = chatStub.firstCall.args[0] + const userContent = chatArgs.messages[0].content as string + const systemPrompt = chatArgs.systemPrompts[0] as string + + assert.ok(userContent.includes('feature/ABC-123-login')) + assert.ok(userContent.includes('')) + assert.ok(userContent.includes('Staged change summaries:')) + assert.ok(systemPrompt.includes('current branch name as optional context')) + }) + + test('Should describe detached HEAD when branch name is missing', async () => { + await generateStructuredCommit(summariesSample) + + const userContent = chatStub.firstCall.args[0].messages[0].content as string + assert.ok(userContent.includes('detached HEAD or unnamed')) + assert.ok(!userContent.includes('')) + }) +}) + +suite('branch context helpers', () => { + test('normalizeBranchName trims and drops empty values', () => { + assert.strictEqual(normalizeBranchName(' feat/x '), 'feat/x') + assert.strictEqual(normalizeBranchName(''), undefined) + assert.strictEqual(normalizeBranchName(' '), undefined) + assert.strictEqual(normalizeBranchName(null), undefined) + assert.strictEqual(normalizeBranchName(undefined), undefined) + }) + + test('buildCommitUserContent wraps branch as untrusted content', () => { + const content = buildCommitUserContent( + [{ file: 'a.ts', summary: 'Added a' }], + 'fix/42-thing', + ) + + assert.ok(content.includes('')) + assert.ok(content.includes('fix/42-thing')) + assert.ok(content.includes('')) + assert.ok(content.includes('- a.ts: Added a')) + }) }) suite('getCommitMessage Tests', () => { @@ -166,6 +214,17 @@ suite('getCommitMessage Tests', () => { assert.ok(chatStub.calledOnce) }) + test('Should pass branch name through to the model request', async () => { + const result = await getCommitMessage( + summariesSample, + 'bugfix/ISSUE-9', + ) + + assert.strictEqual(result, 'feat: Add new feature') + const userContent = chatStub.firstCall.args[0].messages[0].content as string + assert.ok(userContent.includes('bugfix/ISSUE-9')) + }) + test('Should add emojis if configured to use emojis', async () => { const originalUseEmojis = getConfig('useEmojis') const originalCustomEmojis = getConfig('custom.emojis') diff --git a/src/utils.ts b/src/utils.ts index cb791cb..69852a3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -139,7 +139,8 @@ export async function createCommitMessage(repo: Repository) { ) } - const commitMessage = await getCommitMessage(summaries) + const branchName = repo.state.HEAD?.name + const commitMessage = await getCommitMessage(summaries, branchName) repo.inputBox.value = commitMessage } catch (error: unknown) { logExtensionError('createCommitMessage', error)