Skip to content

Commit 6b955f0

Browse files
feat(git): add commit context collector
Adds `getCommitContext()`, which gathers the changes a commit message should describe. Part 1 of 4 for AI commit-message generation; nothing consumes it yet. Staged changes are collected first, since that is what a commit will actually contain. When nothing is staged it falls back to the working tree so callers still have something to summarize before staging. The fallback reads `git status --short` rather than a diff because untracked files appear in no diff and would otherwise be invisible. The fallback deliberately runs `git diff` rather than `git diff HEAD`: the index is known to be empty at that point so the output is identical, but `HEAD` does not resolve in a repository without an initial commit, where it would fail. Reuses the existing `checkGitInstalled`, `checkGitRepo`, and `truncateOutput` helpers. `maxBuffer` is raised past Node's 1MB `exec` default, which real diffs routinely exceed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent abaf732 commit 6b955f0

2 files changed

Lines changed: 188 additions & 0 deletions

File tree

‎src/utils/__tests__/git.spec.ts‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
getWorkspaceGitInfo,
1414
convertGitUrlToHttps,
1515
getGitStatus,
16+
getCommitContext,
1617
} from "../git"
1718
import { truncateOutput } from "../../integrations/misc/extract-text"
1819

@@ -351,6 +352,136 @@ describe("git utils", () => {
351352
})
352353
})
353354

