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 01/12] 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 02/12] =?UTF-8?q?fix(scm):=20=F0=9F=90=9B=20address=20git?= =?UTF-8?q?=20context=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 03/12] =?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 04/12] 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 05/12] =?UTF-8?q?fix(scm):=20=F0=9F=90=9B=20address=20comm?= =?UTF-8?q?it=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 06/12] 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 07/12] =?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 08/12] =?UTF-8?q?fix(scm):=20=F0=9F=90=9B=20address=20comm?= =?UTF-8?q?it=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 09/12] =?UTF-8?q?fix(scm):=20=F0=9F=90=9B=20avoid=20ambigu?= =?UTF-8?q?ous=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", + ) + }) }) From 4c18cb169f8cb8f96980eab9015c2d5d229984fb Mon Sep 17 00:00:00 2001 From: Mirrowel <28632877+Mirrowel@users.noreply.github.com> Date: Sun, 24 May 2026 16:02:49 +0200 Subject: [PATCH 10/12] feat(scm): add commit message settings and profiles --- packages/types/src/global-settings.ts | 186 ++++++ packages/types/src/vscode-extension-host.ts | 2 + src/core/webview/ClineProvider.ts | 6 + src/core/webview/webviewMessageHandler.ts | 1 - src/i18n/locales/ca/common.json | 53 ++ src/i18n/locales/de/common.json | 53 ++ src/i18n/locales/es/common.json | 53 ++ src/i18n/locales/fr/common.json | 53 ++ src/i18n/locales/hi/common.json | 53 ++ src/i18n/locales/id/common.json | 53 ++ src/i18n/locales/it/common.json | 53 ++ src/i18n/locales/ja/common.json | 53 ++ src/i18n/locales/ko/common.json | 53 ++ src/i18n/locales/nl/common.json | 53 ++ src/i18n/locales/pl/common.json | 53 ++ src/i18n/locales/pt-BR/common.json | 53 ++ src/i18n/locales/ru/common.json | 53 ++ src/i18n/locales/tr/common.json | 53 ++ src/i18n/locales/vi/common.json | 53 ++ src/i18n/locales/zh-CN/common.json | 53 ++ src/i18n/locales/zh-TW/common.json | 53 ++ .../commit-message/CommitMessageGenerator.ts | 20 +- .../commit-message/CommitMessageProvider.ts | 1 + .../__tests__/CommitMessageGenerator.spec.ts | 155 ++++- .../__tests__/attribution.spec.ts | 48 ++ .../__tests__/profileSettings.spec.ts | 111 ++++ src/services/commit-message/attribution.ts | 64 ++ .../commit-message/gitContextSettings.ts | 45 +- .../commit-message/profileSettings.ts | 54 ++ .../settings/CommitMessagePromptSettings.tsx | 555 ++++++++++++++++++ .../components/settings/PromptsSettings.tsx | 264 +++++---- .../src/components/settings/SectionHeader.tsx | 6 +- .../src/components/settings/SettingsView.tsx | 24 + .../CommitMessagePromptSettings.spec.tsx | 264 +++++++++ .../src/context/ExtensionStateContext.tsx | 24 + webview-ui/src/i18n/locales/ca/prompts.json | 50 ++ webview-ui/src/i18n/locales/de/prompts.json | 50 ++ webview-ui/src/i18n/locales/en/prompts.json | 50 ++ webview-ui/src/i18n/locales/es/prompts.json | 50 ++ webview-ui/src/i18n/locales/fr/prompts.json | 50 ++ webview-ui/src/i18n/locales/hi/prompts.json | 50 ++ webview-ui/src/i18n/locales/id/prompts.json | 50 ++ webview-ui/src/i18n/locales/it/prompts.json | 50 ++ webview-ui/src/i18n/locales/ja/prompts.json | 50 ++ webview-ui/src/i18n/locales/ko/prompts.json | 50 ++ webview-ui/src/i18n/locales/nl/prompts.json | 50 ++ webview-ui/src/i18n/locales/pl/prompts.json | 50 ++ .../src/i18n/locales/pt-BR/prompts.json | 50 ++ webview-ui/src/i18n/locales/ru/prompts.json | 50 ++ webview-ui/src/i18n/locales/tr/prompts.json | 50 ++ webview-ui/src/i18n/locales/vi/prompts.json | 50 ++ .../src/i18n/locales/zh-CN/prompts.json | 50 ++ .../src/i18n/locales/zh-TW/prompts.json | 50 ++ 53 files changed, 3469 insertions(+), 162 deletions(-) create mode 100644 src/services/commit-message/__tests__/attribution.spec.ts create mode 100644 src/services/commit-message/__tests__/profileSettings.spec.ts create mode 100644 src/services/commit-message/attribution.ts create mode 100644 src/services/commit-message/profileSettings.ts create mode 100644 webview-ui/src/components/settings/CommitMessagePromptSettings.tsx create mode 100644 webview-ui/src/components/settings/__tests__/CommitMessagePromptSettings.spec.tsx diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 13f0152be7..73dd4c77c9 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -49,6 +49,190 @@ export const defaultCommitMessageGitContextSettings: Required + +export const defaultCommitMessageAttributionSettings: Required = { + enabled: false, + template: DEFAULT_COMMIT_MESSAGE_ATTRIBUTION_TEMPLATE, +} + +export const MAX_COMMIT_MESSAGE_PROFILES = 5 +export const DEFAULT_COMMIT_MESSAGE_PROFILE_ID = "default" + +export const commitMessageProfileSchema = z.object({ + id: z.string().optional(), + name: z.string().optional(), + prompt: z.string().optional(), + apiConfigId: z.string().optional(), + gitContext: commitMessageGitContextSchema.optional(), + attribution: commitMessageAttributionSchema.optional(), +}) + +export const commitMessageProfilesSchema = z.object({ + activeProfileId: z.string().optional(), + profiles: z.array(commitMessageProfileSchema).max(MAX_COMMIT_MESSAGE_PROFILES).optional(), +}) + +export type CommitMessageProfileSettings = z.infer +export type CommitMessageProfilesSettings = z.infer + +export type NormalizedCommitMessageProfile = Omit< + CommitMessageProfileSettings, + "id" | "name" | "gitContext" | "attribution" +> & { + id: string + name: string + gitContext: Required + attribution: Required +} + +export interface NormalizedCommitMessageProfiles { + activeProfileId: string + profiles: NormalizedCommitMessageProfile[] +} + +export interface CommitMessageProfileFallbackSettings { + prompt?: string + apiConfigId?: string + gitContext?: CommitMessageGitContextSettings + attribution?: CommitMessageAttributionSettings +} + +export function normalizeCommitMessageGitContextSettings( + settings?: CommitMessageGitContextSettings, +): Required { + return { + ...defaultCommitMessageGitContextSettings, + ...settings, + diffContextLines: clampNumberSetting( + settings?.diffContextLines, + 0, + 20, + defaultCommitMessageGitContextSettings.diffContextLines, + ), + recentCommitCount: clampNumberSetting( + settings?.recentCommitCount, + 1, + 20, + defaultCommitMessageGitContextSettings.recentCommitCount, + ), + recentCommitDiffCount: clampNumberSetting( + settings?.recentCommitDiffCount, + 1, + 5, + defaultCommitMessageGitContextSettings.recentCommitDiffCount, + ), + } +} + +export function normalizeCommitMessageAttributionSettings( + settings?: CommitMessageAttributionSettings, +): Required { + return { + ...defaultCommitMessageAttributionSettings, + ...settings, + template: normalizeOptionalString(settings?.template) ?? defaultCommitMessageAttributionSettings.template, + } +} + +export function normalizeCommitMessageProfiles( + settings?: CommitMessageProfilesSettings, + fallback: CommitMessageProfileFallbackSettings = {}, +): NormalizedCommitMessageProfiles { + const sourceProfiles = settings?.profiles?.length + ? settings.profiles.slice(0, MAX_COMMIT_MESSAGE_PROFILES) + : [ + { + id: DEFAULT_COMMIT_MESSAGE_PROFILE_ID, + name: "Default", + prompt: fallback.prompt, + apiConfigId: fallback.apiConfigId, + gitContext: fallback.gitContext, + attribution: fallback.attribution, + }, + ] + + const profiles: NormalizedCommitMessageProfile[] = sourceProfiles.map((profile, index) => ({ + id: normalizeProfileId(profile.id, index), + name: normalizeProfileName(profile.name, index), + prompt: profile.prompt, + apiConfigId: normalizeOptionalString(profile.apiConfigId), + gitContext: normalizeCommitMessageGitContextSettings(profile.gitContext), + attribution: normalizeCommitMessageAttributionSettings(profile.attribution), + })) + const firstProfile = profiles[0]! + + const activeProfileId = profiles.some((profile) => profile.id === settings?.activeProfileId) + ? settings!.activeProfileId! + : firstProfile.id + + return { + activeProfileId, + profiles, + } +} + +export function getActiveCommitMessageProfile( + settings?: CommitMessageProfilesSettings, + fallback?: CommitMessageProfileFallbackSettings, +): NormalizedCommitMessageProfile { + const normalized = normalizeCommitMessageProfiles(settings, fallback) + return normalized.profiles.find((profile) => profile.id === normalized.activeProfileId) ?? normalized.profiles[0]! +} + +export function createCommitMessageProfileId(): string { + return `profile-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` +} + +export function createCommitMessageProfileName(profiles: Array<{ name?: string }>): string { + for (let index = profiles.length + 1; index <= MAX_COMMIT_MESSAGE_PROFILES + 1; index++) { + const candidate = `Profile ${index}` + if (!profiles.some((profile) => profile.name === candidate)) { + return candidate + } + } + + return `Profile ${profiles.length + 1}` +} + +function normalizeProfileId(id: string | undefined, index: number): string { + const normalized = normalizeOptionalString(id) + if (normalized) { + return normalized + } + + return index === 0 ? DEFAULT_COMMIT_MESSAGE_PROFILE_ID : `profile-${index + 1}` +} + +function normalizeProfileName(name: string | undefined, index: number): string { + const normalized = normalizeOptionalString(name) + return normalized || (index === 0 ? "Default" : `Profile ${index + 1}`) +} + +function normalizeOptionalString(value: string | undefined): string | undefined { + if (typeof value !== "string") { + return undefined + } + + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +function clampNumberSetting(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) +} + /** * Terminal output preview size options for persisted command output. * @@ -261,6 +445,8 @@ export const globalSettingsSchema = z.object({ commitMessageApiConfigId: z.string().optional(), commitMessageGitContext: commitMessageGitContextSchema.optional(), + commitMessageAttribution: commitMessageAttributionSchema.optional(), + commitMessageProfiles: commitMessageProfilesSchema.optional(), }) export type GlobalSettings = z.infer diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 8e7b4afd94..0c32e991be 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -285,6 +285,8 @@ export type ExtensionState = Pick< | "enhancementApiConfigId" | "commitMessageApiConfigId" | "commitMessageGitContext" + | "commitMessageAttribution" + | "commitMessageProfiles" | "customCondensingPrompt" | "codebaseIndexConfig" | "codebaseIndexModels" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index db16a18673..a01b07d5ed 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2059,6 +2059,8 @@ export class ClineProvider enhancementApiConfigId, commitMessageApiConfigId, commitMessageGitContext, + commitMessageAttribution, + commitMessageProfiles, autoApprovalEnabled, customModes, experiments, @@ -2213,6 +2215,8 @@ export class ClineProvider enhancementApiConfigId, commitMessageApiConfigId, commitMessageGitContext, + commitMessageAttribution, + commitMessageProfiles, autoApprovalEnabled: autoApprovalEnabled ?? false, customModes, experiments: experiments ?? experimentDefault, @@ -2421,6 +2425,8 @@ export class ClineProvider enhancementApiConfigId: stateValues.enhancementApiConfigId, commitMessageApiConfigId: stateValues.commitMessageApiConfigId, commitMessageGitContext: stateValues.commitMessageGitContext, + commitMessageAttribution: stateValues.commitMessageAttribution, + commitMessageProfiles: stateValues.commitMessageProfiles, experiments: stateValues.experiments ?? experimentDefault, autoApprovalEnabled: stateValues.autoApprovalEnabled ?? false, customModes, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 429de051b8..76f81ca270 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1605,7 +1605,6 @@ export const webviewMessageHandler = async ( await updateGlobalState("enhancementApiConfigId", message.text) await provider.postStateToWebview() break - case "autoApprovalEnabled": await updateGlobalState("autoApprovalEnabled", message.bool ?? false) await provider.postStateToWebview() diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 59168a1b0a..c6164f6288 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: Connectat correctament! Ara podeu utilitzar Zoo Code com a proveïdor d'IA.", "disconnected": "Zoo Code: Desconnectat correctament." } + }, + "commitMessage": { + "activated": "Generador de missatges de commit de Zoo Code activat", + "gitNotFound": "⚠️ No s'ha trobat el repositori Git o Git no està disponible", + "gitInitError": "⚠️ Error d'inicialització de Git: {{error}}", + "generating": "Zoo: Generant missatge de commit...", + "noChanges": "Zoo: No s'han trobat canvis per analitzar", + "generated": "Zoo: Missatge de commit generat!", + "generationFailed": "Zoo: Error en generar el missatge de commit: {{errorMessage}}", + "contextWarnings": "Zoo: Avís del context de Git: {{warnings}}", + "generatingFromUnstaged": "Zoo: Generant missatge amb els canvis no preparats", + "confirmUnstaged": "No s'han trobat canvis preparats. Vols generar un missatge de commit amb {{count}} canvis no preparats/no rastrejats?", + "confirmUnstagedAction": "Generar amb canvis no preparats", + "activationFailed": "Zoo: Error en activar el generador de missatges: {{error}}", + "providerRegistered": "Zoo: Proveïdor de missatges de commit registrat", + "initializing": "Inicialitzant...", + "discoveringFiles": "Descobrint fitxers...", + "foundChanges": "S'han trobat {{count}} canvis", + "gettingContext": "Obtenint context de Git...", + "errors": { + "connectionFailed": "Error en connectar amb l'extensió Zoo Code", + "timeout": "La sol·licitud ha superat el temps d'espera de 30 segons", + "invalidResponse": "Format de resposta no vàlid rebut de l'extensió", + "missingMessage": "No s'ha rebut cap missatge de commit de l'extensió", + "noChanges": "No s'han trobat canvis per fer commit", + "noProject": "No hi ha cap projecte disponible", + "noWorkspacePath": "No s'ha pogut determinar la ruta de l'espai de treball per al repositori Git", + "workspaceNotFound": "No s'ha pogut determinar la ruta de l'espai de treball per al repositori Git", + "processingError": "Error en processar la generació del missatge de commit: {{error}}" + }, + "error": { + "title": "Error", + "workspacePathNotFound": "No s'ha pogut determinar la ruta de l'espai de treball per al repositori Git", + "generationFailed": "Error en generar el missatge de commit: {{error}}", + "processingFailed": "Error en processar la generació del missatge de commit: {{error}}", + "unknown": "Error desconegut" + }, + "dialogs": { + "info": "Missatge de commit amb IA", + "error": "Error", + "success": "Èxit", + "title": "Missatge de commit amb IA" + }, + "progress": { + "title": "Generant missatge de commit", + "analyzing": "Analitzant canvis...", + "connecting": "Connectant amb Zoo Code...", + "generating": "Generant missatge de commit..." + }, + "ui": { + "generateButton": "Generar missatge de commit", + "generateButtonTooltip": "Genera un missatge de commit utilitzant IA per analitzar els teus canvis de codi" + } } } diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 0d9acc69bb..2291b7bce0 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -262,5 +262,58 @@ "connected": "Zoo Code: Erfolgreich verbunden! Du kannst Zoo Code jetzt als KI-Anbieter verwenden.", "disconnected": "Zoo Code: Erfolgreich getrennt." } + }, + "commitMessage": { + "activated": "Zoo Code Commit-Nachrichtengenerator aktiviert", + "gitNotFound": "⚠️ Git-Repository nicht gefunden oder Git nicht verfügbar", + "gitInitError": "⚠️ Git-Initialisierungsfehler: {{error}}", + "generating": "Zoo: Commit-Nachricht wird generiert...", + "noChanges": "Zoo: Keine Änderungen zum Analysieren gefunden", + "generated": "Zoo: Commit-Nachricht generiert!", + "generationFailed": "Zoo: Fehler beim Generieren der Commit-Nachricht: {{errorMessage}}", + "contextWarnings": "Zoo: Git-Kontextwarnung: {{warnings}}", + "generatingFromUnstaged": "Zoo: Nachricht wird aus nicht gestagten Änderungen generiert", + "confirmUnstaged": "Keine gestagten Änderungen gefunden. Soll eine Commit-Nachricht aus {{count}} nicht gestagten/nicht verfolgten Änderungen generiert werden?", + "confirmUnstagedAction": "Aus nicht gestagten Änderungen generieren", + "activationFailed": "Zoo: Fehler beim Aktivieren des Nachrichtengenerators: {{error}}", + "providerRegistered": "Zoo: Commit-Nachrichtenanbieter registriert", + "initializing": "Initialisierung...", + "discoveringFiles": "Dateien werden erkannt...", + "foundChanges": "{{count}} Änderungen gefunden", + "gettingContext": "Git-Kontext wird abgerufen...", + "errors": { + "connectionFailed": "Verbindung zur Zoo Code-Erweiterung fehlgeschlagen", + "timeout": "Zeitüberschreitung der Anfrage nach 30 Sekunden", + "invalidResponse": "Ungültiges Antwortformat von der Erweiterung erhalten", + "missingMessage": "Keine Commit-Nachricht von der Erweiterung erhalten", + "noChanges": "Keine Änderungen zum Committen gefunden", + "noProject": "Kein Projekt verfügbar", + "noWorkspacePath": "Arbeitsbereichspfad für Git-Repository konnte nicht ermittelt werden", + "workspaceNotFound": "Arbeitsbereichspfad für Git-Repository konnte nicht ermittelt werden", + "processingError": "Fehler bei der Verarbeitung der Commit-Nachrichtengenerierung: {{error}}" + }, + "error": { + "title": "Fehler", + "workspacePathNotFound": "Arbeitsbereichspfad für Git-Repository konnte nicht ermittelt werden", + "generationFailed": "Fehler beim Generieren der Commit-Nachricht: {{error}}", + "processingFailed": "Fehler bei der Verarbeitung der Commit-Nachrichtengenerierung: {{error}}", + "unknown": "Unbekannter Fehler" + }, + "dialogs": { + "info": "KI-Commit-Nachricht", + "error": "Fehler", + "success": "Erfolg", + "title": "KI-Commit-Nachricht" + }, + "progress": { + "title": "Commit-Nachricht wird generiert", + "analyzing": "Änderungen werden analysiert...", + "connecting": "Verbindung zu Zoo Code wird hergestellt...", + "generating": "Commit-Nachricht wird generiert..." + }, + "ui": { + "generateButton": "Commit-Nachricht generieren", + "generateButtonTooltip": "Generiert eine Commit-Nachricht mithilfe von KI zur Analyse deiner Codeänderungen" + } } } diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index f10bf5aa98..43837e296d 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -262,5 +262,58 @@ "connected": "Zoo Code: ¡Conectado correctamente! Ahora puedes usar Zoo Code como proveedor de IA.", "disconnected": "Zoo Code: Desconectado correctamente." } + }, + "commitMessage": { + "activated": "Generador de mensajes de commit de Zoo Code activado", + "gitNotFound": "⚠️ Repositorio Git no encontrado o Git no disponible", + "gitInitError": "⚠️ Error de inicialización de Git: {{error}}", + "generating": "Zoo: Generando mensaje de commit...", + "noChanges": "Zoo: No se encontraron cambios para analizar", + "generated": "Zoo: ¡Mensaje de commit generado!", + "generationFailed": "Zoo: Error al generar el mensaje de commit: {{errorMessage}}", + "contextWarnings": "Zoo: Advertencia del contexto de Git: {{warnings}}", + "generatingFromUnstaged": "Zoo: Generando mensaje con cambios no preparados", + "confirmUnstaged": "No se encontraron cambios preparados. ¿Generar un mensaje de commit con {{count}} cambios no preparados/no rastreados?", + "confirmUnstagedAction": "Generar con cambios no preparados", + "activationFailed": "Zoo: Error al activar el generador de mensajes: {{error}}", + "providerRegistered": "Zoo: Proveedor de mensajes de commit registrado", + "initializing": "Inicializando...", + "discoveringFiles": "Descubriendo archivos...", + "foundChanges": "Se encontraron {{count}} cambios", + "gettingContext": "Obteniendo contexto de Git...", + "errors": { + "connectionFailed": "Error al conectar con la extensión Zoo Code", + "timeout": "La solicitud superó el tiempo de espera de 30 segundos", + "invalidResponse": "Formato de respuesta no válido recibido de la extensión", + "missingMessage": "No se recibió mensaje de commit de la extensión", + "noChanges": "No se encontraron cambios para hacer commit", + "noProject": "No hay proyecto disponible", + "noWorkspacePath": "No se pudo determinar la ruta del espacio de trabajo para el repositorio Git", + "workspaceNotFound": "No se pudo determinar la ruta del espacio de trabajo para el repositorio Git", + "processingError": "Error al procesar la generación del mensaje de commit: {{error}}" + }, + "error": { + "title": "Error", + "workspacePathNotFound": "No se pudo determinar la ruta del espacio de trabajo para el repositorio Git", + "generationFailed": "Error al generar el mensaje de commit: {{error}}", + "processingFailed": "Error al procesar la generación del mensaje de commit: {{error}}", + "unknown": "Error desconocido" + }, + "dialogs": { + "info": "Mensaje de commit con IA", + "error": "Error", + "success": "Éxito", + "title": "Mensaje de commit con IA" + }, + "progress": { + "title": "Generando mensaje de commit", + "analyzing": "Analizando cambios...", + "connecting": "Conectando con Zoo Code...", + "generating": "Generando mensaje de commit..." + }, + "ui": { + "generateButton": "Generar mensaje de commit", + "generateButtonTooltip": "Genera un mensaje de commit usando IA para analizar tus cambios de código" + } } } diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index d61f38d515..7249620cb8 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: Connecté avec succès ! Vous pouvez maintenant utiliser Zoo Code comme fournisseur d'IA.", "disconnected": "Zoo Code: Déconnecté avec succès." } + }, + "commitMessage": { + "activated": "Générateur de messages de commit Zoo Code activé", + "gitNotFound": "⚠️ Dépôt Git introuvable ou Git non disponible", + "gitInitError": "⚠️ Erreur d'initialisation de Git : {{error}}", + "generating": "Zoo : Génération du message de commit...", + "noChanges": "Zoo : Aucun changement trouvé à analyser", + "generated": "Zoo : Message de commit généré !", + "generationFailed": "Zoo : Erreur lors de la génération du message de commit : {{errorMessage}}", + "contextWarnings": "Zoo : Avertissement du contexte Git : {{warnings}}", + "generatingFromUnstaged": "Zoo : Génération du message à partir des changements non indexés", + "confirmUnstaged": "Aucun changement indexé trouvé. Générer un message de commit à partir de {{count}} changements non indexés/non suivis ?", + "confirmUnstagedAction": "Générer à partir des changements non indexés", + "activationFailed": "Zoo : Erreur lors de l'activation du générateur de messages : {{error}}", + "providerRegistered": "Zoo : Fournisseur de messages de commit enregistré", + "initializing": "Initialisation...", + "discoveringFiles": "Découverte des fichiers...", + "foundChanges": "{{count}} changements trouvés", + "gettingContext": "Récupération du contexte Git...", + "errors": { + "connectionFailed": "Échec de la connexion à l'extension Zoo Code", + "timeout": "La requête a expiré après 30 secondes", + "invalidResponse": "Format de réponse invalide reçu de l'extension", + "missingMessage": "Aucun message de commit reçu de l'extension", + "noChanges": "Aucun changement trouvé à committer", + "noProject": "Aucun projet disponible", + "noWorkspacePath": "Impossible de déterminer le chemin de l'espace de travail pour le dépôt Git", + "workspaceNotFound": "Impossible de déterminer le chemin de l'espace de travail pour le dépôt Git", + "processingError": "Erreur lors du traitement de la génération du message de commit : {{error}}" + }, + "error": { + "title": "Erreur", + "workspacePathNotFound": "Impossible de déterminer le chemin de l'espace de travail pour le dépôt Git", + "generationFailed": "Erreur lors de la génération du message de commit : {{error}}", + "processingFailed": "Erreur lors du traitement de la génération du message de commit : {{error}}", + "unknown": "Erreur inconnue" + }, + "dialogs": { + "info": "Message de commit IA", + "error": "Erreur", + "success": "Succès", + "title": "Message de commit IA" + }, + "progress": { + "title": "Génération du message de commit", + "analyzing": "Analyse des changements...", + "connecting": "Connexion à Zoo Code...", + "generating": "Génération du message de commit..." + }, + "ui": { + "generateButton": "Générer le message de commit", + "generateButtonTooltip": "Génère un message de commit en utilisant l'IA pour analyser vos modifications de code" + } } } diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index 46f09e26d1..896c43ed2b 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: सफलतापूर्वक कनेक्ट हो गया! अब तुम Zoo Code को अपने AI प्रदाता के रूप में उपयोग कर सकते हो।", "disconnected": "Zoo Code: सफलतापूर्वक डिस्कनेक्ट हो गया।" } + }, + "commitMessage": { + "activated": "Zoo Code कमिट मैसेज जनरेटर सक्रिय किया गया", + "gitNotFound": "⚠️ Git रिपॉजिटरी नहीं मिली या Git उपलब्ध नहीं है", + "gitInitError": "⚠️ Git प्रारंभीकरण त्रुटि: {{error}}", + "generating": "Zoo: कमिट मैसेज बनाया जा रहा है...", + "noChanges": "Zoo: विश्लेषण के लिए कोई बदलाव नहीं मिला", + "generated": "Zoo: कमिट मैसेज बना दिया गया!", + "generationFailed": "Zoo: कमिट मैसेज बनाने में विफल: {{errorMessage}}", + "contextWarnings": "Zoo: Git संदर्भ चेतावनी: {{warnings}}", + "generatingFromUnstaged": "Zoo: अनस्टेज्ड बदलावों का उपयोग करके मैसेज बनाया जा रहा है", + "confirmUnstaged": "कोई स्टेज्ड बदलाव नहीं मिले। इसके बजाय {{count}} अनस्टेज्ड/अनट्रैक्ड बदलावों से कमिट मैसेज बनाएं?", + "confirmUnstagedAction": "अनस्टेज्ड बदलावों से बनाएं", + "activationFailed": "Zoo: मैसेज जनरेटर सक्रिय करने में विफल: {{error}}", + "providerRegistered": "Zoo: कमिट मैसेज प्रदाता पंजीकृत किया गया", + "initializing": "प्रारंभ हो रहा है...", + "discoveringFiles": "फ़ाइलें खोजी जा रही हैं...", + "foundChanges": "{{count}} बदलाव मिले", + "gettingContext": "Git संदर्भ प्राप्त किया जा रहा है...", + "errors": { + "connectionFailed": "Zoo Code एक्सटेंशन से कनेक्ट करने में विफल", + "timeout": "अनुरोध 30 सेकंड के बाद टाइमआउट हो गया", + "invalidResponse": "एक्सटेंशन से अमान्य प्रतिक्रिया फ़ॉर्मेट प्राप्त हुआ", + "missingMessage": "एक्सटेंशन से कोई कमिट मैसेश प्राप्त नहीं हुआ", + "noChanges": "कमिट करने के लिए कोई बदलाव नहीं मिला", + "noProject": "कोई प्रोजेक्ट उपलब्ध नहीं है", + "noWorkspacePath": "Git रिपॉजिटरी के लिए वर्कस्पेस पाथ निर्धारित नहीं किया जा सका", + "workspaceNotFound": "Git रिपॉजिटरी के लिए वर्कस्पेस पाथ निर्धारित नहीं किया जा सका", + "processingError": "कमिट मैसेज जनरेशन प्रोसेस करने में त्रुटि: {{error}}" + }, + "error": { + "title": "त्रुटि", + "workspacePathNotFound": "Git रिपॉजिटरी के लिए वर्कस्पेस पाथ निर्धारित नहीं किया जा सका", + "generationFailed": "कमिट मैसेज बनाने में विफल: {{error}}", + "processingFailed": "कमिट मैसेज जनरेशन प्रोसेस करने में त्रुटि: {{error}}", + "unknown": "अज्ञात त्रुटि" + }, + "dialogs": { + "info": "AI कमिट मैसेज", + "error": "त्रुटि", + "success": "सफल", + "title": "AI कमिट मैसेज" + }, + "progress": { + "title": "कमिट मैसेज बनाया जा रहा है", + "analyzing": "बदलावों का विश्लेषण किया जा रहा है...", + "connecting": "Zoo Code से कनेक्ट हो रहा है...", + "generating": "कमिट मैसेज बनाया जा रहा है..." + }, + "ui": { + "generateButton": "कमिट मैसेज बनाएं", + "generateButtonTooltip": "आपके कोड परिवर्तनों का विश्लेषण करके AI द्वारा कमिट मैसेज बनाता है" + } } } diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index 252a5a7523..e8bb62461d 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: Berhasil terhubung! Kamu sekarang bisa menggunakan Zoo Code sebagai penyedia AI.", "disconnected": "Zoo Code: Berhasil terputus." } + }, + "commitMessage": { + "activated": "Generator pesan commit Zoo Code telah diaktifkan", + "gitNotFound": "⚠️ Repositori Git tidak ditemukan atau git tidak tersedia", + "gitInitError": "⚠️ Kesalahan inisialisasi Git: {{error}}", + "generating": "Zoo: Membuat pesan commit...", + "noChanges": "Zoo: Tidak ada perubahan ditemukan untuk dianalisis", + "generated": "Zoo: Pesan commit berhasil dibuat!", + "generationFailed": "Zoo: Gagal membuat pesan commit: {{errorMessage}}", + "contextWarnings": "Zoo: Peringatan konteks Git: {{warnings}}", + "generatingFromUnstaged": "Zoo: Membuat pesan menggunakan perubahan yang belum di-stage", + "confirmUnstaged": "Tidak ada perubahan staged yang ditemukan. Buat pesan commit dari {{count}} perubahan unstaged/untracked sebagai gantinya?", + "confirmUnstagedAction": "Buat dari perubahan unstaged", + "activationFailed": "Zoo: Gagal mengaktifkan generator pesan: {{error}}", + "providerRegistered": "Zoo: Penyedia pesan commit terdaftar", + "initializing": "Menginisialisasi...", + "discoveringFiles": "Menemukan file...", + "foundChanges": "Ditemukan {{count}} perubahan", + "gettingContext": "Mendapatkan konteks Git...", + "errors": { + "connectionFailed": "Gagal terhubung ke ekstensi Zoo Code", + "timeout": "Permintaan waktu habis setelah 30 detik", + "invalidResponse": "Format respons tidak valid diterima dari ekstensi", + "missingMessage": "Tidak ada pesan commit yang diterima dari ekstensi", + "noChanges": "Tidak ada perubahan ditemukan untuk di-commit", + "noProject": "Tidak ada proyek tersedia", + "noWorkspacePath": "Tidak dapat menentukan path workspace untuk repositori Git", + "workspaceNotFound": "Tidak dapat menentukan path workspace untuk repositori Git", + "processingError": "Error memproses pembuatan pesan commit: {{error}}" + }, + "error": { + "title": "Error", + "workspacePathNotFound": "Tidak dapat menentukan path workspace untuk repositori Git", + "generationFailed": "Gagal membuat pesan commit: {{error}}", + "processingFailed": "Error memproses pembuatan pesan commit: {{error}}", + "unknown": "Error tidak diketahui" + }, + "dialogs": { + "info": "Pesan Commit AI", + "error": "Error", + "success": "Berhasil", + "title": "Pesan Commit AI" + }, + "progress": { + "title": "Membuat Pesan Commit", + "analyzing": "Menganalisis perubahan...", + "connecting": "Menghubungkan ke Zoo Code...", + "generating": "Membuat pesan commit..." + }, + "ui": { + "generateButton": "Buat Pesan Commit", + "generateButtonTooltip": "Membuat pesan commit menggunakan AI untuk menganalisis perubahan kode kamu" + } } } diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 7d7b1d3033..c83792bffb 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: Connesso con successo! Ora puoi usare Zoo Code come fornitore AI.", "disconnected": "Zoo Code: Disconnesso con successo." } + }, + "commitMessage": { + "activated": "Generatore messaggi commit Zoo Code attivato", + "gitNotFound": "⚠️ Repository Git non trovato o git non disponibile", + "gitInitError": "⚠️ Errore di inizializzazione Git: {{error}}", + "generating": "Zoo: Generazione messaggio commit in corso...", + "noChanges": "Zoo: Nessuna modifica trovata da analizzare", + "generated": "Zoo: Messaggio commit generato!", + "generationFailed": "Zoo: Generazione messaggio commit fallita: {{errorMessage}}", + "contextWarnings": "Zoo: Avviso contesto Git: {{warnings}}", + "generatingFromUnstaged": "Zoo: Generazione messaggio utilizzando le modifiche non in stage", + "confirmUnstaged": "Nessuna modifica in stage trovata. Generare un messaggio commit da {{count}} modifiche unstaged/untracked invece?", + "confirmUnstagedAction": "Genera da modifiche unstaged", + "activationFailed": "Zoo: Attivazione generatore messaggi fallita: {{error}}", + "providerRegistered": "Zoo: Provider messaggi commit registrato", + "initializing": "Inizializzazione in corso...", + "discoveringFiles": "Ricerca file in corso...", + "foundChanges": "Trovate {{count}} modifiche", + "gettingContext": "Ottenimento contesto Git in corso...", + "errors": { + "connectionFailed": "Connessione all'estensione Zoo Code fallita", + "timeout": "Richiesta scaduta dopo 30 secondi", + "invalidResponse": "Formato di risposta non valido ricevuto dall'estensione", + "missingMessage": "Nessun messaggio commit ricevuto dall'estensione", + "noChanges": "Nessuna modifica trovata da committare", + "noProject": "Nessun progetto disponibile", + "noWorkspacePath": "Impossibile determinare il percorso workspace per il repository Git", + "workspaceNotFound": "Impossibile determinare il percorso workspace per il repository Git", + "processingError": "Errore durante l'elaborazione della generazione del messaggio commit: {{error}}" + }, + "error": { + "title": "Errore", + "workspacePathNotFound": "Impossibile determinare il percorso workspace per il repository Git", + "generationFailed": "Generazione messaggio commit fallita: {{error}}", + "processingFailed": "Errore durante l'elaborazione della generazione del messaggio commit: {{error}}", + "unknown": "Errore sconosciuto" + }, + "dialogs": { + "info": "Messaggio Commit AI", + "error": "Errore", + "success": "Successo", + "title": "Messaggio Commit AI" + }, + "progress": { + "title": "Generazione Messaggio Commit", + "analyzing": "Analisi delle modifiche in corso...", + "connecting": "Connessione a Zoo Code in corso...", + "generating": "Generazione messaggio commit in corso..." + }, + "ui": { + "generateButton": "Genera Messaggio Commit", + "generateButtonTooltip": "Genera il messaggio commit utilizzando l'AI per analizzare le modifiche al codice" + } } } diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 4846ea932c..946d3bb3a8 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: 接続に成功しました!Zoo Code を AI プロバイダーとして使用できます。", "disconnected": "Zoo Code: 正常に切断されました。" } + }, + "commitMessage": { + "activated": "Zoo Code コミットメッセージジェネレーターが有効化されました", + "gitNotFound": "⚠️ Gitリポジトリが見つからないか、Gitが利用できません", + "gitInitError": "⚠️ Git初期化エラー: {{error}}", + "generating": "Zoo: コミットメッセージを生成中...", + "noChanges": "Zoo: 分析対象の変更が見つかりません", + "generated": "Zoo: コミットメッセージが生成されました!", + "generationFailed": "Zoo: コミットメッセージの生成に失敗しました: {{errorMessage}}", + "contextWarnings": "Zoo: Gitコンテキスト警告: {{warnings}}", + "generatingFromUnstaged": "Zoo: ステージされていない変更を使用してメッセージを生成中", + "confirmUnstaged": "ステージされた変更が見つかりません。代わりに{{count}}件のステージされていない/トラックされていない変更からコミットメッセージを生成しますか?", + "confirmUnstagedAction": "ステージされていない変更から生成", + "activationFailed": "Zoo: メッセージジェネレーターの有効化に失敗しました: {{error}}", + "providerRegistered": "Zoo: コミットメッセージプロバイダーが登録されました", + "initializing": "初期化中...", + "discoveringFiles": "ファイルを検索中...", + "foundChanges": "{{count}}件の変更が見つかりました", + "gettingContext": "Gitコンテキストを取得中...", + "errors": { + "connectionFailed": "Zoo Code拡張機能への接続に失敗しました", + "timeout": "リクエストが30秒後にタイムアウトしました", + "invalidResponse": "拡張機能から無効なレスポンス形式を受信しました", + "missingMessage": "拡張機能からコミットメッセージを受信できませんでした", + "noChanges": "コミットする変更が見つかりません", + "noProject": "利用可能なプロジェクトがありません", + "noWorkspacePath": "Gitリポジトリのワークスペースパスを特定できませんでした", + "workspaceNotFound": "Gitリポジトリのワークスペースパスを特定できませんでした", + "processingError": "コミットメッセージ生成の処理中にエラーが発生しました: {{error}}" + }, + "error": { + "title": "エラー", + "workspacePathNotFound": "Gitリポジトリのワークスペースパスを特定できませんでした", + "generationFailed": "コミットメッセージの生成に失敗しました: {{error}}", + "processingFailed": "コミットメッセージ生成の処理中にエラーが発生しました: {{error}}", + "unknown": "不明なエラー" + }, + "dialogs": { + "info": "AIコミットメッセージ", + "error": "エラー", + "success": "成功", + "title": "AIコミットメッセージ" + }, + "progress": { + "title": "コミットメッセージを生成中", + "analyzing": "変更を分析中...", + "connecting": "Zoo Codeに接続中...", + "generating": "コミットメッセージを生成中..." + }, + "ui": { + "generateButton": "コミットメッセージを生成", + "generateButtonTooltip": "AIを使用してコードの変更を分析し、コミットメッセージを生成します" + } } } diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 14f6823c44..1faa09198d 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: 연결에 성공했습니다! 이제 Zoo Code를 AI 제공자로 사용할 수 있습니다.", "disconnected": "Zoo Code: 연결이 해제되었습니다." } + }, + "commitMessage": { + "activated": "Zoo Code 커밋 메시지 생성기 활성화됨", + "gitNotFound": "⚠️ Git 저장소를 찾을 수 없거나 Git을 사용할 수 없습니다", + "gitInitError": "⚠️ Git 초기화 오류: {{error}}", + "generating": "Zoo: 커밋 메시지 생성 중...", + "noChanges": "Zoo: 분석할 변경 사항이 없습니다", + "generated": "Zoo: 커밋 메시지가 생성되었습니다!", + "generationFailed": "Zoo: 커밋 메시지 생성 실패: {{errorMessage}}", + "contextWarnings": "Zoo: Git 컨텍스트 경고: {{warnings}}", + "generatingFromUnstaged": "Zoo: 스테이징되지 않은 변경 사항으로 메시지 생성 중", + "confirmUnstaged": "스테이징된 변경 사항이 없습니다. {{count}}개의 스테이징되지 않은/추적되지 않은 변경 사항으로 커밋 메시지를 생성하시겠습니까?", + "confirmUnstagedAction": "스테이징되지 않은 변경 사항에서 생성", + "activationFailed": "Zoo: 메시지 생성기 활성화 실패: {{error}}", + "providerRegistered": "Zoo: 커밋 메시지 제공자 등록됨", + "initializing": "초기화 중...", + "discoveringFiles": "파일 검색 중...", + "foundChanges": "{{count}}개의 변경 사항 발견", + "gettingContext": "Git 컨텍스트 가져오는 중...", + "errors": { + "connectionFailed": "Zoo Code 확장 프로그램에 연결하지 못했습니다", + "timeout": "요청이 30초 후 시간 초과되었습니다", + "invalidResponse": "확장 프로그램에서 유효하지 않은 응답 형식을 받았습니다", + "missingMessage": "확장 프로그램에서 커밋 메시지를 받지 못했습니다", + "noChanges": "커밋할 변경 사항이 없습니다", + "noProject": "사용 가능한 프로젝트가 없습니다", + "noWorkspacePath": "Git 저장소의 작업 공간 경로를 확인할 수 없습니다", + "workspaceNotFound": "Git 저장소의 작업 공간 경로를 확인할 수 없습니다", + "processingError": "커밋 메시지 생성 처리 중 오류 발생: {{error}}" + }, + "error": { + "title": "오류", + "workspacePathNotFound": "Git 저장소의 작업 공간 경로를 확인할 수 없습니다", + "generationFailed": "커밋 메시지 생성 실패: {{error}}", + "processingFailed": "커밋 메시지 생성 처리 중 오류 발생: {{error}}", + "unknown": "알 수 없는 오류" + }, + "dialogs": { + "info": "AI 커밋 메시지", + "error": "오류", + "success": "성공", + "title": "AI 커밋 메시지" + }, + "progress": { + "title": "커밋 메시지 생성 중", + "analyzing": "변경 사항 분석 중...", + "connecting": "Zoo Code에 연결 중...", + "generating": "커밋 메시지 생성 중..." + }, + "ui": { + "generateButton": "커밋 메시지 생성", + "generateButtonTooltip": "AI를 사용하여 코드 변경 사항을 분석하고 커밋 메시지를 생성합니다" + } } } diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 7d38e7fe85..0e527616fa 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: Succesvol verbonden! Je kunt Zoo Code nu gebruiken als AI-provider.", "disconnected": "Zoo Code: Succesvol losgekoppeld." } + }, + "commitMessage": { + "activated": "Zoo Code commitbericht-generator geactiveerd", + "gitNotFound": "⚠️ Git-repository niet gevonden of git niet beschikbaar", + "gitInitError": "⚠️ Git-initialisatiefout: {{error}}", + "generating": "Zoo: Commitbericht genereren...", + "noChanges": "Zoo: Geen wijzigingen gevonden om te analyseren", + "generated": "Zoo: Commitbericht gegenereerd!", + "generationFailed": "Zoo: Genereren van commitbericht mislukt: {{errorMessage}}", + "contextWarnings": "Zoo: Git-contextwaarschuwing: {{warnings}}", + "generatingFromUnstaged": "Zoo: Bericht genereren met niet-geünstagede wijzigingen", + "confirmUnstaged": "Geen gestagete wijzigingen gevonden. Wil je in plaats daarvan een commitbericht genereren op basis van {{count}} niet-gestagete/ongetraceerde wijzigingen?", + "confirmUnstagedAction": "Genereren vanuit niet-gestagete wijzigingen", + "activationFailed": "Zoo: Activeren van berichtgenerator mislukt: {{error}}", + "providerRegistered": "Zoo: Commitbericht-provider geregistreerd", + "initializing": "Initialiseren...", + "discoveringFiles": "Bestanden ontdekken...", + "foundChanges": "{{count}} wijzigingen gevonden", + "gettingContext": "Git-context ophalen...", + "errors": { + "connectionFailed": "Verbinding met Zoo Code-extensie mislukt", + "timeout": "Verzoek verliep na 30 seconden", + "invalidResponse": "Ongeldig antwoordformaat ontvangen van extensie", + "missingMessage": "Geen commitbericht ontvangen van extensie", + "noChanges": "Geen wijzigingen gevonden om te committen", + "noProject": "Geen project beschikbaar", + "noWorkspacePath": "Kon werkruimtepad voor Git-repository niet bepalen", + "workspaceNotFound": "Kon werkruimtepad voor Git-repository niet bepalen", + "processingError": "Fout bij verwerken van commitberichtgeneratie: {{error}}" + }, + "error": { + "title": "Fout", + "workspacePathNotFound": "Kon werkruimtepad voor Git-repository niet bepalen", + "generationFailed": "Genereren van commitbericht mislukt: {{error}}", + "processingFailed": "Fout bij verwerken van commitberichtgeneratie: {{error}}", + "unknown": "Onbekende fout" + }, + "dialogs": { + "info": "AI Commitbericht", + "error": "Fout", + "success": "Succes", + "title": "AI Commitbericht" + }, + "progress": { + "title": "Commitbericht Genereren", + "analyzing": "Wijzigingen analyseren...", + "connecting": "Verbinding maken met Zoo Code...", + "generating": "Commitbericht genereren..." + }, + "ui": { + "generateButton": "Commitbericht Genereren", + "generateButtonTooltip": "Genereert commitbericht met AI om je codewijzigingen te analyseren" + } } } diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 9405ed355c..25de2faec1 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: Połączono pomyślnie! Możesz teraz używać Zoo Code jako dostawcy AI.", "disconnected": "Zoo Code: Rozłączono pomyślnie." } + }, + "commitMessage": { + "activated": "Generator wiadomości commitów Zoo Code został aktywowany", + "gitNotFound": "⚠️ Nie znaleziono repozytorium Git lub Git nie jest dostępny", + "gitInitError": "⚠️ Błąd inicjalizacji Git: {{error}}", + "generating": "Zoo: Generowanie wiadomości commitu...", + "noChanges": "Zoo: Nie znaleziono zmian do analizy", + "generated": "Zoo: Wiadomość commitu została wygenerowana!", + "generationFailed": "Zoo: Nie udało się wygenerować wiadomości commitu: {{errorMessage}}", + "contextWarnings": "Zoo: Ostrzeżenie kontekstu Git: {{warnings}}", + "generatingFromUnstaged": "Zoo: Generowanie wiadomości na podstawie niezatwierdzonych zmian", + "confirmUnstaged": "Nie znaleziono zatwierdzonych zmian. Wygenerować wiadomość commitu na podstawie {{count}} niezatwierdzonych/nieśledzonych zmian?", + "confirmUnstagedAction": "Generuj na podstawie niezatwierdzonych zmian", + "activationFailed": "Zoo: Nie udało się aktywować generatora wiadomości: {{error}}", + "providerRegistered": "Zoo: Dostawca wiadomości commitów zarejestrowany", + "initializing": "Inicjalizacja...", + "discoveringFiles": "Wyszukiwanie plików...", + "foundChanges": "Znaleziono {{count}} zmian", + "gettingContext": "Pobieranie kontekstu Git...", + "errors": { + "connectionFailed": "Nie udało się połączyć z rozszerzeniem Zoo Code", + "timeout": "Przekroczono limit czasu żądania po 30 sekundach", + "invalidResponse": "Otrzymano nieprawidłowy format odpowiedzi z rozszerzenia", + "missingMessage": "Nie otrzymano wiadomości commitu z rozszerzenia", + "noChanges": "Nie znaleziono zmian do commitowania", + "noProject": "Brak dostępnego projektu", + "noWorkspacePath": "Nie można określić ścieżki obszaru roboczego dla repozytorium Git", + "workspaceNotFound": "Nie można określić ścieżki obszaru roboczego dla repozytorium Git", + "processingError": "Błąd przetwarzania generowania wiadomości commitu: {{error}}" + }, + "error": { + "title": "Błąd", + "workspacePathNotFound": "Nie można określić ścieżki obszaru roboczego dla repozytorium Git", + "generationFailed": "Nie udało się wygenerować wiadomości commitu: {{error}}", + "processingFailed": "Błąd przetwarzania generowania wiadomości commitu: {{error}}", + "unknown": "Nieznany błąd" + }, + "dialogs": { + "info": "AI Wiadomość Commitu", + "error": "Błąd", + "success": "Sukces", + "title": "AI Wiadomość Commitu" + }, + "progress": { + "title": "Generowanie Wiadomości Commitu", + "analyzing": "Analizowanie zmian...", + "connecting": "Łączenie z Zoo Code...", + "generating": "Generowanie wiadomości commitu..." + }, + "ui": { + "generateButton": "Generuj Wiadomość Commitu", + "generateButtonTooltip": "Generuje wiadomość commitu przy użyciu AI do analizy zmian w kodzie" + } } } diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 7f04df07e9..62126e5ff6 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: Conectado com sucesso! Agora você pode usar o Zoo Code como provedor de IA.", "disconnected": "Zoo Code: Desconectado com sucesso." } + }, + "commitMessage": { + "activated": "Gerador de mensagens de commit do Zoo Code ativado", + "gitNotFound": "⚠️ Repositório Git não encontrado ou Git não disponível", + "gitInitError": "⚠️ Erro de inicialização do Git: {{error}}", + "generating": "Zoo: Gerando mensagem de commit...", + "noChanges": "Zoo: Nenhuma alteração encontrada para analisar", + "generated": "Zoo: Mensagem de commit gerada!", + "generationFailed": "Zoo: Falha ao gerar mensagem de commit: {{errorMessage}}", + "contextWarnings": "Zoo: Aviso de contexto Git: {{warnings}}", + "generatingFromUnstaged": "Zoo: Gerando mensagem usando alterações não preparadas", + "confirmUnstaged": "Nenhuma alteração preparada encontrada. Gerar uma mensagem de commit a partir de {{count}} alterações não preparadas/não rastreadas?", + "confirmUnstagedAction": "Gerar a partir de alterações não preparadas", + "activationFailed": "Zoo: Falha ao ativar gerador de mensagens: {{error}}", + "providerRegistered": "Zoo: Provedor de mensagens de commit registrado", + "initializing": "Inicializando...", + "discoveringFiles": "Descobrindo arquivos...", + "foundChanges": "{{count}} alterações encontradas", + "gettingContext": "Obtendo contexto Git...", + "errors": { + "connectionFailed": "Falha ao conectar à extensão Zoo Code", + "timeout": "A solicitação excedeu o tempo limite após 30 segundos", + "invalidResponse": "Formato de resposta inválido recebido da extensão", + "missingMessage": "Nenhuma mensagem de commit recebida da extensão", + "noChanges": "Nenhuma alteração encontrada para commit", + "noProject": "Nenhum projeto disponível", + "noWorkspacePath": "Não foi possível determinar o caminho do workspace para o repositório Git", + "workspaceNotFound": "Não foi possível determinar o caminho do workspace para o repositório Git", + "processingError": "Erro ao processar geração de mensagem de commit: {{error}}" + }, + "error": { + "title": "Erro", + "workspacePathNotFound": "Não foi possível determinar o caminho do workspace para o repositório Git", + "generationFailed": "Falha ao gerar mensagem de commit: {{error}}", + "processingFailed": "Erro ao processar geração de mensagem de commit: {{error}}", + "unknown": "Erro desconhecido" + }, + "dialogs": { + "info": "Mensagem de Commit por IA", + "error": "Erro", + "success": "Sucesso", + "title": "Mensagem de Commit por IA" + }, + "progress": { + "title": "Gerando Mensagem de Commit", + "analyzing": "Analisando alterações...", + "connecting": "Conectando ao Zoo Code...", + "generating": "Gerando mensagem de commit..." + }, + "ui": { + "generateButton": "Gerar Mensagem de Commit", + "generateButtonTooltip": "Gera mensagem de commit usando IA para analisar suas alterações de código" + } } } diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index fc39aff020..55332c96c5 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: Успешно подключено! Теперь ты можешь использовать Zoo Code в качестве AI-провайдера.", "disconnected": "Zoo Code: Успешно отключено." } + }, + "commitMessage": { + "activated": "Zoo Code: Генератор сообщений коммитов активирован", + "gitNotFound": "⚠️ Git-репозиторий не найден или git недоступен", + "gitInitError": "⚠️ Ошибка инициализации Git: {{error}}", + "generating": "Zoo: Генерация сообщения коммита...", + "noChanges": "Zoo: Изменения для анализа не найдены", + "generated": "Zoo: Сообщение коммита сгенерировано!", + "generationFailed": "Zoo: Не удалось сгенерировать сообщение коммита: {{errorMessage}}", + "contextWarnings": "Zoo: Предупреждение контекста Git: {{warnings}}", + "generatingFromUnstaged": "Zoo: Генерация сообщения из несохранённых изменений", + "confirmUnstaged": "Подготовленные изменения не найдены. Сгенерировать сообщение коммита из {{count}} неподготовленных/неотслеживаемых изменений?", + "confirmUnstagedAction": "Сгенерировать из неподготовленных изменений", + "activationFailed": "Zoo: Не удалось активировать генератор сообщений: {{error}}", + "providerRegistered": "Zoo: Провайдер сообщений коммитов зарегистрирован", + "initializing": "Инициализация...", + "discoveringFiles": "Обнаружение файлов...", + "foundChanges": "Найдено {{count}} изменений", + "gettingContext": "Получение контекста Git...", + "errors": { + "connectionFailed": "Не удалось подключиться к расширению Zoo Code", + "timeout": "Время ожидания запроса истекло через 30 секунд", + "invalidResponse": "Получен неверный формат ответа от расширения", + "missingMessage": "Не получено сообщение коммита от расширения", + "noChanges": "Изменения для коммита не найдены", + "noProject": "Нет доступного проекта", + "noWorkspacePath": "Не удалось определить путь рабочего пространства для Git-репозитория", + "workspaceNotFound": "Не удалось определить путь рабочего пространства для Git-репозитория", + "processingError": "Ошибка обработки генерации сообщения коммита: {{error}}" + }, + "error": { + "title": "Ошибка", + "workspacePathNotFound": "Не удалось определить путь рабочего пространства для Git-репозитория", + "generationFailed": "Не удалось сгенерировать сообщение коммита: {{error}}", + "processingFailed": "Ошибка обработки генерации сообщения коммита: {{error}}", + "unknown": "Неизвестная ошибка" + }, + "dialogs": { + "info": "AI-сообщение коммита", + "error": "Ошибка", + "success": "Успех", + "title": "AI-сообщение коммита" + }, + "progress": { + "title": "Генерация сообщения коммита", + "analyzing": "Анализ изменений...", + "connecting": "Подключение к Zoo Code...", + "generating": "Генерация сообщения коммита..." + }, + "ui": { + "generateButton": "Сгенерировать сообщение коммита", + "generateButtonTooltip": "Генерирует сообщение коммита с помощью ИИ-анализа изменений кода" + } } } diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 6bb669b454..abda023fe0 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: Başarıyla bağlandı! Artık Zoo Code'u AI sağlayıcısı olarak kullanabilirsin.", "disconnected": "Zoo Code: Başarıyla bağlantı kesildi." } + }, + "commitMessage": { + "activated": "Zoo Code commit mesaj üreteci etkinleştirildi", + "gitNotFound": "⚠️ Git deposu bulunamadı veya git kullanılamıyor", + "gitInitError": "⚠️ Git başlatma hatası: {{error}}", + "generating": "Zoo: Commit mesajı oluşturuluyor...", + "noChanges": "Zoo: Analiz edilecek değişiklik bulunamadı", + "generated": "Zoo: Commit mesajı oluşturuldu!", + "generationFailed": "Zoo: Commit mesajı oluşturulamadı: {{errorMessage}}", + "contextWarnings": "Zoo: Git bağlam uyarısı: {{warnings}}", + "generatingFromUnstaged": "Zoo: İşlenmemiş değişiklikler kullanılarak mesaj oluşturuluyor", + "confirmUnstaged": "İşlenmiş değişiklik bulunamadı. {{count}} işlenmemiş/izlenmeyen değişiklikten commit mesajı oluşturulsun mu?", + "confirmUnstagedAction": "İşlenmemiş değişikliklerden oluştur", + "activationFailed": "Zoo: Mesaj üreteci etkinleştirilemedi: {{error}}", + "providerRegistered": "Zoo: Commit mesajı sağlayıcısı kaydedildi", + "initializing": "Başlatılıyor...", + "discoveringFiles": "Dosyalar keşfediliyor...", + "foundChanges": "{{count}} değişiklik bulundu", + "gettingContext": "Git bağlamı alınıyor...", + "errors": { + "connectionFailed": "Zoo Code uzantısına bağlanılamadı", + "timeout": "İstek 30 saniye sonra zaman aşımına uğradı", + "invalidResponse": "Uzantıdan geçersiz yanıt formatı alındı", + "missingMessage": "Uzantıdan commit mesajı alınamadı", + "noChanges": "Commit edilecek değişiklik bulunamadı", + "noProject": "Kullanılabilir proje yok", + "noWorkspacePath": "Git deposu için çalışma alanı yolu belirlenemedi", + "workspaceNotFound": "Git deposu için çalışma alanı yolu belirlenemedi", + "processingError": "Commit mesajı oluşturma işleminde hata: {{error}}" + }, + "error": { + "title": "Hata", + "workspacePathNotFound": "Git deposu için çalışma alanı yolu belirlenemedi", + "generationFailed": "Commit mesajı oluşturulamadı: {{error}}", + "processingFailed": "Commit mesajı oluşturma işleminde hata: {{error}}", + "unknown": "Bilinmeyen hata" + }, + "dialogs": { + "info": "AI Commit Mesajı", + "error": "Hata", + "success": "Başarılı", + "title": "AI Commit Mesajı" + }, + "progress": { + "title": "Commit Mesajı Oluşturuluyor", + "analyzing": "Değişiklikler analiz ediliyor...", + "connecting": "Zoo Code'a bağlanılıyor...", + "generating": "Commit mesajı oluşturuluyor..." + }, + "ui": { + "generateButton": "Commit Mesajı Oluştur", + "generateButtonTooltip": "Kod değişikliklerinizi analiz ederek AI ile commit mesajı oluşturur" + } } } diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 57b01d682f..da88f89c43 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -274,5 +274,58 @@ "connected": "Zoo Code: Kết nối thành công! Bạn có thể sử dụng Zoo Code làm nhà cung cấp AI.", "disconnected": "Zoo Code: Đã ngắt kết nối thành công." } + }, + "commitMessage": { + "activated": "Zoo Code: Trình tạo thông điệp commit đã được kích hoạt", + "gitNotFound": "⚠️ Không tìm thấy kho Git hoặc git không khả dụng", + "gitInitError": "⚠️ Lỗi khởi tạo Git: {{error}}", + "generating": "Zoo: Đang tạo thông điệp commit...", + "noChanges": "Zoo: Không tìm thấy thay đổi để phân tích", + "generated": "Zoo: Đã tạo thông điệp commit!", + "generationFailed": "Zoo: Không thể tạo thông điệp commit: {{errorMessage}}", + "contextWarnings": "Zoo: Cảnh báo ngữ cảnh Git: {{warnings}}", + "generatingFromUnstaged": "Zoo: Đang tạo thông điệp từ các thay đổi chưa lưu", + "confirmUnstaged": "Không tìm thấy thay đổi đã lưu. Tạo thông điệp commit từ {{count}} thay đổi chưa lưu/chưa theo dõi?", + "confirmUnstagedAction": "Tạo từ thay đổi chưa lưu", + "activationFailed": "Zoo: Không thể kích hoạt trình tạo thông điệp: {{error}}", + "providerRegistered": "Zoo: Nhà cung cấp thông điệp commit đã được đăng ký", + "initializing": "Đang khởi tạo...", + "discoveringFiles": "Đang tìm kiếm tệp...", + "foundChanges": "Tìm thấy {{count}} thay đổi", + "gettingContext": "Đang lấy ngữ cảnh Git...", + "errors": { + "connectionFailed": "Không thể kết nối đến tiện ích Zoo Code", + "timeout": "Yêu cầu đã hết thời gian chờ sau 30 giây", + "invalidResponse": "Nhận được định dạng phản hồi không hợp lệ từ tiện ích", + "missingMessage": "Không nhận được thông điệp commit từ tiện ích", + "noChanges": "Không tìm thấy thay đổi để commit", + "noProject": "Không có dự án nào khả dụng", + "noWorkspacePath": "Không thể xác định đường dẫn workspace cho kho Git", + "workspaceNotFound": "Không thể xác định đường dẫn workspace cho kho Git", + "processingError": "Lỗi xử lý tạo thông điệp commit: {{error}}" + }, + "error": { + "title": "Lỗi", + "workspacePathNotFound": "Không thể xác định đường dẫn workspace cho kho Git", + "generationFailed": "Không thể tạo thông điệp commit: {{error}}", + "processingFailed": "Lỗi xử lý tạo thông điệp commit: {{error}}", + "unknown": "Lỗi không xác định" + }, + "dialogs": { + "info": "Thông điệp commit AI", + "error": "Lỗi", + "success": "Thành công", + "title": "Thông điệp commit AI" + }, + "progress": { + "title": "Đang tạo thông điệp commit", + "analyzing": "Đang phân tích thay đổi...", + "connecting": "Đang kết nối đến Zoo Code...", + "generating": "Đang tạo thông điệp commit..." + }, + "ui": { + "generateButton": "Tạo thông điệp commit", + "generateButtonTooltip": "Tạo thông điệp commit bằng AI để phân tích các thay đổi mã nguồn của bạn" + } } } diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 62a176ff69..d37bcd3f8d 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -272,5 +272,58 @@ "connected": "Zoo Code: 连接成功!你现在可以使用 Zoo Code 作为 AI 提供商。", "disconnected": "Zoo Code: 已成功断开连接。" } + }, + "commitMessage": { + "activated": "Zoo Code: 提交消息生成器已激活", + "gitNotFound": "⚠️ 未找到 Git 仓库或 git 不可用", + "gitInitError": "⚠️ Git 初始化错误:{{error}}", + "generating": "Zoo:正在生成提交消息...", + "noChanges": "Zoo:未找到需要分析的更改", + "generated": "Zoo:提交消息已生成!", + "generationFailed": "Zoo:生成提交消息失败:{{errorMessage}}", + "contextWarnings": "Zoo:Git 上下文警告:{{warnings}}", + "generatingFromUnstaged": "Zoo:使用未暂存的更改生成消息", + "confirmUnstaged": "未找到已暂存的更改。是否从 {{count}} 个未暂存/未跟踪的更改生成提交消息?", + "confirmUnstagedAction": "从未暂存的更改生成", + "activationFailed": "Zoo:激活消息生成器失败:{{error}}", + "providerRegistered": "Zoo:提交消息提供者已注册", + "initializing": "正在初始化...", + "discoveringFiles": "正在发现文件...", + "foundChanges": "发现 {{count}} 个更改", + "gettingContext": "正在获取 Git 上下文...", + "errors": { + "connectionFailed": "无法连接到 Zoo Code 扩展", + "timeout": "请求超时,超过 30 秒", + "invalidResponse": "从扩展收到无效的响应格式", + "missingMessage": "未从扩展收到提交消息", + "noChanges": "未找到需要提交的更改", + "noProject": "没有可用的项目", + "noWorkspacePath": "无法确定 Git 仓库的工作区路径", + "workspaceNotFound": "无法确定 Git 仓库的工作区路径", + "processingError": "处理提交消息生成时出错:{{error}}" + }, + "error": { + "title": "错误", + "workspacePathNotFound": "无法确定 Git 仓库的工作区路径", + "generationFailed": "生成提交消息失败:{{error}}", + "processingFailed": "处理提交消息生成时出错:{{error}}", + "unknown": "未知错误" + }, + "dialogs": { + "info": "AI 提交消息", + "error": "错误", + "success": "成功", + "title": "AI 提交消息" + }, + "progress": { + "title": "正在生成提交消息", + "analyzing": "正在分析更改...", + "connecting": "正在连接 Zoo Code...", + "generating": "正在生成提交消息..." + }, + "ui": { + "generateButton": "生成提交消息", + "generateButtonTooltip": "使用 AI 分析代码更改并生成提交消息" + } } } diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 54325ad022..2ffb8464ee 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: 連線成功!你現在可以使用 Zoo Code 作為 AI 提供商。", "disconnected": "Zoo Code: 已成功中斷連線。" } + }, + "commitMessage": { + "activated": "Zoo Code:提交訊息產生器已啟用", + "gitNotFound": "⚠️ 找不到 Git 儲存庫或 git 不可用", + "gitInitError": "⚠️ Git 初始化錯誤:{{error}}", + "generating": "Zoo:正在產生提交訊息...", + "noChanges": "Zoo:找不到需要分析的變更", + "generated": "Zoo:提交訊息已產生!", + "generationFailed": "Zoo:產生提交訊息失敗:{{errorMessage}}", + "contextWarnings": "Zoo:Git 上下文警告:{{warnings}}", + "generatingFromUnstaged": "Zoo:使用未暫存的變更產生訊息", + "confirmUnstaged": "找不到已暫存的變更。是否從 {{count}} 個未暫存/未追蹤的變更產生提交訊息?", + "confirmUnstagedAction": "從未暫存的變更產生", + "activationFailed": "Zoo:啟用訊息產生器失敗:{{error}}", + "providerRegistered": "Zoo:提交訊息提供者已註冊", + "initializing": "正在初始化...", + "discoveringFiles": "正在探索檔案...", + "foundChanges": "找到 {{count}} 個變更", + "gettingContext": "正在取得 Git 上下文...", + "errors": { + "connectionFailed": "無法連線到 Zoo Code 擴充套件", + "timeout": "請求逾時,超過 30 秒", + "invalidResponse": "從擴充套件收到無效的回應格式", + "missingMessage": "未從擴充套件收到提交訊息", + "noChanges": "找不到需要提交的變更", + "noProject": "沒有可用的專案", + "noWorkspacePath": "無法確定 Git 儲存庫的工作區路徑", + "workspaceNotFound": "無法確定 Git 儲存庫的工作區路徑", + "processingError": "處理提交訊息產生時發生錯誤:{{error}}" + }, + "error": { + "title": "錯誤", + "workspacePathNotFound": "無法確定 Git 儲存庫的工作區路徑", + "generationFailed": "產生提交訊息失敗:{{error}}", + "processingFailed": "處理提交訊息產生時發生錯誤:{{error}}", + "unknown": "未知錯誤" + }, + "dialogs": { + "info": "AI 提交訊息", + "error": "錯誤", + "success": "成功", + "title": "AI 提交訊息" + }, + "progress": { + "title": "正在產生提交訊息", + "analyzing": "正在分析變更...", + "connecting": "正在連線到 Zoo Code...", + "generating": "正在產生提交訊息..." + }, + "ui": { + "generateButton": "產生提交訊息", + "generateButtonTooltip": "使用 AI 分析程式碼變更並產生提交訊息" + } } } diff --git a/src/services/commit-message/CommitMessageGenerator.ts b/src/services/commit-message/CommitMessageGenerator.ts index bd293f8a36..08ea5351c2 100644 --- a/src/services/commit-message/CommitMessageGenerator.ts +++ b/src/services/commit-message/CommitMessageGenerator.ts @@ -7,6 +7,8 @@ import { TelemetryService } from "@roo-code/telemetry" import { TelemetryEventName, type ProviderSettings } from "@roo-code/types" import { GenerateMessageParams, PromptOptions, ProgressUpdate } from "./types/core" +import { getActiveCommitMessageProfileSettings } from "./profileSettings" +import { appendCommitMessageAttribution, createCommitMessageAttribution } from "./attribution" /** Provides the extension settings needed to generate commit messages. */ export interface CommitMessageContextProxy { @@ -155,7 +157,8 @@ FINAL REMINDER: Your message MUST be COMPLETELY DIFFERENT from the previous mess 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 activeProfile = getActiveCommitMessageProfileSettings(contextProxy) + const commitMessageApiConfigId = activeProfile.apiConfigId const listApiConfigMeta = (contextProxy.getValue("listApiConfigMeta") || []) as Array<{ id: string }> const customSupportPrompts = (contextProxy.getValue("customSupportPrompts") || {}) as Record< string, @@ -185,12 +188,12 @@ FINAL REMINDER: Your message MUST be COMPLETELY DIFFERENT from the previous mess const filteredPrompts = Object.fromEntries( Object.entries(customSupportPrompts).filter(([_, value]) => value !== undefined), ) as Record + const profilePrompts = + activeProfile.prompt !== undefined + ? { ...filteredPrompts, COMMIT_MESSAGE: activeProfile.prompt } + : filteredPrompts - const prompt = await this.buildPrompt( - gitContextString, - { customSupportPrompts: filteredPrompts }, - workspacePath, - ) + const prompt = await this.buildPrompt(gitContextString, { customSupportPrompts: profilePrompts }, workspacePath) onProgress?.({ message: "Calling AI service...", @@ -204,7 +207,10 @@ FINAL REMINDER: Your message MUST be COMPLETELY DIFFERENT from the previous mess increment: 10, }) - return this.extractCommitMessage(response) + const message = this.extractCommitMessage(response) + const attribution = createCommitMessageAttribution(activeProfile.attribution, configToUse) + + return appendCommitMessageAttribution(message, attribution) } /** Throws when there is no meaningful Git change data to describe. */ diff --git a/src/services/commit-message/CommitMessageProvider.ts b/src/services/commit-message/CommitMessageProvider.ts index 23533bf406..44c4444da8 100644 --- a/src/services/commit-message/CommitMessageProvider.ts +++ b/src/services/commit-message/CommitMessageProvider.ts @@ -73,6 +73,7 @@ export class CommitMessageProvider implements vscode.Disposable { try { reportProgress(15, t("common:commitMessage.discoveringFiles")) const resolution = await this.resolveCommitChanges(gitCollector) + const gitContextSettings = getCommitMessageGitContextSettings() if (resolution.changes.length === 0) { diff --git a/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts b/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts index 188533d11e..60a62e9c10 100644 --- a/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts +++ b/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts @@ -3,8 +3,16 @@ 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 defaultConfig: ProviderSettings = { + apiProvider: "openai", + openAiApiKey: "default-key", + openAiModelId: "gpt-4", + } + const commitConfig: ProviderSettings = { + apiProvider: "anthropic", + apiKey: "commit-key", + apiModelId: "claude-opus-4-7", + } const providerSettingsManager = { initialize: vi.fn(), getProfile: vi.fn(), @@ -138,6 +146,53 @@ new file mode 100644 ) }) + it("uses the active commit-message profile prompt and API config", async () => { + contextProxy.getValue.mockImplementation((key: string) => { + switch (key) { + case "commitMessageProfiles": + return { + activeProfileId: "release", + profiles: [ + { + id: "default", + name: "Default", + apiConfigId: "default-profile", + }, + { + id: "release", + name: "Release", + prompt: "Release prompt\n${gitContext}\n${customInstructions}", + apiConfigId: "commit-profile", + }, + ], + } + case "commitMessageApiConfigId": + return "default-profile" + case "listApiConfigMeta": + return [{ id: "commit-profile", name: "Commit profile" }] + case "customSupportPrompts": + return { COMMIT_MESSAGE: "Legacy prompt ${gitContext}" } + default: + return undefined + } + }) + completePrompt.mockResolvedValue("chore(release): prepare notes") + const generator = createGenerator() + + await generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: ["CHANGELOG.md"], + gitContext: "diff --git a/CHANGELOG.md b/CHANGELOG.md", + }) + + expect(providerSettingsManager.getProfile).toHaveBeenCalledWith({ id: "commit-profile" }) + expect(completePrompt).toHaveBeenCalledWith( + expect.objectContaining(commitConfig), + expect.stringContaining("Release prompt"), + ) + expect(completePrompt.mock.calls[0][1]).not.toContain("Legacy prompt") + }) + it("falls back to current API config when the selected profile cannot be loaded", async () => { contextProxy.getValue.mockImplementation((key: string) => { switch (key) { @@ -215,4 +270,100 @@ Keep unstaged commit context focused on worktree changes.`) expect(captureGenerated).not.toHaveBeenCalled() }) + + it("appends attribution from the top-level single-profile setting", async () => { + contextProxy.getValue.mockImplementation((key: string) => { + switch (key) { + case "commitMessageAttribution": + return { enabled: true } + case "listApiConfigMeta": + return [] + case "customSupportPrompts": + return {} + default: + return undefined + } + }) + completePrompt.mockResolvedValue("feat(scm): add commit generation") + const generator = createGenerator() + + const message = await generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: ["src/new.ts"], + gitContext: "diff --git a/src/new.ts b/src/new.ts", + }) + + expect(message).toBe("feat(scm): add commit generation\n\nAssisted-by: Zoo Code:openai/gpt-4 [Zoo Code]") + }) + + it("uses the actual profile API config for attribution", async () => { + contextProxy.getValue.mockImplementation((key: string) => { + switch (key) { + case "commitMessageProfiles": + return { + activeProfileId: "release", + profiles: [ + { + id: "release", + name: "Release", + apiConfigId: "commit-profile", + attribution: { enabled: true, template: "Generated-by: ${providerModel}" }, + }, + ], + } + case "listApiConfigMeta": + return [{ id: "commit-profile", name: "Commit profile" }] + case "customSupportPrompts": + return {} + default: + return undefined + } + }) + completePrompt.mockResolvedValue("chore(release): prepare notes") + const generator = createGenerator() + + const message = await generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: ["CHANGELOG.md"], + gitContext: "diff --git a/CHANGELOG.md b/CHANGELOG.md", + }) + + expect(message).toBe("chore(release): prepare notes\n\nGenerated-by: anthropic/claude-opus-4-7") + }) + + it("uses fallback API config for attribution when profile loading fails", async () => { + contextProxy.getValue.mockImplementation((key: string) => { + switch (key) { + case "commitMessageProfiles": + return { + activeProfileId: "release", + profiles: [ + { + id: "release", + name: "Release", + apiConfigId: "missing-profile", + attribution: { enabled: true }, + }, + ], + } + case "listApiConfigMeta": + return [{ id: "missing-profile", name: "Missing profile" }] + case "customSupportPrompts": + return {} + default: + return undefined + } + }) + providerSettingsManager.getProfile.mockRejectedValue(new Error("missing profile")) + completePrompt.mockResolvedValue("fix(scm): handle profile fallback") + const generator = createGenerator() + + const message = await generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: ["src/new.ts"], + gitContext: "diff --git a/src/new.ts b/src/new.ts", + }) + + expect(message).toBe("fix(scm): handle profile fallback\n\nAssisted-by: Zoo Code:openai/gpt-4 [Zoo Code]") + }) }) diff --git a/src/services/commit-message/__tests__/attribution.spec.ts b/src/services/commit-message/__tests__/attribution.spec.ts new file mode 100644 index 0000000000..e8002208bc --- /dev/null +++ b/src/services/commit-message/__tests__/attribution.spec.ts @@ -0,0 +1,48 @@ +import type { ProviderSettings } from "@roo-code/types" + +import { + appendCommitMessageAttribution, + applyCommitMessageAttributionTemplate, + createCommitMessageAttribution, +} from "../attribution" + +describe("commit message attribution", () => { + const apiConfiguration: ProviderSettings = { + apiProvider: "anthropic", + apiModelId: "claude-opus-4-7", + } + + it("returns no attribution when disabled by default", () => { + expect(createCommitMessageAttribution(undefined, apiConfiguration)).toBe("") + }) + + it("creates the default attribution with provider and model", () => { + expect(createCommitMessageAttribution({ enabled: true }, apiConfiguration)).toBe( + "Assisted-by: Zoo Code:anthropic/claude-opus-4-7 [Zoo Code]", + ) + }) + + it("applies custom attribution placeholders", () => { + expect( + applyCommitMessageAttributionTemplate("Co-authored-by: ${agentName} (${providerModel}) [${toolName}]", { + agentName: "Zoo Code", + toolName: "Zoo Code", + provider: "openrouter", + model: "openai/gpt-4", + providerModel: "openrouter/openai/gpt-4", + }), + ).toBe("Co-authored-by: Zoo Code (openrouter/openai/gpt-4) [Zoo Code]") + }) + + it("appends attribution with exactly one blank line", () => { + expect(appendCommitMessageAttribution("feat(scm): generate commits\n", "Assisted-by: Zoo Code")).toBe( + "feat(scm): generate commits\n\nAssisted-by: Zoo Code", + ) + }) + + it("does not duplicate an existing attribution footer", () => { + const message = "feat(scm): generate commits\n\nAssisted-by: Zoo Code" + + expect(appendCommitMessageAttribution(message, "Assisted-by: Zoo Code")).toBe(message) + }) +}) diff --git a/src/services/commit-message/__tests__/profileSettings.spec.ts b/src/services/commit-message/__tests__/profileSettings.spec.ts new file mode 100644 index 0000000000..d84274d9af --- /dev/null +++ b/src/services/commit-message/__tests__/profileSettings.spec.ts @@ -0,0 +1,111 @@ +import { defaultCommitMessageAttributionSettings, defaultCommitMessageGitContextSettings } from "@roo-code/types" + +import { getActiveCommitMessageProfileSettings, getCommitMessageProfileSettings } from "../profileSettings" + +describe("commit message profile settings", () => { + const createContextProxy = (values: Record) => ({ + getValue: vi.fn((key: string) => values[key]), + }) + + it("creates one default profile from old single-profile settings", () => { + const contextProxy = createContextProxy({ + customSupportPrompts: { COMMIT_MESSAGE: "Custom commit prompt ${gitContext}" }, + commitMessageApiConfigId: "commit-profile", + commitMessageGitContext: { diffContextLines: 8, includeRecentCommits: false }, + commitMessageAttribution: { enabled: true, template: "Assisted-by: ${providerModel}" }, + }) + + const settings = getCommitMessageProfileSettings(contextProxy) + + expect(settings.activeProfileId).toBe("default") + expect(settings.profiles).toHaveLength(1) + expect(settings.profiles[0]).toMatchObject({ + id: "default", + name: "Default", + prompt: "Custom commit prompt ${gitContext}", + apiConfigId: "commit-profile", + }) + expect(settings.profiles[0].gitContext).toMatchObject({ + ...defaultCommitMessageGitContextSettings, + diffContextLines: 8, + includeRecentCommits: false, + }) + expect(settings.profiles[0].attribution).toEqual({ + enabled: true, + template: "Assisted-by: ${providerModel}", + }) + }) + + it("clamps profiles to 5 and falls back to the first profile when the active id is missing", () => { + const contextProxy = createContextProxy({ + commitMessageProfiles: { + activeProfileId: "missing", + profiles: Array.from({ length: 7 }, (_, index) => ({ + id: `profile-${index + 1}`, + name: `Profile ${index + 1}`, + })), + }, + }) + + const settings = getCommitMessageProfileSettings(contextProxy) + + expect(settings.profiles).toHaveLength(5) + expect(settings.activeProfileId).toBe("profile-1") + }) + + it("merges default Git context settings for each profile", () => { + const contextProxy = createContextProxy({ + commitMessageProfiles: { + activeProfileId: "detailed", + profiles: [ + { + id: "detailed", + name: "Detailed", + gitContext: { includeRecentCommitDiffs: true, recentCommitDiffCount: 9 }, + }, + ], + }, + }) + + const profile = getActiveCommitMessageProfileSettings(contextProxy) + + expect(profile.gitContext).toMatchObject({ + ...defaultCommitMessageGitContextSettings, + includeRecentCommitDiffs: true, + recentCommitDiffCount: 5, + }) + }) + + it("does not apply top-level attribution fallback to stored profiles", () => { + const contextProxy = createContextProxy({ + commitMessageAttribution: { enabled: true, template: "Assisted-by: ${providerModel}" }, + commitMessageProfiles: { + activeProfileId: "default", + profiles: [{ id: "default", name: "Default" }], + }, + }) + + const profile = getActiveCommitMessageProfileSettings(contextProxy) + + expect(profile.attribution).toEqual(defaultCommitMessageAttributionSettings) + }) + + it("normalizes stored profile attribution independently", () => { + const contextProxy = createContextProxy({ + commitMessageProfiles: { + activeProfileId: "release", + profiles: [ + { id: "default", name: "Default" }, + { id: "release", name: "Release", attribution: { enabled: true } }, + ], + }, + }) + + const profile = getActiveCommitMessageProfileSettings(contextProxy) + + expect(profile.attribution).toEqual({ + ...defaultCommitMessageAttributionSettings, + enabled: true, + }) + }) +}) diff --git a/src/services/commit-message/attribution.ts b/src/services/commit-message/attribution.ts new file mode 100644 index 0000000000..fd5be9f432 --- /dev/null +++ b/src/services/commit-message/attribution.ts @@ -0,0 +1,64 @@ +import { + getModelId, + normalizeCommitMessageAttributionSettings, + type CommitMessageAttributionSettings, + type ProviderSettings, +} from "@roo-code/types" + +const ATTRIBUTION_AGENT_NAME = "Zoo Code" +const ATTRIBUTION_TOOL_NAME = "Zoo Code" +const UNKNOWN_VALUE = "unknown" + +export interface CommitMessageAttributionTemplateValues { + agentName: string + toolName: string + provider: string + model: string + providerModel: string +} + +export function createCommitMessageAttribution( + settings: CommitMessageAttributionSettings | undefined, + apiConfiguration: ProviderSettings, +): string { + const normalized = normalizeCommitMessageAttributionSettings(settings) + if (!normalized.enabled) { + return "" + } + + const provider = apiConfiguration.apiProvider || UNKNOWN_VALUE + const model = getModelId(apiConfiguration) || UNKNOWN_VALUE + + return applyCommitMessageAttributionTemplate(normalized.template, { + agentName: ATTRIBUTION_AGENT_NAME, + toolName: ATTRIBUTION_TOOL_NAME, + provider, + model, + providerModel: `${provider}/${model}`, + }) +} + +export function applyCommitMessageAttributionTemplate( + template: string, + values: CommitMessageAttributionTemplateValues, +): string { + return template.replace( + /\$\{(agentName|toolName|provider|model|providerModel)\}/g, + (_, key) => values[key as keyof typeof values], + ) +} + +export function appendCommitMessageAttribution(message: string, attribution: string): string { + const cleanedMessage = message.trim() + const cleanedAttribution = attribution.trim() + + if (!cleanedAttribution) { + return cleanedMessage + } + + if (cleanedMessage.endsWith(cleanedAttribution)) { + return cleanedMessage + } + + return `${cleanedMessage}\n\n${cleanedAttribution}` +} diff --git a/src/services/commit-message/gitContextSettings.ts b/src/services/commit-message/gitContextSettings.ts index eec746dba3..14c877216d 100644 --- a/src/services/commit-message/gitContextSettings.ts +++ b/src/services/commit-message/gitContextSettings.ts @@ -1,42 +1,11 @@ -import { defaultCommitMessageGitContextSettings, type CommitMessageGitContextSettings } from "@roo-code/types" +import { normalizeCommitMessageGitContextSettings, type CommitMessageGitContextSettings } from "@roo-code/types" -import { ContextProxy } from "../../core/config/ContextProxy" import type { GitContextCollectorOptions } from "../git-context" +import { getActiveCommitMessageProfileSettings } from "./profileSettings" /** Reads and normalizes the persisted Git context settings for commit message generation. */ 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, - ), - } + return getActiveCommitMessageProfileSettings().gitContext } /** Converts commit-message settings into options consumed by the Git context collector. */ @@ -61,11 +30,3 @@ export function toGitContextCollectorOptions( }, } } - -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/profileSettings.ts b/src/services/commit-message/profileSettings.ts new file mode 100644 index 0000000000..b7f7d77859 --- /dev/null +++ b/src/services/commit-message/profileSettings.ts @@ -0,0 +1,54 @@ +import { + getActiveCommitMessageProfile, + normalizeCommitMessageProfiles, + type CommitMessageAttributionSettings, + type CommitMessageGitContextSettings, + type CommitMessageProfilesSettings, + type NormalizedCommitMessageProfile, + type NormalizedCommitMessageProfiles, +} from "@roo-code/types" + +import { ContextProxy } from "../../core/config/ContextProxy" + +export interface CommitMessageProfileContextProxy { + getValue(key: any): unknown +} + +export function getCommitMessageProfileSettings( + contextProxy: CommitMessageProfileContextProxy = ContextProxy.instance, +): NormalizedCommitMessageProfiles { + return normalizeCommitMessageProfiles( + readCommitMessageProfiles(contextProxy), + readSingleProfileFallback(contextProxy), + ) +} + +export function getActiveCommitMessageProfileSettings( + contextProxy: CommitMessageProfileContextProxy = ContextProxy.instance, +): NormalizedCommitMessageProfile { + return getActiveCommitMessageProfile( + readCommitMessageProfiles(contextProxy), + readSingleProfileFallback(contextProxy), + ) +} + +function readCommitMessageProfiles( + contextProxy: CommitMessageProfileContextProxy, +): CommitMessageProfilesSettings | undefined { + return contextProxy.getValue("commitMessageProfiles") as CommitMessageProfilesSettings | undefined +} + +function readSingleProfileFallback(contextProxy: CommitMessageProfileContextProxy) { + const customSupportPrompts = (contextProxy.getValue("customSupportPrompts") || {}) as Record< + string, + string | undefined + > + + // Profile settings layer over the original single-profile keys so profiles can be removed cleanly. + return { + prompt: customSupportPrompts.COMMIT_MESSAGE, + apiConfigId: contextProxy.getValue("commitMessageApiConfigId") as string | undefined, + gitContext: contextProxy.getValue("commitMessageGitContext") as CommitMessageGitContextSettings | undefined, + attribution: contextProxy.getValue("commitMessageAttribution") as CommitMessageAttributionSettings | undefined, + } +} diff --git a/webview-ui/src/components/settings/CommitMessagePromptSettings.tsx b/webview-ui/src/components/settings/CommitMessagePromptSettings.tsx new file mode 100644 index 0000000000..71beb70d45 --- /dev/null +++ b/webview-ui/src/components/settings/CommitMessagePromptSettings.tsx @@ -0,0 +1,555 @@ +import React from "react" +import { VSCodeCheckbox, VSCodeTextArea, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { + MAX_COMMIT_MESSAGE_PROFILES, + createCommitMessageProfileId, + createCommitMessageProfileName, + defaultCommitMessageAttributionSettings, + defaultCommitMessageGitContextSettings, + normalizeCommitMessageProfiles, + type CommitMessageAttributionSettings, + type CommitMessageGitContextSettings, + type CommitMessageProfileSettings, + type CommitMessageProfilesSettings, +} from "@roo-code/types" +import { supportPrompt } from "@roo/support-prompt" +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { + Button, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + StandardTooltip, +} from "@src/components/ui" + +interface CommitMessagePromptSettingsProps { + listApiConfigMeta: Array<{ id: string; name: string }> + customSupportPrompts: Record + setCustomSupportPrompts: (prompts: Record) => void + commitMessageApiConfigId?: string + setCommitMessageApiConfigId: (value: string) => void + commitMessageGitContext?: CommitMessageGitContextSettings + setCommitMessageGitContext: (value: CommitMessageGitContextSettings) => void + commitMessageAttribution?: CommitMessageAttributionSettings + setCommitMessageAttribution: (value: CommitMessageAttributionSettings) => void + commitMessageProfiles?: CommitMessageProfilesSettings + setCommitMessageProfiles: (value: CommitMessageProfilesSettings) => void +} + +const CommitMessagePromptSettings = ({ + listApiConfigMeta, + customSupportPrompts, + setCustomSupportPrompts, + commitMessageApiConfigId, + setCommitMessageApiConfigId, + commitMessageGitContext, + setCommitMessageGitContext, + commitMessageAttribution, + setCommitMessageAttribution, + commitMessageProfiles, + setCommitMessageProfiles, +}: CommitMessagePromptSettingsProps) => { + const { t } = useAppTranslation() + const hasStoredProfiles = Boolean(commitMessageProfiles?.profiles?.length) + const normalizedProfiles = normalizeCommitMessageProfiles(commitMessageProfiles, { + prompt: customSupportPrompts.COMMIT_MESSAGE, + apiConfigId: commitMessageApiConfigId, + gitContext: commitMessageGitContext, + attribution: commitMessageAttribution, + }) + const activeProfile = + normalizedProfiles.profiles.find((profile) => profile.id === normalizedProfiles.activeProfileId) ?? + normalizedProfiles.profiles[0] + const profilePrompt = activeProfile.prompt ?? supportPrompt.get({}, "COMMIT_MESSAGE") + const canAddProfile = normalizedProfiles.profiles.length < MAX_COMMIT_MESSAGE_PROFILES + const canDeleteProfile = normalizedProfiles.profiles.length > 1 + const attributionSettings = activeProfile.attribution + const suppressCheckboxChangesRef = React.useRef(false) + + const getRawProfiles = (): CommitMessageProfileSettings[] => { + if (hasStoredProfiles) { + return (commitMessageProfiles?.profiles ?? []).slice(0, MAX_COMMIT_MESSAGE_PROFILES) + } + + return [ + { + id: activeProfile.id, + name: activeProfile.name, + prompt: activeProfile.prompt, + apiConfigId: activeProfile.apiConfigId, + gitContext: commitMessageGitContext, + attribution: commitMessageAttribution, + }, + ] + } + + const isActiveRawProfile = (_profile: CommitMessageProfileSettings, index: number) => + normalizedProfiles.profiles[index]?.id === activeProfile.id + const getActiveRawProfile = () => getRawProfiles().find(isActiveRawProfile) + + const suppressProfileTransitionCheckboxChanges = () => { + suppressCheckboxChangesRef.current = true + window.setTimeout(() => { + suppressCheckboxChangesRef.current = false + }, 0) + } + + const persistProfiles = ( + profiles: CommitMessageProfileSettings[], + activeProfileId: string, + suppressCheckboxChanges = false, + ) => { + if (suppressCheckboxChanges) { + suppressProfileTransitionCheckboxChanges() + } + + setCommitMessageProfiles({ + activeProfileId, + profiles: profiles.slice(0, MAX_COMMIT_MESSAGE_PROFILES), + }) + } + + const updateActiveProfile = (updates: Partial) => { + if (!hasStoredProfiles) { + if ("prompt" in updates) { + const nextPrompts = { ...customSupportPrompts } + if (updates.prompt === undefined) { + delete nextPrompts.COMMIT_MESSAGE + } else { + nextPrompts.COMMIT_MESSAGE = updates.prompt + } + setCustomSupportPrompts(nextPrompts) + } + + if ("apiConfigId" in updates) { + setCommitMessageApiConfigId(updates.apiConfigId ?? "") + } + + if (updates.gitContext) { + setCommitMessageGitContext(updates.gitContext) + } + + if ("name" in updates) { + persistProfiles([{ ...getRawProfiles()[0], ...updates }], activeProfile.id, true) + } + + return + } + + const profiles = getRawProfiles().map((profile, index) => + isActiveRawProfile(profile, index) ? { ...profile, ...updates } : profile, + ) + + persistProfiles(profiles, activeProfile.id) + } + + const updateAttributionSetting = (updates: Partial) => { + const currentAttribution = hasStoredProfiles + ? (getActiveRawProfile()?.attribution ?? {}) + : (commitMessageAttribution ?? {}) + const nextAttribution = { ...currentAttribution, ...updates } + + if (!hasStoredProfiles) { + setCommitMessageAttribution(nextAttribution) + return + } + + const profiles = getRawProfiles().map((profile, index) => + isActiveRawProfile(profile, index) ? { ...profile, attribution: nextAttribution } : profile, + ) + + persistProfiles(profiles, activeProfile.id) + } + + const updateGitContextSetting = ( + key: K, + value: CommitMessageGitContextSettings[K], + ) => { + const currentGitContext = hasStoredProfiles + ? (getActiveRawProfile()?.gitContext ?? {}) + : (commitMessageGitContext ?? {}) + updateActiveProfile({ gitContext: { ...currentGitContext, [key]: value } }) + } + + const updateNumberSetting = ( + key: keyof CommitMessageGitContextSettings, + value: string, + min: number, + max: number, + ) => { + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + return + } + + updateGitContextSetting(key, Math.min(Math.max(Math.trunc(parsed), min), max) as never) + } + + const handleActiveProfileChange = (profileId: string) => { + const nextActiveProfile = normalizedProfiles.profiles.find((profile) => profile.id === profileId) + if (!nextActiveProfile) { + return + } + + persistProfiles(getRawProfiles(), nextActiveProfile.id, true) + } + + const handleAddProfile = () => { + if (!canAddProfile) { + return + } + + const newProfile: CommitMessageProfileSettings = { + id: createCommitMessageProfileId(), + name: createCommitMessageProfileName(normalizedProfiles.profiles), + gitContext: defaultCommitMessageGitContextSettings, + } + persistProfiles([...getRawProfiles(), newProfile], newProfile.id!, true) + } + + const handleDeleteProfile = () => { + if (!canDeleteProfile) { + return + } + + const profiles = getRawProfiles().filter((profile, index) => !isActiveRawProfile(profile, index)) + const nextActiveProfile = normalizeCommitMessageProfiles({ profiles }).profiles[0] + persistProfiles(profiles, nextActiveProfile.id, true) + } + + const getTextAreaValue = (event: Event | React.FormEvent) => { + return ( + (event as unknown as CustomEvent)?.detail?.target?.value ?? + ((event as any).target as HTMLTextAreaElement).value + ) + } + + return ( +
+ {/* Profile selection controls. Keep this first so users understand which profile they are editing. */} +
+
+ + +
+ {t("prompts:supportPrompts.commitMessage.profiles.description")} +
+
+ +
+ + updateActiveProfile({ name: ((event as any).target as HTMLInputElement).value }) + }> + {t("prompts:supportPrompts.commitMessage.profiles.name")} + +
+ {t("prompts:supportPrompts.commitMessage.profiles.nameDescription")} +
+
+ +
+ + + + {t("prompts:supportPrompts.commitMessage.profiles.limit", { + count: MAX_COMMIT_MESSAGE_PROFILES, + })} + +
+
+ + {/* Prompt editor for the selected commit-message profile. */} +
+
+
+ +
+ {t("prompts:supportPrompts.commitMessage.promptDescription")} +
+
+ + + +
+ updateActiveProfile({ prompt: getTextAreaValue(event) })} + rows={6} + className="w-full" + data-testid="commit-message-prompt-textarea" + /> +
+ + {/* API configuration for the selected commit-message profile. */} +
+ + +
+ {t("prompts:supportPrompts.commitMessage.apiConfigDescription")} +
+
+ + {/* Optional Git context controls. Required diff/change summary behavior is not user-toggleable. */} +
+
+
{t("prompts:supportPrompts.commitMessage.gitContext.title")}
+
+ {t("prompts:supportPrompts.commitMessage.gitContext.description")} +
+
+ +
+ + updateNumberSetting( + "diffContextLines", + ((event as any).target as HTMLInputElement).value, + 0, + 20, + ) + }> + + {t("prompts:supportPrompts.commitMessage.gitContext.contextLines")} + + +
+ {t("prompts:supportPrompts.commitMessage.gitContext.contextLinesDescription")} +
+
+ + updateGitContextSetting("includeDiffStats", checked)} + shouldIgnoreChange={() => suppressCheckboxChangesRef.current} + /> + + updateGitContextSetting("includeCurrentBranch", checked)} + shouldIgnoreChange={() => suppressCheckboxChangesRef.current} + /> + +
+ updateGitContextSetting("includeRecentCommits", checked)} + shouldIgnoreChange={() => suppressCheckboxChangesRef.current} + /> + + {activeProfile.gitContext.includeRecentCommits && ( +
+
+ + updateNumberSetting( + "recentCommitCount", + ((event as any).target as HTMLInputElement).value, + 1, + 20, + ) + }> + + {t("prompts:supportPrompts.commitMessage.gitContext.recentCommitCount")} + + +
+ {t("prompts:supportPrompts.commitMessage.gitContext.recentCommitCountDescription")} +
+
+ + updateGitContextSetting("includeRecentCommitBodies", checked)} + shouldIgnoreChange={() => suppressCheckboxChangesRef.current} + /> + + updateGitContextSetting("includeRecentCommitStats", checked)} + shouldIgnoreChange={() => suppressCheckboxChangesRef.current} + /> + + updateGitContextSetting("includeRecentCommitDiffs", checked)} + shouldIgnoreChange={() => suppressCheckboxChangesRef.current} + /> + + {activeProfile.gitContext.includeRecentCommitDiffs && ( +
+ + updateNumberSetting( + "recentCommitDiffCount", + ((event as any).target as HTMLInputElement).value, + 1, + 5, + ) + }> + + {t("prompts:supportPrompts.commitMessage.gitContext.recentCommitDiffCount")} + + +
+ {t( + "prompts:supportPrompts.commitMessage.gitContext.recentCommitDiffCountDescription", + )} +
+
+ )} +
+ )} +
+
+ + {/* Optional attribution footer appended deterministically after generation. */} +
+
+
+ {t("prompts:supportPrompts.commitMessage.attribution.title")} +
+
+ {t("prompts:supportPrompts.commitMessage.attribution.description")} +
+
+ + updateAttributionSetting({ enabled: checked })} + shouldIgnoreChange={() => suppressCheckboxChangesRef.current} + data-testid="commit-message-attribution-enabled" + /> + + {attributionSettings.enabled && ( +
+ + updateAttributionSetting({ template: getTextAreaValue(event) })} + rows={3} + className="w-full" + data-testid="commit-message-attribution-template" + /> +
+ {t("prompts:supportPrompts.commitMessage.attribution.placeholders")} +
+
+ )} +
+
+ ) +} + +interface CheckboxSettingProps { + checked: boolean + label: string + description: string + onChange: (checked: boolean) => void + shouldIgnoreChange?: () => boolean + "data-testid"?: string +} + +const CheckboxSetting = ({ + checked, + label, + description, + onChange, + shouldIgnoreChange, + "data-testid": dataTestId, +}: CheckboxSettingProps) => ( +
+ { + if (shouldIgnoreChange?.()) { + return + } + + onChange((event.target as HTMLInputElement).checked) + }}> + {label} + +
{description}
+
+) + +export default CommitMessagePromptSettings diff --git a/webview-ui/src/components/settings/PromptsSettings.tsx b/webview-ui/src/components/settings/PromptsSettings.tsx index 54babbcfcb..1f750b7201 100644 --- a/webview-ui/src/components/settings/PromptsSettings.tsx +++ b/webview-ui/src/components/settings/PromptsSettings.tsx @@ -1,5 +1,10 @@ import { useState, useEffect, FormEvent } from "react" import { VSCodeTextArea, VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react" +import type { + CommitMessageAttributionSettings, + CommitMessageGitContextSettings, + CommitMessageProfilesSettings, +} from "@roo-code/types" import { supportPrompt, SupportPromptType } from "@roo/support-prompt" @@ -19,10 +24,19 @@ import { import { SectionHeader } from "./SectionHeader" import { Section } from "./Section" import { SearchableSetting } from "./SearchableSetting" +import CommitMessagePromptSettings from "./CommitMessagePromptSettings" interface PromptsSettingsProps { customSupportPrompts: Record setCustomSupportPrompts: (prompts: Record) => void + commitMessageApiConfigId?: string + setCommitMessageApiConfigId?: (value: string) => void + commitMessageGitContext?: CommitMessageGitContextSettings + setCommitMessageGitContext?: (value: CommitMessageGitContextSettings) => void + commitMessageAttribution?: CommitMessageAttributionSettings + setCommitMessageAttribution?: (value: CommitMessageAttributionSettings) => void + commitMessageProfiles?: CommitMessageProfilesSettings + setCommitMessageProfiles?: (value: CommitMessageProfilesSettings) => void includeTaskHistoryInEnhance?: boolean setIncludeTaskHistoryInEnhance?: (value: boolean) => void } @@ -30,6 +44,14 @@ interface PromptsSettingsProps { const PromptsSettings = ({ customSupportPrompts, setCustomSupportPrompts, + commitMessageApiConfigId, + setCommitMessageApiConfigId, + commitMessageGitContext, + setCommitMessageGitContext, + commitMessageAttribution, + setCommitMessageAttribution, + commitMessageProfiles, + setCommitMessageProfiles, includeTaskHistoryInEnhance: propsIncludeTaskHistoryInEnhance, setIncludeTaskHistoryInEnhance: propsSetIncludeTaskHistoryInEnhance, }: PromptsSettingsProps) => { @@ -101,7 +123,7 @@ const PromptsSettings = ({ return (
- + {t("settings:sections.prompts")} @@ -132,117 +154,143 @@ const PromptsSettings = ({
-
- - - - -
- - { - const value = - (e as unknown as CustomEvent)?.detail?.target?.value ?? - ((e as any).target as HTMLTextAreaElement).value - updateSupportPrompt(activeSupportOption, value) - }} - rows={6} - className="w-full" - /> - - {activeSupportOption === "ENHANCE" && ( -
-
- - -
- {t("prompts:supportPrompts.enhance.apiConfigDescription")} -
+ {activeSupportOption === "COMMIT_MESSAGE" ? ( + {})} + commitMessageGitContext={commitMessageGitContext} + setCommitMessageGitContext={setCommitMessageGitContext ?? (() => {})} + commitMessageAttribution={commitMessageAttribution} + setCommitMessageAttribution={setCommitMessageAttribution ?? (() => {})} + commitMessageProfiles={commitMessageProfiles} + setCommitMessageProfiles={setCommitMessageProfiles ?? (() => {})} + /> + ) : ( + <> +
+ + + +
-
- ) => { - const target = ("target" in e ? e.target : null) as HTMLInputElement | null - - if (!target) { - return - } - - setIncludeTaskHistoryInEnhance(target.checked) - - vscode.postMessage({ - type: "updateSettings", - updatedSettings: { includeTaskHistoryInEnhance: target.checked }, - }) - }}> - - {t("prompts:supportPrompts.enhance.includeTaskHistory")} - - -
- {t("prompts:supportPrompts.enhance.includeTaskHistoryDescription")} -
-
+ { + const value = + (e as unknown as CustomEvent)?.detail?.target?.value ?? + ((e as any).target as HTMLTextAreaElement).value + updateSupportPrompt(activeSupportOption, value) + }} + rows={6} + className="w-full" + /> -
- - setTestPrompt((e.target as HTMLTextAreaElement).value)} - placeholder={t("prompts:supportPrompts.enhance.testPromptPlaceholder")} - rows={3} - className="w-full" - data-testid="test-prompt-textarea" - /> -
- + {activeSupportOption === "ENHANCE" && ( +
+
+ + +
+ {t("prompts:supportPrompts.enhance.apiConfigDescription")} +
+
+ +
+ ) => { + const target = ( + "target" in e ? e.target : null + ) as HTMLInputElement | null + + if (!target) { + return + } + + setIncludeTaskHistoryInEnhance(target.checked) + + vscode.postMessage({ + type: "updateSettings", + updatedSettings: { includeTaskHistoryInEnhance: target.checked }, + }) + }}> + + {t("prompts:supportPrompts.enhance.includeTaskHistory")} + + +
+ {t("prompts:supportPrompts.enhance.includeTaskHistoryDescription")} +
+
+ +
+ + setTestPrompt((e.target as HTMLTextAreaElement).value)} + placeholder={t("prompts:supportPrompts.enhance.testPromptPlaceholder")} + rows={3} + className="w-full" + data-testid="test-prompt-textarea" + /> +
+ +
+
-
-
+ )} + )}
diff --git a/webview-ui/src/components/settings/SectionHeader.tsx b/webview-ui/src/components/settings/SectionHeader.tsx index 4f25fd1a75..861c9ade6e 100644 --- a/webview-ui/src/components/settings/SectionHeader.tsx +++ b/webview-ui/src/components/settings/SectionHeader.tsx @@ -5,13 +5,15 @@ import { cn } from "@/lib/utils" type SectionHeaderProps = HTMLAttributes & { children: React.ReactNode description?: string + sticky?: boolean } -export const SectionHeader = ({ description, children, className, ...props }: SectionHeaderProps) => { +export const SectionHeader = ({ description, children, className, sticky = true, ...props }: SectionHeaderProps) => { return (
diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 47e087615e..eae7d11d35 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -189,6 +189,10 @@ const SettingsView = forwardRef(({ onDone, t maxImageFileSize, maxTotalImageSize, customSupportPrompts, + commitMessageApiConfigId, + commitMessageGitContext, + commitMessageAttribution, + commitMessageProfiles, profileThresholds, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs, @@ -422,6 +426,10 @@ const SettingsView = forwardRef(({ onDone, t openRouterImageGenerationSelectedModel, experiments, customSupportPrompts, + commitMessageApiConfigId, + commitMessageGitContext, + commitMessageAttribution, + commitMessageProfiles, }, }) @@ -880,6 +888,22 @@ const SettingsView = forwardRef(({ onDone, t + setCachedStateField("commitMessageApiConfigId", value) + } + commitMessageGitContext={commitMessageGitContext} + setCommitMessageGitContext={(value) => + setCachedStateField("commitMessageGitContext", value) + } + commitMessageAttribution={commitMessageAttribution} + setCommitMessageAttribution={(value) => + setCachedStateField("commitMessageAttribution", value) + } + commitMessageProfiles={commitMessageProfiles} + setCommitMessageProfiles={(value) => + setCachedStateField("commitMessageProfiles", value) + } includeTaskHistoryInEnhance={includeTaskHistoryInEnhance} setIncludeTaskHistoryInEnhance={(value) => setCachedStateField("includeTaskHistoryInEnhance", value) diff --git a/webview-ui/src/components/settings/__tests__/CommitMessagePromptSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/CommitMessagePromptSettings.spec.tsx new file mode 100644 index 0000000000..fc4e592922 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/CommitMessagePromptSettings.spec.tsx @@ -0,0 +1,264 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { vi } from "vitest" + +import CommitMessagePromptSettings from "../CommitMessagePromptSettings" + +const mockPostMessage = vi.fn() +;(global as any).acquireVsCodeApi = () => ({ postMessage: mockPostMessage }) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string, values?: Record) => + values?.count !== undefined ? `${key} ${values.count}` : key, + }), +})) + +vi.mock("@src/components/ui", () => ({ + Button: ({ children, onClick, disabled, "data-testid": dataTestId }: any) => ( + + ), + Select: ({ children, value, onValueChange }: any) => ( +
+ + {children} +
+ ), + SelectContent: ({ children }: any) =>
{children}
, + SelectItem: ({ children, value, "data-testid": dataTestId }: any) => ( +
+ {children} +
+ ), + SelectTrigger: ({ children, "data-testid": dataTestId }: any) =>
{children}
, + SelectValue: ({ placeholder }: any) =>
{placeholder}
, + StandardTooltip: ({ children }: any) => <>{children}, +})) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeCheckbox: ({ children, checked, onChange, "data-testid": dataTestId }: any) => ( + + ), + VSCodeTextArea: ({ value, onInput, "data-testid": dataTestId }: any) => ( +