Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions common/src/tools/params/tool/run-terminal-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down Expand Up @@ -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'
Expand Down
40 changes: 40 additions & 0 deletions common/src/util/__tests__/git-commit-summary.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
175 changes: 175 additions & 0 deletions common/src/util/git-commit-summary.ts
Original file line number Diff line number Diff line change
@@ -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<string, GitChange>()
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}`
}