diff --git a/src/utils/__tests__/git.spec.ts b/src/utils/__tests__/git.spec.ts index 95040a3d01..6c623bef99 100644 --- a/src/utils/__tests__/git.spec.ts +++ b/src/utils/__tests__/git.spec.ts @@ -13,20 +13,14 @@ import { getWorkspaceGitInfo, convertGitUrlToHttps, getGitStatus, + getCommitContext, } from "../git" import { truncateOutput } from "../../integrations/misc/extract-text" -type ExecFunction = ( - command: string, - options: { cwd?: string }, - callback: (error: ExecException | null, result?: { stdout: string; stderr: string }) => void, -) => void - -type PromisifiedExec = (command: string, options?: { cwd?: string }) => Promise<{ stdout: string; stderr: string }> - // Mock child_process.exec vitest.mock("child_process", () => ({ exec: vitest.fn(), + execFile: vitest.fn(), })) // Mock fs.promises @@ -34,6 +28,7 @@ vitest.mock("fs", () => ({ promises: { access: vitest.fn(), readFile: vitest.fn(), + open: vitest.fn(), }, })) @@ -49,21 +44,27 @@ vitest.mock("vscode", () => ({ // Mock util.promisify to return our own mock function vitest.mock("util", () => ({ - promisify: vitest.fn((fn: ExecFunction): PromisifiedExec => { - return async (command: string, options?: { cwd?: string }) => { + promisify: vitest.fn((fn: (...args: unknown[]) => void) => { + return async (...args: unknown[]) => { // Call the original mock to maintain the mock implementation return new Promise((resolve, reject) => { - fn( - command, - options || {}, - (error: ExecException | null, result?: { stdout: string; stderr: string }) => { - if (error) { - reject(error) - } else { - resolve(result!) - } - }, - ) + const callback = (error: ExecException | null, result?: { stdout: string; stderr: string }) => { + if (error) { + reject(error) + } else { + resolve(result!) + } + } + + // `exec(command, options, cb)` and `execFile(file, args, options, cb)` differ in + // arity, so both shapes are normalized here rather than mocking promisify twice. + const [first, second, third] = args + + if (Array.isArray(second)) { + fn(first, second, third || {}, callback) + } else { + fn(first, second || {}, callback) + } }) } }), @@ -76,7 +77,7 @@ vitest.mock("../../integrations/misc/extract-text", () => ({ }), })) -import { exec } from "child_process" +import { exec, execFile } from "child_process" describe("git utils", () => { const cwd = "/test/path" @@ -351,6 +352,213 @@ describe("git utils", () => { }) }) + describe("getCommitContext", () => { + const NUL = "\0" + const mockDiff = "@@ -1,1 +1,2 @@\n-old line\n+new line" + + type ExecResult = { stdout: string; stderr: string } + type ExecCallback = (error: Error | null, result?: ExecResult) => void + + // `checkGitInstalled` and `checkGitRepo` are fixed strings, so they still run through `exec`. + const mockProbes = ({ installed = true, repo = true } = {}) => { + vitest.mocked(exec).mockImplementation(((command: string, _options: unknown, callback: ExecCallback) => { + const available = command === "git --version" ? installed : repo + + if (available) { + callback(null, { stdout: "ok", stderr: "" }) + } else { + callback(new Error(`unavailable: ${command}`)) + } + + return {} as ReturnType + }) as unknown as typeof exec) + } + + // Keyed by the joined argument array, since that is what the collector passes now. Anything + // not listed rejects, which is how the failure paths are exercised. + const mockGit = (responses: Record) => { + const calls: Array<{ file: string; args: string[] }> = [] + + vitest.mocked(execFile).mockImplementation((( + file: string, + args: string[], + _options: unknown, + callback: ExecCallback, + ) => { + calls.push({ file, args }) + const stdout = responses[args.join(" ")] + + if (stdout === undefined) { + callback(new Error(`unexpected command: git ${args.join(" ")}`)) + } else { + callback(null, { stdout, stderr: "" }) + } + + return {} as ReturnType + }) as unknown as typeof execFile) + + return calls + } + + const staged = (nameStatus: string, diff = mockDiff): Record => ({ + "diff --cached --name-status -z": nameStatus, + "diff --cached --unified=1": diff, + "branch --show-current": "feature/x\n", + "log -n5 --format=%s": "earlier subject\n", + }) + + const workingTree = (status: string, diff = mockDiff): Record => ({ + "diff --cached --name-status -z": "", + "status --porcelain=v1 -z --untracked-files=all": status, + "diff --unified=1": diff, + "rev-parse --show-toplevel": `${cwd}\n`, + "branch --show-current": "main\n", + "log -n5 --format=%s": "earlier subject\n", + }) + + // Narrows the result so a failure reports its reason instead of a property-of-undefined. + const expectContext = async () => { + const result = await getCommitContext(cwd) + + if (!result.ok) { + throw new Error(`expected a context, got "${result.reason}"`) + } + + return result.context + } + + it("should collect staged changes as structured entries", async () => { + mockProbes() + mockGit(staged(`M${NUL}src/file1.ts${NUL}A${NUL}src/new.ts${NUL}D${NUL}src/gone.ts${NUL}`)) + + const context = await expectContext() + expect(context.files).toEqual([ + { status: "modified", path: "src/file1.ts" }, + { status: "added", path: "src/new.ts" }, + { status: "deleted", path: "src/gone.ts" }, + ]) + expect(context.branch).toBe("feature/x") + expect(context.recentCommits).toEqual(["earlier subject"]) + expect(context.diff).toContain("+new line") + }) + + // A rename or copy record carries two paths. Reading one where there are two would shift + // every later record onto the wrong file, so the trailing entry is the real assertion. + it("should parse renames and copies without desyncing later entries", async () => { + mockProbes() + mockGit( + staged( + `R100${NUL}old name.ts${NUL}new name.ts${NUL}` + + `C075${NUL}src/base.ts${NUL}src/copy.ts${NUL}` + + `M${NUL}src/after.ts${NUL}`, + ), + ) + + expect((await expectContext()).files).toEqual([ + { status: "renamed", path: "new name.ts", oldPath: "old name.ts" }, + { status: "copied", path: "src/copy.ts", oldPath: "src/base.ts" }, + { status: "modified", path: "src/after.ts" }, + ]) + }) + + it("should keep paths with spaces and unusual characters verbatim", async () => { + mockProbes() + mockGit(staged(`A${NUL}src/a "quoted" & odd (file).ts${NUL}`)) + + expect((await expectContext()).files).toEqual([{ status: "added", path: 'src/a "quoted" & odd (file).ts' }]) + }) + + // Replaces an older test that checked the command string for shell metacharacters. With + // `execFile` there is no shell at all, so the guard is that arguments stay separate values. + it("should pass every argument as an array element rather than a shell string", async () => { + mockProbes() + const calls = mockGit(staged(`M${NUL}src/file1.ts${NUL}`)) + + await getCommitContext(cwd) + + expect(calls.length).toBeGreaterThan(0) + expect(calls.every((call) => call.file === "git")).toBe(true) + expect(calls.map((call) => call.args)).toContainEqual(["diff", "--cached", "--name-status", "-z"]) + expect(calls.map((call) => call.args)).toContainEqual(["diff", "--cached", "--unified=1"]) + }) + + // Only the index is described, so a dirty working tree with an empty index is a distinct + // outcome: the user can fix it by staging, and the caller says so. + it("should report nothing-staged when the working tree is dirty but the index is empty", async () => { + mockProbes() + mockGit(workingTree(` M src/file1.ts${NUL}?? src/untracked.ts${NUL}`)) + + expect(await getCommitContext(cwd)).toEqual({ ok: false, reason: "nothing-staged" }) + }) + + it("should describe only the index when both it and the working tree have changes", async () => { + mockProbes() + // `workingTree` blanks the staged listing, so the staged responses have to win. + mockGit({ + ...workingTree(` M src/unstaged.ts${NUL}`), + ...staged(`M${NUL}src/staged.ts${NUL}`), + }) + + expect((await expectContext()).files).toEqual([{ status: "modified", path: "src/staged.ts" }]) + }) + + it("should work in a repository without an initial commit", async () => { + mockProbes() + // `git log` fails before the first commit, and must not take the collection down with it. + const responses = staged(`A${NUL}file.txt${NUL}`) + delete responses["log -n5 --format=%s"] + mockGit(responses) + + const context = await expectContext() + expect(context.recentCommits).toEqual([]) + expect(context.files).toEqual([{ status: "added", path: "file.txt" }]) + }) + + // A line limit alone is not a bound: one generated file can be a single enormous line. + it("should cap output by characters as well as by lines", async () => { + mockProbes() + mockGit(staged(`M${NUL}dist/bundle.js${NUL}`, `+${"a".repeat(200_000)}`)) + + await getCommitContext(cwd) + + expect(vitest.mocked(truncateOutput)).toHaveBeenCalledWith(expect.any(String), 500, 102_400) + }) + + it("should report no-changes on a clean tree", async () => { + mockProbes() + mockGit(workingTree("")) + + expect(await getCommitContext(cwd)).toEqual({ ok: false, reason: "no-changes" }) + }) + + it("should report git-missing when git is not installed", async () => { + mockProbes({ installed: false }) + mockGit({}) + + expect(await getCommitContext(cwd)).toEqual({ ok: false, reason: "git-missing" }) + }) + + it("should report not-a-repo outside a repository", async () => { + mockProbes({ repo: false }) + mockGit({}) + + expect(await getCommitContext(cwd)).toEqual({ ok: false, reason: "not-a-repo" }) + }) + + // An oversized diff exceeding `maxBuffer` is expected, not exceptional: the documented + // contract is a reason, never a rejection. + it("should report failed instead of rejecting when a git command fails", async () => { + mockProbes() + const responses = staged(`M${NUL}src/file1.ts${NUL}`) + delete responses["diff --cached --unified=1"] + mockGit(responses) + + const result = await getCommitContext(cwd) + expect(result.ok).toBe(false) + expect(result).toMatchObject({ reason: "failed" }) + }) + }) + describe("getWorkingState", () => { const mockStatus = " M src/file1.ts\n?? src/file2.ts" const mockDiff = "@@ -1,1 +1,2 @@\n-old line\n+new line" diff --git a/src/utils/git.ts b/src/utils/git.ts index 04c028c3d1..a660f296f4 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -1,7 +1,7 @@ import * as vscode from "vscode" import * as path from "path" import { promises as fs } from "fs" -import { exec } from "child_process" +import { exec, execFile } from "child_process" import { promisify } from "util" import type { GitRepositoryInfo, GitCommit } from "@roo-code/types" @@ -10,8 +10,26 @@ import { truncateOutput } from "../integrations/misc/extract-text" const execAsync = promisify(exec) +// Used for the commit-context commands: arguments are passed as an array, so no shell is +// involved and paths never need quoting. +const execFileAsync = promisify(execFile) + const GIT_OUTPUT_LINE_LIMIT = 500 +// A line limit alone is not a bound: one minified or generated file can be a single line of +// several megabytes. This caps the payload regardless of how it is distributed across lines. +const GIT_OUTPUT_CHARACTER_LIMIT = 100 * 1024 + +// Node's default `exec` buffer is 1MB, which real-world diffs routinely exceed. +const GIT_DIFF_MAX_BUFFER = 10 * 1024 * 1024 + +// A commit message needs to know what changed, not every line of how. One line of surrounding +// context per hunk is enough to tell the model where an edit landed, and shrinking the prompt is +// the one latency factor we control without affecting the model's output. +const COMMIT_DIFF_ARGS = ["--unified=1"] + +const RECENT_COMMIT_COUNT = 5 + /** * Extracts git repository information from the workspace's .git directory * @param workspaceRoot The root path of the workspace @@ -346,6 +364,203 @@ export async function getWorkingState(cwd: string): Promise { } } +export type GitFileStatus = "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | "unknown" + +export interface GitFileChange { + status: GitFileStatus + /** Path relative to the repository root, exactly as git reported it. */ + path: string + /** Where the file came from. Only set for renames and copies. */ + oldPath?: string +} + +export interface CommitContext { + /** Undefined when HEAD is detached. */ + branch?: string + recentCommits: string[] + files: GitFileChange[] + /** The staged diff, truncated to fit a prompt. */ + diff: string +} + +export type CommitContextResult = + | { ok: true; context: CommitContext } + | { ok: false; reason: "git-missing" | "not-a-repo" | "no-changes" | "nothing-staged" | "failed"; error?: string } + +async function runGit(args: string[], cwd: string): Promise { + const { stdout } = await execFileAsync("git", args, { cwd, maxBuffer: GIT_DIFF_MAX_BUFFER }) + return stdout +} + +function toFileStatus(code: string): GitFileStatus { + switch (code) { + case "A": + return "added" + case "M": + return "modified" + case "D": + return "deleted" + case "R": + return "renamed" + case "C": + return "copied" + case "?": + return "untracked" + default: + return "unknown" + } +} + +/** + * Parses `git diff --name-status -z`: a NUL-terminated status field followed by one path, or - + * for renames and copies - by two paths, the original first. + * + * Copy records appear whenever the user has `diff.renames = copies` configured, so they have to + * be consumed correctly even though we never ask for copy detection: reading one path where + * there are two would shift every later record onto the wrong file. + */ +function parseNameStatus(stdout: string): GitFileChange[] { + const fields = stdout.split("\0") + const files: GitFileChange[] = [] + + for (let index = 0; index < fields.length; index++) { + const code = fields[index] + + // The final NUL leaves an empty trailing field. + if (!code) { + continue + } + + const status = toFileStatus(code[0]) + const first = fields[++index] + + if (status === "renamed" || status === "copied") { + const second = fields[++index] + + if (!first || !second) { + break + } + + files.push({ status, path: second, oldPath: first }) + continue + } + + if (!first) { + break + } + + files.push({ status, path: first }) + } + + return files +} + +/** + * Parses `git status --porcelain=v1 -z`: `XY`, with renames and copies adding the + * original path as a second NUL-terminated field. + * + * Note the field order is the reverse of `git diff --name-status -z` - here the new path comes + * first. Both formats are NUL-delimited, so paths are emitted verbatim and never quoted. + */ +function parsePorcelainStatus(stdout: string): GitFileChange[] { + const records = stdout.split("\0") + const files: GitFileChange[] = [] + + for (let index = 0; index < records.length; index++) { + const record = records[index] + + // The shortest valid record is two status characters, a space and a single-character path. + if (record.length < 4) { + continue + } + + const indexCode = record[0] + const worktreeCode = record[1] + const filePath = record.slice(3) + + // The index takes precedence, since that is what a commit would contain. + const status = toFileStatus(indexCode === " " ? worktreeCode : indexCode) + + if (status === "renamed" || status === "copied") { + files.push({ status, path: filePath, oldPath: records[++index] }) + continue + } + + files.push({ status, path: filePath }) + } + + return files +} + +/** Both of these are context, not the payload, so a repository without commits still works. */ +async function getCurrentBranch(cwd: string): Promise { + const branch = await runGit(["branch", "--show-current"], cwd).catch(() => "") + return branch.trim() || undefined +} + +async function getRecentCommits(cwd: string): Promise { + const log = await runGit(["log", `-n${RECENT_COMMIT_COUNT}`, "--format=%s"], cwd).catch(() => "") + return log + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) +} + +/** + * Collects the changes to describe in a commit message. + * + * Only the index is described, since that is exactly what a commit will contain. An empty index + * returns `nothing-staged` rather than falling back to the working tree, so the message can never + * describe changes the commit would not include. + * + * Every command runs through `execFile` with an argument array, so no path is ever interpolated + * into a shell string, and every listing is read in NUL-delimited form. + * + * @param cwd The repository root to inspect + * @returns The collected context, or the reason there is none. Never rejects. + */ +export async function getCommitContext(cwd: string): Promise { + if (!(await checkGitInstalled())) { + return { ok: false, reason: "git-missing" } + } + + if (!(await checkGitRepo(cwd))) { + return { ok: false, reason: "not-a-repo" } + } + + try { + const staged = parseNameStatus(await runGit(["diff", "--cached", "--name-status", "-z"], cwd)) + + if (staged.length > 0) { + const diff = await runGit(["diff", "--cached", ...COMMIT_DIFF_ARGS], cwd) + return { ok: true, context: await buildContext(cwd, staged, diff) } + } + + // Only the index is described, so an empty one has nothing to summarize. Whether the + // working tree is dirty decides which of the two messages the caller shows: "stage + // something first" is only useful advice when there is in fact something to stage. + const worktree = parsePorcelainStatus( + await runGit(["status", "--porcelain=v1", "-z", "--untracked-files=all"], cwd), + ) + + return { ok: false, reason: worktree.length > 0 ? "nothing-staged" : "no-changes" } + } catch (error) { + // Failures here are expected rather than exceptional - an oversized diff exceeding + // `maxBuffer`, a repository in a state git refuses to describe - so the caller gets a + // reason rather than a rejection. + return { ok: false, reason: "failed", error: error instanceof Error ? error.message : String(error) } + } +} + +async function buildContext(cwd: string, files: GitFileChange[], diff: string): Promise { + return { + branch: await getCurrentBranch(cwd), + recentCommits: await getRecentCommits(cwd), + files, + diff: truncateOutput(diff.trim(), GIT_OUTPUT_LINE_LIMIT, GIT_OUTPUT_CHARACTER_LIMIT), + } +} + /** * Gets git status output with configurable file limit * @param cwd The working directory to check git status in