From e173b0e828af7923f30cd891242c164124997245 Mon Sep 17 00:00:00 2001 From: Mirrowel <28632877+Mirrowel@users.noreply.github.com> Date: Sun, 24 May 2026 15:56:55 +0200 Subject: [PATCH 1/9] feat(scm): add git context collector --- .../git-context/GitContextCollector.ts | 524 ++++++++++++++++++ .../__tests__/GitContextCollector.spec.ts | 218 ++++++++ src/services/git-context/index.ts | 11 + src/services/git-context/types.ts | 42 ++ 4 files changed, 795 insertions(+) create mode 100644 src/services/git-context/GitContextCollector.ts create mode 100644 src/services/git-context/__tests__/GitContextCollector.spec.ts create mode 100644 src/services/git-context/index.ts create mode 100644 src/services/git-context/types.ts diff --git a/src/services/git-context/GitContextCollector.ts b/src/services/git-context/GitContextCollector.ts new file mode 100644 index 0000000000..5be2e035ab --- /dev/null +++ b/src/services/git-context/GitContextCollector.ts @@ -0,0 +1,524 @@ +import * as path from "path" +import { promises as fs } from "fs" +import { spawn } from "child_process" +import { + GitContextCollection, + GitContextCollectorOptions, + GitChange, + GitDiffContextOptions, + GitContextOptions, + GitRecentCommitContextOptions, + GitContextResult, + GitStatus, +} from "./types" + +export type { + GitChange, + GitContextCollection, + GitContextCollectorOptions, + GitDiffContextOptions, + GitContextOptions, + GitRecentCommitContextOptions, + GitContextResult, +} from "./types" + +const DEFAULT_RECENT_COMMIT_COUNT = 5 +const DEFAULT_RECENT_COMMIT_DIFF_COUNT = 1 + +export class GitContextCollector { + constructor(private workspaceRoot: string) {} + + public async gatherChanges(options: GitContextCollectorOptions): Promise { + const statusOutput = await this.getStatus(options) + if (!statusOutput) { + return [] + } + + return options.staged ? this.parseNameStatus(statusOutput, true) : this.parsePorcelainStatus(statusOutput) + } + + public async collect(options: GitContextCollectorOptions, specificFiles?: string[]): Promise { + const changes = await this.gatherChanges(options) + const result = await this.collectContext(changes, options, specificFiles) + + return { ...result, changes } + } + + private async runGit(args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn("git", args, { + cwd: this.workspaceRoot, + stdio: ["ignore", "pipe", "pipe"], + }) + let stdout = "" + let stderr = "" + + child.stdout.setEncoding("utf8") + child.stderr.setEncoding("utf8") + child.stdout.on("data", (chunk) => (stdout += chunk)) + child.stderr.on("data", (chunk) => (stderr += chunk)) + child.on("error", reject) + child.on("close", (code) => { + if (code === 0) { + resolve(stdout) + return + } + + reject(new Error(`Git command failed (${args.join(" ")}): ${stderr.trim() || `exit code ${code}`}`)) + }) + }) + } + + private async getDiffForChanges(changes: GitChange[], options: GitContextCollectorOptions): Promise { + if (changes.length === 0) { + return "" + } + + const binaryChanges = await this.findBinaryChanges(changes, options.staged) + const diffableChanges = changes.filter((change) => change.status !== "?" && !binaryChanges.has(change.filePath)) + const untrackedFiles = changes.filter((change) => change.status === "?") + const parts: string[] = [] + + if (diffableChanges.length > 0) { + const diffArgs = this.buildDiffArgs(options.staged, diffableChanges, [], options.diff) + const diff = await this.runGit(diffArgs) + if (diff.trim()) { + parts.push(diff) + } + } + + if (untrackedFiles.length > 0) { + parts.push(await this.getUntrackedFileDiffs(untrackedFiles)) + } + + if (binaryChanges.size > 0) { + parts.push( + changes + .filter((change) => binaryChanges.has(change.filePath)) + .map( + (change) => + `Binary file ${this.getReadableStatus(change.status).toLowerCase()}: ${this.getRelativePath(change.filePath)}`, + ) + .join("\n"), + ) + } + + options.onProgress?.(100) + return parts.join("\n") + } + + private async getDiffStats(changes: GitChange[], options: GitContextCollectorOptions): Promise { + const trackedChanges = changes.filter((change) => change.status !== "?") + const untrackedChanges = changes.filter((change) => change.status === "?") + const parts: string[] = [] + + if (trackedChanges.length > 0) { + const args = this.buildDiffArgs(options.staged, trackedChanges, ["--stat"]) + const stats = await this.runGit(args) + if (stats.trim()) { + parts.push(stats.trim()) + } + } + + for (const change of untrackedChanges) { + parts.push(await this.getUntrackedFileStat(change)) + } + + return parts.join("\n") + } + + private async getUntrackedFileStat(change: GitChange): Promise { + const relativePath = this.getRelativePath(change.filePath) + if (await this.isProbablyBinaryFile(change.filePath)) { + return `${relativePath} | Bin 0 -> ${await this.getFileSize(change.filePath)} bytes` + } + + const content = await fs.readFile(change.filePath, "utf8") + const normalizedContent = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + const lineCount = normalizedContent.length === 0 ? 0 : normalizedContent.split("\n").filter(Boolean).length + return `${relativePath} | ${lineCount} ${"+".repeat(Math.min(lineCount, 60))}` + } + + private async getFileSize(filePath: string): Promise { + return (await fs.stat(filePath)).size + } + + private async findBinaryChanges(changes: GitChange[], staged: boolean): Promise> { + const binaryFiles = new Set() + + for (const change of changes) { + if (change.status === "?") { + continue + } + + const args = this.buildNumstatArgs(staged, change) + const output = await this.runGit(args) + if (output.includes("-\t-\t")) { + binaryFiles.add(change.filePath) + } + } + + return binaryFiles + } + + private buildNumstatArgs(staged: boolean, change: GitChange): string[] { + const args = staged ? ["diff", "--cached", "--numstat"] : ["diff", "--numstat"] + return [...args, "--", this.getRelativePath(change.filePath)] + } + + private async isProbablyBinaryFile(filePath: string): Promise { + const fileHandle = await fs.open(filePath, "r") + try { + const buffer = Buffer.alloc(8000) + const { bytesRead } = await fileHandle.read(buffer, 0, buffer.length, 0) + return buffer.subarray(0, bytesRead).includes(0) + } finally { + await fileHandle.close() + } + } + + private async getUntrackedFileDiffs(changes: GitChange[]): Promise { + const diffs: string[] = [] + + for (const change of changes) { + if (await this.isProbablyBinaryFile(change.filePath)) { + diffs.push(`Binary file added: ${this.getRelativePath(change.filePath)}`) + continue + } + + diffs.push(await this.createNewFileDiff(change.filePath)) + } + + return diffs.join("\n") + } + + private async createNewFileDiff(filePath: string): Promise { + const relativePath = this.getRelativePath(filePath) + const content = await fs.readFile(filePath, "utf8") + const normalizedContent = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + + if (normalizedContent.length === 0) { + return [ + `diff --git a/${relativePath} b/${relativePath}`, + "new file mode 100644", + "--- /dev/null", + `+++ b/${relativePath}`, + ].join("\n") + } + + const hasTrailingNewline = normalizedContent.endsWith("\n") + const lines = (hasTrailingNewline ? normalizedContent.slice(0, -1) : normalizedContent).split("\n") + const diffLines = [ + `diff --git a/${relativePath} b/${relativePath}`, + "new file mode 100644", + "--- /dev/null", + `+++ b/${relativePath}`, + `@@ -0,0 +1,${lines.length} @@`, + ...lines.map((line) => `+${line}`), + ] + + if (!hasTrailingNewline) { + diffLines.push("\\ No newline at end of file") + } + + return diffLines.join("\n") + } + + private async getStatus(options: GitContextOptions): Promise { + return options.staged + ? this.runGit(["diff", "--name-status", "--cached", "-z"]) + : this.runGit(["status", "--porcelain=v1", "-z", "--untracked-files=all"]) + } + + private async getCurrentBranch(): Promise { + return this.runGit(["branch", "--show-current"]) + } + + private async getRecentCommits(options: GitRecentCommitContextOptions): Promise { + const count = this.clampNumber(options.count, 1, 20, DEFAULT_RECENT_COMMIT_COUNT) + const args = options.includeBodies + ? ["log", `-${count}`, "--format=commit %h%nSubject: %s%nBody:%n%b"] + : ["log", "--oneline", `-${count}`] + + if (options.includeStats) { + args.push("--stat") + } + + const parts = [await this.runGit(args)] + + if (options.includeDiffs) { + const diffCount = this.clampNumber(options.diffCount, 1, 5, DEFAULT_RECENT_COMMIT_DIFF_COUNT) + const hashes = (await this.runGit(["log", `-${diffCount}`, "--format=%H"])) + .split("\n") + .map((hash) => hash.trim()) + .filter(Boolean) + + for (const hash of hashes) { + parts.push(await this.runGit(["show", "--format=commit %h%nSubject: %s%n%b", "--patch", hash])) + } + } + + return parts.join("\n") + } + + public async collectContext( + changes: GitChange[], + options: GitContextCollectorOptions, + specificFiles?: string[], + ): Promise { + const { includeBranch = false, recentCommits } = options + let context = "## Git Context\n\n" + const warnings: string[] = [] + + const targetChanges = this.filterChanges(changes, specificFiles) + const fileInfo = specificFiles ? ` (${specificFiles.length} selected files)` : "" + const allStaged = targetChanges.every((change) => change.staged) + const allUnstaged = targetChanges.every((change) => !change.staged) + const changeDescriptor = allStaged ? "Staged" : allUnstaged ? "Unstaged" : "Selected" + + if (options.diff?.includeStats) { + const stats = await this.getDiffStats(targetChanges, options) + if (stats.trim()) { + context += `### Diff Stats${fileInfo}\n\`\`\`\n${stats.trim()}\n\`\`\`\n\n` + } + } + + const diff = await this.getDiffForChanges(targetChanges, options) + context += `### Full Diff of ${changeDescriptor} Changes${fileInfo}\n\`\`\`diff\n${diff}\n\`\`\`\n\n` + + if (targetChanges.length > 0) { + const summaryLines = targetChanges.map((change) => { + const relativePath = this.getRelativePath(change.filePath) + const scope = change.staged ? "staged" : "unstaged" + const status = this.getReadableStatus(change.status) + + if (change.oldFilePath) { + const oldRelativePath = this.getRelativePath(change.oldFilePath) + return `${status} (${scope}): ${oldRelativePath} -> ${relativePath}` + } + + return `${status} (${scope}): ${relativePath}` + }) + + context += "### Change Summary\n```\n" + summaryLines.join("\n") + "\n```\n\n" + } else { + context += "### Change Summary\n```\n(No changes matched selection)\n```\n\n" + } + + if (includeBranch || recentCommits?.include) { + context += "### Repository Context\n\n" + } + + if (includeBranch) { + try { + const currentBranch = await this.getCurrentBranch() + if (currentBranch) { + context += "**Current branch:** `" + currentBranch.trim() + "`\n\n" + } + } catch (error) { + warnings.push(`Current branch unavailable: ${this.getErrorMessage(error)}`) + } + } + + if (recentCommits?.include) { + try { + const recentCommitContext = await this.getRecentCommits(recentCommits) + if (recentCommitContext) { + context += "**Recent commits:**\n```\n" + recentCommitContext + "\n```\n" + } + } catch (error) { + warnings.push(`Recent commits unavailable: ${this.getErrorMessage(error)}`) + } + } + + if (warnings.length > 0) { + context += "\n### Git Context Warnings\n```\n" + warnings.join("\n") + "\n```\n" + } + + return { context, warnings } + } + + public async getContext( + changes: GitChange[], + options: GitContextCollectorOptions, + specificFiles?: string[], + ): Promise { + return (await this.collectContext(changes, options, specificFiles)).context + } + + private getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) + } + + private parseNameStatus(output: string, staged: boolean): GitChange[] { + const fields = this.splitNullDelimited(output) + const changes: GitChange[] = [] + + for (let index = 0; index < fields.length; index++) { + const statusCode = fields[index] + const status = this.getChangeStatusFromCode(statusCode) + + if (status === "R" || status === "C") { + const oldFilePath = fields[++index] + const filePath = fields[++index] + if (oldFilePath && filePath) { + changes.push({ + filePath: path.join(this.workspaceRoot, filePath), + oldFilePath: path.join(this.workspaceRoot, oldFilePath), + status, + staged, + }) + } + continue + } + + const filePath = fields[++index] + if (filePath) { + changes.push({ + filePath: path.join(this.workspaceRoot, filePath), + status, + staged, + }) + } + } + + return changes + } + + private parsePorcelainStatus(output: string): GitChange[] { + const fields = this.splitNullDelimited(output) + const changes: GitChange[] = [] + + for (let index = 0; index < fields.length; index++) { + const entry = fields[index] + if (entry.length < 4) { + continue + } + + const indexStatus = entry.charAt(0) + const workingStatus = entry.charAt(1) + const statusCode = indexStatus === "?" && workingStatus === "?" ? "?" : workingStatus.trim() || indexStatus + const status = this.getChangeStatusFromCode(statusCode) + const filePath = entry.substring(3) + + if (status === "R" || status === "C") { + const oldFilePath = fields[++index] + changes.push({ + filePath: path.join(this.workspaceRoot, filePath), + oldFilePath: oldFilePath ? path.join(this.workspaceRoot, oldFilePath) : undefined, + status, + staged: false, + }) + continue + } + + changes.push({ + filePath: path.join(this.workspaceRoot, filePath), + status, + staged: false, + }) + } + + return changes + } + + private splitNullDelimited(output: string): string[] { + return output.split("\0").filter(Boolean) + } + + private filterChanges(changes: GitChange[], specificFiles?: string[]): GitChange[] { + if (!specificFiles || specificFiles.length === 0) { + return changes + } + + return changes.filter((change) => { + const absolutePath = change.filePath + const relativePath = this.getRelativePath(absolutePath) + return specificFiles.some((file) => { + const normalizedFile = path.normalize(file).replace(/\\/g, "/") + return ( + file === absolutePath || + file === relativePath || + absolutePath.endsWith(file) || + relativePath === normalizedFile + ) + }) + }) + } + + private buildDiffArgs( + staged: boolean, + changes: GitChange[], + extraArgs: string[] = [], + diffOptions?: GitDiffContextOptions, + ): string[] { + const args = staged ? ["diff", "--cached"] : ["diff"] + const contextLines = + !extraArgs.includes("--stat") && diffOptions?.contextLines !== undefined + ? [`--unified=${this.clampNumber(diffOptions.contextLines, 0, 20, 3)}`] + : [] + const paths = Array.from( + new Set( + changes.flatMap((change) => + [change.filePath, change.oldFilePath] + .filter((filePath): filePath is string => Boolean(filePath)) + .map((filePath) => this.getRelativePath(filePath)), + ), + ), + ) + + return paths.length > 0 ? [...args, ...extraArgs, ...contextLines, "--", ...paths] : [...args, ...extraArgs] + } + + private clampNumber(value: number | undefined, min: number, max: number, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return fallback + } + + return Math.min(Math.max(Math.trunc(value), min), max) + } + + private getRelativePath(filePath: string): string { + return path.relative(this.workspaceRoot, filePath).replace(/\\/g, "/") + } + + private getChangeStatusFromCode(code: string): GitStatus { + const status = code.charAt(0) + switch (status) { + case "M": + case "A": + case "D": + case "R": + case "C": + case "U": + case "?": + return status as GitStatus + default: + return "Unknown" + } + } + + private getReadableStatus(status: GitStatus): string { + switch (status) { + case "M": + return "Modified" + case "A": + return "Added" + case "D": + return "Deleted" + case "R": + return "Renamed" + case "C": + return "Copied" + case "U": + return "Updated" + case "?": + return "Untracked" + case "Unknown": + default: + return "Unknown" + } + } + + public dispose(): void {} +} diff --git a/src/services/git-context/__tests__/GitContextCollector.spec.ts b/src/services/git-context/__tests__/GitContextCollector.spec.ts new file mode 100644 index 0000000000..493204ed62 --- /dev/null +++ b/src/services/git-context/__tests__/GitContextCollector.spec.ts @@ -0,0 +1,218 @@ +import * as os from "os" +import * as path from "path" +import { EventEmitter } from "events" +import { promises as fs } from "fs" +import { spawn } from "child_process" + +import { GitContextCollector } from "../GitContextCollector" + +vi.mock("child_process", () => ({ + spawn: vi.fn(), +})) + +const mockSpawn = vi.mocked(spawn) +const workspaceRoot = path.resolve("/repo") +const requiredContextOnly = { includeBranch: false, recentCommits: { include: false } } + +function mockGitCommand(stdout: string, stderr = "", code = 0) { + mockSpawn.mockImplementationOnce((() => { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter & { setEncoding: ReturnType } + stderr: EventEmitter & { setEncoding: ReturnType } + } + + child.stdout = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }) + child.stderr = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }) + + queueMicrotask(() => { + if (stdout) { + child.stdout.emit("data", stdout) + } + + if (stderr) { + child.stderr.emit("data", stderr) + } + + child.emit("close", code) + }) + + return child + }) as unknown as typeof spawn) +} + +describe("GitContextCollector", () => { + beforeEach(() => { + mockSpawn.mockReset() + }) + + it("parses staged name-status output including renames and copies", async () => { + mockGitCommand( + ["M", "src/file.ts", "R100", "src/old.ts", "src/new.ts", "C075", "src/a.ts", "src/b.ts", ""].join("\0"), + ) + + const collector = new GitContextCollector(workspaceRoot) + const changes = await collector.gatherChanges({ staged: true }) + + expect(mockSpawn).toHaveBeenCalledWith( + "git", + ["diff", "--name-status", "--cached", "-z"], + expect.objectContaining({ cwd: workspaceRoot }), + ) + expect(changes).toEqual([ + { filePath: path.join(workspaceRoot, "src/file.ts"), status: "M", staged: true }, + { + filePath: path.join(workspaceRoot, "src/new.ts"), + oldFilePath: path.join(workspaceRoot, "src/old.ts"), + status: "R", + staged: true, + }, + { + filePath: path.join(workspaceRoot, "src/b.ts"), + oldFilePath: path.join(workspaceRoot, "src/a.ts"), + status: "C", + staged: true, + }, + ]) + }) + + it("requests all untracked files instead of collapsed untracked directories", async () => { + mockGitCommand(["?? src/new.ts", ""].join("\0")) + + const collector = new GitContextCollector(workspaceRoot) + const changes = await collector.gatherChanges({ staged: false }) + + expect(mockSpawn).toHaveBeenCalledWith( + "git", + ["status", "--porcelain=v1", "-z", "--untracked-files=all"], + expect.objectContaining({ cwd: workspaceRoot }), + ) + expect(changes).toEqual([{ filePath: path.join(workspaceRoot, "src/new.ts"), status: "?", staged: false }]) + }) + + it("keeps lockfiles in git context because git state is authoritative", async () => { + mockGitCommand("1\t1\tpackage-lock.json\n") + mockGitCommand("diff --git a/package-lock.json b/package-lock.json\n") + + const collector = new GitContextCollector(workspaceRoot) + const context = await collector.getContext( + [{ filePath: path.join(workspaceRoot, "package-lock.json"), status: "M", staged: true }], + { staged: true, ...requiredContextOnly }, + ) + + expect(context).toContain("diff --git a/package-lock.json b/package-lock.json") + expect(context).toContain("Modified (staged): package-lock.json") + }) + + it("can gather changes and collect context in one reusable call", async () => { + mockGitCommand(["M", "src/file.ts", ""].join("\0")) + mockGitCommand("1\t1\tsrc/file.ts\n") + mockGitCommand("diff --git a/src/file.ts b/src/file.ts\n") + + const collector = new GitContextCollector(workspaceRoot) + const result = await collector.collect({ staged: true, ...requiredContextOnly }) + + expect(result.changes).toEqual([ + { filePath: path.join(workspaceRoot, "src/file.ts"), status: "M", staged: true }, + ]) + expect(result.context).toContain("diff --git a/src/file.ts b/src/file.ts") + expect(result.warnings).toEqual([]) + }) + + it("uses requested diff context lines and includes diff stats", async () => { + mockGitCommand("src/file.ts | 3 ++-\n") + mockGitCommand("2\t1\tsrc/file.ts\n") + mockGitCommand("diff --git a/src/file.ts b/src/file.ts\n") + + const collector = new GitContextCollector(workspaceRoot) + const context = await collector.getContext( + [{ filePath: path.join(workspaceRoot, "src/file.ts"), status: "M", staged: true }], + { staged: true, ...requiredContextOnly, diff: { contextLines: 0, includeStats: true } }, + ) + + expect(mockSpawn).toHaveBeenNthCalledWith( + 3, + "git", + ["diff", "--cached", "--unified=0", "--", "src/file.ts"], + expect.objectContaining({ cwd: workspaceRoot }), + ) + expect(context).toContain("### Diff Stats") + expect(context).toContain("src/file.ts | 3 ++-") + }) + + it("includes full new-file diffs for untracked text files", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-git-context-")) + try { + const filePath = path.join(tempRoot, "src", "new.ts") + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, "export const value = 1\n") + + const collector = new GitContextCollector(tempRoot) + const context = await collector.getContext([{ filePath, status: "?", staged: false }], { + staged: false, + ...requiredContextOnly, + }) + + expect(mockSpawn).not.toHaveBeenCalled() + expect(context).toContain("diff --git a/src/new.ts b/src/new.ts") + expect(context).toContain("--- /dev/null") + expect(context).toContain("+export const value = 1") + expect(context).toContain("Untracked (unstaged): src/new.ts") + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }) + } + }) + + it("summarizes untracked binary files without binary payload", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-git-context-")) + try { + const filePath = path.join(tempRoot, "image.bin") + await fs.writeFile(filePath, Buffer.from([0, 1, 2, 3])) + + const collector = new GitContextCollector(tempRoot) + const context = await collector.getContext([{ filePath, status: "?", staged: false }], { + staged: false, + ...requiredContextOnly, + }) + + expect(context).toContain("Binary file added: image.bin") + expect(context).not.toContain("@@ -0,0") + expect(context).toContain("Untracked (unstaged): image.bin") + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }) + } + }) + + it("fails required diff collection instead of emitting partial context", async () => { + mockGitCommand("1\t1\tsrc/file.ts\n") + mockGitCommand("", "fatal: bad revision", 128) + + const collector = new GitContextCollector(workspaceRoot) + + await expect( + collector.getContext([{ filePath: path.join(workspaceRoot, "src/file.ts"), status: "M", staged: true }], { + staged: true, + ...requiredContextOnly, + }), + ).rejects.toThrow("fatal: bad revision") + }) + + it("returns warnings when supplemental repository context is unavailable", async () => { + mockGitCommand("1\t1\tsrc/file.ts\n") + mockGitCommand("diff --git a/src/file.ts b/src/file.ts\n") + mockGitCommand("", "fatal: branch unavailable", 128) + mockGitCommand("", "fatal: log unavailable", 128) + + const collector = new GitContextCollector(workspaceRoot) + const result = await collector.collectContext( + [{ filePath: path.join(workspaceRoot, "src/file.ts"), status: "M", staged: true }], + { staged: true, includeBranch: true, recentCommits: { include: true } }, + ) + + expect(result.warnings).toEqual([ + expect.stringContaining("Current branch unavailable"), + expect.stringContaining("Recent commits unavailable"), + ]) + expect(result.context).toContain("### Git Context Warnings") + expect(result.context).toContain("diff --git a/src/file.ts b/src/file.ts") + }) +}) diff --git a/src/services/git-context/index.ts b/src/services/git-context/index.ts new file mode 100644 index 0000000000..525a1c0bcf --- /dev/null +++ b/src/services/git-context/index.ts @@ -0,0 +1,11 @@ +export { GitContextCollector } from "./GitContextCollector" +export type { + GitChange, + GitContextCollection, + GitContextCollectorOptions, + GitDiffContextOptions, + GitContextOptions, + GitRecentCommitContextOptions, + GitContextResult, + GitStatus, +} from "./types" diff --git a/src/services/git-context/types.ts b/src/services/git-context/types.ts new file mode 100644 index 0000000000..73550dd462 --- /dev/null +++ b/src/services/git-context/types.ts @@ -0,0 +1,42 @@ +export type GitStatus = "M" | "A" | "D" | "R" | "C" | "U" | "?" | "Unknown" + +export interface GitChange { + filePath: string + oldFilePath?: string + status: GitStatus + staged: boolean +} + +export interface GitContextResult { + context: string + warnings: string[] +} + +export interface GitContextCollection extends GitContextResult { + changes: GitChange[] +} + +export interface GitContextOptions { + staged: boolean +} + +export interface GitDiffContextOptions { + contextLines?: number + includeStats?: boolean +} + +export interface GitRecentCommitContextOptions { + include?: boolean + count?: number + includeBodies?: boolean + includeStats?: boolean + includeDiffs?: boolean + diffCount?: number +} + +export interface GitContextCollectorOptions extends GitContextOptions { + onProgress?: (percentage: number) => void + diff?: GitDiffContextOptions + includeBranch?: boolean + recentCommits?: GitRecentCommitContextOptions +} From b8226ad8bb3b742cd44bc2425b0f51e2d173cd25 Mon Sep 17 00:00:00 2001 From: Mirrowel <28632877+Mirrowel@users.noreply.github.com> Date: Sun, 24 May 2026 17:17:00 +0200 Subject: [PATCH 2/9] =?UTF-8?q?fix(scm):=20=F0=9F=90=9B=20address=20git=20?= =?UTF-8?q?context=20review=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../git-context/GitContextCollector.ts | 124 +++++++++++++----- .../__tests__/GitContextCollector.spec.ts | 110 +++++++++++++++- src/services/git-context/types.ts | 28 ++++ 3 files changed, 230 insertions(+), 32 deletions(-) diff --git a/src/services/git-context/GitContextCollector.ts b/src/services/git-context/GitContextCollector.ts index 5be2e035ab..e347e5adce 100644 --- a/src/services/git-context/GitContextCollector.ts +++ b/src/services/git-context/GitContextCollector.ts @@ -1,7 +1,7 @@ import * as path from "path" import { promises as fs } from "fs" import { spawn } from "child_process" -import { +import type { GitContextCollection, GitContextCollectorOptions, GitChange, @@ -12,22 +12,15 @@ import { GitStatus, } from "./types" -export type { - GitChange, - GitContextCollection, - GitContextCollectorOptions, - GitDiffContextOptions, - GitContextOptions, - GitRecentCommitContextOptions, - GitContextResult, -} from "./types" - const DEFAULT_RECENT_COMMIT_COUNT = 5 const DEFAULT_RECENT_COMMIT_DIFF_COUNT = 1 +/** Collects Git status, diff, and repository metadata for commit-message generation. */ export class GitContextCollector { + /** Creates a collector scoped to one workspace repository root. */ constructor(private workspaceRoot: string) {} + /** Returns changed files from staged or unstaged Git state. */ public async gatherChanges(options: GitContextCollectorOptions): Promise { const statusOutput = await this.getStatus(options) if (!statusOutput) { @@ -37,6 +30,7 @@ export class GitContextCollector { return options.staged ? this.parseNameStatus(statusOutput, true) : this.parsePorcelainStatus(statusOutput) } + /** Gathers changes and formats their Git context in one call. */ public async collect(options: GitContextCollectorOptions, specificFiles?: string[]): Promise { const changes = await this.gatherChanges(options) const result = await this.collectContext(changes, options, specificFiles) @@ -44,6 +38,7 @@ export class GitContextCollector { return { ...result, changes } } + /** Runs a Git subprocess in the workspace root and returns stdout. */ private async runGit(args: string[]): Promise { return new Promise((resolve, reject) => { const child = spawn("git", args, { @@ -69,12 +64,16 @@ export class GitContextCollector { }) } + /** Builds full diff text for tracked, untracked, and binary changes. */ private async getDiffForChanges(changes: GitChange[], options: GitContextCollectorOptions): Promise { + options.onProgress?.(0) if (changes.length === 0) { + options.onProgress?.(100) return "" } const binaryChanges = await this.findBinaryChanges(changes, options.staged) + options.onProgress?.(25) const diffableChanges = changes.filter((change) => change.status !== "?" && !binaryChanges.has(change.filePath)) const untrackedFiles = changes.filter((change) => change.status === "?") const parts: string[] = [] @@ -86,10 +85,12 @@ export class GitContextCollector { parts.push(diff) } } + options.onProgress?.(65) if (untrackedFiles.length > 0) { parts.push(await this.getUntrackedFileDiffs(untrackedFiles)) } + options.onProgress?.(85) if (binaryChanges.size > 0) { parts.push( @@ -107,6 +108,7 @@ export class GitContextCollector { return parts.join("\n") } + /** Builds diff-stat text for tracked changes and synthesized untracked files. */ private async getDiffStats(changes: GitChange[], options: GitContextCollectorOptions): Promise { const trackedChanges = changes.filter((change) => change.status !== "?") const untrackedChanges = changes.filter((change) => change.status === "?") @@ -127,6 +129,7 @@ export class GitContextCollector { return parts.join("\n") } + /** Returns a Git-style stat summary for an untracked working-tree file. */ private async getUntrackedFileStat(change: GitChange): Promise { const relativePath = this.getRelativePath(change.filePath) if (await this.isProbablyBinaryFile(change.filePath)) { @@ -135,25 +138,34 @@ export class GitContextCollector { const content = await fs.readFile(change.filePath, "utf8") const normalizedContent = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n") - const lineCount = normalizedContent.length === 0 ? 0 : normalizedContent.split("\n").filter(Boolean).length + const lineCount = this.countTextLines(normalizedContent) return `${relativePath} | ${lineCount} ${"+".repeat(Math.min(lineCount, 60))}` } + /** Returns the byte size for a file on disk. */ private async getFileSize(filePath: string): Promise { return (await fs.stat(filePath)).size } + /** Detects binary tracked changes with a single numstat invocation. */ private async findBinaryChanges(changes: GitChange[], staged: boolean): Promise> { const binaryFiles = new Set() + const trackedChanges = changes.filter((change) => change.status !== "?") + if (trackedChanges.length === 0) { + return binaryFiles + } - for (const change of changes) { - if (change.status === "?") { - continue - } - - const args = this.buildNumstatArgs(staged, change) - const output = await this.runGit(args) - if (output.includes("-\t-\t")) { + const args = this.buildNumstatArgs(staged, trackedChanges) + const output = await this.runGit(args) + const binaryRelativePaths = output + .split("\n") + .map((line) => line.split("\t")) + .filter(([added, deleted, filePath]) => added === "-" && deleted === "-" && Boolean(filePath)) + .map(([, , ...filePathParts]) => filePathParts.join("\t")) + + for (const change of trackedChanges) { + const relativePath = this.getRelativePath(change.filePath) + if (binaryRelativePaths.includes(relativePath)) { binaryFiles.add(change.filePath) } } @@ -161,11 +173,13 @@ export class GitContextCollector { return binaryFiles } - private buildNumstatArgs(staged: boolean, change: GitChange): string[] { + /** Builds path-limited numstat arguments for binary detection. */ + private buildNumstatArgs(staged: boolean, changes: GitChange[]): string[] { const args = staged ? ["diff", "--cached", "--numstat"] : ["diff", "--numstat"] - return [...args, "--", this.getRelativePath(change.filePath)] + return [...args, "--", ...changes.map((change) => this.getRelativePath(change.filePath))] } + /** Checks the first bytes of a file for NUL bytes. */ private async isProbablyBinaryFile(filePath: string): Promise { const fileHandle = await fs.open(filePath, "r") try { @@ -177,6 +191,7 @@ export class GitContextCollector { } } + /** Builds synthesized diff text for untracked files. */ private async getUntrackedFileDiffs(changes: GitChange[]): Promise { const diffs: string[] = [] @@ -192,6 +207,7 @@ export class GitContextCollector { return diffs.join("\n") } + /** Creates a unified new-file diff from working-tree file content. */ private async createNewFileDiff(filePath: string): Promise { const relativePath = this.getRelativePath(filePath) const content = await fs.readFile(filePath, "utf8") @@ -224,16 +240,19 @@ export class GitContextCollector { return diffLines.join("\n") } + /** Returns raw Git status output for staged or unstaged collection. */ private async getStatus(options: GitContextOptions): Promise { return options.staged ? this.runGit(["diff", "--name-status", "--cached", "-z"]) : this.runGit(["status", "--porcelain=v1", "-z", "--untracked-files=all"]) } + /** Returns the currently checked-out branch name. */ private async getCurrentBranch(): Promise { return this.runGit(["branch", "--show-current"]) } + /** Returns recent commit summaries and optional stats or patch context. */ private async getRecentCommits(options: GitRecentCommitContextOptions): Promise { const count = this.clampNumber(options.count, 1, 20, DEFAULT_RECENT_COMMIT_COUNT) const args = options.includeBodies @@ -261,6 +280,7 @@ export class GitContextCollector { return parts.join("\n") } + /** Formats collected changes as Markdown context for prompt input. */ public async collectContext( changes: GitChange[], options: GitContextCollectorOptions, @@ -338,6 +358,7 @@ export class GitContextCollector { return { context, warnings } } + /** Formats collected changes and returns only the Markdown context body. */ public async getContext( changes: GitChange[], options: GitContextCollectorOptions, @@ -346,10 +367,12 @@ export class GitContextCollector { return (await this.collectContext(changes, options, specificFiles)).context } + /** Normalizes unknown thrown values into displayable error messages. */ private getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } + /** Parses NUL-delimited git diff --name-status output. */ private parseNameStatus(output: string, staged: boolean): GitChange[] { const fields = this.splitNullDelimited(output) const changes: GitChange[] = [] @@ -359,6 +382,10 @@ export class GitContextCollector { const status = this.getChangeStatusFromCode(statusCode) if (status === "R" || status === "C") { + if (index + 2 >= fields.length) { + break + } + const oldFilePath = fields[++index] const filePath = fields[++index] if (oldFilePath && filePath) { @@ -372,6 +399,10 @@ export class GitContextCollector { continue } + if (index + 1 >= fields.length) { + break + } + const filePath = fields[++index] if (filePath) { changes.push({ @@ -385,6 +416,7 @@ export class GitContextCollector { return changes } + /** Parses NUL-delimited git status --porcelain=v1 output for unstaged changes. */ private parsePorcelainStatus(output: string): GitChange[] { const fields = this.splitNullDelimited(output) const changes: GitChange[] = [] @@ -397,12 +429,18 @@ export class GitContextCollector { const indexStatus = entry.charAt(0) const workingStatus = entry.charAt(1) - const statusCode = indexStatus === "?" && workingStatus === "?" ? "?" : workingStatus.trim() || indexStatus + const isUntracked = indexStatus === "?" && workingStatus === "?" + const worktreeStatus = workingStatus.trim() + if (!isUntracked && !worktreeStatus) { + continue + } + + const statusCode = isUntracked ? "?" : worktreeStatus const status = this.getChangeStatusFromCode(statusCode) const filePath = entry.substring(3) if (status === "R" || status === "C") { - const oldFilePath = fields[++index] + const oldFilePath = index + 1 < fields.length ? fields[++index] : undefined changes.push({ filePath: path.join(this.workspaceRoot, filePath), oldFilePath: oldFilePath ? path.join(this.workspaceRoot, oldFilePath) : undefined, @@ -422,30 +460,38 @@ export class GitContextCollector { return changes } + /** Splits NUL-delimited Git output and drops the trailing empty field. */ private splitNullDelimited(output: string): string[] { return output.split("\0").filter(Boolean) } + /** Applies exact path or basename-only file selection to collected changes. */ private filterChanges(changes: GitChange[], specificFiles?: string[]): GitChange[] { if (!specificFiles || specificFiles.length === 0) { return changes } return changes.filter((change) => { - const absolutePath = change.filePath - const relativePath = this.getRelativePath(absolutePath) + const absolutePath = this.normalizePath(change.filePath) + const relativePath = this.getRelativePath(change.filePath) return specificFiles.some((file) => { const normalizedFile = path.normalize(file).replace(/\\/g, "/") + const absoluteFile = this.normalizePath( + path.isAbsolute(file) ? file : path.join(this.workspaceRoot, file), + ) + const isBasenameOnly = !normalizedFile.includes("/") + return ( - file === absolutePath || - file === relativePath || - absolutePath.endsWith(file) || - relativePath === normalizedFile + absoluteFile === absolutePath || + relativePath === normalizedFile || + // Basename-only matching is intentional for SCM selections that pass only file names. + (isBasenameOnly && path.basename(relativePath) === normalizedFile) ) }) }) } + /** Builds path-limited diff arguments for the requested change set. */ private buildDiffArgs( staged: boolean, changes: GitChange[], @@ -470,6 +516,7 @@ export class GitContextCollector { return paths.length > 0 ? [...args, ...extraArgs, ...contextLines, "--", ...paths] : [...args, ...extraArgs] } + /** Clamps a numeric option to an integer range with fallback handling. */ private clampNumber(value: number | undefined, min: number, max: number, fallback: number): number { if (typeof value !== "number" || !Number.isFinite(value)) { return fallback @@ -478,10 +525,12 @@ export class GitContextCollector { return Math.min(Math.max(Math.trunc(value), min), max) } + /** Converts an absolute file path to a slash-normalized repository-relative path. */ private getRelativePath(filePath: string): string { return path.relative(this.workspaceRoot, filePath).replace(/\\/g, "/") } + /** Converts a Git status code into the collector's status enum. */ private getChangeStatusFromCode(code: string): GitStatus { const status = code.charAt(0) switch (status) { @@ -498,6 +547,7 @@ export class GitContextCollector { } } + /** Converts a status enum to a human-readable label. */ private getReadableStatus(status: GitStatus): string { switch (status) { case "M": @@ -520,5 +570,17 @@ export class GitContextCollector { } } - public dispose(): void {} + /** Counts text lines while preserving blank lines and ignoring a final newline terminator. */ + private countTextLines(content: string): number { + if (content.length === 0) { + return 0 + } + + return (content.endsWith("\n") ? content.slice(0, -1) : content).split("\n").length + } + + /** Normalizes absolute paths for platform-independent comparisons. */ + private normalizePath(filePath: string): string { + return path.normalize(filePath).replace(/\\/g, "/") + } } diff --git a/src/services/git-context/__tests__/GitContextCollector.spec.ts b/src/services/git-context/__tests__/GitContextCollector.spec.ts index 493204ed62..c36cdc5770 100644 --- a/src/services/git-context/__tests__/GitContextCollector.spec.ts +++ b/src/services/git-context/__tests__/GitContextCollector.spec.ts @@ -14,6 +14,7 @@ const mockSpawn = vi.mocked(spawn) const workspaceRoot = path.resolve("/repo") const requiredContextOnly = { includeBranch: false, recentCommits: { include: false } } +/** Queues a mocked Git subprocess response for the next spawn call. */ function mockGitCommand(stdout: string, stderr = "", code = 0) { mockSpawn.mockImplementationOnce((() => { const child = new EventEmitter() as EventEmitter & { @@ -75,6 +76,23 @@ describe("GitContextCollector", () => { ]) }) + it("parses staged paths with spaces, special characters, and deleted status", async () => { + mockGitCommand(["M", "src/file with spaces.ts", "D", "src/old'file.ts", ""].join("\0")) + + const collector = new GitContextCollector(workspaceRoot) + const changes = await collector.gatherChanges({ staged: true }) + + expect(mockSpawn).toHaveBeenCalledWith( + "git", + ["diff", "--name-status", "--cached", "-z"], + expect.objectContaining({ cwd: workspaceRoot }), + ) + expect(changes).toEqual([ + { filePath: path.join(workspaceRoot, "src/file with spaces.ts"), status: "M", staged: true }, + { filePath: path.join(workspaceRoot, "src/old'file.ts"), status: "D", staged: true }, + ]) + }) + it("requests all untracked files instead of collapsed untracked directories", async () => { mockGitCommand(["?? src/new.ts", ""].join("\0")) @@ -89,6 +107,29 @@ describe("GitContextCollector", () => { expect(changes).toEqual([{ filePath: path.join(workspaceRoot, "src/new.ts"), status: "?", staged: false }]) }) + it("ignores staged-only entries when gathering unstaged changes", async () => { + mockGitCommand(["M src/staged.ts", ""].join("\0")) + + const collector = new GitContextCollector(workspaceRoot) + const changes = await collector.gatherChanges({ staged: false }) + + expect(mockSpawn).toHaveBeenCalledWith( + "git", + ["status", "--porcelain=v1", "-z", "--untracked-files=all"], + expect.objectContaining({ cwd: workspaceRoot }), + ) + expect(changes).toEqual([]) + }) + + it("skips malformed staged name-status entries without reading past the output", async () => { + mockGitCommand(["R100", "src/old.ts", ""].join("\0")) + + const collector = new GitContextCollector(workspaceRoot) + const changes = await collector.gatherChanges({ staged: true }) + + expect(changes).toEqual([]) + }) + it("keeps lockfiles in git context because git state is authoritative", async () => { mockGitCommand("1\t1\tpackage-lock.json\n") mockGitCommand("diff --git a/package-lock.json b/package-lock.json\n") @@ -139,6 +180,54 @@ describe("GitContextCollector", () => { expect(context).toContain("src/file.ts | 3 ++-") }) + it("batches binary detection for tracked changes", async () => { + mockGitCommand("-\t-\tsrc/image.png\n1\t1\tsrc/file.ts\n") + mockGitCommand("diff --git a/src/file.ts b/src/file.ts\n") + + const collector = new GitContextCollector(workspaceRoot) + const context = await collector.getContext( + [ + { filePath: path.join(workspaceRoot, "src/image.png"), status: "M", staged: true }, + { filePath: path.join(workspaceRoot, "src/file.ts"), status: "M", staged: true }, + ], + { staged: true, ...requiredContextOnly }, + ) + + expect(mockSpawn).toHaveBeenNthCalledWith( + 1, + "git", + ["diff", "--cached", "--numstat", "--", "src/image.png", "src/file.ts"], + expect.objectContaining({ cwd: workspaceRoot }), + ) + expect(mockSpawn).toHaveBeenCalledTimes(2) + expect(context).toContain("Binary file modified: src/image.png") + expect(context).toContain("diff --git a/src/file.ts b/src/file.ts") + }) + + it("matches selected files by exact path or basename without suffix matching", async () => { + mockGitCommand("1\t1\tsrc/test.ts\n") + mockGitCommand("diff --git a/src/test.ts b/src/test.ts\n") + + const collector = new GitContextCollector(workspaceRoot) + const context = await collector.getContext( + [ + { filePath: path.join(workspaceRoot, "src/mytest.ts"), status: "M", staged: true }, + { filePath: path.join(workspaceRoot, "src/test.ts"), status: "M", staged: true }, + ], + { staged: true, ...requiredContextOnly }, + ["test.ts"], + ) + + expect(mockSpawn).toHaveBeenNthCalledWith( + 2, + "git", + ["diff", "--cached", "--", "src/test.ts"], + expect.objectContaining({ cwd: workspaceRoot }), + ) + expect(context).toContain("Modified (staged): src/test.ts") + expect(context).not.toContain("src/mytest.ts") + }) + it("includes full new-file diffs for untracked text files", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-git-context-")) try { @@ -152,7 +241,6 @@ describe("GitContextCollector", () => { ...requiredContextOnly, }) - expect(mockSpawn).not.toHaveBeenCalled() expect(context).toContain("diff --git a/src/new.ts b/src/new.ts") expect(context).toContain("--- /dev/null") expect(context).toContain("+export const value = 1") @@ -162,6 +250,26 @@ describe("GitContextCollector", () => { } }) + it("counts blank lines in untracked text file stats", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-git-context-")) + try { + const filePath = path.join(tempRoot, "src", "new.ts") + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, "first\n\nthird\n") + + const collector = new GitContextCollector(tempRoot) + const context = await collector.getContext([{ filePath, status: "?", staged: false }], { + staged: false, + ...requiredContextOnly, + diff: { includeStats: true }, + }) + + expect(context).toContain("src/new.ts | 3 +++") + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }) + } + }) + it("summarizes untracked binary files without binary payload", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-git-context-")) try { diff --git a/src/services/git-context/types.ts b/src/services/git-context/types.ts index 73550dd462..b9a1e4786c 100644 --- a/src/services/git-context/types.ts +++ b/src/services/git-context/types.ts @@ -1,42 +1,70 @@ +/** Git status code emitted by porcelain and name-status commands. */ export type GitStatus = "M" | "A" | "D" | "R" | "C" | "U" | "?" | "Unknown" +/** One repository file change selected for commit-message context. */ export interface GitChange { + /** Absolute path to the changed file. */ filePath: string + /** Absolute previous path for rename or copy changes. */ oldFilePath?: string + /** Parsed Git status for the changed file. */ status: GitStatus + /** Whether this change came from the index instead of the working tree. */ staged: boolean } +/** Formatted Git context and non-fatal supplemental context warnings. */ export interface GitContextResult { + /** Markdown context suitable for commit-message prompt input. */ context: string + /** Warnings from optional branch or recent-commit collection. */ warnings: string[] } +/** Combined change discovery and formatted context result. */ export interface GitContextCollection extends GitContextResult { + /** File changes used to build the formatted context. */ changes: GitChange[] } +/** Base Git collection mode options. */ export interface GitContextOptions { + /** Collect staged changes when true, otherwise collect unstaged changes. */ staged: boolean } +/** Diff formatting options for collected changes. */ export interface GitDiffContextOptions { + /** Number of unchanged context lines around each hunk. */ contextLines?: number + /** Include diff-stat output before the full diff. */ includeStats?: boolean } +/** Recent-commit context options appended to formatted Git context. */ export interface GitRecentCommitContextOptions { + /** Include recent commit context when true. */ include?: boolean + /** Number of recent commits to include. */ count?: number + /** Include commit body text when true. */ includeBodies?: boolean + /** Include recent commit stats when true. */ includeStats?: boolean + /** Include recent commit patches when true. */ includeDiffs?: boolean + /** Number of recent commit patches to include. */ diffCount?: number } +/** Full collector options for change discovery and context formatting. */ export interface GitContextCollectorOptions extends GitContextOptions { + /** Receives coarse progress percentages during diff collection. */ onProgress?: (percentage: number) => void + /** Controls full-diff and stat formatting. */ diff?: GitDiffContextOptions + /** Include the current branch name when true. */ includeBranch?: boolean + /** Controls recent commit context inclusion. */ recentCommits?: GitRecentCommitContextOptions } From 9fb5a1a5d9a680af3e311a8b95978a330b765ccf Mon Sep 17 00:00:00 2001 From: Mirrowel <28632877+Mirrowel@users.noreply.github.com> Date: Sun, 24 May 2026 17:19:40 +0200 Subject: [PATCH 3/9] =?UTF-8?q?fix(scm):=20=F0=9F=90=9B=20preserve=20git?= =?UTF-8?q?=20collector=20disposal=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/git-context/GitContextCollector.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/services/git-context/GitContextCollector.ts b/src/services/git-context/GitContextCollector.ts index e347e5adce..f4d294feb4 100644 --- a/src/services/git-context/GitContextCollector.ts +++ b/src/services/git-context/GitContextCollector.ts @@ -570,6 +570,9 @@ export class GitContextCollector { } } + /** Keeps collector cleanup compatible with provider lifecycle hooks. */ + public dispose(): void {} + /** Counts text lines while preserving blank lines and ignoring a final newline terminator. */ private countTextLines(content: string): number { if (content.length === 0) { From b871b804860e6edb69b2676cd815ecf435562f0d Mon Sep 17 00:00:00 2001 From: Mirrowel <28632877+Mirrowel@users.noreply.github.com> Date: Sun, 24 May 2026 15:58:17 +0200 Subject: [PATCH 4/9] feat(scm): add commit message generator service --- packages/types/src/global-settings.ts | 29 +++ packages/types/src/telemetry.ts | 3 + .../commit-message/CommitMessageGenerator.ts | 199 ++++++++++++++++++ ...ommitMessageGeneration.integration.spec.ts | 80 +++++++ .../__tests__/CommitMessageGenerator.spec.ts | 156 ++++++++++++++ src/services/commit-message/types/core.ts | 18 ++ src/shared/support-prompt.ts | 74 +++++++ 7 files changed, 559 insertions(+) create mode 100644 src/services/commit-message/CommitMessageGenerator.ts create mode 100644 src/services/commit-message/__tests__/CommitMessageGeneration.integration.spec.ts create mode 100644 src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts create mode 100644 src/services/commit-message/types/core.ts diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 3a5a99eb98..13f0152be7 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -23,6 +23,32 @@ import { languagesSchema } from "./vscode.js" */ export const DEFAULT_WRITE_DELAY_MS = 1000 +export const commitMessageGitContextSchema = z.object({ + diffContextLines: z.number().int().min(0).max(20).optional(), + includeDiffStats: z.boolean().optional(), + includeCurrentBranch: z.boolean().optional(), + includeRecentCommits: z.boolean().optional(), + recentCommitCount: z.number().int().min(1).max(20).optional(), + includeRecentCommitBodies: z.boolean().optional(), + includeRecentCommitStats: z.boolean().optional(), + includeRecentCommitDiffs: z.boolean().optional(), + recentCommitDiffCount: z.number().int().min(1).max(5).optional(), +}) + +export type CommitMessageGitContextSettings = z.infer + +export const defaultCommitMessageGitContextSettings: Required = { + diffContextLines: 3, + includeDiffStats: true, + includeCurrentBranch: true, + includeRecentCommits: true, + recentCommitCount: 5, + includeRecentCommitBodies: false, + includeRecentCommitStats: false, + includeRecentCommitDiffs: false, + recentCommitDiffCount: 1, +} + /** * Terminal output preview size options for persisted command output. * @@ -232,6 +258,9 @@ export const globalSettingsSchema = z.object({ * Tools in this list will be excluded from prompt generation and rejected at execution time. */ disabledTools: z.array(toolNamesSchema).optional(), + + commitMessageApiConfigId: z.string().optional(), + commitMessageGitContext: commitMessageGitContextSchema.optional(), }) export type GlobalSettings = z.infer diff --git a/packages/types/src/telemetry.ts b/packages/types/src/telemetry.ts index 402cd571c8..1e9d80f202 100644 --- a/packages/types/src/telemetry.ts +++ b/packages/types/src/telemetry.ts @@ -74,6 +74,8 @@ export enum TelemetryEventName { TELEMETRY_SETTINGS_CHANGED = "Telemetry Settings Changed", MODEL_CACHE_EMPTY_RESPONSE = "Model Cache Empty Response", READ_FILE_LEGACY_FORMAT_USED = "Read File Legacy Format Used", + + COMMIT_MSG_GENERATED = "Commit Message Generated", } /** @@ -206,6 +208,7 @@ export const rooCodeTelemetryEventSchema = z.discriminatedUnion("type", [ TelemetryEventName.MODE_SETTINGS_CHANGED, TelemetryEventName.CUSTOM_MODE_CREATED, TelemetryEventName.READ_FILE_LEGACY_FORMAT_USED, + TelemetryEventName.COMMIT_MSG_GENERATED, ]), properties: telemetryPropertiesSchema, }), diff --git a/src/services/commit-message/CommitMessageGenerator.ts b/src/services/commit-message/CommitMessageGenerator.ts new file mode 100644 index 0000000000..ad0efd813c --- /dev/null +++ b/src/services/commit-message/CommitMessageGenerator.ts @@ -0,0 +1,199 @@ +import { ContextProxy } from "../../core/config/ContextProxy" +import { ProviderSettingsManager } from "../../core/config/ProviderSettingsManager" +import { singleCompletionHandler as defaultSingleCompletionHandler } from "../../utils/single-completion-handler" +import { supportPrompt } from "../../shared/support-prompt" +import { addCustomInstructions as defaultAddCustomInstructions } from "../../core/prompts/sections/custom-instructions" +import { TelemetryService } from "@roo-code/telemetry" +import { TelemetryEventName, type ProviderSettings } from "@roo-code/types" + +import { GenerateMessageParams, PromptOptions, ProgressUpdate } from "./types/core" + +export interface CommitMessageContextProxy { + isInitialized: boolean + getProviderSettings(): ProviderSettings + getValue(key: any): unknown +} + +export interface CommitMessageGeneratorDependencies { + getContextProxy?: () => CommitMessageContextProxy + completePrompt?: (apiConfiguration: ProviderSettings, promptText: string) => Promise + addCustomInstructions?: typeof defaultAddCustomInstructions + captureGenerated?: () => void + logger?: Pick +} + +export class CommitMessageGenerator { + private readonly providerSettingsManager: ProviderSettingsManager + private readonly dependencies: Required + private previousGitContext: string | null = null + private previousCommitMessage: string | null = null + + constructor( + providerSettingsManager: ProviderSettingsManager, + dependencies: CommitMessageGeneratorDependencies = {}, + ) { + this.providerSettingsManager = providerSettingsManager + this.dependencies = { + getContextProxy: dependencies.getContextProxy ?? (() => ContextProxy.instance), + completePrompt: dependencies.completePrompt ?? defaultSingleCompletionHandler, + addCustomInstructions: dependencies.addCustomInstructions ?? defaultAddCustomInstructions, + captureGenerated: + dependencies.captureGenerated ?? + (() => TelemetryService.instance.captureEvent(TelemetryEventName.COMMIT_MSG_GENERATED)), + logger: dependencies.logger ?? console, + } + } + + async generateMessage(params: GenerateMessageParams): Promise { + const { gitContext, onProgress } = params + + try { + onProgress?.({ + message: "Generating commit message...", + percentage: 75, + }) + + const generatedMessage = await this.callAIForCommitMessage(gitContext, params.workspacePath, onProgress) + + this.previousGitContext = gitContext + this.previousCommitMessage = generatedMessage + + this.dependencies.captureGenerated() + + onProgress?.({ + message: "Commit message generated successfully", + percentage: 100, + }) + + return generatedMessage + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred" + throw new Error(`Failed to generate commit message: ${errorMessage}`) + } + } + + async buildPrompt(gitContext: string, options: PromptOptions, workspacePath: string): Promise { + const { customSupportPrompts = {}, previousContext, previousMessage } = options + + const customInstructions = await this.dependencies.addCustomInstructions("", "", workspacePath, "commit", { + language: "en", + }) + + const shouldGenerateDifferentMessage = + (previousContext === gitContext || this.previousGitContext === gitContext) && + (previousMessage !== null || this.previousCommitMessage !== null) + + const targetPreviousMessage = previousMessage || this.previousCommitMessage + + if (shouldGenerateDifferentMessage && targetPreviousMessage) { + const differentMessagePrefix = `# CRITICAL INSTRUCTION: GENERATE A COMPLETELY DIFFERENT COMMIT MESSAGE +The user has requested a new commit message for the same changes. +The previous message was: "${targetPreviousMessage}" +YOU MUST create a message that is COMPLETELY DIFFERENT by: +- Using entirely different wording and phrasing +- Focusing on different aspects of the changes +- Using a different structure or format if appropriate +- Possibly using a different type or scope if justifiable +This is the MOST IMPORTANT requirement for this task. + +` + const baseTemplate = supportPrompt.get(customSupportPrompts, "COMMIT_MESSAGE") + const modifiedTemplate = + differentMessagePrefix + + baseTemplate + + ` + +FINAL REMINDER: Your message MUST be COMPLETELY DIFFERENT from the previous message: "${targetPreviousMessage}". This is a critical requirement.` + + return supportPrompt.create( + "COMMIT_MESSAGE", + { + gitContext, + customInstructions: customInstructions || "", + }, + { + ...customSupportPrompts, + COMMIT_MESSAGE: modifiedTemplate, + }, + ) + } else { + return supportPrompt.create( + "COMMIT_MESSAGE", + { + gitContext, + customInstructions: customInstructions || "", + }, + customSupportPrompts, + ) + } + } + + private async callAIForCommitMessage( + gitContextString: string, + workspacePath: string, + onProgress?: (progress: ProgressUpdate) => void, + ): Promise { + const contextProxy = this.dependencies.getContextProxy() + if (!contextProxy.isInitialized) { + throw new Error("ContextProxy not initialized. Please try again after the extension has fully loaded.") + } + const apiConfiguration = contextProxy.getProviderSettings() + const commitMessageApiConfigId = contextProxy.getValue("commitMessageApiConfigId") as string | undefined + const listApiConfigMeta = (contextProxy.getValue("listApiConfigMeta") || []) as Array<{ id: string }> + const customSupportPrompts = (contextProxy.getValue("customSupportPrompts") || {}) as Record< + string, + string | undefined + > + + let configToUse: ProviderSettings = apiConfiguration + + if (commitMessageApiConfigId && listApiConfigMeta.find(({ id }) => id === commitMessageApiConfigId)) { + try { + await this.providerSettingsManager.initialize() + const { name: _, ...providerSettings } = await this.providerSettingsManager.getProfile({ + id: commitMessageApiConfigId, + }) + + if (providerSettings.apiProvider) { + configToUse = providerSettings + } + } catch (error) { + this.dependencies.logger.warn( + `Failed to load commit message API profile ${commitMessageApiConfigId}; falling back to current API configuration`, + error, + ) + } + } + + const filteredPrompts = Object.fromEntries( + Object.entries(customSupportPrompts).filter(([_, value]) => value !== undefined), + ) as Record + + const prompt = await this.buildPrompt( + gitContextString, + { customSupportPrompts: filteredPrompts }, + workspacePath, + ) + + onProgress?.({ + message: "Calling AI service...", + increment: 10, + }) + + const response = await this.dependencies.completePrompt(configToUse, prompt) + + onProgress?.({ + message: "Processing AI response...", + increment: 10, + }) + + return this.extractCommitMessage(response) + } + + private extractCommitMessage(response: string): string { + const cleaned = response.trim() + const withoutCodeBlocks = cleaned.replace(/```[a-z]*\n|```/g, "") + const withoutQuotes = withoutCodeBlocks.replace(/^["'`]|["'`]$/g, "") + return withoutQuotes.trim() + } +} diff --git a/src/services/commit-message/__tests__/CommitMessageGeneration.integration.spec.ts b/src/services/commit-message/__tests__/CommitMessageGeneration.integration.spec.ts new file mode 100644 index 0000000000..ccdb84ce38 --- /dev/null +++ b/src/services/commit-message/__tests__/CommitMessageGeneration.integration.spec.ts @@ -0,0 +1,80 @@ +import * as os from "os" +import * as path from "path" +import { execFile } from "child_process" +import { promisify } from "util" +import { promises as fs } from "fs" +import type { ProviderSettings } from "@roo-code/types" + +import { GitContextCollector } from "../../git-context" +import { CommitMessageGenerator } from "../CommitMessageGenerator" + +const execFileAsync = promisify(execFile) + +async function runGit(cwd: string, args: string[]) { + await execFileAsync("git", args, { cwd }) +} + +describe("commit message generation flow", () => { + const defaultConfig: ProviderSettings = { apiProvider: "openai", openAiApiKey: "default-key" } + const providerSettingsManager = { + initialize: vi.fn(), + getProfile: vi.fn(), + } + const contextProxy = { + isInitialized: true, + getProviderSettings: vi.fn(() => defaultConfig), + getValue: vi.fn((key: string) => { + switch (key) { + case "listApiConfigMeta": + return [] + case "customSupportPrompts": + return {} + default: + return undefined + } + }), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("passes collected git context with untracked file diff to the LLM", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-commit-generation-")) + try { + await runGit(tempRoot, ["init"]) + const filePath = path.join(tempRoot, "src", "new.ts") + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, "export const value = 1\n") + + const gitContext = await new GitContextCollector(tempRoot).collect({ + staged: false, + includeBranch: false, + recentCommits: { include: false }, + }) + const completePrompt = vi.fn().mockResolvedValue("feat(src): add new module") + const generator = new CommitMessageGenerator(providerSettingsManager as any, { + getContextProxy: () => contextProxy, + completePrompt, + addCustomInstructions: vi.fn().mockResolvedValue(""), + captureGenerated: vi.fn(), + }) + + const message = await generator.generateMessage({ + workspacePath: tempRoot, + selectedFiles: gitContext.changes.map((change) => change.filePath), + gitContext: gitContext.context, + }) + + expect(message).toBe("feat(src): add new module") + expect(gitContext.context).toContain("diff --git a/src/new.ts b/src/new.ts") + expect(gitContext.context).toContain("+export const value = 1") + expect(completePrompt).toHaveBeenCalledWith( + defaultConfig, + expect.stringContaining("+export const value = 1"), + ) + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }) + } + }) +}) diff --git a/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts b/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts new file mode 100644 index 0000000000..299d4d6764 --- /dev/null +++ b/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts @@ -0,0 +1,156 @@ +import type { ProviderSettings } from "@roo-code/types" + +import { CommitMessageGenerator } from "../CommitMessageGenerator" + +describe("CommitMessageGenerator", () => { + const defaultConfig: ProviderSettings = { apiProvider: "openai", openAiApiKey: "default-key" } + const commitConfig: ProviderSettings = { apiProvider: "anthropic", apiKey: "commit-key" } + const providerSettingsManager = { + initialize: vi.fn(), + getProfile: vi.fn(), + } + const contextProxy = { + isInitialized: true, + getProviderSettings: vi.fn(), + getValue: vi.fn(), + } + const completePrompt = vi.fn() + const addCustomInstructions = vi.fn() + const captureGenerated = vi.fn() + const warn = vi.fn() + + const createGenerator = () => + new CommitMessageGenerator(providerSettingsManager as any, { + getContextProxy: () => contextProxy, + completePrompt, + addCustomInstructions: addCustomInstructions as any, + captureGenerated, + logger: { warn }, + }) + + beforeEach(() => { + vi.clearAllMocks() + contextProxy.isInitialized = true + contextProxy.getProviderSettings.mockReturnValue(defaultConfig) + contextProxy.getValue.mockImplementation((key: string) => { + switch (key) { + case "commitMessageApiConfigId": + return undefined + case "listApiConfigMeta": + return [] + case "customSupportPrompts": + return {} + default: + return undefined + } + }) + addCustomInstructions.mockResolvedValue("Follow repo commit rules.") + completePrompt.mockResolvedValue("```\nfeat(core): add commit generator\n```") + providerSettingsManager.initialize.mockResolvedValue(undefined) + providerSettingsManager.getProfile.mockResolvedValue({ name: "Commit profile", ...commitConfig }) + }) + + it("sends the full git context to the LLM and returns cleaned commit text", async () => { + const gitContext = `## Git Context + +### Full Diff of Staged Changes +\`\`\`diff +diff --git a/src/new.ts b/src/new.ts +new file mode 100644 +--- /dev/null ++++ b/src/new.ts +@@ -0,0 +1,1 @@ ++export const value = 1 +\`\`\`` + const generator = createGenerator() + + const message = await generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: ["src/new.ts"], + gitContext, + }) + + expect(message).toBe("feat(core): add commit generator") + expect(completePrompt).toHaveBeenCalledTimes(1) + const [config, prompt] = completePrompt.mock.calls[0] + expect(config).toBe(defaultConfig) + expect(prompt).toContain("# Conventional Commit Message Generator") + expect(prompt).toContain("Follow repo commit rules.") + expect(prompt).toContain(gitContext) + expect(captureGenerated).toHaveBeenCalledTimes(1) + }) + + it("uses the selected commit-message API profile when configured", async () => { + contextProxy.getValue.mockImplementation((key: string) => { + switch (key) { + case "commitMessageApiConfigId": + return "commit-profile" + case "listApiConfigMeta": + return [{ id: "commit-profile", name: "Commit profile" }] + case "customSupportPrompts": + return {} + default: + return undefined + } + }) + completePrompt.mockResolvedValue("fix(git): include untracked file diffs") + const generator = createGenerator() + + await generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: ["src/new.ts"], + gitContext: "diff --git a/src/new.ts b/src/new.ts", + }) + + expect(providerSettingsManager.initialize).toHaveBeenCalledTimes(1) + expect(providerSettingsManager.getProfile).toHaveBeenCalledWith({ id: "commit-profile" }) + expect(completePrompt).toHaveBeenCalledWith( + expect.objectContaining(commitConfig), + expect.stringContaining("diff --git a/src/new.ts b/src/new.ts"), + ) + }) + + it("falls back to current API config when the selected profile cannot be loaded", async () => { + contextProxy.getValue.mockImplementation((key: string) => { + switch (key) { + case "commitMessageApiConfigId": + return "deleted-profile" + case "listApiConfigMeta": + return [{ id: "deleted-profile", name: "Deleted profile" }] + case "customSupportPrompts": + return {} + default: + return undefined + } + }) + providerSettingsManager.getProfile.mockRejectedValue(new Error("missing profile")) + const generator = createGenerator() + + await generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: ["src/new.ts"], + gitContext: "diff --git a/src/new.ts b/src/new.ts", + }) + + expect(completePrompt).toHaveBeenCalledWith(defaultConfig, expect.any(String)) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("Failed to load commit message API profile deleted-profile"), + expect.any(Error), + ) + }) + + it("asks for a different message when regenerating for the same git context", async () => { + completePrompt.mockResolvedValueOnce("feat(git): collect git context") + completePrompt.mockResolvedValueOnce("chore(git): improve diff handling") + const generator = createGenerator() + const gitContext = "diff --git a/src/file.ts b/src/file.ts" + + await generator.generateMessage({ workspacePath: "/repo", selectedFiles: ["src/file.ts"], gitContext }) + await generator.generateMessage({ workspacePath: "/repo", selectedFiles: ["src/file.ts"], gitContext }) + + const secondPrompt = completePrompt.mock.calls[1][1] + expect(secondPrompt).toContain("GENERATE A COMPLETELY DIFFERENT COMMIT MESSAGE") + expect(secondPrompt).toContain('The previous message was: "feat(git): collect git context"') + expect(secondPrompt).toContain(gitContext) + }) +}) diff --git a/src/services/commit-message/types/core.ts b/src/services/commit-message/types/core.ts new file mode 100644 index 0000000000..3bfed717a7 --- /dev/null +++ b/src/services/commit-message/types/core.ts @@ -0,0 +1,18 @@ +export interface GenerateMessageParams { + workspacePath: string + selectedFiles: string[] + gitContext: string + onProgress?: (progress: ProgressUpdate) => void +} + +export interface PromptOptions { + customSupportPrompts?: Record + previousContext?: string + previousMessage?: string +} + +export interface ProgressUpdate { + message?: string + percentage?: number + increment?: number +} diff --git a/src/shared/support-prompt.ts b/src/shared/support-prompt.ts index da14c4367f..3f3c27257d 100644 --- a/src/shared/support-prompt.ts +++ b/src/shared/support-prompt.ts @@ -44,6 +44,7 @@ type SupportPromptType = | "TERMINAL_FIX" | "TERMINAL_EXPLAIN" | "NEW_TASK" + | "COMMIT_MESSAGE" const supportPromptConfigs: Record = { ENHANCE: { @@ -240,6 +241,79 @@ Please provide: NEW_TASK: { template: `\${userInput}`, }, + COMMIT_MESSAGE: { + template: `# Conventional Commit Message Generator +## System Instructions +You are an expert Git commit message generator that creates conventional commit messages based on provided Git changes. Analyze the provided git diff output and generate appropriate conventional commit messages following the specification. + +\${customInstructions} + +## CRITICAL: Commit Message Output Rules +- DO NOT include any internal status indicators or bracketed metadata (e.g. "[Status: Active]", "[Context: Missing]") +- DO NOT include any task-specific formatting or artifacts from other rules +- ONLY Generate a clean conventional commit message as specified below + +\${gitContext} + +## Conventional Commits Format +Generate commit messages following this exact structure: +\`\`\` +[optional scope]: +[optional body] +[optional footer(s)] +\`\`\` + +### Core Types (Required) +- **feat**: New feature or functionality (MINOR version bump) +- **fix**: Bug fix or error correction (PATCH version bump) + +### Additional Types (Extended) +- **docs**: Documentation changes only +- **style**: Code style changes (whitespace, formatting, semicolons, etc.) +- **refactor**: Code refactoring without feature changes or bug fixes +- **perf**: Performance improvements +- **test**: Adding or fixing tests +- **build**: Build system or external dependency changes +- **ci**: CI/CD configuration changes +- **chore**: Maintenance tasks, tooling changes +- **revert**: Reverting previous commits + +### Scope Guidelines +- Use parentheses: \`feat(api):\`, \`fix(ui):\` +- Common scopes: \`api\`, \`ui\`, \`auth\`, \`db\`, \`config\`, \`deps\`, \`docs\` +- For monorepos: package or module names +- Keep scope concise and lowercase + +### Description Rules +- Use imperative mood ("add" not "added" or "adds") +- Start with lowercase letter +- No period at the end +- Maximum 50 characters +- Be concise but descriptive + +### Body Guidelines (Optional) +- Start one blank line after description +- Explain the "what" and "why", not the "how" +- Wrap at 72 characters per line +- Use for complex changes requiring explanation + +### Footer Guidelines (Optional) +- Start one blank line after body +- **Breaking Changes**: \`BREAKING CHANGE: description\` + +## Analysis Instructions +When analyzing provided Git changes: +1. Determine Primary Type based on the nature of changes +2. Identify Scope from modified directories or modules +3. Craft Description focusing on the most significant change +4. Determine if there are Breaking Changes +5. For complex changes, include a detailed body explaining what and why +6. Add appropriate footers for issue references or breaking changes + +For significant changes, include a detailed body explaining the changes. + +Return ONLY the commit message in the conventional format, nothing else.`, + }, } as const export const supportPrompt = { From 48a5bad723ddf8a4cd73d36b64af1f130673f08f Mon Sep 17 00:00:00 2001 From: Mirrowel <28632877+Mirrowel@users.noreply.github.com> Date: Sun, 24 May 2026 17:41:50 +0200 Subject: [PATCH 5/9] =?UTF-8?q?fix(scm):=20=F0=9F=90=9B=20address=20commit?= =?UTF-8?q?=20message=20generator=20review=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../commit-message/CommitMessageGenerator.ts | 44 ++++++++++++++++- .../__tests__/CommitMessageGenerator.spec.ts | 47 +++++++++++++++++++ src/services/commit-message/types/core.ts | 13 +++++ 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/services/commit-message/CommitMessageGenerator.ts b/src/services/commit-message/CommitMessageGenerator.ts index ad0efd813c..d6252b98f8 100644 --- a/src/services/commit-message/CommitMessageGenerator.ts +++ b/src/services/commit-message/CommitMessageGenerator.ts @@ -8,26 +8,38 @@ import { TelemetryEventName, type ProviderSettings } from "@roo-code/types" import { GenerateMessageParams, PromptOptions, ProgressUpdate } from "./types/core" +/** Provides the extension settings needed to generate commit messages. */ export interface CommitMessageContextProxy { + /** Whether the underlying extension configuration is ready to read. */ isInitialized: boolean + /** Returns the active provider settings used as the default generation profile. */ getProviderSettings(): ProviderSettings + /** Reads a persisted extension setting by key. */ getValue(key: any): unknown } +/** Overrides used to isolate commit message generation in tests and integrations. */ export interface CommitMessageGeneratorDependencies { + /** Supplies the context proxy that owns provider settings and user configuration. */ getContextProxy?: () => CommitMessageContextProxy + /** Completes the prepared commit-message prompt with the selected provider. */ completePrompt?: (apiConfiguration: ProviderSettings, promptText: string) => Promise + /** Adds repository-specific custom instructions to the commit-message prompt. */ addCustomInstructions?: typeof defaultAddCustomInstructions + /** Records successful commit-message generation telemetry. */ captureGenerated?: () => void + /** Receives non-fatal generation warnings, such as profile fallback failures. */ logger?: Pick } +/** Builds prompts, selects provider settings, and extracts AI generated commit messages. */ export class CommitMessageGenerator { private readonly providerSettingsManager: ProviderSettingsManager private readonly dependencies: Required private previousGitContext: string | null = null private previousCommitMessage: string | null = null + /** Creates a generator using the provider settings manager and optional test seams. */ constructor( providerSettingsManager: ProviderSettingsManager, dependencies: CommitMessageGeneratorDependencies = {}, @@ -44,10 +56,13 @@ export class CommitMessageGenerator { } } + /** Generates a commit message for the supplied Git context. */ async generateMessage(params: GenerateMessageParams): Promise { const { gitContext, onProgress } = params try { + this.validateGitContext(gitContext) + onProgress?.({ message: "Generating commit message...", percentage: 75, @@ -72,6 +87,7 @@ export class CommitMessageGenerator { } } + /** Creates the final model prompt, including custom and regeneration instructions. */ async buildPrompt(gitContext: string, options: PromptOptions, workspacePath: string): Promise { const { customSupportPrompts = {}, previousContext, previousMessage } = options @@ -128,6 +144,7 @@ FINAL REMINDER: Your message MUST be COMPLETELY DIFFERENT from the previous mess } } + /** Calls the configured AI provider and returns the cleaned commit message text. */ private async callAIForCommitMessage( gitContextString: string, workspacePath: string, @@ -190,9 +207,34 @@ FINAL REMINDER: Your message MUST be COMPLETELY DIFFERENT from the previous mess return this.extractCommitMessage(response) } + /** Throws when there is no meaningful Git change data to describe. */ + private validateGitContext(gitContext: string): void { + if (!this.hasGitChanges(gitContext)) { + throw new Error("No changes to generate a commit message for") + } + } + + /** Detects whether collected Git context includes at least one changed file. */ + private hasGitChanges(gitContext: string): boolean { + const normalizedContext = gitContext.trim() + + if (!normalizedContext || normalizedContext.includes("(No changes matched selection)")) { + return false + } + + return ( + /^diff --git /m.test(normalizedContext) || + /^Binary file /m.test(normalizedContext) || + /^(Added|Modified|Deleted|Renamed|Copied|Updated|Untracked|Unknown) \((staged|unstaged)\): .+$/m.test( + normalizedContext, + ) + ) + } + + /** Cleans formatting wrappers from an AI response without enforcing message style. */ private extractCommitMessage(response: string): string { const cleaned = response.trim() - const withoutCodeBlocks = cleaned.replace(/```[a-z]*\n|```/g, "") + const withoutCodeBlocks = cleaned.replace(/^```[a-zA-Z0-9_-]*\r?\n/, "").replace(/\r?\n```$/, "") const withoutQuotes = withoutCodeBlocks.replace(/^["'`]|["'`]$/g, "") return withoutQuotes.trim() } diff --git a/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts b/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts index 299d4d6764..55424c5e9d 100644 --- a/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts +++ b/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts @@ -19,6 +19,7 @@ describe("CommitMessageGenerator", () => { const captureGenerated = vi.fn() const warn = vi.fn() + /** Creates a generator with mocked provider and configuration dependencies. */ const createGenerator = () => new CommitMessageGenerator(providerSettingsManager as any, { getContextProxy: () => contextProxy, @@ -50,6 +51,33 @@ describe("CommitMessageGenerator", () => { providerSettingsManager.getProfile.mockResolvedValue({ name: "Commit profile", ...commitConfig }) }) + it("fails before progress or AI calls when git context has no changes", async () => { + const onProgress = vi.fn() + const generator = createGenerator() + + await expect( + generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: [], + gitContext: `## Git Context + +### Full Diff of Staged Changes +\`\`\`diff +\`\`\` + +### Change Summary +\`\`\` +(No changes matched selection) +\`\`\``, + onProgress, + }), + ).rejects.toThrow("No changes to generate a commit message for") + + expect(onProgress).not.toHaveBeenCalled() + expect(completePrompt).not.toHaveBeenCalled() + expect(captureGenerated).not.toHaveBeenCalled() + }) + it("sends the full git context to the LLM and returns cleaned commit text", async () => { const gitContext = `## Git Context @@ -153,4 +181,23 @@ new file mode 100644 expect(secondPrompt).toContain('The previous message was: "feat(git): collect git context"') expect(secondPrompt).toContain(gitContext) }) + + it("cleans formatting wrappers without enforcing conventional commit format", async () => { + completePrompt.mockResolvedValue(`\`\`\` +Update Git context parsing for staged-only entries + +Keep unstaged commit context focused on worktree changes. +\`\`\``) + const generator = createGenerator() + + const message = await generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: ["src/file.ts"], + gitContext: "Modified (staged): src/file.ts", + }) + + expect(message).toBe(`Update Git context parsing for staged-only entries + +Keep unstaged commit context focused on worktree changes.`) + }) }) diff --git a/src/services/commit-message/types/core.ts b/src/services/commit-message/types/core.ts index 3bfed717a7..9926fdb576 100644 --- a/src/services/commit-message/types/core.ts +++ b/src/services/commit-message/types/core.ts @@ -1,18 +1,31 @@ +/** Parameters required to generate a commit message for selected Git changes. */ export interface GenerateMessageParams { + /** Absolute workspace path used to resolve repository custom instructions. */ workspacePath: string + /** File paths included in the Git context used for generation. */ selectedFiles: string[] + /** Markdown Git context describing the changes to summarize. */ gitContext: string + /** Optional progress callback for UI updates during generation. */ onProgress?: (progress: ProgressUpdate) => void } +/** Prompt customization and regeneration context for commit-message prompts. */ export interface PromptOptions { + /** User-defined support prompt templates keyed by prompt type. */ customSupportPrompts?: Record + /** Previous Git context used to detect regeneration for the same changes. */ previousContext?: string + /** Previous generated message to avoid repeating during regeneration. */ previousMessage?: string } +/** Incremental status update emitted while generating a commit message. */ export interface ProgressUpdate { + /** Human-readable status message for the current generation step. */ message?: string + /** Absolute progress percentage for the current generation step. */ percentage?: number + /** Relative progress increment for the current generation step. */ increment?: number } From 0871a1c7d2a56daa433a998f525fddde7b8ea14d Mon Sep 17 00:00:00 2001 From: Mirrowel <28632877+Mirrowel@users.noreply.github.com> Date: Sun, 24 May 2026 16:01:43 +0200 Subject: [PATCH 6/9] feat(scm): integrate commit generation with source control --- packages/build/src/types.ts | 2 +- packages/types/src/vscode-extension-host.ts | 2 + src/core/webview/ClineProvider.ts | 6 + src/extension.ts | 9 + src/i18n/locales/en/common.json | 51 +++++ src/package.json | 21 ++ src/package.nls.json | 1 + .../commit-message/CommitMessageProvider.ts | 183 ++++++++++++++++++ .../__tests__/CommitMessageProvider.spec.ts | 16 ++ .../commit-message/gitContextSettings.ts | 69 +++++++ src/services/commit-message/index.ts | 18 ++ 11 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 src/services/commit-message/CommitMessageProvider.ts create mode 100644 src/services/commit-message/__tests__/CommitMessageProvider.spec.ts create mode 100644 src/services/commit-message/gitContextSettings.ts create mode 100644 src/services/commit-message/index.ts diff --git a/packages/build/src/types.ts b/packages/build/src/types.ts index 18db4f2e7c..89f95c0a75 100644 --- a/packages/build/src/types.ts +++ b/packages/build/src/types.ts @@ -31,7 +31,7 @@ const commandsSchema = z.array( command: z.string(), title: z.string(), category: z.string().optional(), - icon: z.string().optional(), + icon: z.union([z.string(), z.object({ light: z.string(), dark: z.string() })]).optional(), }), ) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index c09f22aed7..8e7b4afd94 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -283,6 +283,8 @@ export type ExtensionState = Pick< | "customModePrompts" | "customSupportPrompts" | "enhancementApiConfigId" + | "commitMessageApiConfigId" + | "commitMessageGitContext" | "customCondensingPrompt" | "codebaseIndexConfig" | "codebaseIndexModels" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 3f5af94cae..db16a18673 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2057,6 +2057,8 @@ export class ClineProvider customModePrompts, customSupportPrompts, enhancementApiConfigId, + commitMessageApiConfigId, + commitMessageGitContext, autoApprovalEnabled, customModes, experiments, @@ -2209,6 +2211,8 @@ export class ClineProvider customModePrompts: customModePrompts ?? {}, customSupportPrompts: customSupportPrompts ?? {}, enhancementApiConfigId, + commitMessageApiConfigId, + commitMessageGitContext, autoApprovalEnabled: autoApprovalEnabled ?? false, customModes, experiments: experiments ?? experimentDefault, @@ -2415,6 +2419,8 @@ export class ClineProvider customModePrompts: stateValues.customModePrompts ?? {}, customSupportPrompts: stateValues.customSupportPrompts ?? {}, enhancementApiConfigId: stateValues.enhancementApiConfigId, + commitMessageApiConfigId: stateValues.commitMessageApiConfigId, + commitMessageGitContext: stateValues.commitMessageGitContext, experiments: stateValues.experiments ?? experimentDefault, autoApprovalEnabled: stateValues.autoApprovalEnabled ?? false, customModes, diff --git a/src/extension.ts b/src/extension.ts index 44c1243528..58ee685144 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -50,6 +50,7 @@ import { import { initializeI18n } from "./i18n" import { initializeModelCacheRefresh } from "./api/providers/fetchers/modelCache" import { initZooCodeAuth } from "./services/zoo-code-auth" +import { registerCommitMessageProvider } from "./services/commit-message" /** * Built using https://github.com/microsoft/vscode-webview-ui-toolkit @@ -256,6 +257,14 @@ export async function activate(context: vscode.ExtensionContext) { registerCommands({ context, outputChannel, provider }) + try { + registerCommitMessageProvider(context, outputChannel) + } catch (error) { + outputChannel.appendLine( + `Failed to register commit message provider: ${error instanceof Error ? error.message : String(error)}`, + ) + } + /** * We use the text document content provider API to show the left side for diff * view by creating a virtual document for the original content. This makes it diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 157a87c5dc..de4f767ce7 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -259,5 +259,56 @@ "connected": "Zoo Code: Successfully connected! You can now use Zoo Code as your AI provider.", "disconnected": "Zoo Code: Disconnected successfully." } + }, + "commitMessage": { + "activated": "Zoo Code commit message generator activated", + "gitNotFound": "⚠️ Git repository not found or git not available", + "gitInitError": "⚠️ Git initialization error: {{error}}", + "generating": "Zoo: Generating commit message...", + "noChanges": "Zoo: No changes found to analyze", + "generated": "Zoo: Commit message generated!", + "generationFailed": "Zoo: Failed to generate commit message: {{errorMessage}}", + "contextWarnings": "Zoo: Git context warning: {{warnings}}", + "generatingFromUnstaged": "Zoo: Generating message using unstaged changes", + "activationFailed": "Zoo: Failed to activate message generator: {{error}}", + "providerRegistered": "Zoo: Commit message provider registered", + "initializing": "Initializing...", + "discoveringFiles": "Discovering files...", + "foundChanges": "Found {{count}} changes", + "gettingContext": "Getting git context...", + "errors": { + "connectionFailed": "Failed to connect to Zoo Code extension", + "timeout": "Request timed out after 30 seconds", + "invalidResponse": "Invalid response format received from extension", + "missingMessage": "No commit message received from extension", + "noChanges": "No changes found to commit", + "noProject": "No project available", + "noWorkspacePath": "Could not determine workspace path for Git repository", + "workspaceNotFound": "Could not determine workspace path for Git repository", + "processingError": "Error processing commit message generation: {{error}}" + }, + "error": { + "title": "Error", + "workspacePathNotFound": "Could not determine workspace path for Git repository", + "generationFailed": "Failed to generate commit message: {{error}}", + "processingFailed": "Error processing commit message generation: {{error}}", + "unknown": "Unknown error" + }, + "dialogs": { + "info": "AI Commit Message", + "error": "Error", + "success": "Success", + "title": "AI Commit Message" + }, + "progress": { + "title": "Generating Commit Message", + "analyzing": "Analyzing changes...", + "connecting": "Connecting to Zoo Code...", + "generating": "Generating commit message..." + }, + "ui": { + "generateButton": "Generate Commit Message", + "generateButtonTooltip": "Generates commit message using AI to analyze your code changes" + } } } diff --git a/src/package.json b/src/package.json index f62f421d43..e7e000c3e0 100644 --- a/src/package.json +++ b/src/package.json @@ -164,6 +164,14 @@ "command": "zoo-code.toggleAutoApprove", "title": "%command.toggleAutoApprove.title%", "category": "%configuration.title%" + }, + { + "command": "zoo-code.generateCommitMessage", + "title": "%command.generateCommitMessage.title%", + "icon": { + "light": "assets/icons/panel_light.png", + "dark": "assets/icons/panel_dark.png" + } } ], "menus": { @@ -207,6 +215,19 @@ "group": "1_actions@3" } ], + "scm/input": [ + { + "command": "zoo-code.generateCommitMessage", + "group": "navigation" + } + ], + "scm/title": [ + { + "command": "zoo-code.generateCommitMessage", + "when": "scmProvider == git", + "group": "navigation" + } + ], "view/title": [ { "command": "zoo-code.plusButtonClicked", diff --git a/src/package.nls.json b/src/package.nls.json index 23c9b02d92..7aa9485f8d 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -24,6 +24,7 @@ "command.terminal.explainCommand.title": "Explain This Command", "command.acceptInput.title": "Accept Input/Suggestion", "command.toggleAutoApprove.title": "Toggle Auto-Approve", + "command.generateCommitMessage.title": "Generate Commit Message with Zoo", "configuration.title": "Zoo Code", "commands.allowedCommands.description": "Commands that can be auto-executed when 'Always approve execute operations' is enabled", "commands.deniedCommands.description": "Command prefixes that will be automatically denied without asking for approval. In case of conflicts with allowed commands, the longest prefix match takes precedence. Add * to deny all commands.", diff --git a/src/services/commit-message/CommitMessageProvider.ts b/src/services/commit-message/CommitMessageProvider.ts new file mode 100644 index 0000000000..0fe980491c --- /dev/null +++ b/src/services/commit-message/CommitMessageProvider.ts @@ -0,0 +1,183 @@ +import * as vscode from "vscode" +import { ProviderSettingsManager } from "../../core/config/ProviderSettingsManager" +import { t } from "../../i18n" +import { Package } from "../../shared/package" +import { GitChange, GitContextCollector } from "../git-context" + +import { CommitMessageGenerator } from "./CommitMessageGenerator" +import { getCommitMessageGitContextSettings, toGitContextCollectorOptions } from "./gitContextSettings" + +interface VscGenerationRequest { + inputBox: { value: string } + rootUri?: vscode.Uri +} + +export class CommitMessageProvider implements vscode.Disposable { + private generator: CommitMessageGenerator + + constructor( + private context: vscode.ExtensionContext, + private outputChannel: vscode.OutputChannel, + ) { + const providerSettingsManager = new ProviderSettingsManager(this.context) + + this.generator = new CommitMessageGenerator(providerSettingsManager) + } + + public async activate(): Promise { + this.outputChannel.appendLine(t("common:commitMessage.activated")) + + const disposables = [ + vscode.commands.registerCommand( + `${Package.name}.generateCommitMessage`, + (vsRequest?: VscGenerationRequest) => this.handleVSCodeCommand(vsRequest), + ), + ] + this.context.subscriptions.push(...disposables) + } + + private async handleVSCodeCommand(vsRequest?: VscGenerationRequest): Promise { + try { + const workspacePath = this.determineWorkspacePath(vsRequest?.rootUri) + const targetRepository = await this.determineTargetRepository(workspacePath) + if (!targetRepository?.rootUri) { + throw new Error("Could not determine Git repository") + } + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.SourceControl, + title: t("common:commitMessage.generating"), + cancellable: false, + }, + async (progress) => { + let lastPercentage = 0 + const reportProgress = (percentage: number, message?: string) => { + progress.report({ + increment: Math.max(0, percentage - lastPercentage), + message: message || t("common:commitMessage.generating"), + }) + lastPercentage = percentage + } + + reportProgress(5, t("common:commitMessage.initializing")) + const gitCollector = new GitContextCollector(workspacePath) + + try { + reportProgress(15, t("common:commitMessage.discoveringFiles")) + const resolution = await this.resolveCommitChanges(gitCollector) + const gitContextSettings = getCommitMessageGitContextSettings() + + if (resolution.changes.length === 0) { + vscode.window.showInformationMessage(t("common:commitMessage.noChanges")) + return + } + + reportProgress(25, t("common:commitMessage.foundChanges", { count: resolution.changes.length })) + + if (!resolution.usedStaged) { + vscode.window.showInformationMessage(t("common:commitMessage.generatingFromUnstaged")) + } + + reportProgress(40, t("common:commitMessage.gettingContext")) + const gitContextResult = await gitCollector.collectContext( + resolution.changes, + toGitContextCollectorOptions(resolution.usedStaged, gitContextSettings), + resolution.files, + ) + if (gitContextResult.warnings.length > 0) { + vscode.window.showWarningMessage( + t("common:commitMessage.contextWarnings", { + warnings: gitContextResult.warnings.join("; "), + }), + ) + } + + reportProgress(70, t("common:commitMessage.generating")) + const message = await this.generator.generateMessage({ + workspacePath, + selectedFiles: resolution.files, + gitContext: gitContextResult.context, + onProgress: (update) => { + if (update.percentage !== undefined) { + reportProgress(70 + update.percentage * 0.25, update.message) + } + }, + }) + + targetRepository.inputBox.value = message + reportProgress(100, t("common:commitMessage.generated")) + } finally { + gitCollector.dispose() + } + }, + ) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred" + vscode.window.showErrorMessage(t("common:commitMessage.generationFailed", { errorMessage })) + } + } + + private async resolveCommitChanges(gitCollector: GitContextCollector): Promise<{ + changes: GitChange[] + files: string[] + usedStaged: boolean + }> { + let changes = await gitCollector.gatherChanges({ staged: true }) + let usedStaged = true + + if (changes.length === 0) { + changes = await gitCollector.gatherChanges({ staged: false }) + usedStaged = false + } + + return { + changes, + files: changes.map((change) => change.filePath), + usedStaged, + } + } + + private async determineTargetRepository(workspacePath: string): Promise { + try { + const gitExtension = vscode.extensions.getExtension("vscode.git") + if (!gitExtension) { + return null + } + + if (!gitExtension.isActive) { + await gitExtension.activate() + } + + const gitApi = gitExtension.exports.getAPI(1) + if (!gitApi) { + return null + } + + for (const repo of gitApi.repositories ?? []) { + if (repo.rootUri && workspacePath.startsWith(repo.rootUri.fsPath)) { + return repo + } + } + + return gitApi.repositories[0] ?? null + } catch (error) { + return null + } + } + + private determineWorkspacePath(resourceUri?: vscode.Uri): string { + if (resourceUri) { + return resourceUri.fsPath + } + + const workspaceFolders = vscode.workspace.workspaceFolders + if (workspaceFolders && workspaceFolders.length > 0) { + return workspaceFolders[0].uri.fsPath + } + + throw new Error("Could not determine workspace path") + } + + public dispose(): void {} +} diff --git a/src/services/commit-message/__tests__/CommitMessageProvider.spec.ts b/src/services/commit-message/__tests__/CommitMessageProvider.spec.ts new file mode 100644 index 0000000000..58ea039d59 --- /dev/null +++ b/src/services/commit-message/__tests__/CommitMessageProvider.spec.ts @@ -0,0 +1,16 @@ +import * as path from "path" + +import { isPathWithinRepository } from "../CommitMessageProvider" + +vi.mock("vscode", () => ({})) + +describe("CommitMessageProvider", () => { + it("matches repository roots by path containment instead of string prefix", () => { + const root = path.parse(process.cwd()).root + const repositoryPath = path.join(root, "work", "app") + + expect(isPathWithinRepository(path.join(repositoryPath, "src", "index.ts"), repositoryPath)).toBe(true) + expect(isPathWithinRepository(repositoryPath, repositoryPath)).toBe(true) + expect(isPathWithinRepository(path.join(root, "work", "application"), repositoryPath)).toBe(false) + }) +}) diff --git a/src/services/commit-message/gitContextSettings.ts b/src/services/commit-message/gitContextSettings.ts new file mode 100644 index 0000000000..5927ae4e66 --- /dev/null +++ b/src/services/commit-message/gitContextSettings.ts @@ -0,0 +1,69 @@ +import { defaultCommitMessageGitContextSettings, type CommitMessageGitContextSettings } from "@roo-code/types" + +import { ContextProxy } from "../../core/config/ContextProxy" +import type { GitContextCollectorOptions } from "../git-context" + +export function getCommitMessageGitContextSettings(): Required { + const rawSettings = ContextProxy.instance.getValue("commitMessageGitContext") as + | CommitMessageGitContextSettings + | undefined + + return normalizeCommitMessageGitContextSettings(rawSettings) +} + +export function normalizeCommitMessageGitContextSettings( + settings?: CommitMessageGitContextSettings, +): Required { + return { + ...defaultCommitMessageGitContextSettings, + ...settings, + diffContextLines: clamp( + settings?.diffContextLines, + 0, + 20, + defaultCommitMessageGitContextSettings.diffContextLines, + ), + recentCommitCount: clamp( + settings?.recentCommitCount, + 1, + 20, + defaultCommitMessageGitContextSettings.recentCommitCount, + ), + recentCommitDiffCount: clamp( + settings?.recentCommitDiffCount, + 1, + 5, + defaultCommitMessageGitContextSettings.recentCommitDiffCount, + ), + } +} + +export function toGitContextCollectorOptions( + staged: boolean, + settings: Required, +): GitContextCollectorOptions { + return { + staged, + diff: { + contextLines: settings.diffContextLines, + includeStats: settings.includeDiffStats, + }, + includeBranch: settings.includeCurrentBranch, + recentCommits: { + include: settings.includeRecentCommits, + count: settings.recentCommitCount, + includeBodies: settings.includeRecentCommitBodies, + includeStats: settings.includeRecentCommitStats, + includeDiffs: settings.includeRecentCommitDiffs, + diffCount: settings.recentCommitDiffCount, + }, + } +} + +function clamp(value: number | undefined, min: number, max: number, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return fallback + } + + return Math.min(Math.max(Math.trunc(value), min), max) +} diff --git a/src/services/commit-message/index.ts b/src/services/commit-message/index.ts new file mode 100644 index 0000000000..095596386e --- /dev/null +++ b/src/services/commit-message/index.ts @@ -0,0 +1,18 @@ +import * as vscode from "vscode" +import { CommitMessageProvider } from "./CommitMessageProvider" +import { t } from "../../i18n" + +export function registerCommitMessageProvider( + context: vscode.ExtensionContext, + outputChannel: vscode.OutputChannel, +): void { + const commitProvider = new CommitMessageProvider(context, outputChannel) + context.subscriptions.push(commitProvider) + + commitProvider.activate().catch((error) => { + outputChannel.appendLine(t("common:commitMessage.activationFailed", { error: error.message })) + console.error("Commit message provider activation failed:", error) + }) + + outputChannel.appendLine(t("common:commitMessage.providerRegistered")) +} From 12c90ebf282e590967dbc01a44583e4b9452700c Mon Sep 17 00:00:00 2001 From: Mirrowel <28632877+Mirrowel@users.noreply.github.com> Date: Sun, 24 May 2026 18:21:35 +0200 Subject: [PATCH 7/9] =?UTF-8?q?fix(scm):=20=F0=9F=90=9B=20reject=20empty?= =?UTF-8?q?=20generated=20commit=20messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../commit-message/CommitMessageGenerator.ts | 8 +++++++- .../__tests__/CommitMessageGenerator.spec.ts | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/services/commit-message/CommitMessageGenerator.ts b/src/services/commit-message/CommitMessageGenerator.ts index d6252b98f8..bd293f8a36 100644 --- a/src/services/commit-message/CommitMessageGenerator.ts +++ b/src/services/commit-message/CommitMessageGenerator.ts @@ -236,6 +236,12 @@ FINAL REMINDER: Your message MUST be COMPLETELY DIFFERENT from the previous mess const cleaned = response.trim() const withoutCodeBlocks = cleaned.replace(/^```[a-zA-Z0-9_-]*\r?\n/, "").replace(/\r?\n```$/, "") const withoutQuotes = withoutCodeBlocks.replace(/^["'`]|["'`]$/g, "") - return withoutQuotes.trim() + const normalized = withoutQuotes.trim() + + if (!normalized) { + throw new Error("AI returned an empty commit message") + } + + return normalized } } diff --git a/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts b/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts index 55424c5e9d..188533d11e 100644 --- a/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts +++ b/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts @@ -200,4 +200,19 @@ Keep unstaged commit context focused on worktree changes. Keep unstaged commit context focused on worktree changes.`) }) + + it("fails when AI output is empty after cleanup", async () => { + completePrompt.mockResolvedValue("```\n \n```") + const generator = createGenerator() + + await expect( + generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: ["src/file.ts"], + gitContext: "Modified (staged): src/file.ts", + }), + ).rejects.toThrow("AI returned an empty commit message") + + expect(captureGenerated).not.toHaveBeenCalled() + }) }) From a203bb3fa88d4fb04c85635f2b7233fdcd838dcb Mon Sep 17 00:00:00 2001 From: Mirrowel <28632877+Mirrowel@users.noreply.github.com> Date: Sun, 24 May 2026 18:22:10 +0200 Subject: [PATCH 8/9] =?UTF-8?q?fix(scm):=20=F0=9F=90=9B=20address=20commit?= =?UTF-8?q?=20message=20provider=20review=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/i18n/locales/en/common.json | 3 + src/package.json | 1 + .../commit-message/CommitMessageProvider.ts | 84 +++++++++++++++++-- .../__tests__/CommitMessageProvider.spec.ts | 76 ++++++++++++++++- .../commit-message/gitContextSettings.ts | 2 + src/services/commit-message/index.ts | 1 + 6 files changed, 158 insertions(+), 9 deletions(-) diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index de4f767ce7..b036c97cd2 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -270,6 +270,9 @@ "generationFailed": "Zoo: Failed to generate commit message: {{errorMessage}}", "contextWarnings": "Zoo: Git context warning: {{warnings}}", "generatingFromUnstaged": "Zoo: Generating message using unstaged changes", + "confirmUnstaged": "No staged changes found. Generate a commit message from {{count}} unstaged/untracked changes instead?", + "confirmUnstagedAction": "Generate from unstaged changes", + "useUnstagedConfirm": "No staged changes were found. Generate a commit message from unstaged changes instead?", "activationFailed": "Zoo: Failed to activate message generator: {{error}}", "providerRegistered": "Zoo: Commit message provider registered", "initializing": "Initializing...", diff --git a/src/package.json b/src/package.json index e7e000c3e0..175014720b 100644 --- a/src/package.json +++ b/src/package.json @@ -218,6 +218,7 @@ "scm/input": [ { "command": "zoo-code.generateCommitMessage", + "when": "scmProvider == git", "group": "navigation" } ], diff --git a/src/services/commit-message/CommitMessageProvider.ts b/src/services/commit-message/CommitMessageProvider.ts index 0fe980491c..5d5507e7dc 100644 --- a/src/services/commit-message/CommitMessageProvider.ts +++ b/src/services/commit-message/CommitMessageProvider.ts @@ -1,3 +1,4 @@ +import * as path from "path" import * as vscode from "vscode" import { ProviderSettingsManager } from "../../core/config/ProviderSettingsManager" import { t } from "../../i18n" @@ -8,13 +9,17 @@ import { CommitMessageGenerator } from "./CommitMessageGenerator" import { getCommitMessageGitContextSettings, toGitContextCollectorOptions } from "./gitContextSettings" interface VscGenerationRequest { + /** Source control input box that should receive the generated message. */ inputBox: { value: string } + /** Root URI supplied by VS Code for the source control command invocation. */ rootUri?: vscode.Uri } +/** Registers and handles the VS Code command that writes AI commit messages into SCM input. */ export class CommitMessageProvider implements vscode.Disposable { private generator: CommitMessageGenerator + /** Creates the provider and wires it to the extension settings store. */ constructor( private context: vscode.ExtensionContext, private outputChannel: vscode.OutputChannel, @@ -24,6 +29,7 @@ export class CommitMessageProvider implements vscode.Disposable { this.generator = new CommitMessageGenerator(providerSettingsManager) } + /** Registers the generate commit message command with VS Code. */ public async activate(): Promise { this.outputChannel.appendLine(t("common:commitMessage.activated")) @@ -36,6 +42,7 @@ export class CommitMessageProvider implements vscode.Disposable { this.context.subscriptions.push(...disposables) } + /** Handles the command invocation from VS Code's SCM UI. */ private async handleVSCodeCommand(vsRequest?: VscGenerationRequest): Promise { try { const workspacePath = this.determineWorkspacePath(vsRequest?.rootUri) @@ -72,7 +79,6 @@ export class CommitMessageProvider implements vscode.Disposable { vscode.window.showInformationMessage(t("common:commitMessage.noChanges")) return } - reportProgress(25, t("common:commitMessage.foundChanges", { count: resolution.changes.length })) if (!resolution.usedStaged) { @@ -94,10 +100,14 @@ export class CommitMessageProvider implements vscode.Disposable { } reportProgress(70, t("common:commitMessage.generating")) + const gitContext = this.appendExistingCommitMessageDraft( + gitContextResult.context, + targetRepository.inputBox.value, + ) const message = await this.generator.generateMessage({ workspacePath, selectedFiles: resolution.files, - gitContext: gitContextResult.context, + gitContext, onProgress: (update) => { if (update.percentage !== undefined) { reportProgress(70 + update.percentage * 0.25, update.message) @@ -118,6 +128,7 @@ export class CommitMessageProvider implements vscode.Disposable { } } + /** Resolves staged changes, asking before falling back to unstaged worktree changes. */ private async resolveCommitChanges(gitCollector: GitContextCollector): Promise<{ changes: GitChange[] files: string[] @@ -127,6 +138,15 @@ export class CommitMessageProvider implements vscode.Disposable { let usedStaged = true if (changes.length === 0) { + const useUnstaged = await this.confirmUnstagedGeneration() + if (!useUnstaged) { + return { + changes: [], + files: [], + usedStaged, + } + } + changes = await gitCollector.gatherChanges({ staged: false }) usedStaged = false } @@ -138,6 +158,7 @@ export class CommitMessageProvider implements vscode.Disposable { } } + /** Finds the Git repository that owns the requested workspace path. */ private async determineTargetRepository(workspacePath: string): Promise { try { const gitExtension = vscode.extensions.getExtension("vscode.git") @@ -154,18 +175,31 @@ export class CommitMessageProvider implements vscode.Disposable { return null } - for (const repo of gitApi.repositories ?? []) { - if (repo.rootUri && workspacePath.startsWith(repo.rootUri.fsPath)) { - return repo - } + const repositories = gitApi.repositories ?? [] + const matchingRepositories = repositories + .filter((repo: VscGenerationRequest) => + repo.rootUri ? isPathWithinRepository(workspacePath, repo.rootUri.fsPath) : false, + ) + .sort( + (a: VscGenerationRequest, b: VscGenerationRequest) => + (b.rootUri?.fsPath.length ?? 0) - (a.rootUri?.fsPath.length ?? 0), + ) + + if (matchingRepositories.length > 0) { + return matchingRepositories[0] + } + + if (repositories.length === 1) { + return repositories[0] } - return gitApi.repositories[0] ?? null + return null } catch (error) { return null } } + /** Derives the workspace path from the SCM resource or active workspace. */ private determineWorkspacePath(resourceUri?: vscode.Uri): string { if (resourceUri) { return resourceUri.fsPath @@ -179,5 +213,41 @@ export class CommitMessageProvider implements vscode.Disposable { throw new Error("Could not determine workspace path") } + /** Adds an existing commit input draft to the model context so the next message can improve it. */ + private appendExistingCommitMessageDraft(gitContext: string, existingDraft: string): string { + const normalizedDraft = existingDraft.trim() + if (!normalizedDraft) { + return gitContext + } + + return `${gitContext} + +## Existing Commit Message Draft +The Git commit input already contains this draft. Use it as guidance and generate the best final commit message for the changes. You may improve, replace, or preserve parts of it as appropriate. + +\`\`\` +${normalizedDraft} +\`\`\`` + } + + /** Confirms whether unstaged changes may be gathered when there are no staged changes. */ + private async confirmUnstagedGeneration(): Promise { + const confirmAction = t("common:commitMessage.confirmUnstagedAction") + const choice = await vscode.window.showWarningMessage( + t("common:commitMessage.useUnstagedConfirm"), + { modal: true }, + confirmAction, + ) + + return choice === confirmAction + } + + /** Keeps provider cleanup compatible with VS Code disposable registration. */ public dispose(): void {} } + +/** Returns true when the target path is the repository root or is contained by it. */ +export function isPathWithinRepository(targetPath: string, repositoryPath: string): boolean { + const relativePath = path.relative(path.resolve(repositoryPath), path.resolve(targetPath)) + return relativePath === "" || (!!relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath)) +} diff --git a/src/services/commit-message/__tests__/CommitMessageProvider.spec.ts b/src/services/commit-message/__tests__/CommitMessageProvider.spec.ts index 58ea039d59..d4900d7a8a 100644 --- a/src/services/commit-message/__tests__/CommitMessageProvider.spec.ts +++ b/src/services/commit-message/__tests__/CommitMessageProvider.spec.ts @@ -1,10 +1,25 @@ import * as path from "path" +import * as vscode from "vscode" -import { isPathWithinRepository } from "../CommitMessageProvider" +import { CommitMessageProvider, isPathWithinRepository } from "../CommitMessageProvider" -vi.mock("vscode", () => ({})) +vi.mock("vscode", () => ({ + window: { + showWarningMessage: vi.fn(), + }, +})) describe("CommitMessageProvider", () => { + const createProvider = () => + new CommitMessageProvider( + {} as vscode.ExtensionContext, + { appendLine: vi.fn() } as unknown as vscode.OutputChannel, + ) + + beforeEach(() => { + vi.clearAllMocks() + }) + it("matches repository roots by path containment instead of string prefix", () => { const root = path.parse(process.cwd()).root const repositoryPath = path.join(root, "work", "app") @@ -13,4 +28,61 @@ describe("CommitMessageProvider", () => { expect(isPathWithinRepository(repositoryPath, repositoryPath)).toBe(true) expect(isPathWithinRepository(path.join(root, "work", "application"), repositoryPath)).toBe(false) }) + + it("adds existing commit input to the generation context", () => { + const provider = createProvider() + const gitContext = "diff --git a/src/file.ts b/src/file.ts" + + const contextWithDraft = (provider as any).appendExistingCommitMessageDraft(gitContext, "existing message") + + expect(contextWithDraft).toContain(gitContext) + expect(contextWithDraft).toContain("## Existing Commit Message Draft") + expect(contextWithDraft).toContain("existing message") + }) + + it("does not add empty commit input to the generation context", () => { + const provider = createProvider() + const gitContext = "diff --git a/src/file.ts b/src/file.ts" + + expect((provider as any).appendExistingCommitMessageDraft(gitContext, " ")).toBe(gitContext) + }) + + it("asks before falling back to unstaged changes", async () => { + vi.mocked(vscode.window.showWarningMessage).mockResolvedValue("commitMessage.confirmUnstagedAction" as never) + const provider = createProvider() + const gitCollector = { + gatherChanges: vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ filePath: "src/file.ts" }]), + } + + const resolution = await (provider as any).resolveCommitChanges(gitCollector) + + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + "commitMessage.useUnstagedConfirm", + { modal: true }, + "commitMessage.confirmUnstagedAction", + ) + expect(gitCollector.gatherChanges).toHaveBeenNthCalledWith(1, { staged: true }) + expect(gitCollector.gatherChanges).toHaveBeenNthCalledWith(2, { staged: false }) + expect(resolution).toEqual({ + changes: [{ filePath: "src/file.ts" }], + files: ["src/file.ts"], + usedStaged: false, + }) + }) + + it("does not read unstaged changes when fallback is declined", async () => { + vi.mocked(vscode.window.showWarningMessage).mockResolvedValue(undefined) + const provider = createProvider() + const gitCollector = { + gatherChanges: vi.fn().mockResolvedValueOnce([]), + } + + const resolution = await (provider as any).resolveCommitChanges(gitCollector) + + expect(gitCollector.gatherChanges).toHaveBeenCalledTimes(1) + expect(resolution).toEqual({ changes: [], files: [], usedStaged: true }) + }) }) diff --git a/src/services/commit-message/gitContextSettings.ts b/src/services/commit-message/gitContextSettings.ts index 5927ae4e66..eec746dba3 100644 --- a/src/services/commit-message/gitContextSettings.ts +++ b/src/services/commit-message/gitContextSettings.ts @@ -3,6 +3,7 @@ import { defaultCommitMessageGitContextSettings, type CommitMessageGitContextSet import { ContextProxy } from "../../core/config/ContextProxy" import type { GitContextCollectorOptions } from "../git-context" +/** Reads and normalizes the persisted Git context settings for commit message generation. */ export function getCommitMessageGitContextSettings(): Required { const rawSettings = ContextProxy.instance.getValue("commitMessageGitContext") as | CommitMessageGitContextSettings @@ -38,6 +39,7 @@ export function normalizeCommitMessageGitContextSettings( } } +/** Converts commit-message settings into options consumed by the Git context collector. */ export function toGitContextCollectorOptions( staged: boolean, settings: Required, diff --git a/src/services/commit-message/index.ts b/src/services/commit-message/index.ts index 095596386e..b9019840b3 100644 --- a/src/services/commit-message/index.ts +++ b/src/services/commit-message/index.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode" import { CommitMessageProvider } from "./CommitMessageProvider" import { t } from "../../i18n" +/** Registers the commit message provider and reports activation failures to the output channel. */ export function registerCommitMessageProvider( context: vscode.ExtensionContext, outputChannel: vscode.OutputChannel, From 411160ce43b835e7a42a2d991e65bd92d09a4ed7 Mon Sep 17 00:00:00 2001 From: Mirrowel <28632877+Mirrowel@users.noreply.github.com> Date: Sun, 24 May 2026 18:49:37 +0200 Subject: [PATCH 9/9] =?UTF-8?q?fix(scm):=20=F0=9F=90=9B=20avoid=20ambiguou?= =?UTF-8?q?s=20commit=20workspace=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../commit-message/CommitMessageProvider.ts | 8 +++-- .../__tests__/CommitMessageProvider.spec.ts | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/services/commit-message/CommitMessageProvider.ts b/src/services/commit-message/CommitMessageProvider.ts index 5d5507e7dc..23533bf406 100644 --- a/src/services/commit-message/CommitMessageProvider.ts +++ b/src/services/commit-message/CommitMessageProvider.ts @@ -205,11 +205,15 @@ export class CommitMessageProvider implements vscode.Disposable { return resourceUri.fsPath } - const workspaceFolders = vscode.workspace.workspaceFolders - if (workspaceFolders && workspaceFolders.length > 0) { + const workspaceFolders = vscode.workspace.workspaceFolders ?? [] + if (workspaceFolders.length === 1) { return workspaceFolders[0].uri.fsPath } + if (workspaceFolders.length > 1) { + throw new Error("Run this command from a specific Git source control input in a multi-root workspace") + } + throw new Error("Could not determine workspace path") } diff --git a/src/services/commit-message/__tests__/CommitMessageProvider.spec.ts b/src/services/commit-message/__tests__/CommitMessageProvider.spec.ts index d4900d7a8a..a187db073d 100644 --- a/src/services/commit-message/__tests__/CommitMessageProvider.spec.ts +++ b/src/services/commit-message/__tests__/CommitMessageProvider.spec.ts @@ -7,6 +7,12 @@ vi.mock("vscode", () => ({ window: { showWarningMessage: vi.fn(), }, + workspace: { + workspaceFolders: undefined, + }, + Uri: { + file: (fsPath: string) => ({ fsPath }), + }, })) describe("CommitMessageProvider", () => { @@ -18,6 +24,7 @@ describe("CommitMessageProvider", () => { beforeEach(() => { vi.clearAllMocks() + ;(vscode.workspace as any).workspaceFolders = undefined }) it("matches repository roots by path containment instead of string prefix", () => { @@ -85,4 +92,29 @@ describe("CommitMessageProvider", () => { expect(gitCollector.gatherChanges).toHaveBeenCalledTimes(1) expect(resolution).toEqual({ changes: [], files: [], usedStaged: true }) }) + + it("uses the SCM resource URI as the workspace path when provided", () => { + const provider = createProvider() + + expect((provider as any).determineWorkspacePath(vscode.Uri.file("/repo"))).toBe("/repo") + }) + + it("falls back to the workspace folder only when exactly one folder is open", () => { + ;(vscode.workspace as any).workspaceFolders = [{ uri: vscode.Uri.file("/single-root") }] + const provider = createProvider() + + expect((provider as any).determineWorkspacePath()).toBe("/single-root") + }) + + it("fails clearly instead of guessing in multi-root workspaces", () => { + ;(vscode.workspace as any).workspaceFolders = [ + { uri: vscode.Uri.file("/first-root") }, + { uri: vscode.Uri.file("/second-root") }, + ] + const provider = createProvider() + + expect(() => (provider as any).determineWorkspacePath()).toThrow( + "Run this command from a specific Git source control input in a multi-root workspace", + ) + }) })