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/api/providers/openai.ts b/src/api/providers/openai.ts index 98ea18df8a..221bfc250f 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -354,7 +354,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } - async completePrompt(prompt: string, _options?: CompletePromptOptions): Promise { + async completePrompt(prompt: string, options?: CompletePromptOptions): Promise { try { const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) const model = this.getModel() @@ -372,10 +372,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl let response try { - response = await this.client.chat.completions.create( - requestOptions, - isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}, - ) + response = await this.client.chat.completions.create(requestOptions, { + ...(isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}), + ...(options?.abortSignal ? { signal: options.abortSignal } : {}), + }) } catch (error) { throw handleOpenAIError(error, this.providerName) } diff --git a/src/package.json b/src/package.json index 8a30d614e1..5dc5da81c9 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/inputBox": [ + { + "command": "zoo-code.generateGitCommitMessage", + "group": "inline", + "when": "scmRepository && 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..1996c8213a --- /dev/null +++ b/src/services/git/__tests__/commitMessageGenerator.spec.ts @@ -0,0 +1,794 @@ +import * as vscode from "vscode" +import type { Mock } from "vitest" +import { providerIdentifiers } from "@roo-code/types" + +import { + buildCommitMessagePrompt, + cleanCommitMessage, + generateCommitMessage, + getGitApi, + getRepositoryDiff, + 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(), +})) + +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("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) + }) + }) + + describe("buildCommitMessagePrompt", () => { + it("includes diff and instructions", () => { + const prompt = 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") + }) + + 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]") + }) + }) + + 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 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("falls back to working tree diff if staged diff throws", async () => { + const repo: GitRepository = { + rootUri: makeUri("/workspace/repo"), + inputBox: { value: "" }, + diff: vi.fn().mockImplementation((cached?: boolean) => { + if (cached) return Promise.reject(new Error("Git diff staged failed")) + return Promise.resolve("working diff fallback") + }), + } + + const diff = await getRepositoryDiff(repo) + expect(diff).toBe("working diff fallback") + expect(repo.diff).toHaveBeenCalledWith(true) + expect(repo.diff).toHaveBeenCalledWith(false) + }) + + 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("returns undefined if working diff throws after empty staged diff", async () => { + const repo: GitRepository = { + rootUri: makeUri("/workspace/repo"), + inputBox: { value: "" }, + diff: vi.fn().mockImplementation((cached?: boolean) => { + if (cached) return Promise.resolve("") + return Promise.reject(new Error("Git working tree diff failed")) + }), + } + + const diff = await getRepositoryDiff(repo) + expect(diff).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 staged or working directory changes found to generate commit message.", + ) + }) + + 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 LLM call when cancelled between diff fetch and provider state read", 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 when token triggers cancellation callback", async () => { + let cancellationHandler: (() => void) | undefined + ;(vscode.window.withProgress as Mock).mockImplementation((_opts, callback) => { + const progress = { report: vi.fn() } + const token = { + isCancellationRequested: false, + onCancellationRequested: vi.fn((fn: () => void) => { + cancellationHandler = fn + return { dispose: vi.fn() } + }), + } + return callback(progress, token) + }) + ;(singleCompletionHandler as Mock).mockImplementation(async (_config, _prompt, options) => { + cancellationHandler?.() + expect(options.abortSignal.aborted).toBe(true) + return "" + }) + + await generateCommitMessage({ + provider: mockProvider, + outputChannel: mockOutputChannel, + }) + + expect(mockRepo.inputBox.value).toBe("") + }) + + it("aborts the in-flight provider request when cancellation is requested", async () => { + let cancelHandler: () => void = () => {} + let capturedSignal: AbortSignal | undefined + ;(singleCompletionHandler as Mock).mockImplementation((_config, _prompt, options) => { + capturedSignal = options.abortSignal as AbortSignal + cancelHandler() + 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) => { + cancelHandler = fn + 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("ignores AbortError without showing an error message", async () => { + const abortError = new Error("This operation was aborted") + abortError.name = "AbortError" + ;(vscode.window.withProgress as Mock).mockRejectedValue(abortError) + + await generateCommitMessage({ + provider: mockProvider, + outputChannel: mockOutputChannel, + }) + + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + expect(mockOutputChannel.appendLine).not.toHaveBeenCalled() + }) + + 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..b09e28cc75 --- /dev/null +++ b/src/services/git/commitMessageGenerator.ts @@ -0,0 +1,306 @@ +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 +} + +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 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.` + +const SENSITIVE_PATTERNS = [ + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, + /(?:api[_-]?key|auth[_-]?token|bearer|secret|password)[\s]*[=:]\s*['"]?([a-zA-Z0-9_\-.]{8,})['"]?/gi, + /\bBearer\s+([a-zA-Z0-9_\-.~+/]{8,}=*)/gi, +] + +/** + * Redacts secrets, private keys, and credential tokens from content before sending to an LLM. + */ +export function redactSensitiveInformation(content: string): string { + let redacted = content + for (const pattern of SENSITIVE_PATTERNS) { + redacted = redacted.replace(pattern, (match, captured) => { + if (captured) { + return match.replace(captured, "[REDACTED]") + } + return "[REDACTED_SECRET]" + }) + } + return 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) +} + +/** + * Resolves the appropriate Git repository given an optional SCM invocation context. + */ +export function resolveGitRepository(git: GitAPI, context?: unknown): GitRepository | undefined { + if (!git.repositories || git.repositories.length === 0) { + return undefined + } + + if (context && typeof context === "object") { + // Bare vscode.Uri or URI-like object passed from scm/inputBox menu. + // Return undefined (not first repo) when it doesn't match any repository. + if ("scheme" in context && "fsPath" in context) { + const uri = context as vscode.Uri + return git.repositories.find( + (r) => r.rootUri.toString() === uri.toString() || r.rootUri.fsPath === uri.fsPath, + ) + } + + const candidate = context as Partial + if (typeof candidate.diff === "function" && candidate.inputBox && typeof candidate.inputBox === "object") { + return candidate as GitRepository + } + + const sc = context as { rootUri?: vscode.Uri; inputBox?: unknown } + // When an explicit SCM rootUri or inputBox is provided but matches no repository, + // return undefined rather than silently falling through to a different repository. + if (sc.rootUri) { + return git.repositories.find( + (r) => r.rootUri.toString() === sc.rootUri?.toString() || r.rootUri.fsPath === sc.rootUri?.fsPath, + ) + } + + 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 diff from the repository, prioritizing staged changes and falling back to working tree changes. + */ +export async function getRepositoryDiff(repository: GitRepository): Promise { + let diff = "" + try { + diff = await repository.diff(true) + } catch { + // Staged diff failed, continue to working tree diff + } + + if (!diff || !diff.trim()) { + try { + diff = await repository.diff(false) + } catch { + // Working tree diff failed + } + } + + const trimmed = diff ? diff.trim() : "" + if (trimmed.length > 0) { + return trimmed + } + + // `git diff` omits untracked files; surface them as a plain listing so the + // LLM can still generate an "initial commit" or "add files" message. + const UNTRACKED_STATUS = 7 // vscode.git Status.UNTRACKED + const untracked = repository.state?.workingTreeChanges?.filter((c) => c.status === UNTRACKED_STATUS) + if (untracked && untracked.length > 0) { + const fileList = untracked.map((c) => c.uri.fsPath).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. + */ +export function buildCommitMessagePrompt(diff: string): string { + const sanitizedDiff = redactSensitiveInformation(diff) + const truncatedDiff = truncateOutput(sanitizedDiff, 1000, 50000) + return `${COMMIT_MESSAGE_SYSTEM_PROMPT}\n\nDiff:\n\`\`\`diff\n${truncatedDiff}\n\`\`\`\n\nCommit Message:` +} + +/** + * 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 + + 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 + } + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Generating commit message...", + cancellable: true, + }, + async (_progress, token) => { + const abortController = new AbortController() + const cancellationListener = token.onCancellationRequested(() => { + abortController.abort() + }) + + try { + const diff = await getRepositoryDiff(repository) + if (!diff) { + vscode.window.showInformationMessage( + "No staged or working directory changes found to generate commit message.", + ) + return + } + + if (token.isCancellationRequested) { + return + } + + const { apiConfiguration } = await provider.getState() + if (!apiConfiguration || !apiConfiguration.apiProvider) { + vscode.window.showErrorMessage( + "No valid API provider configured. Please configure an API provider in Zoo Code settings.", + ) + return + } + + if (token.isCancellationRequested) { + return + } + + const prompt = buildCommitMessagePrompt(diff) + const rawMessage = await singleCompletionHandler(apiConfiguration as ProviderSettings, prompt, { + abortSignal: abortController.signal, + }) + + if (token.isCancellationRequested || abortController.signal.aborted) { + return + } + + const commitMessage = cleanCommitMessage(rawMessage) + if (commitMessage) { + repository.inputBox.value = commitMessage + } else { + vscode.window.showWarningMessage("No commit message generated.") + } + } finally { + cancellationListener?.dispose() + } + }, + ) + } catch (error) { + if (error instanceof Error && (error.name === "AbortError" || error.message.includes("aborted"))) { + return + } + const errorMessage = error instanceof Error ? error.message : String(error) + outputChannel?.appendLine(`[generateGitCommitMessage] failed: ${errorMessage}`) + vscode.window.showErrorMessage(`Failed to generate commit message: ${errorMessage}`) + } +}