diff --git a/packages/types/src/vscode.ts b/packages/types/src/vscode.ts index 811ceda165..fbb343ce82 100644 --- a/packages/types/src/vscode.ts +++ b/packages/types/src/vscode.ts @@ -48,6 +48,8 @@ export const commandIds = [ "retryWebviewRecovery", "toggleAutoApprove", + "generateGitCommitMessage", + "showRipgrepDiagnostic", "createDiagnosticsReport", ] as const diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index c59933a7c1..c68be5a052 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -100,6 +100,10 @@ vi.mock("../../services/diagnostics", () => ({ createDiagnosticsReport: vi.fn().mockResolvedValue(undefined), })) +vi.mock("../../services/git/commitMessageGenerator", () => ({ + generateCommitMessage: vi.fn().mockResolvedValue(undefined), +})) + describe("getVisibleProviderOrLog", () => { let mockOutputChannel: vscode.OutputChannel @@ -282,6 +286,32 @@ describe("registerCommands handlers", () => { }) }) + it("generateGitCommitMessage calls generateCommitMessage with visible provider and context", async () => { + const { generateCommitMessage } = await import("../../services/git/commitMessageGenerator") + const mockScmContext = { rootUri: { fsPath: "/mock/repo" } } + + await handlers["zoo-code.generateGitCommitMessage"](mockScmContext) + + expect(generateCommitMessage).toHaveBeenCalledWith({ + provider: mockVisibleProviderInstance, + outputChannel: mockOutputChannel, + context: mockScmContext, + }) + }) + + it("generateGitCommitMessage falls back to default provider when no visible provider", async () => { + const { generateCommitMessage } = await import("../../services/git/commitMessageGenerator") + ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(undefined) + + await handlers["zoo-code.generateGitCommitMessage"]() + + expect(generateCommitMessage).toHaveBeenCalledWith({ + provider: mockProvider, + outputChannel: mockOutputChannel, + context: undefined, + }) + }) + it("settingsButtonClicked posts both settingsButtonClicked and didBecomeVisible actions", () => { handlers["zoo-code.settingsButtonClicked"]() diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index f0f4747eee..2e483c4de0 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -14,6 +14,7 @@ import { importSettingsWithFeedback } from "../core/config/importExport" import { MdmService } from "../services/mdm/MdmService" import { registerRipgrepDiagnosticCommand } from "../services/ripgrep/diagnostic" import { createDiagnosticsReport } from "../services/diagnostics" +import { generateCommitMessage } from "../services/git/commitMessageGenerator" /** * Helper to get the visible ClineProvider instance or log if not found. @@ -225,6 +226,14 @@ const getCommandsMap = ({ outputChannel.appendLine(`[toggleAutoApprove] postMessageToWebview failed: ${error}`) } }, + generateGitCommitMessage: async (context?: unknown) => { + const visibleProvider = ClineProvider.getVisibleInstance() ?? provider + await generateCommitMessage({ + provider: visibleProvider, + outputChannel, + context, + }) + }, }) type OpenClineInNewTabOptions = { diff --git a/src/package.json b/src/package.json index 6c59828b1d..3b3b4406eb 100644 --- a/src/package.json +++ b/src/package.json @@ -179,9 +179,21 @@ "command": "zoo-code.toggleAutoApprove", "title": "%command.toggleAutoApprove.title%", "category": "%configuration.title%" + }, + { + "command": "zoo-code.generateGitCommitMessage", + "title": "%command.generateGitCommitMessage.title%", + "icon": "$(wand)" } ], "menus": { + "scm/title": [ + { + "command": "zoo-code.generateGitCommitMessage", + "group": "navigation", + "when": "scmProvider == git" + } + ], "editor/context": [ { "submenu": "zoo-code.contextMenu", diff --git a/src/package.nls.ca.json b/src/package.nls.ca.json index d6b40efba3..231c0b2e55 100644 --- a/src/package.nls.ca.json +++ b/src/package.nls.ca.json @@ -18,6 +18,7 @@ "command.showRipgrepDiagnostic.title": "Mostra el diagnòstic de Ripgrep", "command.createDiagnosticsReport.title": "Crea un informe de diagnòstic", "command.toggleAutoApprove.title": "Alternar Auto-Aprovació", + "command.generateGitCommitMessage.title": "Generar Missatge de Commit (Zoo Code)", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.de.json b/src/package.nls.de.json index a8bbadbf15..05a57bdf5b 100644 --- a/src/package.nls.de.json +++ b/src/package.nls.de.json @@ -18,6 +18,7 @@ "command.showRipgrepDiagnostic.title": "Ripgrep-Diagnose anzeigen", "command.createDiagnosticsReport.title": "Diagnosebericht erstellen", "command.toggleAutoApprove.title": "Auto-Genehmigung Umschalten", + "command.generateGitCommitMessage.title": "Commit-Nachricht generieren (Zoo Code)", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.es.json b/src/package.nls.es.json index 26d9f87e9d..175a53e9be 100644 --- a/src/package.nls.es.json +++ b/src/package.nls.es.json @@ -18,6 +18,7 @@ "command.showRipgrepDiagnostic.title": "Mostrar diagnóstico de Ripgrep", "command.createDiagnosticsReport.title": "Crear informe de diagnóstico", "command.toggleAutoApprove.title": "Alternar Auto-Aprobación", + "command.generateGitCommitMessage.title": "Generar mensaje de confirmación (Zoo Code)", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.fr.json b/src/package.nls.fr.json index 364a8878c4..9e71eb9a94 100644 --- a/src/package.nls.fr.json +++ b/src/package.nls.fr.json @@ -18,6 +18,7 @@ "command.showRipgrepDiagnostic.title": "Afficher le diagnostic Ripgrep", "command.createDiagnosticsReport.title": "Créer un rapport de diagnostic", "command.toggleAutoApprove.title": "Basculer Auto-Approbation", + "command.generateGitCommitMessage.title": "Générer un message de commit (Zoo Code)", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.hi.json b/src/package.nls.hi.json index db70bc8417..62a352cd81 100644 --- a/src/package.nls.hi.json +++ b/src/package.nls.hi.json @@ -18,6 +18,7 @@ "command.showRipgrepDiagnostic.title": "Ripgrep डायग्नोस्टिक दिखाएं", "command.createDiagnosticsReport.title": "डायग्नोस्टिक रिपोर्ट बनाएं", "command.toggleAutoApprove.title": "ऑटो-अनुमोदन टॉगल करें", + "command.generateGitCommitMessage.title": "कमिट संदेश बनाएं (Zoo Code)", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.id.json b/src/package.nls.id.json index b7e150ee53..31ac13fcc6 100644 --- a/src/package.nls.id.json +++ b/src/package.nls.id.json @@ -27,6 +27,7 @@ "command.showRipgrepDiagnostic.title": "Tampilkan Diagnostik Ripgrep", "command.createDiagnosticsReport.title": "Buat Laporan Diagnostik", "command.toggleAutoApprove.title": "Alihkan Persetujuan Otomatis", + "command.generateGitCommitMessage.title": "Buat Pesan Commit (Zoo Code)", "configuration.title": "Zoo Code", "commands.allowedCommands.description": "Perintah yang dapat dijalankan secara otomatis ketika 'Selalu setujui operasi eksekusi' diaktifkan", "commands.deniedCommands.description": "Awalan perintah yang akan otomatis ditolak tanpa meminta persetujuan. Jika terjadi konflik dengan perintah yang diizinkan, pencocokan awalan terpanjang akan diprioritaskan. Tambahkan * untuk menolak semua perintah.", diff --git a/src/package.nls.it.json b/src/package.nls.it.json index 6d50b96bc4..ef3a0ff2dc 100644 --- a/src/package.nls.it.json +++ b/src/package.nls.it.json @@ -18,6 +18,7 @@ "command.showRipgrepDiagnostic.title": "Mostra diagnostica Ripgrep", "command.createDiagnosticsReport.title": "Crea rapporto di diagnostica", "command.toggleAutoApprove.title": "Attiva/Disattiva Auto-Approvazione", + "command.generateGitCommitMessage.title": "Genera Messaggio di Commit (Zoo Code)", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.ja.json b/src/package.nls.ja.json index 22f4f0dfce..d2ce85c395 100644 --- a/src/package.nls.ja.json +++ b/src/package.nls.ja.json @@ -27,6 +27,7 @@ "command.showRipgrepDiagnostic.title": "Ripgrep 診断を表示", "command.createDiagnosticsReport.title": "診断レポートを作成", "command.toggleAutoApprove.title": "自動承認を切替", + "command.generateGitCommitMessage.title": "コミットメッセージの生成 (Zoo Code)", "configuration.title": "Zoo Code", "commands.allowedCommands.description": "'常に実行操作を承認する'が有効な場合に自動実行できるコマンド", "commands.deniedCommands.description": "承認を求めずに自動的に拒否されるコマンドプレフィックス。許可されたコマンドとの競合がある場合、最長プレフィックスマッチが優先されます。すべてのコマンドを拒否するには * を追加してください。", diff --git a/src/package.nls.json b/src/package.nls.json index d1f9afbc39..cc0c7fd454 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -27,6 +27,7 @@ "command.showRipgrepDiagnostic.title": "Show Ripgrep Diagnostic", "command.createDiagnosticsReport.title": "Create Diagnostics Report", "command.toggleAutoApprove.title": "Toggle Auto-Approve", + "command.generateGitCommitMessage.title": "Generate Commit Message (Zoo Code)", "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/package.nls.ko.json b/src/package.nls.ko.json index 48d8695a1d..9397f4d752 100644 --- a/src/package.nls.ko.json +++ b/src/package.nls.ko.json @@ -18,6 +18,7 @@ "command.showRipgrepDiagnostic.title": "Ripgrep 진단 표시", "command.createDiagnosticsReport.title": "진단 보고서 만들기", "command.toggleAutoApprove.title": "자동 승인 전환", + "command.generateGitCommitMessage.title": "커밋 메시지 생성 (Zoo Code)", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.nl.json b/src/package.nls.nl.json index 81113c4b5a..c2c9699ce5 100644 --- a/src/package.nls.nl.json +++ b/src/package.nls.nl.json @@ -27,6 +27,7 @@ "command.showRipgrepDiagnostic.title": "Ripgrep-diagnose weergeven", "command.createDiagnosticsReport.title": "Diagnoserapport maken", "command.toggleAutoApprove.title": "Auto-Goedkeuring Schakelen", + "command.generateGitCommitMessage.title": "Commitbericht genereren (Zoo Code)", "configuration.title": "Zoo Code", "commands.allowedCommands.description": "Commando's die automatisch kunnen worden uitgevoerd wanneer 'Altijd goedkeuren uitvoerbewerkingen' is ingeschakeld", "commands.deniedCommands.description": "Commando-prefixen die automatisch worden geweigerd zonder om goedkeuring te vragen. Bij conflicten met toegestane commando's heeft de langste prefix-match voorrang. Voeg * toe om alle commando's te weigeren.", diff --git a/src/package.nls.pl.json b/src/package.nls.pl.json index 267ddff277..b4d0c54539 100644 --- a/src/package.nls.pl.json +++ b/src/package.nls.pl.json @@ -18,6 +18,7 @@ "command.showRipgrepDiagnostic.title": "Pokaż diagnostykę Ripgrep", "command.createDiagnosticsReport.title": "Utwórz raport diagnostyczny", "command.toggleAutoApprove.title": "Przełącz Auto-Zatwierdzanie", + "command.generateGitCommitMessage.title": "Wygeneruj komunikat commita (Zoo Code)", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.pt-BR.json b/src/package.nls.pt-BR.json index 853832b699..7ec732d98c 100644 --- a/src/package.nls.pt-BR.json +++ b/src/package.nls.pt-BR.json @@ -18,6 +18,7 @@ "command.showRipgrepDiagnostic.title": "Mostrar diagnóstico do Ripgrep", "command.createDiagnosticsReport.title": "Criar relatório de diagnóstico", "command.toggleAutoApprove.title": "Alternar Auto-Aprovação", + "command.generateGitCommitMessage.title": "Gerar mensagem de commit (Zoo Code)", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.ru.json b/src/package.nls.ru.json index d46ea14245..3ad4f5ada0 100644 --- a/src/package.nls.ru.json +++ b/src/package.nls.ru.json @@ -27,6 +27,7 @@ "command.showRipgrepDiagnostic.title": "Показать диагностику Ripgrep", "command.createDiagnosticsReport.title": "Создать диагностический отчёт", "command.toggleAutoApprove.title": "Переключить Авто-Подтверждение", + "command.generateGitCommitMessage.title": "Сгенерировать сообщение коммита (Zoo Code)", "configuration.title": "Zoo Code", "commands.allowedCommands.description": "Команды, которые могут быть автоматически выполнены, когда включена опция 'Всегда подтверждать операции выполнения'", "commands.deniedCommands.description": "Префиксы команд, которые будут автоматически отклонены без запроса подтверждения. В случае конфликтов с разрешенными командами приоритет имеет самое длинное совпадение префикса. Добавьте * чтобы отклонить все команды.", diff --git a/src/package.nls.tr.json b/src/package.nls.tr.json index cbc81d959f..799f8c0d2c 100644 --- a/src/package.nls.tr.json +++ b/src/package.nls.tr.json @@ -18,6 +18,7 @@ "command.showRipgrepDiagnostic.title": "Ripgrep Tanılamasını Göster", "command.createDiagnosticsReport.title": "Tanılama Raporu Oluştur", "command.toggleAutoApprove.title": "Otomatik Onayı Değiştir", + "command.generateGitCommitMessage.title": "Commit Mesajı Oluştur (Zoo Code)", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.vi.json b/src/package.nls.vi.json index 1ee4eabd70..36d255e048 100644 --- a/src/package.nls.vi.json +++ b/src/package.nls.vi.json @@ -18,6 +18,7 @@ "command.showRipgrepDiagnostic.title": "Hiển thị chẩn đoán Ripgrep", "command.createDiagnosticsReport.title": "Tạo báo cáo chẩn đoán", "command.toggleAutoApprove.title": "Bật/Tắt Tự Động Phê Duyệt", + "command.generateGitCommitMessage.title": "Tạo thông điệp commit (Zoo Code)", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.zh-CN.json b/src/package.nls.zh-CN.json index 85dafc3bc7..5005fd0b5d 100644 --- a/src/package.nls.zh-CN.json +++ b/src/package.nls.zh-CN.json @@ -18,6 +18,7 @@ "command.showRipgrepDiagnostic.title": "显示 Ripgrep 诊断", "command.createDiagnosticsReport.title": "创建诊断报告", "command.toggleAutoApprove.title": "切换自动批准", + "command.generateGitCommitMessage.title": "生成提交信息 (Zoo Code)", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/package.nls.zh-TW.json b/src/package.nls.zh-TW.json index cda075e306..1b4a7ec91b 100644 --- a/src/package.nls.zh-TW.json +++ b/src/package.nls.zh-TW.json @@ -18,6 +18,7 @@ "command.showRipgrepDiagnostic.title": "顯示 Ripgrep 診斷", "command.createDiagnosticsReport.title": "建立診斷報告", "command.toggleAutoApprove.title": "切換自動批准", + "command.generateGitCommitMessage.title": "產生提交訊息 (Zoo Code)", "views.activitybar.title": "Zoo Code", "views.contextMenu.label": "Zoo Code", "views.terminalMenu.label": "Zoo Code", diff --git a/src/services/git/__tests__/commitMessageGenerator.spec.ts b/src/services/git/__tests__/commitMessageGenerator.spec.ts new file mode 100644 index 0000000000..5aa8d89153 --- /dev/null +++ b/src/services/git/__tests__/commitMessageGenerator.spec.ts @@ -0,0 +1,1292 @@ +import * as vscode from "vscode" +import type { Mock } from "vitest" +import { providerIdentifiers } from "@roo-code/types" + +import { + buildCommitMessagePrompt, + cleanCommitMessage, + COMMIT_MESSAGE_SYSTEM_PROMPT, + DIFF_CHARACTER_LIMIT, + DIFF_LINE_LIMIT, + DIFF_TRUNCATED_MESSAGE, + DRAFT_CHANGED_MESSAGE, + generateCommitMessage, + getGitApi, + getRepositoryDiff, + NO_CHANGES_MESSAGE, + redactSensitiveInformation, + resolveGitRepository, + type GitAPI, + type GitRepository, +} from "../commitMessageGenerator" +import { ClineProvider } from "../../../core/webview/ClineProvider" +import { singleCompletionHandler } from "../../../utils/single-completion-handler" +import { makeUri } from "../../../test-utils/vscode" + +vi.mock("vscode", () => ({ + ProgressLocation: { + Notification: 15, + }, + extensions: { + getExtension: vi.fn(), + }, + window: { + activeTextEditor: undefined, + showErrorMessage: vi.fn(), + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + withProgress: vi.fn(), + }, + Uri: { + file: (path: string) => makeUri(path), + parse: (uri: string) => makeUri(uri), + }, +})) + +vi.mock("../../../utils/single-completion-handler", () => ({ + singleCompletionHandler: vi.fn(), +})) + +function makeRepo(root: string, overrides: Partial = {}): GitRepository { + return { + rootUri: makeUri(root), + inputBox: { value: "" }, + diff: vi.fn().mockResolvedValue("diff content"), + ...overrides, + } +} + +function deferred() { + let resolve: (value: T) => void = () => {} + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +/** + * Installs a withProgress mock whose token can be cancelled from the test. Every registered + * cancellation listener runs on cancel(), like a real vscode.CancellationTokenSource. + */ +function useCancellableProgress() { + let cancelled = false + const listeners: Array<() => void> = [] + const disposable = { dispose: vi.fn() } + ;(vscode.window.withProgress as Mock).mockImplementation((_opts, callback) => { + const token = { + get isCancellationRequested() { + return cancelled + }, + onCancellationRequested: vi.fn((fn: () => void) => { + listeners.push(fn) + return disposable + }), + } + return callback({ report: vi.fn() }, token) + }) + return { + disposable, + cancel: () => { + cancelled = true + listeners.forEach((fn) => fn()) + }, + } +} + +describe("commitMessageGenerator", () => { + beforeEach(() => { + vi.clearAllMocks() + ;(vscode.window as unknown as { activeTextEditor?: vscode.TextEditor }).activeTextEditor = undefined + }) + + describe("cleanCommitMessage", () => { + it("removes markdown code fences and backticks", () => { + const input = "```markdown\nfeat(auth): add login flow\n```" + expect(cleanCommitMessage(input)).toBe("feat(auth): add login flow") + }) + + it("removes surrounding quotes", () => { + expect(cleanCommitMessage('"feat: simple commit"')).toBe("feat: simple commit") + expect(cleanCommitMessage("'feat: simple commit'")).toBe("feat: simple commit") + }) + + it("trims whitespace", () => { + expect(cleanCommitMessage(" fix: bug \n")).toBe("fix: bug") + }) + }) + + describe("redactSensitiveInformation", () => { + it("redacts private keys", () => { + const content = + "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA0...\n-----END RSA PRIVATE KEY-----\nother text" + expect(redactSensitiveInformation(content)).toBe("[REDACTED_SECRET]\nother text") + }) + + it("redacts api keys and secrets", () => { + const content = 'apiKey: "super-secret-key-12345"\npassword=mysecretpassword123\nother_config=123' + const redacted = redactSensitiveInformation(content) + expect(redacted).toContain('apiKey: "[REDACTED]"') + expect(redacted).toContain("password=[REDACTED]") + expect(redacted).toContain("other_config=123") + }) + + it("redacts Bearer tokens in Authorization headers", () => { + const content = + "+Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature\n+other: value" + const redacted = redactSensitiveInformation(content) + expect(redacted).not.toContain("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature") + expect(redacted).toContain("[REDACTED]") + expect(redacted).toContain("+other: value") + }) + + it("redacts secret_key values", () => { + const content = "secret_key=supersecret12345\nother=unchanged" + const redacted = redactSensitiveInformation(content) + expect(redacted).not.toContain("supersecret12345") + expect(redacted).toContain("[REDACTED]") + expect(redacted).toContain("other=unchanged") + }) + + it("redacts passwords containing special characters like @", () => { + const content = "password=pass@word123\nother=unchanged" + const redacted = redactSensitiveInformation(content) + expect(redacted).not.toContain("pass@word123") + expect(redacted).toContain("[REDACTED]") + expect(redacted).toContain("other=unchanged") + }) + + it.each([ + ["JSON key", '+ "apiKey": "sk-live-123456789",', '+ "apiKey": "[REDACTED]",', "sk-live-123456789"], + ["YAML key", "+ client_secret: hunter2", "+ client_secret: [REDACTED]", "hunter2"], + ["env var with prefix", "+OPENAI_API_KEY=abc", "+OPENAI_API_KEY=[REDACTED]", "abc"], + ["short password", "+DB_PASSWORD=a", "+DB_PASSWORD=[REDACTED]", "=a"], + ["camelCase key", '+ openAiApiKey: "abc123",', '+ openAiApiKey: "[REDACTED]",', "abc123"], + ["bare bearer token", "+curl -H x Bearer abc123def456", "+curl -H x Bearer [REDACTED]", "abc123def456"], + ["access token", "+access_token: 'tok_1'", "+access_token: '[REDACTED]'", "tok_1"], + [ + "Basic authorization header", + "+Authorization: Basic dXNlcjpwYXNzd29yZA==", + "+Authorization: Basic [REDACTED]", + "dXNlcjpwYXNzd29yZA", + ], + [ + "JSON authorization header", + '+"Authorization": "Bearer abc.def.ghi"', + '+"Authorization": "Bearer [REDACTED]"', + "abc.def.ghi", + ], + ["raw authorization header", "+authorization: rawtoken", "+authorization: [REDACTED]", "rawtoken"], + [ + "URL credentials", + "+url = postgres://admin:s3cr3t@db.internal:5432/app", + "+url = postgres://admin:[REDACTED]@db.internal:5432/app", + "s3cr3t", + ], + ["email address", '+ "author": "alice@example.com",', '+ "author": "[REDACTED_EMAIL]",', "alice@"], + [ + "a double-quoted value with a semicolon", + '+password: "pass;word123"', + '+password: "[REDACTED]"', + "word123", + ], + [ + "a JSON value with commas and semicolons", + '+ "api_key": "a,b;c-123",', + '+ "api_key": "[REDACTED]",', + "b;c-123", + ], + ["a single-quoted value with spaces", "+secret = 'two words;x'", "+secret = '[REDACTED]'", "words"], + ["a backtick value", "+const pwd = `p;w`", "+const pwd = `[REDACTED]`", "p;w"], + ["a quoted value with an escaped quote", String.raw`+password: "a\";b"`, '+password: "[REDACTED]"', ";b"], + ["an unterminated quoted value", '+password: "abc;def', "+password: [REDACTED]", "abc"], + [ + "a quoted Authorization value with a semicolon", + '+"Authorization": "Basic ab;cd=="', + '+"Authorization": "Basic [REDACTED]"', + ";cd", + ], + ])("redacts %s", (_label, content, expected, secret) => { + const redacted = redactSensitiveInformation(content) + expect(redacted).toBe(expected) + expect(redacted).not.toContain(secret) + }) + + it.each([ + ["OpenAI key", "sk-proj-abcdefghijklmnopqrstuvwx"], + ["GitHub token", "ghp_abcdefghijklmnopqrstuvwxyz0123456789"], + ["AWS access key id", "AKIAABCDEFGHIJKLMNOP"], + ["Slack token", "xoxb-1234567890-abcdef"], + ["JWT", "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N"], + ])("redacts a bare %s", (_label, token) => { + const redacted = redactSensitiveInformation(`+const value = callWith(${token})`) + expect(redacted).toBe("+const value = callWith([REDACTED_SECRET])") + }) + + it.each([ + ["comparisons", "+if (password === confirmation) {"], + ["arrow functions", "+const check = (password) => verify(password)"], + ["prose mentioning auth schemes", "+// Adds basic functionality for Bearer authentication"], + ["scoped package names", '+import { x } from "@roo-code/types"'], + ["versioned package specs", "+npx react@18.3.1"], + ["empty quoted credentials", '+ password: "",'], + ])("leaves %s unchanged", (_label, content) => { + expect(redactSensitiveInformation(content)).toBe(content) + }) + + it("leaves normal diff content unchanged", () => { + const content = "diff --git a/src/app.ts b/src/app.ts\n+console.log('hello world')" + expect(redactSensitiveInformation(content)).toBe(content) + }) + + it.each([ + ["back-to-back addresses", "a@b.co+c@d.co", "[REDACTED_EMAIL][REDACTED_EMAIL]"], + ["addresses in a list", "to: x@y.io, z@w.dev", "to: [REDACTED_EMAIL], [REDACTED_EMAIL]"], + ["an address after a bare @", "@ then bob@example.org", "@ then [REDACTED_EMAIL]"], + ["an address next to a decorator", "@Input() admin@corp.example", "@Input() [REDACTED_EMAIL]"], + ])("redacts %s", (_label, content, expected) => { + expect(redactSensitiveInformation(content)).toBe(expected) + }) + + it.each([ + ["a long run without @", "a".repeat(200_000)], + ["many @ signs with no domain", "a@".repeat(100_000)], + ["many local-part characters before one @", `${"a.".repeat(100_000)}@`], + ])("redacts %s in linear time", (_label, content) => { + // A quadratic scan of 200k characters takes far longer than this budget. + const startedAt = performance.now() + expect(redactSensitiveInformation(content)).toBe(content) + expect(performance.now() - startedAt).toBeLessThan(1000) + }) + }) + + describe("buildCommitMessagePrompt", () => { + it("includes diff and instructions", () => { + const { prompt, truncated } = buildCommitMessagePrompt("diff --git a/file b/file\n+test") + expect(prompt).toContain("diff --git a/file b/file\n+test") + expect(prompt).toContain("Conventional Commits") + expect(truncated).toBe(false) + }) + + it("redacts sensitive information in the prompt", () => { + const { prompt } = buildCommitMessagePrompt("apiKey: 'secret_token_12345678'") + expect(prompt).not.toContain("secret_token_12345678") + expect(prompt).toContain("[REDACTED]") + }) + + it.each([ + ["character", "x".repeat(DIFF_CHARACTER_LIMIT + 1)], + ["line", Array.from({ length: DIFF_LINE_LIMIT + 1 }, (_, i) => `+line ${i}`).join("\n")], + ])("reports truncation when the diff exceeds the %s limit", (_label, diff) => { + const { prompt, truncated } = buildCommitMessagePrompt(diff) + expect(truncated).toBe(true) + expect(prompt).toContain(COMMIT_MESSAGE_SYSTEM_PROMPT) + expect(prompt).toMatch(/\[\.\.\.\d+ (?:characters|lines) omitted\.\.\.\]/) + }) + + it("does not report truncation at exactly the character limit", () => { + const { truncated } = buildCommitMessagePrompt("x".repeat(DIFF_CHARACTER_LIMIT)) + expect(truncated).toBe(false) + }) + }) + + describe("getGitApi", () => { + it("returns undefined if git extension not found", async () => { + ;(vscode.extensions.getExtension as Mock).mockReturnValue(undefined) + const api = await getGitApi() + expect(api).toBeUndefined() + }) + + it("activates git extension if not active and returns API", async () => { + const mockApi = { repositories: [] } + const mockExt = { + isActive: false, + activate: vi.fn().mockResolvedValue(undefined), + exports: { + getAPI: vi.fn().mockReturnValue(mockApi), + }, + } + ;(vscode.extensions.getExtension as Mock).mockReturnValue(mockExt) + + const api = await getGitApi() + expect(mockExt.activate).toHaveBeenCalled() + expect(mockExt.exports.getAPI).toHaveBeenCalledWith(1) + expect(api).toBe(mockApi) + }) + }) + + describe("resolveGitRepository", () => { + it("returns undefined if repositories list is empty", () => { + const git: GitAPI = { repositories: [] } + expect(resolveGitRepository(git)).toBeUndefined() + }) + + it("resolves from repository object context directly", () => { + const repo: GitRepository = { + rootUri: makeUri("/workspace/repo"), + inputBox: { value: "" }, + diff: vi.fn(), + } + const git: GitAPI = { repositories: [repo] } + + expect(resolveGitRepository(git, repo)).toBe(repo) + }) + + it("resolves a SourceControl context from the scm/title menu by rootUri", () => { + const repo1 = makeRepo("/workspace/repo1") + const repo2 = makeRepo("/workspace/repo2") + const git: GitAPI = { repositories: [repo1, repo2] } + const sourceControl = { id: "git", rootUri: makeUri("/workspace/repo2"), inputBox: { value: "" } } + + expect(resolveGitRepository(git, sourceControl)).toBe(repo2) + }) + + it("rejects a repository-shaped context that is not a known repository", () => { + const known = makeRepo("/workspace/repo1") + const other = makeRepo("/workspace/repo2") + const git: GitAPI = { repositories: [known, other] } + const fake = makeRepo("/workspace/attacker") + + expect(resolveGitRepository(git, fake)).toBeUndefined() + expect(fake.diff).not.toHaveBeenCalled() + }) + + it("returns the known repository, not the context object, when a fake context reuses its rootUri", () => { + const known = makeRepo("/workspace/repo") + const git: GitAPI = { repositories: [known, makeRepo("/workspace/other")] } + const fake = makeRepo("/workspace/repo") + + const resolved = resolveGitRepository(git, fake) + expect(resolved).toBe(known) + expect(resolved).not.toBe(fake) + }) + + it("rejects a fake context with a matching-looking inputBox", () => { + const git: GitAPI = { repositories: [makeRepo("/workspace/repo1"), makeRepo("/workspace/repo2")] } + const fake = { inputBox: { value: "" }, diff: vi.fn() } + + expect(resolveGitRepository(git, fake)).toBeUndefined() + expect(fake.diff).not.toHaveBeenCalled() + }) + + it("resolves from bare vscode.Uri context", () => { + const repo1: GitRepository = { + rootUri: makeUri("/workspace/repo1"), + inputBox: { value: "" }, + diff: vi.fn(), + } + const repo2: GitRepository = { + rootUri: makeUri("/workspace/repo2"), + inputBox: { value: "" }, + diff: vi.fn(), + } + const git: GitAPI = { repositories: [repo1, repo2] } + + expect(resolveGitRepository(git, makeUri("/workspace/repo2"))).toBe(repo2) + }) + + it("resolves from scm context with rootUri", () => { + const repo1: GitRepository = { + rootUri: makeUri("/workspace/repo1"), + inputBox: { value: "" }, + diff: vi.fn(), + } + const repo2: GitRepository = { + rootUri: makeUri("/workspace/repo2"), + inputBox: { value: "" }, + diff: vi.fn(), + } + const git: GitAPI = { repositories: [repo1, repo2] } + + expect(resolveGitRepository(git, { rootUri: makeUri("/workspace/repo2") })).toBe(repo2) + }) + + it("resolves from scm context with inputBox", () => { + const inputBox1 = { value: "" } + const inputBox2 = { value: "" } + const repo1: GitRepository = { + rootUri: makeUri("/workspace/repo1"), + inputBox: inputBox1, + diff: vi.fn(), + } + const repo2: GitRepository = { + rootUri: makeUri("/workspace/repo2"), + inputBox: inputBox2, + diff: vi.fn(), + } + const git: GitAPI = { repositories: [repo1, repo2] } + + expect(resolveGitRepository(git, { inputBox: inputBox2 })).toBe(repo2) + }) + + it("falls back to first repository when only one exists", () => { + const repo: GitRepository = { + rootUri: makeUri("/workspace/repo"), + inputBox: { value: "" }, + diff: vi.fn(), + } + const git: GitAPI = { repositories: [repo] } + + expect(resolveGitRepository(git)).toBe(repo) + }) + + it("matches active editor URI if multiple repositories exist", () => { + const repo1: GitRepository = { + rootUri: makeUri("/workspace/repo1"), + inputBox: { value: "" }, + diff: vi.fn(), + } + const repo2: GitRepository = { + rootUri: makeUri("/workspace/repo2"), + inputBox: { value: "" }, + diff: vi.fn(), + } + const git: GitAPI = { repositories: [repo1, repo2] } + + const mutableWindow = vscode.window as unknown as { activeTextEditor?: vscode.TextEditor } + mutableWindow.activeTextEditor = { + document: { + uri: makeUri("/workspace/repo2/src/file.ts"), + }, + } as unknown as vscode.TextEditor // Structural test double providing document.uri + + expect(resolveGitRepository(git)).toBe(repo2) + }) + + it("returns undefined if bare vscode.Uri context does not match any repository", () => { + const repo1: GitRepository = { + rootUri: makeUri("/workspace/repo1"), + inputBox: { value: "" }, + diff: vi.fn(), + } + const repo2: GitRepository = { + rootUri: makeUri("/workspace/repo2"), + inputBox: { value: "" }, + diff: vi.fn(), + } + const git: GitAPI = { repositories: [repo1, repo2] } + + expect(resolveGitRepository(git, makeUri("/workspace/unknown"))).toBeUndefined() + }) + + it("returns undefined if scm context rootUri does not match any repository", () => { + const repo1: GitRepository = { + rootUri: makeUri("/workspace/repo1"), + inputBox: { value: "" }, + diff: vi.fn(), + } + const repo2: GitRepository = { + rootUri: makeUri("/workspace/repo2"), + inputBox: { value: "" }, + diff: vi.fn(), + } + const git: GitAPI = { repositories: [repo1, repo2] } + + expect(resolveGitRepository(git, { rootUri: makeUri("/workspace/unknown") })).toBeUndefined() + }) + + it("returns undefined if scm context inputBox does not match any repository", () => { + const repo1: GitRepository = { + rootUri: makeUri("/workspace/repo1"), + inputBox: { value: "" }, + diff: vi.fn(), + } + const repo2: GitRepository = { + rootUri: makeUri("/workspace/repo2"), + inputBox: { value: "" }, + diff: vi.fn(), + } + const git: GitAPI = { repositories: [repo1, repo2] } + + expect(resolveGitRepository(git, { inputBox: { value: "other" } })).toBeUndefined() + }) + + it("falls back to first repository when active editor does not match any repository", () => { + const repo1: GitRepository = { + rootUri: makeUri("/workspace/repo1"), + inputBox: { value: "" }, + diff: vi.fn(), + } + const repo2: GitRepository = { + rootUri: makeUri("/workspace/repo2"), + inputBox: { value: "" }, + diff: vi.fn(), + } + const git: GitAPI = { repositories: [repo1, repo2] } + + const mutableWindow = vscode.window as unknown as { activeTextEditor?: vscode.TextEditor } + mutableWindow.activeTextEditor = { + document: { + uri: makeUri("/workspace/other/file.ts"), + }, + } as unknown as vscode.TextEditor // Structural test double providing document.uri + + expect(resolveGitRepository(git)).toBe(repo1) + }) + + it("distinguishes repository roots on path segment boundaries", () => { + const repo1: GitRepository = { + rootUri: makeUri("/workspace/repo"), + inputBox: { value: "" }, + diff: vi.fn(), + } + const repo2: GitRepository = { + rootUri: makeUri("/workspace/repo-other"), + inputBox: { value: "" }, + diff: vi.fn(), + } + const git: GitAPI = { repositories: [repo1, repo2] } + + const mutableWindow = vscode.window as unknown as { activeTextEditor?: vscode.TextEditor } + mutableWindow.activeTextEditor = { + document: { + uri: makeUri("/workspace/repo-other/file.ts"), + }, + } as unknown as vscode.TextEditor + + expect(resolveGitRepository(git)).toBe(repo2) + }) + + it("matches longest root when repositories are nested", () => { + const parentRepo: GitRepository = { + rootUri: makeUri("/workspace/monorepo"), + inputBox: { value: "" }, + diff: vi.fn(), + } + const childRepo: GitRepository = { + rootUri: makeUri("/workspace/monorepo/packages/nested"), + inputBox: { value: "" }, + diff: vi.fn(), + } + const git: GitAPI = { repositories: [parentRepo, childRepo] } + + const mutableWindow = vscode.window as unknown as { activeTextEditor?: vscode.TextEditor } + mutableWindow.activeTextEditor = { + document: { + uri: makeUri("/workspace/monorepo/packages/nested/src/index.ts"), + }, + } as unknown as vscode.TextEditor + + expect(resolveGitRepository(git)).toBe(childRepo) + }) + }) + + describe("getRepositoryDiff", () => { + it("prioritizes staged diff", async () => { + const repo: GitRepository = { + rootUri: makeUri("/workspace/repo"), + inputBox: { value: "" }, + diff: vi.fn().mockImplementation((cached?: boolean) => { + if (cached) return Promise.resolve("staged diff") + return Promise.resolve("working diff") + }), + } + + const diff = await getRepositoryDiff(repo) + expect(diff).toBe("staged diff") + expect(repo.diff).toHaveBeenCalledWith(true) + }) + + it("falls back to working tree diff if staged diff is empty", async () => { + const repo: GitRepository = { + rootUri: makeUri("/workspace/repo"), + inputBox: { value: "" }, + diff: vi.fn().mockImplementation((cached?: boolean) => { + if (cached) return Promise.resolve("") + return Promise.resolve("working diff") + }), + } + + const diff = await getRepositoryDiff(repo) + expect(diff).toBe("working diff") + expect(repo.diff).toHaveBeenCalledWith(true) + expect(repo.diff).toHaveBeenCalledWith(false) + }) + + it("rejects without reading the working tree when the staged diff fails", async () => { + const repo = makeRepo("/workspace/repo", { + diff: vi.fn().mockImplementation((cached?: boolean) => { + if (cached) return Promise.reject(new Error("Git diff staged failed")) + return Promise.resolve("working diff fallback") + }), + }) + + await expect(getRepositoryDiff(repo)).rejects.toThrow("Git diff staged failed") + expect(repo.diff).not.toHaveBeenCalledWith(false) + }) + + it("rejects with the staged error when both staged and working tree diffs fail", async () => { + const repo = makeRepo("/workspace/repo", { + diff: vi + .fn() + .mockImplementation((cached?: boolean) => + Promise.reject(new Error(cached ? "staged failed" : "working failed")), + ), + }) + + await expect(getRepositoryDiff(repo)).rejects.toThrow("staged failed") + }) + + it("returns undefined if both staged and working diffs are empty", async () => { + const repo: GitRepository = { + rootUri: makeUri("/workspace/repo"), + inputBox: { value: "" }, + diff: vi.fn().mockResolvedValue(""), + } + + const diff = await getRepositoryDiff(repo) + expect(diff).toBeUndefined() + }) + + it("rejects if working diff fails after empty staged diff", async () => { + const repo = makeRepo("/workspace/repo", { + diff: vi.fn().mockImplementation((cached?: boolean) => { + if (cached) return Promise.resolve("") + return Promise.reject(new Error("Git working tree diff failed")) + }), + }) + + await expect(getRepositoryDiff(repo)).rejects.toThrow("Git working tree diff failed") + }) + + it("lists untracked files relative to the repository root", async () => { + const repo = makeRepo("/home/alice/workspace/repo", { + diff: vi.fn().mockResolvedValue(""), + state: { + workingTreeChanges: [{ uri: makeUri("/home/alice/workspace/repo/src/new-file.ts"), status: 7 }], + }, + }) + + const diff = await getRepositoryDiff(repo) + // path.relative uses the platform separator. + expect(diff).toMatch(/^Untracked files:\nsrc[\\/]new-file\.ts$/) + }) + + it("lists untracked files from the separate untrackedChanges group", async () => { + const repo = makeRepo("/workspace/repo", { + diff: vi.fn().mockResolvedValue(""), + state: { + workingTreeChanges: [], + untrackedChanges: [{ uri: makeUri("/workspace/repo/brand-new.ts"), status: 7 }], + }, + }) + + expect(await getRepositoryDiff(repo)).toBe("Untracked files:\nbrand-new.ts") + }) + + it("de-duplicates a file listed in both untracked locations", async () => { + const repo = makeRepo("/workspace/repo", { + diff: vi.fn().mockResolvedValue(""), + state: { + workingTreeChanges: [ + { uri: makeUri("/workspace/repo/a.ts"), status: 7 }, + { uri: makeUri("/workspace/repo/modified.ts"), status: 5 }, + ], + untrackedChanges: [ + { uri: makeUri("/workspace/repo/a.ts"), status: 7 }, + { uri: makeUri("/workspace/repo/b.ts"), status: 7 }, + ], + }, + }) + + expect(await getRepositoryDiff(repo)).toBe("Untracked files:\na.ts\nb.ts") + }) + + it("returns undefined when both untracked locations are empty", async () => { + const repo = makeRepo("/workspace/repo", { + diff: vi.fn().mockResolvedValue(""), + state: { workingTreeChanges: [], untrackedChanges: [] }, + }) + + expect(await getRepositoryDiff(repo)).toBeUndefined() + }) + + it("surfaces untracked files when both diffs are empty", async () => { + const repo: GitRepository = { + rootUri: makeUri("/workspace/repo"), + inputBox: { value: "" }, + diff: vi.fn().mockResolvedValue(""), + state: { + workingTreeChanges: [ + { uri: makeUri("/workspace/repo/new-file.ts"), status: 7 }, // UNTRACKED + { uri: makeUri("/workspace/repo/modified.ts"), status: 5 }, // MODIFIED + ], + }, + } + + const diff = await getRepositoryDiff(repo) + expect(diff).toContain("Untracked files:") + expect(diff).toContain("new-file.ts") + expect(diff).not.toContain("modified.ts") + }) + + it("surfaces all untracked files when mixed with tracked changes", async () => { + const repo: GitRepository = { + rootUri: makeUri("/workspace/repo"), + inputBox: { value: "" }, + diff: vi.fn().mockResolvedValue(""), + state: { + workingTreeChanges: [ + { uri: makeUri("/workspace/repo/new-a.ts"), status: 7 }, // UNTRACKED + { uri: makeUri("/workspace/repo/new-b.ts"), status: 7 }, // UNTRACKED + { uri: makeUri("/workspace/repo/modified.ts"), status: 5 }, // MODIFIED (not untracked) + ], + }, + } + + const diff = await getRepositoryDiff(repo) + expect(diff).toContain("Untracked files:") + expect(diff).toContain("new-a.ts") + expect(diff).toContain("new-b.ts") + expect(diff).not.toContain("modified.ts") + }) + + it("returns undefined when both diffs are empty and no untracked files exist", async () => { + const repo: GitRepository = { + rootUri: makeUri("/workspace/repo"), + inputBox: { value: "" }, + diff: vi.fn().mockResolvedValue(""), + state: { + workingTreeChanges: [ + { uri: makeUri("/workspace/repo/modified.ts"), status: 5 }, // MODIFIED (not untracked) + ], + }, + } + + const diff = await getRepositoryDiff(repo) + expect(diff).toBeUndefined() + }) + }) + + describe("generateCommitMessage", () => { + let mockProvider: ClineProvider + let mockOutputChannel: vscode.OutputChannel + let mockRepo: GitRepository + + beforeEach(() => { + mockOutputChannel = { + appendLine: vi.fn(), + append: vi.fn(), + clear: vi.fn(), + hide: vi.fn(), + name: "test", + replace: vi.fn(), + show: vi.fn(), + dispose: vi.fn(), + } + + mockRepo = { + rootUri: makeUri("/workspace/repo"), + inputBox: { value: "" }, + diff: vi.fn().mockResolvedValue("diff content"), + } + + // Structural test double providing getState for generateCommitMessage + mockProvider = { + getState: vi.fn().mockResolvedValue({ + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + apiKey: "test-key", + }, + }), + } as unknown as ClineProvider + + const mockGitApi: GitAPI = { + repositories: [mockRepo], + } + ;(vscode.extensions.getExtension as Mock).mockReturnValue({ + isActive: true, + exports: { + getAPI: () => mockGitApi, + }, + }) + + // Default withProgress mock: runs the callback immediately with a non-cancelled token. + // Individual tests may override this for cancellation or error scenarios. + ;(vscode.window.withProgress as Mock).mockImplementation((_opts, callback) => { + const progress = { report: vi.fn() } + const token = { + isCancellationRequested: false, + onCancellationRequested: vi.fn().mockReturnValue({ dispose: vi.fn() }), + } + return callback(progress, token) + }) + }) + + it("shows error if git extension is unavailable", async () => { + ;(vscode.extensions.getExtension as Mock).mockReturnValue(undefined) + + await generateCommitMessage({ + provider: mockProvider, + outputChannel: mockOutputChannel, + }) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Git extension not found or not enabled.") + }) + + it("shows error if no git repository found", async () => { + ;(vscode.extensions.getExtension as Mock).mockReturnValue({ + isActive: true, + exports: { + getAPI: () => ({ repositories: [] }), + }, + }) + + await generateCommitMessage({ + provider: mockProvider, + outputChannel: mockOutputChannel, + }) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("No Git repository found.") + }) + + it("shows info if no diff is found", async () => { + mockRepo.diff = vi.fn().mockResolvedValue("") + + await generateCommitMessage({ + provider: mockProvider, + outputChannel: mockOutputChannel, + }) + + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(NO_CHANGES_MESSAGE) + }) + + it("reports a Git diff failure as an error instead of 'no changes'", async () => { + mockRepo.diff = vi.fn().mockRejectedValue(new Error("fatal: bad index file")) + + await generateCommitMessage({ + provider: mockProvider, + outputChannel: mockOutputChannel, + }) + + expect(vscode.window.showInformationMessage).not.toHaveBeenCalledWith(NO_CHANGES_MESSAGE) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to generate commit message: fatal: bad index file", + ) + expect(singleCompletionHandler).not.toHaveBeenCalled() + }) + + it("never reads from or writes to a command context that is not a known repository", async () => { + const fake = makeRepo("/workspace/attacker") + + await generateCommitMessage({ + provider: mockProvider, + outputChannel: mockOutputChannel, + context: fake, + }) + + expect(fake.diff).not.toHaveBeenCalled() + expect(singleCompletionHandler).not.toHaveBeenCalled() + expect(fake.inputBox.value).toBe("") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("No Git repository found.") + }) + + it("generates for the repository named by an scm/title SourceControl context", async () => { + ;(singleCompletionHandler as Mock).mockResolvedValue("fix: second repo") + const second = makeRepo("/workspace/second") + ;(vscode.extensions.getExtension as Mock).mockReturnValue({ + isActive: true, + exports: { getAPI: () => ({ repositories: [mockRepo, second] }) }, + }) + + await generateCommitMessage({ + provider: mockProvider, + outputChannel: mockOutputChannel, + context: { rootUri: makeUri("/workspace/second"), inputBox: { value: "" } }, + }) + + expect(second.inputBox.value).toBe("fix: second repo") + expect(mockRepo.inputBox.value).toBe("") + }) + + it("warns after writing when the diff was truncated", async () => { + mockRepo.diff = vi.fn().mockResolvedValue("x".repeat(DIFF_CHARACTER_LIMIT + 1)) + ;(singleCompletionHandler as Mock).mockResolvedValue("feat: large change") + + await generateCommitMessage({ + provider: mockProvider, + outputChannel: mockOutputChannel, + }) + + expect(mockRepo.inputBox.value).toBe("feat: large change") + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith(DIFF_TRUNCATED_MESSAGE) + }) + + it("does not warn about truncation for a diff within the limits", async () => { + ;(singleCompletionHandler as Mock).mockResolvedValue("feat: small change") + + await generateCommitMessage({ + provider: mockProvider, + outputChannel: mockOutputChannel, + }) + + expect(mockRepo.inputBox.value).toBe("feat: small change") + expect(vscode.window.showWarningMessage).not.toHaveBeenCalled() + }) + + it("keeps text the user typed while the request was pending", async () => { + mockRepo.inputBox.value = "draft" + ;(singleCompletionHandler as Mock).mockImplementation(async () => { + mockRepo.inputBox.value = "typed by user" + return "feat: generated" + }) + + await generateCommitMessage({ + provider: mockProvider, + outputChannel: mockOutputChannel, + }) + + expect(mockRepo.inputBox.value).toBe("typed by user") + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(DRAFT_CHANGED_MESSAGE) + }) + + it("replaces an unchanged draft with the generated message", async () => { + mockRepo.inputBox.value = "draft" + ;(singleCompletionHandler as Mock).mockResolvedValue("feat: generated") + + await generateCommitMessage({ + provider: mockProvider, + outputChannel: mockOutputChannel, + }) + + expect(mockRepo.inputBox.value).toBe("feat: generated") + expect(vscode.window.showInformationMessage).not.toHaveBeenCalledWith(DRAFT_CHANGED_MESSAGE) + }) + + it("discards an older result that finishes after a newer request for the same repository", async () => { + const first = deferred() + const second = deferred() + ;(singleCompletionHandler as Mock) + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise) + + const older = generateCommitMessage({ provider: mockProvider, outputChannel: mockOutputChannel }) + const newer = generateCommitMessage({ provider: mockProvider, outputChannel: mockOutputChannel }) + await vi.waitFor(() => expect(singleCompletionHandler).toHaveBeenCalledTimes(2)) + + second.resolve("feat: newer") + await newer + first.resolve("feat: older") + await older + + expect(mockRepo.inputBox.value).toBe("feat: newer") + expect(vscode.window.showInformationMessage).not.toHaveBeenCalledWith(DRAFT_CHANGED_MESSAGE) + }) + + it("keeps the latest request of each repository independent", async () => { + const otherRepo = makeRepo("/workspace/other") + ;(vscode.extensions.getExtension as Mock).mockReturnValue({ + isActive: true, + exports: { getAPI: () => ({ repositories: [mockRepo, otherRepo] }) }, + }) + const first = deferred() + const second = deferred() + ;(singleCompletionHandler as Mock) + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise) + + const forMockRepo = generateCommitMessage({ provider: mockProvider, context: mockRepo }) + const forOtherRepo = generateCommitMessage({ provider: mockProvider, context: otherRepo }) + await vi.waitFor(() => expect(singleCompletionHandler).toHaveBeenCalledTimes(2)) + + second.resolve("feat: other") + await forOtherRepo + first.resolve("feat: mock") + await forMockRepo + + expect(mockRepo.inputBox.value).toBe("feat: mock") + expect(otherRepo.inputBox.value).toBe("feat: other") + }) + + it("closes the progress immediately when cancelled while the LLM call is pending", async () => { + const { cancel, disposable } = useCancellableProgress() + let capturedSignal: AbortSignal | undefined + // The provider ignores the abort signal and never settles. + ;(singleCompletionHandler as Mock).mockImplementation((_config, _prompt, options) => { + capturedSignal = options.abortSignal as AbortSignal + return new Promise(() => {}) + }) + + const run = generateCommitMessage({ provider: mockProvider, outputChannel: mockOutputChannel }) + await vi.waitFor(() => expect(singleCompletionHandler).toHaveBeenCalled()) + cancel() + await run + + expect(capturedSignal?.aborted).toBe(true) + expect(disposable.dispose).toHaveBeenCalled() + expect(mockRepo.inputBox.value).toBe("") + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("does not report a provider rejection that arrives after cancellation", async () => { + const { cancel } = useCancellableProgress() + let rejectCompletion: (error: Error) => void = () => {} + ;(singleCompletionHandler as Mock).mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectCompletion = reject + }), + ) + + const run = generateCommitMessage({ provider: mockProvider, outputChannel: mockOutputChannel }) + await vi.waitFor(() => expect(singleCompletionHandler).toHaveBeenCalled()) + cancel() + await run + rejectCompletion(new Error("late provider failure")) + await Promise.resolve() + + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + expect(mockRepo.inputBox.value).toBe("") + }) + + it("shows error if no valid API provider is configured", async () => { + mockProvider.getState = vi.fn().mockResolvedValue({ + apiConfiguration: {}, + }) + + await generateCommitMessage({ + provider: mockProvider, + outputChannel: mockOutputChannel, + }) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "No valid API provider configured. Please configure an API provider in Zoo Code settings.", + ) + }) + + it("generates commit message and assigns it to inputBox.value", async () => { + ;(singleCompletionHandler as Mock).mockResolvedValue("feat(core): implement git commit message") + ;(vscode.window.withProgress as Mock).mockImplementation((_opts, callback) => { + const progress = { report: vi.fn() } + const token = { + isCancellationRequested: false, + onCancellationRequested: vi.fn().mockReturnValue({ dispose: vi.fn() }), + } + return callback(progress, token) + }) + + await generateCommitMessage({ + provider: mockProvider, + outputChannel: mockOutputChannel, + }) + + expect(singleCompletionHandler).toHaveBeenCalled() + expect(mockRepo.inputBox.value).toBe("feat(core): implement git commit message") + }) + + it("does not update inputBox if cancelled", async () => { + ;(singleCompletionHandler as Mock).mockResolvedValue("feat: should be cancelled") + ;(vscode.window.withProgress as Mock).mockImplementation((_opts, callback) => { + const progress = { report: vi.fn() } + let cancellationHandler: () => void = () => {} + let isCancelled = false + const token = { + get isCancellationRequested() { + return isCancelled + }, + onCancellationRequested: vi.fn((fn: () => void) => { + cancellationHandler = fn + return { dispose: vi.fn() } + }), + } + cancellationHandler() + isCancelled = true + return callback(progress, token) + }) + + await generateCommitMessage({ + provider: mockProvider, + outputChannel: mockOutputChannel, + }) + + expect(mockRepo.inputBox.value).toBe("") + }) + + it("skips provider and LLM call when cancelled while diff is pending", async () => { + let isCancelled = false + const cancellationHandlers: Array<() => void> = [] + // diff never resolves on its own — simulates a slow/stuck git process + mockRepo.diff = vi.fn().mockImplementation(() => new Promise(() => {})) + ;(vscode.window.withProgress as Mock).mockImplementation((_opts, callback) => { + const progress = { report: vi.fn() } + const token = { + get isCancellationRequested() { + return isCancelled + }, + onCancellationRequested: vi.fn((fn: () => void) => { + cancellationHandlers.push(fn) + return { dispose: vi.fn() } + }), + } + // Trigger cancellation after the race has started (next microtask) + void Promise.resolve().then(() => { + isCancelled = true + cancellationHandlers.forEach((fn) => fn()) + }) + return callback(progress, token) + }) + + await generateCommitMessage({ + provider: mockProvider, + outputChannel: mockOutputChannel, + }) + + expect(mockProvider.getState).not.toHaveBeenCalled() + expect(singleCompletionHandler).not.toHaveBeenCalled() + expect(mockRepo.inputBox.value).toBe("") + }) + + it("skips LLM call when cancelled after provider state resolves", async () => { + let isCancelled = false + ;(vscode.window.withProgress as Mock).mockImplementation((_opts, callback) => { + const progress = { report: vi.fn() } + const token = { + get isCancellationRequested() { + return isCancelled + }, + onCancellationRequested: vi.fn().mockReturnValue({ dispose: vi.fn() }), + } + return callback(progress, token) + }) + // Set cancelled = true inside getState so the guard after getState fires + mockProvider.getState = vi.fn().mockImplementation(async () => { + isCancelled = true + return { apiConfiguration: { apiProvider: providerIdentifiers.openai, apiKey: "sk-test" } } + }) + + await generateCommitMessage({ + provider: mockProvider, + outputChannel: mockOutputChannel, + }) + + expect(singleCompletionHandler).not.toHaveBeenCalled() + expect(mockRepo.inputBox.value).toBe("") + }) + + it("aborts the in-flight provider request when cancellation is requested", async () => { + // Collect ALL registered cancellation handlers so both abortController.abort() + // and the cancelPromise resolver are called when the test triggers cancellation. + const registeredHandlers: Array<() => void> = [] + let triggerCancellation: () => void = () => {} + let capturedSignal: AbortSignal | undefined + ;(singleCompletionHandler as Mock).mockImplementation((_config, _prompt, options) => { + capturedSignal = options.abortSignal as AbortSignal + triggerCancellation() + return Promise.resolve("feat: should be cancelled") + }) + ;(vscode.window.withProgress as Mock).mockImplementation((_opts, callback) => { + const progress = { report: vi.fn() } + const token = { + isCancellationRequested: false, + onCancellationRequested: vi.fn((fn: () => void) => { + registeredHandlers.push(fn) + triggerCancellation = () => registeredHandlers.forEach((h) => h()) + return { dispose: vi.fn() } + }), + } + return callback(progress, token) + }) + + await generateCommitMessage({ + provider: mockProvider, + outputChannel: mockOutputChannel, + }) + + expect(capturedSignal?.aborted).toBe(true) + expect(mockRepo.inputBox.value).toBe("") + }) + + it("does not update inputBox and shows warning if generated message is empty", async () => { + mockRepo.inputBox.value = "preserved value" + const mockDisposable = { dispose: vi.fn() } + ;(singleCompletionHandler as Mock).mockResolvedValue(" ") + ;(vscode.window.withProgress as Mock).mockImplementation((_opts, callback) => { + const progress = { report: vi.fn() } + const token = { + isCancellationRequested: false, + onCancellationRequested: vi.fn().mockReturnValue(mockDisposable), + } + return callback(progress, token) + }) + + await generateCommitMessage({ + provider: mockProvider, + outputChannel: mockOutputChannel, + }) + + expect(mockRepo.inputBox.value).toBe("preserved value") + expect(mockDisposable.dispose).toHaveBeenCalled() + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith("No commit message generated.") + }) + + it("stays quiet when the provider rejects synchronously on the user's cancel", async () => { + const { cancel } = useCancellableProgress() + ;(singleCompletionHandler as Mock).mockImplementation( + (_config, _prompt, options: { abortSignal: AbortSignal }) => + new Promise((_resolve, reject) => { + options.abortSignal.addEventListener("abort", () => { + const abortError = new Error("This operation was aborted") + abortError.name = "AbortError" + reject(abortError) + }) + }), + ) + + const run = generateCommitMessage({ provider: mockProvider, outputChannel: mockOutputChannel }) + await vi.waitFor(() => expect(singleCompletionHandler).toHaveBeenCalled()) + cancel() + await run + + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + expect(mockOutputChannel.appendLine).not.toHaveBeenCalled() + expect(mockRepo.inputBox.value).toBe("") + }) + + it.each([ + ["a message that mentions 'aborted'", "request aborted by server", "Error"], + ["a provider timeout", "The operation was aborted due to timeout", "TimeoutError"], + ["an AbortError the user did not cause", "This operation was aborted", "AbortError"], + ])("reports %s when the user did not cancel", async (_label, message, name) => { + const error = new Error(message) + error.name = name + ;(singleCompletionHandler as Mock).mockRejectedValue(error) + + await generateCommitMessage({ provider: mockProvider, outputChannel: mockOutputChannel }) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(`Failed to generate commit message: ${message}`) + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(`[generateGitCommitMessage] failed: ${message}`) + expect(mockRepo.inputBox.value).toBe("") + }) + + it("handles non-Error objects gracefully and shows error message", async () => { + ;(vscode.window.withProgress as Mock).mockRejectedValue("String-based error") + + await generateCommitMessage({ + provider: mockProvider, + outputChannel: mockOutputChannel, + }) + + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining("[generateGitCommitMessage] failed: String-based error"), + ) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to generate commit message: String-based error", + ) + }) + + it("handles errors gracefully and shows error message", async () => { + ;(vscode.window.withProgress as Mock).mockRejectedValue(new Error("Network error")) + + await generateCommitMessage({ + provider: mockProvider, + outputChannel: mockOutputChannel, + }) + + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining("[generateGitCommitMessage] failed: Network error"), + ) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to generate commit message: Network error", + ) + }) + }) +}) diff --git a/src/services/git/commitMessageGenerator.ts b/src/services/git/commitMessageGenerator.ts new file mode 100644 index 0000000000..0af021a431 --- /dev/null +++ b/src/services/git/commitMessageGenerator.ts @@ -0,0 +1,493 @@ +import * as path from "path" +import * as vscode from "vscode" +import type { ProviderSettings } from "@roo-code/types" + +import { ClineProvider } from "../../core/webview/ClineProvider" +import { truncateOutput } from "../../integrations/misc/extract-text" +import { singleCompletionHandler } from "../../utils/single-completion-handler" + +export interface GitRepositoryChange { + readonly uri: vscode.Uri + readonly status: number +} + +export interface GitRepositoryState { + readonly workingTreeChanges?: ReadonlyArray + readonly untrackedChanges?: ReadonlyArray +} + +export interface GitRepository { + readonly rootUri: vscode.Uri + readonly inputBox: { + value: string + } + readonly state?: GitRepositoryState + diff(cached?: boolean): Promise +} + +export interface GitAPI { + readonly repositories: readonly GitRepository[] +} + +export interface GitExtension { + readonly enabled?: boolean + getAPI(version: 1): GitAPI +} + +export interface GenerateCommitMessageOptions { + provider: ClineProvider + outputChannel?: vscode.OutputChannel + context?: unknown +} + +export interface CommitMessagePrompt { + prompt: string + truncated: boolean +} + +export const COMMIT_MESSAGE_SYSTEM_PROMPT = `You are an expert developer specializing in Git commit messages. +Generate a concise, meaningful Git commit message for the provided diff following the Conventional Commits specification (v1.0.0). + +Guidelines: +1. Format: (): + [optional body] + [optional footer(s)] +2. Common types: + - feat: A new feature + - fix: A bug fix + - docs: Documentation changes + - style: Formatting or code style changes (no logic change) + - refactor: Refactoring code without changing behavior + - perf: Performance improvements + - test: Adding or updating tests + - build: Changes affecting build systems or dependencies + - ci: CI/CD configuration changes + - chore: Other maintenance changes +3. Use imperative, present tense ("add" not "added", "fix" not "fixes"). +4. Keep the subject line within 72 characters and do not end with a period. +5. If the changes warrant explanation, include a blank line followed by a concise body or bullet points detailing what changed and why. +6. Return ONLY the commit message. Do NOT include markdown code blocks, quotes, or any introductory or concluding text. +7. Never output secrets, passwords, or personal identifiable information in commit messages.` + +export const DIFF_LINE_LIMIT = 1000 +export const DIFF_CHARACTER_LIMIT = 50_000 + +export const NO_CHANGES_MESSAGE = "No staged or working directory changes found to generate commit message." +export const DRAFT_CHANGED_MESSAGE = + "The commit message box changed while Zoo Code generated a message. Your text was kept. Run the command again to replace it." +export const DIFF_TRUNCATED_MESSAGE = + "The diff was too large and Zoo Code shortened it before generation. Make sure the commit message covers all changes." + +// vscode.git Status.UNTRACKED +const UNTRACKED_STATUS = 7 + +const CREDENTIAL_KEYWORDS = [ + "api[_-]?key", + "access[_-]?key(?:[_-]?id)?", + "access[_-]?token", + "refresh[_-]?token", + "auth[_-]?token", + "client[_-]?secret", + "private[_-]?key", + "secret(?:[_-]?(?:access[_-]?)?key)?", + "passw(?:or)?d", + "pwd", + "credentials?", +].join("|") + +// `\x60` is a backtick. An assignment operator not followed by `=` or `>` skips comparisons +// (`password === x`) and arrow functions. +const ASSIGNMENT = String.raw`["'\x60]?\s*[:=](?![=>])\s*` +// A quoted value runs to its closing quote, so `;` or `,` inside the quotes is redacted too. +// Backslash escapes are honored. Empty quotes are left alone. +const QUOTED_VALUE = String.raw`(?["'\x60])(?:\\.|(?!\k)[^\\\n])+\k` +// An unquoted (or unterminated) value runs to the next whitespace or quote. +const UNQUOTED_VALUE = String.raw`["'\x60]?[^\s"'\x60]+` +const AUTH_SCHEME = String.raw`(?:Bearer|Basic|Digest|Token)\s+` + +/** + * Each entry replaces one class of sensitive value. Patterns with a `prefix` capture group keep the + * prefix (key name, header scheme, quotes) and replace only the value, so the model still sees which + * setting changed. The order matters: header schemes run before the generic key/value pattern, and + * URL credentials run before the email pattern. A named group that did not take part in the match is + * replaced with an empty string. + */ +const REDACTION_RULES: ReadonlyArray<{ pattern: RegExp; replacement: string }> = [ + // PEM private key blocks + { + pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, + replacement: "[REDACTED_SECRET]", + }, + // Authorization header values, with or without a scheme ("Authorization: Basic dXNlcjpwYXNz") + { + pattern: new RegExp( + String.raw`(?\bauthorization${ASSIGNMENT})(?:(?["'\x60])(?${AUTH_SCHEME})?(?:\\.|(?!\k)[^\\\n])+\k|(?${AUTH_SCHEME})?${UNQUOTED_VALUE})`, + "gi", + ), + replacement: "$$$$[REDACTED]$", + }, + // Bearer tokens outside an Authorization header. The token must contain a digit so prose such as + // "Bearer authentication" is not redacted. + { + pattern: /(?\b[Bb]earer\s+)(?=[A-Za-z_\-.~+/]*\d)[A-Za-z0-9_\-.~+/]{8,}=*/g, + replacement: "$[REDACTED]", + }, + // key = value, key: value, "key": "value" (env files, YAML, JSON, code) + { + pattern: new RegExp( + String.raw`(?(?:${CREDENTIAL_KEYWORDS})${ASSIGNMENT})(?:${QUOTED_VALUE}|${UNQUOTED_VALUE})`, + "gi", + ), + replacement: "$$[REDACTED]$", + }, + // Well-known token formats that can appear without a key name + { + pattern: + /\b(?:sk-(?:proj-|live-|test-|ant-)?[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,}|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})/g, + replacement: "[REDACTED_SECRET]", + }, + // Credentials embedded in URLs: scheme://user:password@host + { + pattern: /(?:\/\/[^\s:@/]+:)[^\s@/]+(?=@)/g, + replacement: "$[REDACTED]", + }, +] + +const EMAIL_LOCAL_PART_CHAR = /[A-Za-z0-9._%+-]/ +const EMAIL_DOMAIN = /[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}\b/y + +/** + * Replaces email addresses with [REDACTED_EMAIL] in linear time. + * + * An unanchored `local@domain` regex rescans a long run of local-part characters from every start + * position when the run has no `@`, which is quadratic on a large diff. This scan starts at each `@` + * instead. The local part is read backwards and stops at the end of the previous replacement or at + * the previous `@`, so every character is visited a constant number of times. Back-to-back addresses + * such as `a@b.co+c@d.co` are both replaced. + */ +function redactEmailAddresses(content: string): string { + let result = "" + let copiedUpTo = 0 + let at = content.indexOf("@") + while (at !== -1) { + let start = at + while (start > copiedUpTo && EMAIL_LOCAL_PART_CHAR.test(content[start - 1])) { + start-- + } + EMAIL_DOMAIN.lastIndex = at + 1 + const domain = start < at ? EMAIL_DOMAIN.exec(content) : null + if (domain) { + result += content.slice(copiedUpTo, start) + "[REDACTED_EMAIL]" + copiedUpTo = at + 1 + domain[0].length + at = content.indexOf("@", copiedUpTo) + } else { + at = content.indexOf("@", at + 1) + } + } + return result + content.slice(copiedUpTo) +} + +/** + * Redacts secrets, private keys, credential tokens, and email addresses from content before it is + * sent to an LLM. + */ +export function redactSensitiveInformation(content: string): string { + let redacted = content + for (const { pattern, replacement } of REDACTION_RULES) { + redacted = redacted.replace(pattern, replacement) + } + // Runs after the URL credential rule so `user:password@host` is handled there first. + return redactEmailAddresses(redacted) +} + +/** + * Accesses the VS Code Git extension API. + */ +export async function getGitApi(): Promise { + const gitExtension = vscode.extensions.getExtension("vscode.git") + if (!gitExtension) { + return undefined + } + if (!gitExtension.isActive) { + await gitExtension.activate() + } + return gitExtension.exports?.getAPI(1) +} + +function isUriLike(value: unknown): value is vscode.Uri { + return ( + typeof value === "object" && + value !== null && + typeof (value as { scheme?: unknown }).scheme === "string" && + typeof (value as { fsPath?: unknown }).fsPath === "string" + ) +} + +function findRepositoryByRoot(git: GitAPI, uri: vscode.Uri): GitRepository | undefined { + const uriString = uri.toString() + return git.repositories.find((r) => r.rootUri.toString() === uriString || r.rootUri.fsPath === uri.fsPath) +} + +/** + * Resolves the Git repository for an optional SCM command context. + * + * The command is public, so the context is untrusted. The result is always an entry of + * `git.repositories`. The function never returns the context object and never calls its methods. + */ +export function resolveGitRepository(git: GitAPI, context?: unknown): GitRepository | undefined { + if (!git.repositories || git.repositories.length === 0) { + return undefined + } + + if (context && typeof context === "object") { + const known = git.repositories.find((r) => r === context) + if (known) { + return known + } + + // A bare Uri, or a SourceControl from the scm/title menu (rootUri + inputBox). + // When an explicit context matches no repository, return undefined instead of + // silently picking a different repository. + if (isUriLike(context)) { + return findRepositoryByRoot(git, context) + } + + const sc = context as { rootUri?: unknown; inputBox?: unknown } + if (isUriLike(sc.rootUri)) { + return findRepositoryByRoot(git, sc.rootUri) + } + + if (sc.inputBox) { + return git.repositories.find((r) => r.inputBox === sc.inputBox) + } + } + + if (git.repositories.length === 1) { + return git.repositories[0] + } + + const activeDocUri = vscode.window.activeTextEditor?.document.uri + if (activeDocUri) { + const matchingRepos = git.repositories.filter((r) => { + const root = r.rootUri.fsPath + return ( + activeDocUri.fsPath === root || + activeDocUri.fsPath.startsWith(root + "/") || + activeDocUri.fsPath.startsWith(root + "\\") + ) + }) + if (matchingRepos.length > 0) { + matchingRepos.sort((a, b) => b.rootUri.fsPath.length - a.rootUri.fsPath.length) + return matchingRepos[0] + } + } + + return git.repositories[0] +} + +/** + * Retrieves the diff from the repository. Staged changes come first. Working tree changes are used + * only when nothing is staged, and untracked files only when both diffs are empty. + * + * A failed `git diff` rejects. It is not reported as "no changes", because a failed staged read + * would otherwise make the message describe unstaged changes that the next commit does not include. + */ +export async function getRepositoryDiff(repository: GitRepository): Promise { + const staged = (await repository.diff(true))?.trim() + if (staged) { + return staged + } + + const workingTree = (await repository.diff(false))?.trim() + if (workingTree) { + return workingTree + } + + // `git diff` omits untracked files; surface them as a plain listing so the + // LLM can still generate an "initial commit" or "add files" message. + // With `git.untrackedChanges: "separate"` they are in `untrackedChanges`, otherwise in + // `workingTreeChanges`. Read both and de-duplicate by path. + // Paths are relative to the repository root so absolute local paths are not sent. + const state = repository.state + const untrackedPaths = new Set( + [ + ...(state?.workingTreeChanges ?? []).filter((c) => c.status === UNTRACKED_STATUS), + ...(state?.untrackedChanges ?? []), + ].map((c) => c.uri.fsPath), + ) + if (untrackedPaths.size > 0) { + const rootPath = repository.rootUri.fsPath + const fileList = [...untrackedPaths].map((p) => path.relative(rootPath, p) || p).join("\n") + return `Untracked files:\n${fileList}` + } + + return undefined +} + +/** + * Cleans the commit message output from LLM completions. + */ +export function cleanCommitMessage(message: string): string { + let cleaned = message.trim() + if (cleaned.startsWith("```")) { + cleaned = cleaned.replace(/^```[a-zA-Z]*\r?\n?/, "") + cleaned = cleaned.replace(/\r?\n?```$/, "") + cleaned = cleaned.trim() + } + if ((cleaned.startsWith('"') && cleaned.endsWith('"')) || (cleaned.startsWith("'") && cleaned.endsWith("'"))) { + cleaned = cleaned.slice(1, -1).trim() + } + return cleaned +} + +/** + * Builds the full prompt to generate a commit message from a git diff. + * `truncated` is true when the diff exceeded the line or character limit and was shortened. + */ +export function buildCommitMessagePrompt(diff: string): CommitMessagePrompt { + const sanitizedDiff = redactSensitiveInformation(diff) + const truncatedDiff = truncateOutput(sanitizedDiff, DIFF_LINE_LIMIT, DIFF_CHARACTER_LIMIT) + return { + prompt: `${COMMIT_MESSAGE_SYSTEM_PROMPT}\n\nDiff:\n\`\`\`diff\n${truncatedDiff}\n\`\`\`\n\nCommit Message:`, + truncated: truncatedDiff !== sanitizedDiff, + } +} + +// Invocation order for the stale-write check. Numbers are assigned before the first await, so they +// follow call order. The latest number is tracked per repository so that a request for one +// repository never discards the result for another. +let invocationCounter = 0 +const latestInvocationByRepository = new WeakMap() + +/** + * Generates a commit message using the active LLM provider and populates the SCM input box. + */ +export async function generateCommitMessage(options: GenerateCommitMessageOptions): Promise { + const { provider, outputChannel, context } = options + const invocationId = ++invocationCounter + + try { + const git = await getGitApi() + if (!git) { + vscode.window.showErrorMessage("Git extension not found or not enabled.") + return + } + + const repository = resolveGitRepository(git, context) + if (!repository) { + vscode.window.showErrorMessage("No Git repository found.") + return + } + + if (invocationId > (latestInvocationByRepository.get(repository) ?? 0)) { + latestInvocationByRepository.set(repository, invocationId) + } + const draft = repository.inputBox.value + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Generating commit message...", + cancellable: true, + }, + async (_progress, token) => { + // One listener aborts the provider request and ends every pending wait below, so the + // notification closes as soon as the user cancels, even when Git or the provider + // ignores the abort signal. + const abortController = new AbortController() + let resolveCancelled: () => void = () => {} + const cancelled = new Promise((resolve) => { + resolveCancelled = () => resolve(undefined) + }) + const cancel = () => { + abortController.abort() + resolveCancelled() + } + const cancellationListener = token.onCancellationRequested(cancel) + if (token.isCancellationRequested) { + cancel() + } + const isCancelled = () => token.isCancellationRequested || abortController.signal.aborted + const untilCancelled = (work: Promise): Promise => { + // A rejection caused by the user's cancel (for example, a provider that rejects + // synchronously on abort and wins the race) ends the step quietly. Any other + // rejection reaches the outer catch and is reported. + const guarded = work.catch((error: unknown) => { + if (isCancelled()) { + return undefined + } + throw error + }) + return Promise.race([guarded, cancelled]) + } + + try { + if (isCancelled()) { + return + } + + const diff = await untilCancelled(getRepositoryDiff(repository)) + if (isCancelled()) { + return + } + if (!diff) { + vscode.window.showInformationMessage(NO_CHANGES_MESSAGE) + return + } + + const state = await untilCancelled(provider.getState()) + if (isCancelled()) { + return + } + const apiConfiguration = state?.apiConfiguration + if (!apiConfiguration || !apiConfiguration.apiProvider) { + vscode.window.showErrorMessage( + "No valid API provider configured. Please configure an API provider in Zoo Code settings.", + ) + return + } + + const { prompt, truncated } = buildCommitMessagePrompt(diff) + const rawMessage = await untilCancelled( + singleCompletionHandler(apiConfiguration as ProviderSettings, prompt, { + abortSignal: abortController.signal, + }), + ) + if (isCancelled() || rawMessage === undefined) { + return + } + + // A newer request for this repository owns the input box now. + if (latestInvocationByRepository.get(repository) !== invocationId) { + return + } + + const commitMessage = cleanCommitMessage(rawMessage) + if (!commitMessage) { + vscode.window.showWarningMessage("No commit message generated.") + return + } + + // Do not overwrite text the user typed while the request was pending. + if (repository.inputBox.value !== draft) { + vscode.window.showInformationMessage(DRAFT_CHANGED_MESSAGE) + return + } + + repository.inputBox.value = commitMessage + if (truncated) { + vscode.window.showWarningMessage(DIFF_TRUNCATED_MESSAGE) + } + } finally { + cancellationListener.dispose() + } + }, + ) + } catch (error) { + // The cancel path is handled inside the progress callback, so every error here is a real + // failure, including provider timeouts and errors whose text mentions "aborted". + const errorMessage = error instanceof Error ? error.message : String(error) + outputChannel?.appendLine(`[generateGitCommitMessage] failed: ${errorMessage}`) + vscode.window.showErrorMessage(`Failed to generate commit message: ${errorMessage}`) + } +}