Skip to content

Commit 0c80a8a

Browse files
committed
fix(freebuff): derive commit summaries from git changes
1 parent 46347b4 commit 0c80a8a

4 files changed

Lines changed: 220 additions & 1 deletion

File tree

common/src/templates/initial-agents-dir/examples/02-intermediate-git-committer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const definition: AgentDefinition = {
2424
'You are an expert software developer. Your job is to create a git commit with a really good commit message.',
2525

2626
instructionsPrompt:
27-
'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.',
27+
'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.',
2828

2929
handleSteps: function* ({ agentState, prompt, params }: AgentStepContext) {
3030
// Step 1: Run git diff and git log to analyze changes.

common/src/tools/params/tool/run-terminal-command.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,15 @@ When the user requests a new git commit, please follow these steps closely:
4949
- Note which files have been altered or added.
5050
- Categorize the nature of the changes (e.g., new feature, fix, refactor, documentation, etc.).
5151
- Consider the purpose or motivation behind the alterations.
52+
- Base the commit title and body on the actual git status/diff, not on the original user prompt.
53+
- 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.
5254
- Refrain from using tools to inspect code beyond what is presented in the git context.
5355
- Evaluate the overall impact on the project.
5456
- Check for sensitive details that should not be committed.
5557
- Draft a concise, one- to two-sentence commit message focusing on the “why” rather than the “what.”
5658
- Use precise, straightforward language that accurately represents the changes.
5759
- Ensure the message provides clarity—avoid generic or vague terms like “Update” or “Fix” without context.
60+
- If your draft could still describe the user's request without seeing the diff, rewrite it from the changed files and behavior.
5861
- Revisit your draft to confirm it truly reflects the changes and their intention.
5962
6063
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:
9396
- Avoid using interactive flags (e.g., \`-i\`) that require unsupported interactive input.
9497
- Do not create an empty commit if there are no changes.
9598
- Make sure your commit message is concise yet descriptive, focusing on the intention behind the changes rather than merely describing them.
99+
- 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.
96100
`
97101

98102
const toolName = 'run_terminal_command'
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, it } from 'bun:test'
2+
3+
import { summarizeGitChangesForCommit } from '../git-commit-summary'
4+
5+
describe('summarizeGitChangesForCommit', () => {
6+
it('summarizes the reported AI instructions and changelog changes from actual files', () => {
7+
expect(
8+
summarizeGitChangesForCommit({
9+
status: ' M AGENTS.md\n?? CHANGELOG.md',
10+
}),
11+
).toBe('Update AI agent instructions and add changelog')
12+
})
13+
14+
it('does not need the user prompt to produce a meaningful title', () => {
15+
expect(
16+
summarizeGitChangesForCommit({
17+
status: ' M src/components/LoginButton.tsx\n M src/auth/session.ts',
18+
}),
19+
).toBe('Update LoginButton and Session')
20+
})
21+
22+
it('uses diff metadata when status is unavailable', () => {
23+
expect(
24+
summarizeGitChangesForCommit({
25+
diffCached: `diff --git a/src/old-name.ts b/src/new-name.ts
26+
similarity index 91%
27+
rename from src/old-name.ts
28+
rename to src/new-name.ts
29+
diff --git a/docs/setup.md b/docs/setup.md
30+
index 1111111..2222222 100644
31+
--- a/docs/setup.md
32+
+++ b/docs/setup.md`,
33+
}),
34+
).toBe('Update New Name and Setup')
35+
})
36+
37+
it('falls back to a generic change summary when no git changes are present', () => {
38+
expect(summarizeGitChangesForCommit({})).toBe('Update project files')
39+
})
40+
})
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
type GitChangeKind = 'added' | 'modified' | 'deleted' | 'renamed'
2+
3+
type GitChange = {
4+
path: string
5+
kind: GitChangeKind
6+
}
7+
8+
export type GitCommitSummaryInput = {
9+
status?: string
10+
diff?: string
11+
diffCached?: string
12+
maxFiles?: number
13+
}
14+
15+
const DEFAULT_SUMMARY = 'Update project files'
16+
17+
const normalizePath = (path: string) =>
18+
path.trim().replace(/^"|"$/g, '').replace(/^a\//, '').replace(/^b\//, '')
19+
20+
const basename = (path: string) =>
21+
path.split('/').filter(Boolean).at(-1) ?? path
22+
23+
const stripExtension = (fileName: string) => fileName.replace(/\.[^.]+$/, '')
24+
25+
const humanizeFileName = (fileName: string) =>
26+
stripExtension(fileName)
27+
.replace(/[-_]+/g, ' ')
28+
.replace(/\b\w/g, (char) => char.toUpperCase())
29+
30+
const parseStatusKind = (code: string): GitChangeKind => {
31+
if (code.includes('A') || code.includes('?')) return 'added'
32+
if (code.includes('D')) return 'deleted'
33+
if (code.includes('R')) return 'renamed'
34+
return 'modified'
35+
}
36+
37+
const parseStatusChanges = (status: string): GitChange[] => {
38+
return status
39+
.split('\n')
40+
.map((line) => line.trimEnd())
41+
.filter(Boolean)
42+
.flatMap((line) => {
43+
const porcelain = line.match(/^([ MADRCU?!]{1,2})\s+(.+)$/)
44+
if (!porcelain) return []
45+
46+
const [, code, rawPath] = porcelain
47+
const path = rawPath.includes(' -> ')
48+
? rawPath.split(' -> ').at(-1)!
49+
: rawPath
50+
51+
return [{ path: normalizePath(path), kind: parseStatusKind(code) }]
52+
})
53+
}
54+
55+
const parseDiffChanges = (diff: string): GitChange[] => {
56+
const changes: GitChange[] = []
57+
let currentPath: string | null = null
58+
let currentKind: GitChangeKind = 'modified'
59+
60+
const flush = () => {
61+
if (currentPath) {
62+
changes.push({ path: normalizePath(currentPath), kind: currentKind })
63+
}
64+
}
65+
66+
for (const line of diff.split('\n')) {
67+
const header = line.match(/^diff --git a\/(.+?) b\/(.+)$/)
68+
if (header) {
69+
flush()
70+
currentPath = header[2]
71+
currentKind = 'modified'
72+
continue
73+
}
74+
75+
if (line.startsWith('new file mode')) {
76+
currentKind = 'added'
77+
} else if (line.startsWith('deleted file mode')) {
78+
currentKind = 'deleted'
79+
} else if (line.startsWith('rename to ')) {
80+
currentPath = line.slice('rename to '.length)
81+
currentKind = 'renamed'
82+
}
83+
}
84+
85+
flush()
86+
return changes
87+
}
88+
89+
const dedupeChanges = (changes: GitChange[]): GitChange[] => {
90+
const byPath = new Map<string, GitChange>()
91+
for (const change of changes) {
92+
if (!change.path) continue
93+
byPath.set(change.path, change)
94+
}
95+
return [...byPath.values()]
96+
}
97+
98+
const listNames = (names: string[]) => {
99+
if (names.length === 1) return names[0]
100+
if (names.length === 2) return `${names[0]} and ${names[1]}`
101+
return `${names.slice(0, -1).join(', ')}, and ${names.at(-1)}`
102+
}
103+
104+
const summarizeSpecialChanges = (changes: GitChange[]) => {
105+
const paths = new Set(changes.map((change) => change.path.toLowerCase()))
106+
const summaries: string[] = []
107+
108+
if (
109+
[...paths].some((path) =>
110+
/(^|\/)(agents|ai-instructions|instructions)\.md$/.test(path),
111+
)
112+
) {
113+
summaries.push('update AI agent instructions')
114+
}
115+
116+
if (
117+
paths.has('changelog.md') ||
118+
[...paths].some((path) => path.endsWith('/changelog.md'))
119+
) {
120+
summaries.push('add changelog')
121+
}
122+
123+
if (
124+
[...paths].some((path) => /(^|\/)(package\.json|bun\.lock)$/.test(path))
125+
) {
126+
summaries.push('update dependencies')
127+
}
128+
129+
return summaries
130+
}
131+
132+
const getAction = (changes: GitChange[]) => {
133+
if (changes.every((change) => change.kind === 'added')) return 'Add'
134+
if (changes.every((change) => change.kind === 'deleted')) return 'Remove'
135+
if (changes.every((change) => change.kind === 'renamed')) return 'Rename'
136+
return 'Update'
137+
}
138+
139+
/**
140+
* Builds a concise commit title from actual git changes. This is intentionally
141+
* deterministic so push flows can avoid falling back to the user's prompt.
142+
*/
143+
export const summarizeGitChangesForCommit = ({
144+
status = '',
145+
diff = '',
146+
diffCached = '',
147+
maxFiles = 3,
148+
}: GitCommitSummaryInput) => {
149+
const changes = dedupeChanges([
150+
...parseStatusChanges(status),
151+
...parseDiffChanges(diffCached),
152+
...parseDiffChanges(diff),
153+
])
154+
155+
if (changes.length === 0) {
156+
return DEFAULT_SUMMARY
157+
}
158+
159+
const specialSummaries = summarizeSpecialChanges(changes)
160+
if (specialSummaries.length > 0) {
161+
return specialSummaries
162+
.map((summary, index) =>
163+
index === 0 ? summary[0].toUpperCase() + summary.slice(1) : summary,
164+
)
165+
.join(' and ')
166+
}
167+
168+
const names = changes
169+
.slice(0, maxFiles)
170+
.map((change) => humanizeFileName(basename(change.path)))
171+
const suffix =
172+
changes.length > maxFiles ? ` and ${changes.length - maxFiles} more` : ''
173+
174+
return `${getAction(changes)} ${listNames(names)}${suffix}`
175+
}

0 commit comments

Comments
 (0)