diff --git a/common/src/templates/initial-agents-dir/examples/02-intermediate-git-committer.ts b/common/src/templates/initial-agents-dir/examples/02-intermediate-git-committer.ts index b11e63a487..67eae1c3dd 100644 --- a/common/src/templates/initial-agents-dir/examples/02-intermediate-git-committer.ts +++ b/common/src/templates/initial-agents-dir/examples/02-intermediate-git-committer.ts @@ -24,7 +24,7 @@ const definition: AgentDefinition = { 'You are an expert software developer. Your job is to create a git commit with a really good commit message.', instructionsPrompt: - 'Follow the steps to create a good commit: analyze changes with git diff and git log, read relevant files for context, stage appropriate files, analyze changes, and create a commit with proper formatting.', + 'Follow the steps to create a good commit: analyze changes with git diff and git log, read relevant files for context, stage appropriate files, analyze changes, and create a commit with proper formatting. Base the commit message on the actual changed files and behavior, never by copying or truncating the original user prompt.', handleSteps: function* ({ agentState, prompt, params }: AgentStepContext) { // Step 1: Run git diff and git log to analyze changes. diff --git a/common/src/tools/params/tool/run-terminal-command.ts b/common/src/tools/params/tool/run-terminal-command.ts index 4bd53f0c23..2954be014a 100644 --- a/common/src/tools/params/tool/run-terminal-command.ts +++ b/common/src/tools/params/tool/run-terminal-command.ts @@ -49,12 +49,15 @@ When the user requests a new git commit, please follow these steps closely: - Note which files have been altered or added. - Categorize the nature of the changes (e.g., new feature, fix, refactor, documentation, etc.). - Consider the purpose or motivation behind the alterations. + - Base the commit title and body on the actual git status/diff, not on the original user prompt. + - Treat the user prompt only as background context. Never copy the prompt, a truncated prompt, or a user instruction such as "add this" as the commit title. - Refrain from using tools to inspect code beyond what is presented in the git context. - Evaluate the overall impact on the project. - Check for sensitive details that should not be committed. - Draft a concise, one- to two-sentence commit message focusing on the “why” rather than the “what.” - Use precise, straightforward language that accurately represents the changes. - Ensure the message provides clarity—avoid generic or vague terms like “Update” or “Fix” without context. + - If your draft could still describe the user's request without seeing the diff, rewrite it from the changed files and behavior. - Revisit your draft to confirm it truly reflects the changes and their intention. 4. **Create the commit, ending with this specific footer:** @@ -93,6 +96,7 @@ When the user requests a new git commit, please follow these steps closely: - Avoid using interactive flags (e.g., \`-i\`) that require unsupported interactive input. - Do not create an empty commit if there are no changes. - Make sure your commit message is concise yet descriptive, focusing on the intention behind the changes rather than merely describing them. +- Do not use the original user prompt as the commit message, even if the user asked for the same change in natural language. Summarize what actually changed. ` const toolName = 'run_terminal_command' diff --git a/common/src/util/__tests__/git-commit-summary.test.ts b/common/src/util/__tests__/git-commit-summary.test.ts new file mode 100644 index 0000000000..eba932ed79 --- /dev/null +++ b/common/src/util/__tests__/git-commit-summary.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'bun:test' + +import { summarizeGitChangesForCommit } from '../git-commit-summary' + +describe('summarizeGitChangesForCommit', () => { + it('summarizes the reported AI instructions and changelog changes from actual files', () => { + expect( + summarizeGitChangesForCommit({ + status: ' M AGENTS.md\n?? CHANGELOG.md', + }), + ).toBe('Update AI agent instructions and add changelog') + }) + + it('does not need the user prompt to produce a meaningful title', () => { + expect( + summarizeGitChangesForCommit({ + status: ' M src/components/LoginButton.tsx\n M src/auth/session.ts', + }), + ).toBe('Update LoginButton and Session') + }) + + it('uses diff metadata when status is unavailable', () => { + expect( + summarizeGitChangesForCommit({ + diffCached: `diff --git a/src/old-name.ts b/src/new-name.ts +similarity index 91% +rename from src/old-name.ts +rename to src/new-name.ts +diff --git a/docs/setup.md b/docs/setup.md +index 1111111..2222222 100644 +--- a/docs/setup.md ++++ b/docs/setup.md`, + }), + ).toBe('Update New Name and Setup') + }) + + it('falls back to a generic change summary when no git changes are present', () => { + expect(summarizeGitChangesForCommit({})).toBe('Update project files') + }) +}) diff --git a/common/src/util/git-commit-summary.ts b/common/src/util/git-commit-summary.ts new file mode 100644 index 0000000000..0200ed2a80 --- /dev/null +++ b/common/src/util/git-commit-summary.ts @@ -0,0 +1,175 @@ +type GitChangeKind = 'added' | 'modified' | 'deleted' | 'renamed' + +type GitChange = { + path: string + kind: GitChangeKind +} + +export type GitCommitSummaryInput = { + status?: string + diff?: string + diffCached?: string + maxFiles?: number +} + +const DEFAULT_SUMMARY = 'Update project files' + +const normalizePath = (path: string) => + path.trim().replace(/^"|"$/g, '').replace(/^a\//, '').replace(/^b\//, '') + +const basename = (path: string) => + path.split('/').filter(Boolean).at(-1) ?? path + +const stripExtension = (fileName: string) => fileName.replace(/\.[^.]+$/, '') + +const humanizeFileName = (fileName: string) => + stripExtension(fileName) + .replace(/[-_]+/g, ' ') + .replace(/\b\w/g, (char) => char.toUpperCase()) + +const parseStatusKind = (code: string): GitChangeKind => { + if (code.includes('A') || code.includes('?')) return 'added' + if (code.includes('D')) return 'deleted' + if (code.includes('R')) return 'renamed' + return 'modified' +} + +const parseStatusChanges = (status: string): GitChange[] => { + return status + .split('\n') + .map((line) => line.trimEnd()) + .filter(Boolean) + .flatMap((line) => { + const porcelain = line.match(/^([ MADRCU?!]{1,2})\s+(.+)$/) + if (!porcelain) return [] + + const [, code, rawPath] = porcelain + const path = rawPath.includes(' -> ') + ? rawPath.split(' -> ').at(-1)! + : rawPath + + return [{ path: normalizePath(path), kind: parseStatusKind(code) }] + }) +} + +const parseDiffChanges = (diff: string): GitChange[] => { + const changes: GitChange[] = [] + let currentPath: string | null = null + let currentKind: GitChangeKind = 'modified' + + const flush = () => { + if (currentPath) { + changes.push({ path: normalizePath(currentPath), kind: currentKind }) + } + } + + for (const line of diff.split('\n')) { + const header = line.match(/^diff --git a\/(.+?) b\/(.+)$/) + if (header) { + flush() + currentPath = header[2] + currentKind = 'modified' + continue + } + + if (line.startsWith('new file mode')) { + currentKind = 'added' + } else if (line.startsWith('deleted file mode')) { + currentKind = 'deleted' + } else if (line.startsWith('rename to ')) { + currentPath = line.slice('rename to '.length) + currentKind = 'renamed' + } + } + + flush() + return changes +} + +const dedupeChanges = (changes: GitChange[]): GitChange[] => { + const byPath = new Map() + for (const change of changes) { + if (!change.path) continue + byPath.set(change.path, change) + } + return [...byPath.values()] +} + +const listNames = (names: string[]) => { + if (names.length === 1) return names[0] + if (names.length === 2) return `${names[0]} and ${names[1]}` + return `${names.slice(0, -1).join(', ')}, and ${names.at(-1)}` +} + +const summarizeSpecialChanges = (changes: GitChange[]) => { + const paths = new Set(changes.map((change) => change.path.toLowerCase())) + const summaries: string[] = [] + + if ( + [...paths].some((path) => + /(^|\/)(agents|ai-instructions|instructions)\.md$/.test(path), + ) + ) { + summaries.push('update AI agent instructions') + } + + if ( + paths.has('changelog.md') || + [...paths].some((path) => path.endsWith('/changelog.md')) + ) { + summaries.push('add changelog') + } + + if ( + [...paths].some((path) => /(^|\/)(package\.json|bun\.lock)$/.test(path)) + ) { + summaries.push('update dependencies') + } + + return summaries +} + +const getAction = (changes: GitChange[]) => { + if (changes.every((change) => change.kind === 'added')) return 'Add' + if (changes.every((change) => change.kind === 'deleted')) return 'Remove' + if (changes.every((change) => change.kind === 'renamed')) return 'Rename' + return 'Update' +} + +/** + * Builds a concise commit title from actual git changes. This is intentionally + * deterministic so push flows can avoid falling back to the user's prompt. + */ +export const summarizeGitChangesForCommit = ({ + status = '', + diff = '', + diffCached = '', + maxFiles = 3, +}: GitCommitSummaryInput) => { + const changes = dedupeChanges([ + ...parseStatusChanges(status), + ...parseDiffChanges(diffCached), + ...parseDiffChanges(diff), + ]) + + if (changes.length === 0) { + return DEFAULT_SUMMARY + } + + const specialSummaries = summarizeSpecialChanges(changes) + if (specialSummaries.length > 0) { + return specialSummaries + .map((summary, index) => + index === 0 ? summary[0].toUpperCase() + summary.slice(1) : summary, + ) + .join(' and ') + } + + const names = changes + .slice(0, maxFiles) + .map((change) => humanizeFileName(basename(change.path))) + const suffix = + changes.length > maxFiles ? ` and ${changes.length - maxFiles} more` : '' + + return `${getAction(changes)} ${listNames(names)}${suffix}` +}