355+
describe("getCommitContext", () => {
356+
const mockDiff = "@@ -1,1 +1,2 @@\n-old line\n+new line"
357+
358+
type ExecResult = { stdout: string; stderr: string }
359+
360+
const mockExec = (responses: Map<string, ExecResult>) => {
361+
const implementation = (
362+
command: string,
363+
_options: unknown,
364+
callback: (error: Error | null, result?: ExecResult) => void,
365+
) => {
366+
const response = responses.get(command)
367+
368+
if (response) {
369+
callback(null, response)
370+
} else {
371+
callback(new Error("Unexpected command"))
372+
}
373+
374+
// `exec` returns a ChildProcess that none of these tests inspect.
375+
return {} as ReturnType<typeof exec>
376+
}
377+
378+
vitest.mocked(exec).mockImplementation(implementation as unknown as typeof exec)
379+
}
380+
381+
const gitAvailable: Array<[string, { stdout: string; stderr: string }]> = [
382+
["git --version", { stdout: "git version 2.39.2", stderr: "" }],
383+
["git rev-parse --git-dir", { stdout: ".git", stderr: "" }],
384+
]
385+
386+
it("should use staged changes when something is staged", async () => {
387+
mockExec(
388+
new Map([
389+
...gitAvailable,
390+
["git diff --cached --stat", { stdout: " src/file1.ts | 2 +-", stderr: "" }],
391+
["git diff --cached --unified=1", { stdout: mockDiff, stderr: "" }],
392+
]),
393+
)
394+
395+
const result = await getCommitContext(cwd)
396+
expect(result).toContain("Staged changes:")
397+
expect(result).toContain("src/file1.ts")
398+
expect(result).toContain("+new line")
399+
})
400+
401+
// These tests mock `exec`, so they cannot catch a diff flag that real git rejects. `exec`
402+
// runs through cmd.exe on Windows, which does not strip the single quotes that
403+
// `:(exclude)` pathspecs need - keep the argument string free of shell metacharacters.
404+
it("should build diff arguments that need no shell quoting", async () => {
405+
const commands: string[] = []
406+
407+
vitest.mocked(exec).mockImplementation(((
408+
command: string,
409+
_options: unknown,
410+
callback: (error: Error | null, result?: ExecResult) => void,
411+
) => {
412+
commands.push(command)
413+
const stdout = command.includes("--stat") ? " src/file1.ts | 2 +-" : mockDiff
414+
callback(null, { stdout, stderr: "" })
415+
return {} as ReturnType<typeof exec>
416+
}) as unknown as typeof exec)
417+
418+
await getCommitContext(cwd)
419+
420+
const diffCommands = commands.filter((c) => c.startsWith("git diff"))
421+
expect(diffCommands.length).toBeGreaterThan(0)
422+
423+
for (const command of diffCommands) {
424+
expect(command).not.toMatch(/['"()]/)
425+
}
426+
})
427+
428+
it("should fall back to the working tree when nothing is staged", async () => {
429+
mockExec(
430+
new Map([
431+
...gitAvailable,
432+
["git diff --cached --stat", { stdout: "", stderr: "" }],
433+
["git status --short", { stdout: " M src/file1.ts\n?? src/untracked.ts", stderr: "" }],
434+
["git diff --unified=1", { stdout: mockDiff, stderr: "" }],
435+
]),
436+
)
437+
438+
const result = await getCommitContext(cwd)
439+
expect(result).toContain("Unstaged changes:")
440+
// `git status --short` is used here specifically so untracked files are visible.
441+
expect(result).toContain("src/untracked.ts")
442+
expect(result).toContain("+new line")
443+
})
444+
445+
it("should work in a repository without an initial commit", async () => {
446+
// `HEAD` does not resolve before the first commit, so the fallback must not reference it.
447+
mockExec(
448+
new Map([
449+
...gitAvailable,
450+
["git diff --cached --stat", { stdout: "", stderr: "" }],
451+
["git status --short", { stdout: "?? file.txt", stderr: "" }],
452+
["git diff --unified=1", { stdout: "", stderr: "" }],
453+
]),
454+
)
455+
456+
const result = await getCommitContext(cwd)
457+
expect(result).toContain("file.txt")
458+
})
459+
460+
it("should return null when the tree is clean", async () => {
461+
mockExec(
462+
new Map([
463+
...gitAvailable,
464+
["git diff --cached --stat", { stdout: "", stderr: "" }],
465+
["git status --short", { stdout: "", stderr: "" }],
466+
]),
467+
)
468+
469+
expect(await getCommitContext(cwd)).toBeNull()
470+
})
471+
472+
it("should return null when git is not installed", async () => {
473+
mockExec(new Map())
474+
475+
expect(await getCommitContext(cwd)).toBeNull()
476+
})
477+
478+
it("should return null when not in a git repository", async () => {
479+
mockExec(new Map([gitAvailable[0]]))
480+
481+
expect(await getCommitContext(cwd)).toBeNull()
482+
})
483+
})
484+
354485
describe("getWorkingState", () => {
355486
const mockStatus = " M src/file1.ts\n?? src/file2.ts"
356487
const mockDiff = "@@ -1,1 +1,2 @@\n-old line\n+new line"

‎src/utils/git.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,17 @@ const execAsync = promisify(exec)
1212

1313
const GIT_OUTPUT_LINE_LIMIT = 500
1414

15+
// Node's default `exec` buffer is 1MB, which real-world diffs routinely exceed.
16+
const GIT_DIFF_MAX_BUFFER = 10 * 1024 * 1024
17+
18+
// A commit message needs to know what changed, not every line of how. One line of surrounding
19+
// context per hunk is enough to tell the model where an edit landed, and shrinking the prompt is
20+
// the one latency factor we control without affecting the model's output.
21+
//
22+
// Deliberately no `:(exclude)` pathspecs here: `exec` goes through cmd.exe on Windows, which does
23+
// not strip the single quotes those require. Oversized diffs are handled by `truncateOutput`.
24+
const COMMIT_DIFF_ARGS = "--unified=1"
25+
1526
/**
1627
* Extracts git repository information from the workspace's .git directory
1728
* @param workspaceRoot The root path of the workspace
@@ -346,6 +357,52 @@ export async function getWorkingState(cwd: string): Promise<string> {
346357
}
347358
}
348359

360+
/**
361+
* Collects the changes to describe in a commit message.
362+
*
363+
* Prefers staged changes, since that is what a commit will actually contain. When nothing is
364+
* staged, falls back to the whole working tree so the caller still has something to summarize.
365+
*
366+
* @param cwd The repository root to inspect
367+
* @returns A summary + diff suitable for a prompt, or null if there is nothing to commit
368+
*/
369+
export async function getCommitContext(cwd: string): Promise<string | null> {
370+
const isInstalled = await checkGitInstalled()
371+
if (!isInstalled) {
372+
return null
373+
}
374+
375+
const isRepo = await checkGitRepo(cwd)
376+
if (!isRepo) {
377+
return null
378+
}
379+
380+
const options = { cwd, maxBuffer: GIT_DIFF_MAX_BUFFER }
381+
382+
const { stdout: stagedSummary } = await execAsync("git diff --cached --stat", options)
383+
384+
if (stagedSummary.trim()) {
385+
const { stdout: stagedDiff } = await execAsync(`git diff --cached ${COMMIT_DIFF_ARGS}`, options)
386+
const output = `Staged changes:\n\n${stagedSummary.trim()}\n\n${stagedDiff.trim()}`
387+
return truncateOutput(output, GIT_OUTPUT_LINE_LIMIT)
388+
}
389+
390+
// Nothing staged - describe the working tree instead. `git status --short` is used rather than
391+
// `--stat` here because it also lists untracked files, which no diff would show.
392+
const { stdout: status } = await execAsync("git status --short", options)
393+
394+
if (!status.trim()) {
395+
return null
396+
}
397+
398+
// Deliberately `git diff` rather than `git diff HEAD`: we only reach this branch when the index
399+
// is empty, so the two produce identical output - but `HEAD` does not resolve in a repository
400+
// without an initial commit, where it would fail outright.
401+
const { stdout: diff } = await execAsync(`git diff ${COMMIT_DIFF_ARGS}`, options)
402+
const output = `Unstaged changes:\n\n${status.trim()}\n\n${diff.trim()}`.trim()
403+
return truncateOutput(output, GIT_OUTPUT_LINE_LIMIT)
404+
}
405+
349406
/**
350407
* Gets git status output with configurable file limit
351408
* @param cwd The working directory to check git status in

0 commit comments

Comments
 (0)