diff --git a/packages/build/src/types.ts b/packages/build/src/types.ts index 18db4f2e7c..89f95c0a75 100644 --- a/packages/build/src/types.ts +++ b/packages/build/src/types.ts @@ -31,7 +31,7 @@ const commandsSchema = z.array( command: z.string(), title: z.string(), category: z.string().optional(), - icon: z.string().optional(), + icon: z.union([z.string(), z.object({ light: z.string(), dark: z.string() })]).optional(), }), ) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 3a5a99eb98..c7dda6cc07 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -23,6 +23,240 @@ import { languagesSchema } from "./vscode.js" */ export const DEFAULT_WRITE_DELAY_MS = 1000 +/** Schema for optional Git context included with generated commit message prompts. */ +export const commitMessageGitContextSchema = z.object({ + diffContextLines: z.number().int().min(0).max(20).optional(), + includeDiffStats: z.boolean().optional(), + includeCurrentBranch: z.boolean().optional(), + includeRecentCommits: z.boolean().optional(), + recentCommitCount: z.number().int().min(1).max(20).optional(), + includeRecentCommitBodies: z.boolean().optional(), + includeRecentCommitStats: z.boolean().optional(), + includeRecentCommitDiffs: z.boolean().optional(), + recentCommitDiffCount: z.number().int().min(1).max(5).optional(), +}) + +export type CommitMessageGitContextSettings = z.infer + +/** Default Git context options for commit message generation. */ +export const defaultCommitMessageGitContextSettings: Required = { + diffContextLines: 3, + includeDiffStats: true, + includeCurrentBranch: true, + includeRecentCommits: true, + recentCommitCount: 5, + includeRecentCommitBodies: false, + includeRecentCommitStats: false, + includeRecentCommitDiffs: false, + recentCommitDiffCount: 1, +} + +/** Default attribution template appended to generated commit messages when enabled. */ +export const DEFAULT_COMMIT_MESSAGE_ATTRIBUTION_TEMPLATE = "Assisted-by: ${agentName}:${providerModel} [${toolName}]" + +/** Schema for the optional attribution footer appended to generated commit messages. */ +export const commitMessageAttributionSchema = z.object({ + enabled: z.boolean().optional(), + template: z.string().optional(), +}) + +export type CommitMessageAttributionSettings = z.infer + +/** Default attribution settings for commit message generation. */ +export const defaultCommitMessageAttributionSettings: Required = { + enabled: false, + template: DEFAULT_COMMIT_MESSAGE_ATTRIBUTION_TEMPLATE, +} + +/** Maximum number of named commit-message profiles users can store. */ +export const MAX_COMMIT_MESSAGE_PROFILES = 5 + +/** Stable id used by the synthesized default commit-message profile. */ +export const DEFAULT_COMMIT_MESSAGE_PROFILE_ID = "default" + +/** Schema for one named commit-message generation profile. */ +export const commitMessageProfileSchema = z.object({ + id: z.string().optional(), + name: z.string().optional(), + prompt: z.string().optional(), + apiConfigId: z.string().optional(), + gitContext: commitMessageGitContextSchema.optional(), + attribution: commitMessageAttributionSchema.optional(), +}) + +/** Schema for persisted commit-message profile settings. */ +export const commitMessageProfilesSchema = z.object({ + activeProfileId: z.string().optional(), + profiles: z.array(commitMessageProfileSchema).max(MAX_COMMIT_MESSAGE_PROFILES).optional(), +}) + +export type CommitMessageProfileSettings = z.infer +export type CommitMessageProfilesSettings = z.infer + +/** Fully-normalized commit-message profile used by runtime code and UI controls. */ +export type NormalizedCommitMessageProfile = Omit< + CommitMessageProfileSettings, + "id" | "name" | "gitContext" | "attribution" +> & { + id: string + name: string + gitContext: Required + attribution: Required +} + +export interface NormalizedCommitMessageProfiles { + /** Id of the profile currently selected for generation. */ + activeProfileId: string + /** Normalized profiles available for generation. */ + profiles: NormalizedCommitMessageProfile[] +} + +/** Legacy single-profile settings used when named profiles are not stored yet. */ +export interface CommitMessageProfileFallbackSettings { + /** Optional custom prompt from the legacy support prompt setting. */ + prompt?: string + /** Optional API configuration id from the legacy single-profile setting. */ + apiConfigId?: string + /** Optional Git context settings from the legacy single-profile setting. */ + gitContext?: CommitMessageGitContextSettings + /** Optional attribution settings from the legacy single-profile setting. */ + attribution?: CommitMessageAttributionSettings +} + +/** Normalizes Git context settings and clamps numeric options to supported bounds. */ +export function normalizeCommitMessageGitContextSettings( + settings?: CommitMessageGitContextSettings, +): Required { + return { + ...defaultCommitMessageGitContextSettings, + ...settings, + diffContextLines: clampNumberSetting( + settings?.diffContextLines, + 0, + 20, + defaultCommitMessageGitContextSettings.diffContextLines, + ), + recentCommitCount: clampNumberSetting( + settings?.recentCommitCount, + 1, + 20, + defaultCommitMessageGitContextSettings.recentCommitCount, + ), + recentCommitDiffCount: clampNumberSetting( + settings?.recentCommitDiffCount, + 1, + 5, + defaultCommitMessageGitContextSettings.recentCommitDiffCount, + ), + } +} + +/** Normalizes attribution settings and restores the default template when needed. */ +export function normalizeCommitMessageAttributionSettings( + settings?: CommitMessageAttributionSettings, +): Required { + return { + ...defaultCommitMessageAttributionSettings, + ...settings, + template: normalizeOptionalString(settings?.template) ?? defaultCommitMessageAttributionSettings.template, + } +} + +/** Normalizes persisted profiles or creates a default profile from fallback settings. */ +export function normalizeCommitMessageProfiles( + settings?: CommitMessageProfilesSettings, + fallback: CommitMessageProfileFallbackSettings = {}, +): NormalizedCommitMessageProfiles { + const sourceProfiles = settings?.profiles?.length + ? settings.profiles.slice(0, MAX_COMMIT_MESSAGE_PROFILES) + : [ + { + id: DEFAULT_COMMIT_MESSAGE_PROFILE_ID, + name: "Default", + prompt: fallback.prompt, + apiConfigId: fallback.apiConfigId, + gitContext: fallback.gitContext, + attribution: fallback.attribution, + }, + ] + + const profiles: NormalizedCommitMessageProfile[] = sourceProfiles.map((profile, index) => ({ + id: normalizeProfileId(profile.id, index), + name: normalizeProfileName(profile.name, index), + prompt: profile.prompt, + apiConfigId: normalizeOptionalString(profile.apiConfigId), + gitContext: normalizeCommitMessageGitContextSettings(profile.gitContext), + attribution: normalizeCommitMessageAttributionSettings(profile.attribution), + })) + const firstProfile = profiles[0]! + + const activeProfileId = profiles.some((profile) => profile.id === settings?.activeProfileId) + ? settings!.activeProfileId! + : firstProfile.id + + return { + activeProfileId, + profiles, + } +} + +/** Returns the active normalized commit-message profile. */ +export function getActiveCommitMessageProfile( + settings?: CommitMessageProfilesSettings, + fallback?: CommitMessageProfileFallbackSettings, +): NormalizedCommitMessageProfile { + const normalized = normalizeCommitMessageProfiles(settings, fallback) + return normalized.profiles.find((profile) => profile.id === normalized.activeProfileId) ?? normalized.profiles[0]! +} + +/** Creates a locally unique id for a new commit-message profile. */ +export function createCommitMessageProfileId(): string { + return `profile-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` +} + +/** Creates the next available display name for a new commit-message profile. */ +export function createCommitMessageProfileName(profiles: Array<{ name?: string }>): string { + for (let index = profiles.length + 1; index <= MAX_COMMIT_MESSAGE_PROFILES + 1; index++) { + const candidate = `Profile ${index}` + if (!profiles.some((profile) => profile.name === candidate)) { + return candidate + } + } + + return `Profile ${profiles.length + 1}` +} + +function normalizeProfileId(id: string | undefined, index: number): string { + const normalized = normalizeOptionalString(id) + if (normalized) { + return normalized + } + + return index === 0 ? DEFAULT_COMMIT_MESSAGE_PROFILE_ID : `profile-${index + 1}` +} + +function normalizeProfileName(name: string | undefined, index: number): string { + const normalized = normalizeOptionalString(name) + return normalized || (index === 0 ? "Default" : `Profile ${index + 1}`) +} + +function normalizeOptionalString(value: string | undefined): string | undefined { + if (typeof value !== "string") { + return undefined + } + + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +function clampNumberSetting(value: number | undefined, min: number, max: number, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return fallback + } + + return Math.min(Math.max(Math.trunc(value), min), max) +} + /** * Terminal output preview size options for persisted command output. * @@ -232,6 +466,11 @@ export const globalSettingsSchema = z.object({ * Tools in this list will be excluded from prompt generation and rejected at execution time. */ disabledTools: z.array(toolNamesSchema).optional(), + + commitMessageApiConfigId: z.string().optional(), + commitMessageGitContext: commitMessageGitContextSchema.optional(), + commitMessageAttribution: commitMessageAttributionSchema.optional(), + commitMessageProfiles: commitMessageProfilesSchema.optional(), }) export type GlobalSettings = z.infer diff --git a/packages/types/src/telemetry.ts b/packages/types/src/telemetry.ts index 402cd571c8..1e9d80f202 100644 --- a/packages/types/src/telemetry.ts +++ b/packages/types/src/telemetry.ts @@ -74,6 +74,8 @@ export enum TelemetryEventName { TELEMETRY_SETTINGS_CHANGED = "Telemetry Settings Changed", MODEL_CACHE_EMPTY_RESPONSE = "Model Cache Empty Response", READ_FILE_LEGACY_FORMAT_USED = "Read File Legacy Format Used", + + COMMIT_MSG_GENERATED = "Commit Message Generated", } /** @@ -206,6 +208,7 @@ export const rooCodeTelemetryEventSchema = z.discriminatedUnion("type", [ TelemetryEventName.MODE_SETTINGS_CHANGED, TelemetryEventName.CUSTOM_MODE_CREATED, TelemetryEventName.READ_FILE_LEGACY_FORMAT_USED, + TelemetryEventName.COMMIT_MSG_GENERATED, ]), properties: telemetryPropertiesSchema, }), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index c09f22aed7..0c32e991be 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -283,6 +283,10 @@ export type ExtensionState = Pick< | "customModePrompts" | "customSupportPrompts" | "enhancementApiConfigId" + | "commitMessageApiConfigId" + | "commitMessageGitContext" + | "commitMessageAttribution" + | "commitMessageProfiles" | "customCondensingPrompt" | "codebaseIndexConfig" | "codebaseIndexModels" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 3f5af94cae..a01b07d5ed 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2057,6 +2057,10 @@ export class ClineProvider customModePrompts, customSupportPrompts, enhancementApiConfigId, + commitMessageApiConfigId, + commitMessageGitContext, + commitMessageAttribution, + commitMessageProfiles, autoApprovalEnabled, customModes, experiments, @@ -2209,6 +2213,10 @@ export class ClineProvider customModePrompts: customModePrompts ?? {}, customSupportPrompts: customSupportPrompts ?? {}, enhancementApiConfigId, + commitMessageApiConfigId, + commitMessageGitContext, + commitMessageAttribution, + commitMessageProfiles, autoApprovalEnabled: autoApprovalEnabled ?? false, customModes, experiments: experiments ?? experimentDefault, @@ -2415,6 +2423,10 @@ export class ClineProvider customModePrompts: stateValues.customModePrompts ?? {}, customSupportPrompts: stateValues.customSupportPrompts ?? {}, enhancementApiConfigId: stateValues.enhancementApiConfigId, + commitMessageApiConfigId: stateValues.commitMessageApiConfigId, + commitMessageGitContext: stateValues.commitMessageGitContext, + commitMessageAttribution: stateValues.commitMessageAttribution, + commitMessageProfiles: stateValues.commitMessageProfiles, experiments: stateValues.experiments ?? experimentDefault, autoApprovalEnabled: stateValues.autoApprovalEnabled ?? false, customModes, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 429de051b8..76f81ca270 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1605,7 +1605,6 @@ export const webviewMessageHandler = async ( await updateGlobalState("enhancementApiConfigId", message.text) await provider.postStateToWebview() break - case "autoApprovalEnabled": await updateGlobalState("autoApprovalEnabled", message.bool ?? false) await provider.postStateToWebview() diff --git a/src/extension.ts b/src/extension.ts index 44c1243528..58ee685144 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -50,6 +50,7 @@ import { import { initializeI18n } from "./i18n" import { initializeModelCacheRefresh } from "./api/providers/fetchers/modelCache" import { initZooCodeAuth } from "./services/zoo-code-auth" +import { registerCommitMessageProvider } from "./services/commit-message" /** * Built using https://github.com/microsoft/vscode-webview-ui-toolkit @@ -256,6 +257,14 @@ export async function activate(context: vscode.ExtensionContext) { registerCommands({ context, outputChannel, provider }) + try { + registerCommitMessageProvider(context, outputChannel) + } catch (error) { + outputChannel.appendLine( + `Failed to register commit message provider: ${error instanceof Error ? error.message : String(error)}`, + ) + } + /** * We use the text document content provider API to show the left side for diff * view by creating a virtual document for the original content. This makes it diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 59168a1b0a..c6164f6288 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: Connectat correctament! Ara podeu utilitzar Zoo Code com a proveïdor d'IA.", "disconnected": "Zoo Code: Desconnectat correctament." } + }, + "commitMessage": { + "activated": "Generador de missatges de commit de Zoo Code activat", + "gitNotFound": "⚠️ No s'ha trobat el repositori Git o Git no està disponible", + "gitInitError": "⚠️ Error d'inicialització de Git: {{error}}", + "generating": "Zoo: Generant missatge de commit...", + "noChanges": "Zoo: No s'han trobat canvis per analitzar", + "generated": "Zoo: Missatge de commit generat!", + "generationFailed": "Zoo: Error en generar el missatge de commit: {{errorMessage}}", + "contextWarnings": "Zoo: Avís del context de Git: {{warnings}}", + "generatingFromUnstaged": "Zoo: Generant missatge amb els canvis no preparats", + "confirmUnstaged": "No s'han trobat canvis preparats. Vols generar un missatge de commit amb {{count}} canvis no preparats/no rastrejats?", + "confirmUnstagedAction": "Generar amb canvis no preparats", + "activationFailed": "Zoo: Error en activar el generador de missatges: {{error}}", + "providerRegistered": "Zoo: Proveïdor de missatges de commit registrat", + "initializing": "Inicialitzant...", + "discoveringFiles": "Descobrint fitxers...", + "foundChanges": "S'han trobat {{count}} canvis", + "gettingContext": "Obtenint context de Git...", + "errors": { + "connectionFailed": "Error en connectar amb l'extensió Zoo Code", + "timeout": "La sol·licitud ha superat el temps d'espera de 30 segons", + "invalidResponse": "Format de resposta no vàlid rebut de l'extensió", + "missingMessage": "No s'ha rebut cap missatge de commit de l'extensió", + "noChanges": "No s'han trobat canvis per fer commit", + "noProject": "No hi ha cap projecte disponible", + "noWorkspacePath": "No s'ha pogut determinar la ruta de l'espai de treball per al repositori Git", + "workspaceNotFound": "No s'ha pogut determinar la ruta de l'espai de treball per al repositori Git", + "processingError": "Error en processar la generació del missatge de commit: {{error}}" + }, + "error": { + "title": "Error", + "workspacePathNotFound": "No s'ha pogut determinar la ruta de l'espai de treball per al repositori Git", + "generationFailed": "Error en generar el missatge de commit: {{error}}", + "processingFailed": "Error en processar la generació del missatge de commit: {{error}}", + "unknown": "Error desconegut" + }, + "dialogs": { + "info": "Missatge de commit amb IA", + "error": "Error", + "success": "Èxit", + "title": "Missatge de commit amb IA" + }, + "progress": { + "title": "Generant missatge de commit", + "analyzing": "Analitzant canvis...", + "connecting": "Connectant amb Zoo Code...", + "generating": "Generant missatge de commit..." + }, + "ui": { + "generateButton": "Generar missatge de commit", + "generateButtonTooltip": "Genera un missatge de commit utilitzant IA per analitzar els teus canvis de codi" + } } } diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 0d9acc69bb..2291b7bce0 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -262,5 +262,58 @@ "connected": "Zoo Code: Erfolgreich verbunden! Du kannst Zoo Code jetzt als KI-Anbieter verwenden.", "disconnected": "Zoo Code: Erfolgreich getrennt." } + }, + "commitMessage": { + "activated": "Zoo Code Commit-Nachrichtengenerator aktiviert", + "gitNotFound": "⚠️ Git-Repository nicht gefunden oder Git nicht verfügbar", + "gitInitError": "⚠️ Git-Initialisierungsfehler: {{error}}", + "generating": "Zoo: Commit-Nachricht wird generiert...", + "noChanges": "Zoo: Keine Änderungen zum Analysieren gefunden", + "generated": "Zoo: Commit-Nachricht generiert!", + "generationFailed": "Zoo: Fehler beim Generieren der Commit-Nachricht: {{errorMessage}}", + "contextWarnings": "Zoo: Git-Kontextwarnung: {{warnings}}", + "generatingFromUnstaged": "Zoo: Nachricht wird aus nicht gestagten Änderungen generiert", + "confirmUnstaged": "Keine gestagten Änderungen gefunden. Soll eine Commit-Nachricht aus {{count}} nicht gestagten/nicht verfolgten Änderungen generiert werden?", + "confirmUnstagedAction": "Aus nicht gestagten Änderungen generieren", + "activationFailed": "Zoo: Fehler beim Aktivieren des Nachrichtengenerators: {{error}}", + "providerRegistered": "Zoo: Commit-Nachrichtenanbieter registriert", + "initializing": "Initialisierung...", + "discoveringFiles": "Dateien werden erkannt...", + "foundChanges": "{{count}} Änderungen gefunden", + "gettingContext": "Git-Kontext wird abgerufen...", + "errors": { + "connectionFailed": "Verbindung zur Zoo Code-Erweiterung fehlgeschlagen", + "timeout": "Zeitüberschreitung der Anfrage nach 30 Sekunden", + "invalidResponse": "Ungültiges Antwortformat von der Erweiterung erhalten", + "missingMessage": "Keine Commit-Nachricht von der Erweiterung erhalten", + "noChanges": "Keine Änderungen zum Committen gefunden", + "noProject": "Kein Projekt verfügbar", + "noWorkspacePath": "Arbeitsbereichspfad für Git-Repository konnte nicht ermittelt werden", + "workspaceNotFound": "Arbeitsbereichspfad für Git-Repository konnte nicht ermittelt werden", + "processingError": "Fehler bei der Verarbeitung der Commit-Nachrichtengenerierung: {{error}}" + }, + "error": { + "title": "Fehler", + "workspacePathNotFound": "Arbeitsbereichspfad für Git-Repository konnte nicht ermittelt werden", + "generationFailed": "Fehler beim Generieren der Commit-Nachricht: {{error}}", + "processingFailed": "Fehler bei der Verarbeitung der Commit-Nachrichtengenerierung: {{error}}", + "unknown": "Unbekannter Fehler" + }, + "dialogs": { + "info": "KI-Commit-Nachricht", + "error": "Fehler", + "success": "Erfolg", + "title": "KI-Commit-Nachricht" + }, + "progress": { + "title": "Commit-Nachricht wird generiert", + "analyzing": "Änderungen werden analysiert...", + "connecting": "Verbindung zu Zoo Code wird hergestellt...", + "generating": "Commit-Nachricht wird generiert..." + }, + "ui": { + "generateButton": "Commit-Nachricht generieren", + "generateButtonTooltip": "Generiert eine Commit-Nachricht mithilfe von KI zur Analyse deiner Codeänderungen" + } } } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 157a87c5dc..b036c97cd2 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -259,5 +259,59 @@ "connected": "Zoo Code: Successfully connected! You can now use Zoo Code as your AI provider.", "disconnected": "Zoo Code: Disconnected successfully." } + }, + "commitMessage": { + "activated": "Zoo Code commit message generator activated", + "gitNotFound": "⚠️ Git repository not found or git not available", + "gitInitError": "⚠️ Git initialization error: {{error}}", + "generating": "Zoo: Generating commit message...", + "noChanges": "Zoo: No changes found to analyze", + "generated": "Zoo: Commit message generated!", + "generationFailed": "Zoo: Failed to generate commit message: {{errorMessage}}", + "contextWarnings": "Zoo: Git context warning: {{warnings}}", + "generatingFromUnstaged": "Zoo: Generating message using unstaged changes", + "confirmUnstaged": "No staged changes found. Generate a commit message from {{count}} unstaged/untracked changes instead?", + "confirmUnstagedAction": "Generate from unstaged changes", + "useUnstagedConfirm": "No staged changes were found. Generate a commit message from unstaged changes instead?", + "activationFailed": "Zoo: Failed to activate message generator: {{error}}", + "providerRegistered": "Zoo: Commit message provider registered", + "initializing": "Initializing...", + "discoveringFiles": "Discovering files...", + "foundChanges": "Found {{count}} changes", + "gettingContext": "Getting git context...", + "errors": { + "connectionFailed": "Failed to connect to Zoo Code extension", + "timeout": "Request timed out after 30 seconds", + "invalidResponse": "Invalid response format received from extension", + "missingMessage": "No commit message received from extension", + "noChanges": "No changes found to commit", + "noProject": "No project available", + "noWorkspacePath": "Could not determine workspace path for Git repository", + "workspaceNotFound": "Could not determine workspace path for Git repository", + "processingError": "Error processing commit message generation: {{error}}" + }, + "error": { + "title": "Error", + "workspacePathNotFound": "Could not determine workspace path for Git repository", + "generationFailed": "Failed to generate commit message: {{error}}", + "processingFailed": "Error processing commit message generation: {{error}}", + "unknown": "Unknown error" + }, + "dialogs": { + "info": "AI Commit Message", + "error": "Error", + "success": "Success", + "title": "AI Commit Message" + }, + "progress": { + "title": "Generating Commit Message", + "analyzing": "Analyzing changes...", + "connecting": "Connecting to Zoo Code...", + "generating": "Generating commit message..." + }, + "ui": { + "generateButton": "Generate Commit Message", + "generateButtonTooltip": "Generates commit message using AI to analyze your code changes" + } } } diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index f10bf5aa98..43837e296d 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -262,5 +262,58 @@ "connected": "Zoo Code: ¡Conectado correctamente! Ahora puedes usar Zoo Code como proveedor de IA.", "disconnected": "Zoo Code: Desconectado correctamente." } + }, + "commitMessage": { + "activated": "Generador de mensajes de commit de Zoo Code activado", + "gitNotFound": "⚠️ Repositorio Git no encontrado o Git no disponible", + "gitInitError": "⚠️ Error de inicialización de Git: {{error}}", + "generating": "Zoo: Generando mensaje de commit...", + "noChanges": "Zoo: No se encontraron cambios para analizar", + "generated": "Zoo: ¡Mensaje de commit generado!", + "generationFailed": "Zoo: Error al generar el mensaje de commit: {{errorMessage}}", + "contextWarnings": "Zoo: Advertencia del contexto de Git: {{warnings}}", + "generatingFromUnstaged": "Zoo: Generando mensaje con cambios no preparados", + "confirmUnstaged": "No se encontraron cambios preparados. ¿Generar un mensaje de commit con {{count}} cambios no preparados/no rastreados?", + "confirmUnstagedAction": "Generar con cambios no preparados", + "activationFailed": "Zoo: Error al activar el generador de mensajes: {{error}}", + "providerRegistered": "Zoo: Proveedor de mensajes de commit registrado", + "initializing": "Inicializando...", + "discoveringFiles": "Descubriendo archivos...", + "foundChanges": "Se encontraron {{count}} cambios", + "gettingContext": "Obteniendo contexto de Git...", + "errors": { + "connectionFailed": "Error al conectar con la extensión Zoo Code", + "timeout": "La solicitud superó el tiempo de espera de 30 segundos", + "invalidResponse": "Formato de respuesta no válido recibido de la extensión", + "missingMessage": "No se recibió mensaje de commit de la extensión", + "noChanges": "No se encontraron cambios para hacer commit", + "noProject": "No hay proyecto disponible", + "noWorkspacePath": "No se pudo determinar la ruta del espacio de trabajo para el repositorio Git", + "workspaceNotFound": "No se pudo determinar la ruta del espacio de trabajo para el repositorio Git", + "processingError": "Error al procesar la generación del mensaje de commit: {{error}}" + }, + "error": { + "title": "Error", + "workspacePathNotFound": "No se pudo determinar la ruta del espacio de trabajo para el repositorio Git", + "generationFailed": "Error al generar el mensaje de commit: {{error}}", + "processingFailed": "Error al procesar la generación del mensaje de commit: {{error}}", + "unknown": "Error desconocido" + }, + "dialogs": { + "info": "Mensaje de commit con IA", + "error": "Error", + "success": "Éxito", + "title": "Mensaje de commit con IA" + }, + "progress": { + "title": "Generando mensaje de commit", + "analyzing": "Analizando cambios...", + "connecting": "Conectando con Zoo Code...", + "generating": "Generando mensaje de commit..." + }, + "ui": { + "generateButton": "Generar mensaje de commit", + "generateButtonTooltip": "Genera un mensaje de commit usando IA para analizar tus cambios de código" + } } } diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index d61f38d515..7249620cb8 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: Connecté avec succès ! Vous pouvez maintenant utiliser Zoo Code comme fournisseur d'IA.", "disconnected": "Zoo Code: Déconnecté avec succès." } + }, + "commitMessage": { + "activated": "Générateur de messages de commit Zoo Code activé", + "gitNotFound": "⚠️ Dépôt Git introuvable ou Git non disponible", + "gitInitError": "⚠️ Erreur d'initialisation de Git : {{error}}", + "generating": "Zoo : Génération du message de commit...", + "noChanges": "Zoo : Aucun changement trouvé à analyser", + "generated": "Zoo : Message de commit généré !", + "generationFailed": "Zoo : Erreur lors de la génération du message de commit : {{errorMessage}}", + "contextWarnings": "Zoo : Avertissement du contexte Git : {{warnings}}", + "generatingFromUnstaged": "Zoo : Génération du message à partir des changements non indexés", + "confirmUnstaged": "Aucun changement indexé trouvé. Générer un message de commit à partir de {{count}} changements non indexés/non suivis ?", + "confirmUnstagedAction": "Générer à partir des changements non indexés", + "activationFailed": "Zoo : Erreur lors de l'activation du générateur de messages : {{error}}", + "providerRegistered": "Zoo : Fournisseur de messages de commit enregistré", + "initializing": "Initialisation...", + "discoveringFiles": "Découverte des fichiers...", + "foundChanges": "{{count}} changements trouvés", + "gettingContext": "Récupération du contexte Git...", + "errors": { + "connectionFailed": "Échec de la connexion à l'extension Zoo Code", + "timeout": "La requête a expiré après 30 secondes", + "invalidResponse": "Format de réponse invalide reçu de l'extension", + "missingMessage": "Aucun message de commit reçu de l'extension", + "noChanges": "Aucun changement trouvé à committer", + "noProject": "Aucun projet disponible", + "noWorkspacePath": "Impossible de déterminer le chemin de l'espace de travail pour le dépôt Git", + "workspaceNotFound": "Impossible de déterminer le chemin de l'espace de travail pour le dépôt Git", + "processingError": "Erreur lors du traitement de la génération du message de commit : {{error}}" + }, + "error": { + "title": "Erreur", + "workspacePathNotFound": "Impossible de déterminer le chemin de l'espace de travail pour le dépôt Git", + "generationFailed": "Erreur lors de la génération du message de commit : {{error}}", + "processingFailed": "Erreur lors du traitement de la génération du message de commit : {{error}}", + "unknown": "Erreur inconnue" + }, + "dialogs": { + "info": "Message de commit IA", + "error": "Erreur", + "success": "Succès", + "title": "Message de commit IA" + }, + "progress": { + "title": "Génération du message de commit", + "analyzing": "Analyse des changements...", + "connecting": "Connexion à Zoo Code...", + "generating": "Génération du message de commit..." + }, + "ui": { + "generateButton": "Générer le message de commit", + "generateButtonTooltip": "Génère un message de commit en utilisant l'IA pour analyser vos modifications de code" + } } } diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index 46f09e26d1..6da9410bbe 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: सफलतापूर्वक कनेक्ट हो गया! अब तुम Zoo Code को अपने AI प्रदाता के रूप में उपयोग कर सकते हो।", "disconnected": "Zoo Code: सफलतापूर्वक डिस्कनेक्ट हो गया।" } + }, + "commitMessage": { + "activated": "Zoo Code कमिट मैसेज जनरेटर सक्रिय किया गया", + "gitNotFound": "⚠️ Git रिपॉजिटरी नहीं मिली या Git उपलब्ध नहीं है", + "gitInitError": "⚠️ Git प्रारंभीकरण त्रुटि: {{error}}", + "generating": "Zoo: कमिट मैसेज बनाया जा रहा है...", + "noChanges": "Zoo: विश्लेषण के लिए कोई बदलाव नहीं मिला", + "generated": "Zoo: कमिट मैसेज बना दिया गया!", + "generationFailed": "Zoo: कमिट मैसेज बनाने में विफल: {{errorMessage}}", + "contextWarnings": "Zoo: Git संदर्भ चेतावनी: {{warnings}}", + "generatingFromUnstaged": "Zoo: अनस्टेज्ड बदलावों का उपयोग करके मैसेज बनाया जा रहा है", + "confirmUnstaged": "कोई स्टेज्ड बदलाव नहीं मिले। इसके बजाय {{count}} अनस्टेज्ड/अनट्रैक्ड बदलावों से कमिट मैसेज बनाएं?", + "confirmUnstagedAction": "अनस्टेज्ड बदलावों से बनाएं", + "activationFailed": "Zoo: मैसेज जनरेटर सक्रिय करने में विफल: {{error}}", + "providerRegistered": "Zoo: कमिट मैसेज प्रदाता पंजीकृत किया गया", + "initializing": "प्रारंभ हो रहा है...", + "discoveringFiles": "फ़ाइलें खोजी जा रही हैं...", + "foundChanges": "{{count}} बदलाव मिले", + "gettingContext": "Git संदर्भ प्राप्त किया जा रहा है...", + "errors": { + "connectionFailed": "Zoo Code एक्सटेंशन से कनेक्ट करने में विफल", + "timeout": "अनुरोध 30 सेकंड के बाद टाइमआउट हो गया", + "invalidResponse": "एक्सटेंशन से अमान्य प्रतिक्रिया फ़ॉर्मेट प्राप्त हुआ", + "missingMessage": "एक्सटेंशन से कोई कमिट मैसेज प्राप्त नहीं हुआ", + "noChanges": "कमिट करने के लिए कोई बदलाव नहीं मिला", + "noProject": "कोई प्रोजेक्ट उपलब्ध नहीं है", + "noWorkspacePath": "Git रिपॉजिटरी के लिए वर्कस्पेस पाथ निर्धारित नहीं किया जा सका", + "workspaceNotFound": "Git रिपॉजिटरी के लिए वर्कस्पेस पाथ निर्धारित नहीं किया जा सका", + "processingError": "कमिट मैसेज जनरेशन प्रोसेस करने में त्रुटि: {{error}}" + }, + "error": { + "title": "त्रुटि", + "workspacePathNotFound": "Git रिपॉजिटरी के लिए वर्कस्पेस पाथ निर्धारित नहीं किया जा सका", + "generationFailed": "कमिट मैसेज बनाने में विफल: {{error}}", + "processingFailed": "कमिट मैसेज जनरेशन प्रोसेस करने में त्रुटि: {{error}}", + "unknown": "अज्ञात त्रुटि" + }, + "dialogs": { + "info": "AI कमिट मैसेज", + "error": "त्रुटि", + "success": "सफल", + "title": "AI कमिट मैसेज" + }, + "progress": { + "title": "कमिट मैसेज बनाया जा रहा है", + "analyzing": "बदलावों का विश्लेषण किया जा रहा है...", + "connecting": "Zoo Code से कनेक्ट हो रहा है...", + "generating": "कमिट मैसेज बनाया जा रहा है..." + }, + "ui": { + "generateButton": "कमिट मैसेज बनाएं", + "generateButtonTooltip": "आपके कोड परिवर्तनों का विश्लेषण करके AI द्वारा कमिट मैसेज बनाता है" + } } } diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index 252a5a7523..e8bb62461d 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: Berhasil terhubung! Kamu sekarang bisa menggunakan Zoo Code sebagai penyedia AI.", "disconnected": "Zoo Code: Berhasil terputus." } + }, + "commitMessage": { + "activated": "Generator pesan commit Zoo Code telah diaktifkan", + "gitNotFound": "⚠️ Repositori Git tidak ditemukan atau git tidak tersedia", + "gitInitError": "⚠️ Kesalahan inisialisasi Git: {{error}}", + "generating": "Zoo: Membuat pesan commit...", + "noChanges": "Zoo: Tidak ada perubahan ditemukan untuk dianalisis", + "generated": "Zoo: Pesan commit berhasil dibuat!", + "generationFailed": "Zoo: Gagal membuat pesan commit: {{errorMessage}}", + "contextWarnings": "Zoo: Peringatan konteks Git: {{warnings}}", + "generatingFromUnstaged": "Zoo: Membuat pesan menggunakan perubahan yang belum di-stage", + "confirmUnstaged": "Tidak ada perubahan staged yang ditemukan. Buat pesan commit dari {{count}} perubahan unstaged/untracked sebagai gantinya?", + "confirmUnstagedAction": "Buat dari perubahan unstaged", + "activationFailed": "Zoo: Gagal mengaktifkan generator pesan: {{error}}", + "providerRegistered": "Zoo: Penyedia pesan commit terdaftar", + "initializing": "Menginisialisasi...", + "discoveringFiles": "Menemukan file...", + "foundChanges": "Ditemukan {{count}} perubahan", + "gettingContext": "Mendapatkan konteks Git...", + "errors": { + "connectionFailed": "Gagal terhubung ke ekstensi Zoo Code", + "timeout": "Permintaan waktu habis setelah 30 detik", + "invalidResponse": "Format respons tidak valid diterima dari ekstensi", + "missingMessage": "Tidak ada pesan commit yang diterima dari ekstensi", + "noChanges": "Tidak ada perubahan ditemukan untuk di-commit", + "noProject": "Tidak ada proyek tersedia", + "noWorkspacePath": "Tidak dapat menentukan path workspace untuk repositori Git", + "workspaceNotFound": "Tidak dapat menentukan path workspace untuk repositori Git", + "processingError": "Error memproses pembuatan pesan commit: {{error}}" + }, + "error": { + "title": "Error", + "workspacePathNotFound": "Tidak dapat menentukan path workspace untuk repositori Git", + "generationFailed": "Gagal membuat pesan commit: {{error}}", + "processingFailed": "Error memproses pembuatan pesan commit: {{error}}", + "unknown": "Error tidak diketahui" + }, + "dialogs": { + "info": "Pesan Commit AI", + "error": "Error", + "success": "Berhasil", + "title": "Pesan Commit AI" + }, + "progress": { + "title": "Membuat Pesan Commit", + "analyzing": "Menganalisis perubahan...", + "connecting": "Menghubungkan ke Zoo Code...", + "generating": "Membuat pesan commit..." + }, + "ui": { + "generateButton": "Buat Pesan Commit", + "generateButtonTooltip": "Membuat pesan commit menggunakan AI untuk menganalisis perubahan kode kamu" + } } } diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 7d7b1d3033..c83792bffb 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: Connesso con successo! Ora puoi usare Zoo Code come fornitore AI.", "disconnected": "Zoo Code: Disconnesso con successo." } + }, + "commitMessage": { + "activated": "Generatore messaggi commit Zoo Code attivato", + "gitNotFound": "⚠️ Repository Git non trovato o git non disponibile", + "gitInitError": "⚠️ Errore di inizializzazione Git: {{error}}", + "generating": "Zoo: Generazione messaggio commit in corso...", + "noChanges": "Zoo: Nessuna modifica trovata da analizzare", + "generated": "Zoo: Messaggio commit generato!", + "generationFailed": "Zoo: Generazione messaggio commit fallita: {{errorMessage}}", + "contextWarnings": "Zoo: Avviso contesto Git: {{warnings}}", + "generatingFromUnstaged": "Zoo: Generazione messaggio utilizzando le modifiche non in stage", + "confirmUnstaged": "Nessuna modifica in stage trovata. Generare un messaggio commit da {{count}} modifiche unstaged/untracked invece?", + "confirmUnstagedAction": "Genera da modifiche unstaged", + "activationFailed": "Zoo: Attivazione generatore messaggi fallita: {{error}}", + "providerRegistered": "Zoo: Provider messaggi commit registrato", + "initializing": "Inizializzazione in corso...", + "discoveringFiles": "Ricerca file in corso...", + "foundChanges": "Trovate {{count}} modifiche", + "gettingContext": "Ottenimento contesto Git in corso...", + "errors": { + "connectionFailed": "Connessione all'estensione Zoo Code fallita", + "timeout": "Richiesta scaduta dopo 30 secondi", + "invalidResponse": "Formato di risposta non valido ricevuto dall'estensione", + "missingMessage": "Nessun messaggio commit ricevuto dall'estensione", + "noChanges": "Nessuna modifica trovata da committare", + "noProject": "Nessun progetto disponibile", + "noWorkspacePath": "Impossibile determinare il percorso workspace per il repository Git", + "workspaceNotFound": "Impossibile determinare il percorso workspace per il repository Git", + "processingError": "Errore durante l'elaborazione della generazione del messaggio commit: {{error}}" + }, + "error": { + "title": "Errore", + "workspacePathNotFound": "Impossibile determinare il percorso workspace per il repository Git", + "generationFailed": "Generazione messaggio commit fallita: {{error}}", + "processingFailed": "Errore durante l'elaborazione della generazione del messaggio commit: {{error}}", + "unknown": "Errore sconosciuto" + }, + "dialogs": { + "info": "Messaggio Commit AI", + "error": "Errore", + "success": "Successo", + "title": "Messaggio Commit AI" + }, + "progress": { + "title": "Generazione Messaggio Commit", + "analyzing": "Analisi delle modifiche in corso...", + "connecting": "Connessione a Zoo Code in corso...", + "generating": "Generazione messaggio commit in corso..." + }, + "ui": { + "generateButton": "Genera Messaggio Commit", + "generateButtonTooltip": "Genera il messaggio commit utilizzando l'AI per analizzare le modifiche al codice" + } } } diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 4846ea932c..946d3bb3a8 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: 接続に成功しました!Zoo Code を AI プロバイダーとして使用できます。", "disconnected": "Zoo Code: 正常に切断されました。" } + }, + "commitMessage": { + "activated": "Zoo Code コミットメッセージジェネレーターが有効化されました", + "gitNotFound": "⚠️ Gitリポジトリが見つからないか、Gitが利用できません", + "gitInitError": "⚠️ Git初期化エラー: {{error}}", + "generating": "Zoo: コミットメッセージを生成中...", + "noChanges": "Zoo: 分析対象の変更が見つかりません", + "generated": "Zoo: コミットメッセージが生成されました!", + "generationFailed": "Zoo: コミットメッセージの生成に失敗しました: {{errorMessage}}", + "contextWarnings": "Zoo: Gitコンテキスト警告: {{warnings}}", + "generatingFromUnstaged": "Zoo: ステージされていない変更を使用してメッセージを生成中", + "confirmUnstaged": "ステージされた変更が見つかりません。代わりに{{count}}件のステージされていない/トラックされていない変更からコミットメッセージを生成しますか?", + "confirmUnstagedAction": "ステージされていない変更から生成", + "activationFailed": "Zoo: メッセージジェネレーターの有効化に失敗しました: {{error}}", + "providerRegistered": "Zoo: コミットメッセージプロバイダーが登録されました", + "initializing": "初期化中...", + "discoveringFiles": "ファイルを検索中...", + "foundChanges": "{{count}}件の変更が見つかりました", + "gettingContext": "Gitコンテキストを取得中...", + "errors": { + "connectionFailed": "Zoo Code拡張機能への接続に失敗しました", + "timeout": "リクエストが30秒後にタイムアウトしました", + "invalidResponse": "拡張機能から無効なレスポンス形式を受信しました", + "missingMessage": "拡張機能からコミットメッセージを受信できませんでした", + "noChanges": "コミットする変更が見つかりません", + "noProject": "利用可能なプロジェクトがありません", + "noWorkspacePath": "Gitリポジトリのワークスペースパスを特定できませんでした", + "workspaceNotFound": "Gitリポジトリのワークスペースパスを特定できませんでした", + "processingError": "コミットメッセージ生成の処理中にエラーが発生しました: {{error}}" + }, + "error": { + "title": "エラー", + "workspacePathNotFound": "Gitリポジトリのワークスペースパスを特定できませんでした", + "generationFailed": "コミットメッセージの生成に失敗しました: {{error}}", + "processingFailed": "コミットメッセージ生成の処理中にエラーが発生しました: {{error}}", + "unknown": "不明なエラー" + }, + "dialogs": { + "info": "AIコミットメッセージ", + "error": "エラー", + "success": "成功", + "title": "AIコミットメッセージ" + }, + "progress": { + "title": "コミットメッセージを生成中", + "analyzing": "変更を分析中...", + "connecting": "Zoo Codeに接続中...", + "generating": "コミットメッセージを生成中..." + }, + "ui": { + "generateButton": "コミットメッセージを生成", + "generateButtonTooltip": "AIを使用してコードの変更を分析し、コミットメッセージを生成します" + } } } diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 14f6823c44..1faa09198d 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: 연결에 성공했습니다! 이제 Zoo Code를 AI 제공자로 사용할 수 있습니다.", "disconnected": "Zoo Code: 연결이 해제되었습니다." } + }, + "commitMessage": { + "activated": "Zoo Code 커밋 메시지 생성기 활성화됨", + "gitNotFound": "⚠️ Git 저장소를 찾을 수 없거나 Git을 사용할 수 없습니다", + "gitInitError": "⚠️ Git 초기화 오류: {{error}}", + "generating": "Zoo: 커밋 메시지 생성 중...", + "noChanges": "Zoo: 분석할 변경 사항이 없습니다", + "generated": "Zoo: 커밋 메시지가 생성되었습니다!", + "generationFailed": "Zoo: 커밋 메시지 생성 실패: {{errorMessage}}", + "contextWarnings": "Zoo: Git 컨텍스트 경고: {{warnings}}", + "generatingFromUnstaged": "Zoo: 스테이징되지 않은 변경 사항으로 메시지 생성 중", + "confirmUnstaged": "스테이징된 변경 사항이 없습니다. {{count}}개의 스테이징되지 않은/추적되지 않은 변경 사항으로 커밋 메시지를 생성하시겠습니까?", + "confirmUnstagedAction": "스테이징되지 않은 변경 사항에서 생성", + "activationFailed": "Zoo: 메시지 생성기 활성화 실패: {{error}}", + "providerRegistered": "Zoo: 커밋 메시지 제공자 등록됨", + "initializing": "초기화 중...", + "discoveringFiles": "파일 검색 중...", + "foundChanges": "{{count}}개의 변경 사항 발견", + "gettingContext": "Git 컨텍스트 가져오는 중...", + "errors": { + "connectionFailed": "Zoo Code 확장 프로그램에 연결하지 못했습니다", + "timeout": "요청이 30초 후 시간 초과되었습니다", + "invalidResponse": "확장 프로그램에서 유효하지 않은 응답 형식을 받았습니다", + "missingMessage": "확장 프로그램에서 커밋 메시지를 받지 못했습니다", + "noChanges": "커밋할 변경 사항이 없습니다", + "noProject": "사용 가능한 프로젝트가 없습니다", + "noWorkspacePath": "Git 저장소의 작업 공간 경로를 확인할 수 없습니다", + "workspaceNotFound": "Git 저장소의 작업 공간 경로를 확인할 수 없습니다", + "processingError": "커밋 메시지 생성 처리 중 오류 발생: {{error}}" + }, + "error": { + "title": "오류", + "workspacePathNotFound": "Git 저장소의 작업 공간 경로를 확인할 수 없습니다", + "generationFailed": "커밋 메시지 생성 실패: {{error}}", + "processingFailed": "커밋 메시지 생성 처리 중 오류 발생: {{error}}", + "unknown": "알 수 없는 오류" + }, + "dialogs": { + "info": "AI 커밋 메시지", + "error": "오류", + "success": "성공", + "title": "AI 커밋 메시지" + }, + "progress": { + "title": "커밋 메시지 생성 중", + "analyzing": "변경 사항 분석 중...", + "connecting": "Zoo Code에 연결 중...", + "generating": "커밋 메시지 생성 중..." + }, + "ui": { + "generateButton": "커밋 메시지 생성", + "generateButtonTooltip": "AI를 사용하여 코드 변경 사항을 분석하고 커밋 메시지를 생성합니다" + } } } diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 7d38e7fe85..0e527616fa 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: Succesvol verbonden! Je kunt Zoo Code nu gebruiken als AI-provider.", "disconnected": "Zoo Code: Succesvol losgekoppeld." } + }, + "commitMessage": { + "activated": "Zoo Code commitbericht-generator geactiveerd", + "gitNotFound": "⚠️ Git-repository niet gevonden of git niet beschikbaar", + "gitInitError": "⚠️ Git-initialisatiefout: {{error}}", + "generating": "Zoo: Commitbericht genereren...", + "noChanges": "Zoo: Geen wijzigingen gevonden om te analyseren", + "generated": "Zoo: Commitbericht gegenereerd!", + "generationFailed": "Zoo: Genereren van commitbericht mislukt: {{errorMessage}}", + "contextWarnings": "Zoo: Git-contextwaarschuwing: {{warnings}}", + "generatingFromUnstaged": "Zoo: Bericht genereren met niet-geünstagede wijzigingen", + "confirmUnstaged": "Geen gestagete wijzigingen gevonden. Wil je in plaats daarvan een commitbericht genereren op basis van {{count}} niet-gestagete/ongetraceerde wijzigingen?", + "confirmUnstagedAction": "Genereren vanuit niet-gestagete wijzigingen", + "activationFailed": "Zoo: Activeren van berichtgenerator mislukt: {{error}}", + "providerRegistered": "Zoo: Commitbericht-provider geregistreerd", + "initializing": "Initialiseren...", + "discoveringFiles": "Bestanden ontdekken...", + "foundChanges": "{{count}} wijzigingen gevonden", + "gettingContext": "Git-context ophalen...", + "errors": { + "connectionFailed": "Verbinding met Zoo Code-extensie mislukt", + "timeout": "Verzoek verliep na 30 seconden", + "invalidResponse": "Ongeldig antwoordformaat ontvangen van extensie", + "missingMessage": "Geen commitbericht ontvangen van extensie", + "noChanges": "Geen wijzigingen gevonden om te committen", + "noProject": "Geen project beschikbaar", + "noWorkspacePath": "Kon werkruimtepad voor Git-repository niet bepalen", + "workspaceNotFound": "Kon werkruimtepad voor Git-repository niet bepalen", + "processingError": "Fout bij verwerken van commitberichtgeneratie: {{error}}" + }, + "error": { + "title": "Fout", + "workspacePathNotFound": "Kon werkruimtepad voor Git-repository niet bepalen", + "generationFailed": "Genereren van commitbericht mislukt: {{error}}", + "processingFailed": "Fout bij verwerken van commitberichtgeneratie: {{error}}", + "unknown": "Onbekende fout" + }, + "dialogs": { + "info": "AI Commitbericht", + "error": "Fout", + "success": "Succes", + "title": "AI Commitbericht" + }, + "progress": { + "title": "Commitbericht Genereren", + "analyzing": "Wijzigingen analyseren...", + "connecting": "Verbinding maken met Zoo Code...", + "generating": "Commitbericht genereren..." + }, + "ui": { + "generateButton": "Commitbericht Genereren", + "generateButtonTooltip": "Genereert commitbericht met AI om je codewijzigingen te analyseren" + } } } diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 9405ed355c..25de2faec1 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: Połączono pomyślnie! Możesz teraz używać Zoo Code jako dostawcy AI.", "disconnected": "Zoo Code: Rozłączono pomyślnie." } + }, + "commitMessage": { + "activated": "Generator wiadomości commitów Zoo Code został aktywowany", + "gitNotFound": "⚠️ Nie znaleziono repozytorium Git lub Git nie jest dostępny", + "gitInitError": "⚠️ Błąd inicjalizacji Git: {{error}}", + "generating": "Zoo: Generowanie wiadomości commitu...", + "noChanges": "Zoo: Nie znaleziono zmian do analizy", + "generated": "Zoo: Wiadomość commitu została wygenerowana!", + "generationFailed": "Zoo: Nie udało się wygenerować wiadomości commitu: {{errorMessage}}", + "contextWarnings": "Zoo: Ostrzeżenie kontekstu Git: {{warnings}}", + "generatingFromUnstaged": "Zoo: Generowanie wiadomości na podstawie niezatwierdzonych zmian", + "confirmUnstaged": "Nie znaleziono zatwierdzonych zmian. Wygenerować wiadomość commitu na podstawie {{count}} niezatwierdzonych/nieśledzonych zmian?", + "confirmUnstagedAction": "Generuj na podstawie niezatwierdzonych zmian", + "activationFailed": "Zoo: Nie udało się aktywować generatora wiadomości: {{error}}", + "providerRegistered": "Zoo: Dostawca wiadomości commitów zarejestrowany", + "initializing": "Inicjalizacja...", + "discoveringFiles": "Wyszukiwanie plików...", + "foundChanges": "Znaleziono {{count}} zmian", + "gettingContext": "Pobieranie kontekstu Git...", + "errors": { + "connectionFailed": "Nie udało się połączyć z rozszerzeniem Zoo Code", + "timeout": "Przekroczono limit czasu żądania po 30 sekundach", + "invalidResponse": "Otrzymano nieprawidłowy format odpowiedzi z rozszerzenia", + "missingMessage": "Nie otrzymano wiadomości commitu z rozszerzenia", + "noChanges": "Nie znaleziono zmian do commitowania", + "noProject": "Brak dostępnego projektu", + "noWorkspacePath": "Nie można określić ścieżki obszaru roboczego dla repozytorium Git", + "workspaceNotFound": "Nie można określić ścieżki obszaru roboczego dla repozytorium Git", + "processingError": "Błąd przetwarzania generowania wiadomości commitu: {{error}}" + }, + "error": { + "title": "Błąd", + "workspacePathNotFound": "Nie można określić ścieżki obszaru roboczego dla repozytorium Git", + "generationFailed": "Nie udało się wygenerować wiadomości commitu: {{error}}", + "processingFailed": "Błąd przetwarzania generowania wiadomości commitu: {{error}}", + "unknown": "Nieznany błąd" + }, + "dialogs": { + "info": "AI Wiadomość Commitu", + "error": "Błąd", + "success": "Sukces", + "title": "AI Wiadomość Commitu" + }, + "progress": { + "title": "Generowanie Wiadomości Commitu", + "analyzing": "Analizowanie zmian...", + "connecting": "Łączenie z Zoo Code...", + "generating": "Generowanie wiadomości commitu..." + }, + "ui": { + "generateButton": "Generuj Wiadomość Commitu", + "generateButtonTooltip": "Generuje wiadomość commitu przy użyciu AI do analizy zmian w kodzie" + } } } diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 7f04df07e9..62126e5ff6 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: Conectado com sucesso! Agora você pode usar o Zoo Code como provedor de IA.", "disconnected": "Zoo Code: Desconectado com sucesso." } + }, + "commitMessage": { + "activated": "Gerador de mensagens de commit do Zoo Code ativado", + "gitNotFound": "⚠️ Repositório Git não encontrado ou Git não disponível", + "gitInitError": "⚠️ Erro de inicialização do Git: {{error}}", + "generating": "Zoo: Gerando mensagem de commit...", + "noChanges": "Zoo: Nenhuma alteração encontrada para analisar", + "generated": "Zoo: Mensagem de commit gerada!", + "generationFailed": "Zoo: Falha ao gerar mensagem de commit: {{errorMessage}}", + "contextWarnings": "Zoo: Aviso de contexto Git: {{warnings}}", + "generatingFromUnstaged": "Zoo: Gerando mensagem usando alterações não preparadas", + "confirmUnstaged": "Nenhuma alteração preparada encontrada. Gerar uma mensagem de commit a partir de {{count}} alterações não preparadas/não rastreadas?", + "confirmUnstagedAction": "Gerar a partir de alterações não preparadas", + "activationFailed": "Zoo: Falha ao ativar gerador de mensagens: {{error}}", + "providerRegistered": "Zoo: Provedor de mensagens de commit registrado", + "initializing": "Inicializando...", + "discoveringFiles": "Descobrindo arquivos...", + "foundChanges": "{{count}} alterações encontradas", + "gettingContext": "Obtendo contexto Git...", + "errors": { + "connectionFailed": "Falha ao conectar à extensão Zoo Code", + "timeout": "A solicitação excedeu o tempo limite após 30 segundos", + "invalidResponse": "Formato de resposta inválido recebido da extensão", + "missingMessage": "Nenhuma mensagem de commit recebida da extensão", + "noChanges": "Nenhuma alteração encontrada para commit", + "noProject": "Nenhum projeto disponível", + "noWorkspacePath": "Não foi possível determinar o caminho do workspace para o repositório Git", + "workspaceNotFound": "Não foi possível determinar o caminho do workspace para o repositório Git", + "processingError": "Erro ao processar geração de mensagem de commit: {{error}}" + }, + "error": { + "title": "Erro", + "workspacePathNotFound": "Não foi possível determinar o caminho do workspace para o repositório Git", + "generationFailed": "Falha ao gerar mensagem de commit: {{error}}", + "processingFailed": "Erro ao processar geração de mensagem de commit: {{error}}", + "unknown": "Erro desconhecido" + }, + "dialogs": { + "info": "Mensagem de Commit por IA", + "error": "Erro", + "success": "Sucesso", + "title": "Mensagem de Commit por IA" + }, + "progress": { + "title": "Gerando Mensagem de Commit", + "analyzing": "Analisando alterações...", + "connecting": "Conectando ao Zoo Code...", + "generating": "Gerando mensagem de commit..." + }, + "ui": { + "generateButton": "Gerar Mensagem de Commit", + "generateButtonTooltip": "Gera mensagem de commit usando IA para analisar suas alterações de código" + } } } diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index fc39aff020..55332c96c5 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: Успешно подключено! Теперь ты можешь использовать Zoo Code в качестве AI-провайдера.", "disconnected": "Zoo Code: Успешно отключено." } + }, + "commitMessage": { + "activated": "Zoo Code: Генератор сообщений коммитов активирован", + "gitNotFound": "⚠️ Git-репозиторий не найден или git недоступен", + "gitInitError": "⚠️ Ошибка инициализации Git: {{error}}", + "generating": "Zoo: Генерация сообщения коммита...", + "noChanges": "Zoo: Изменения для анализа не найдены", + "generated": "Zoo: Сообщение коммита сгенерировано!", + "generationFailed": "Zoo: Не удалось сгенерировать сообщение коммита: {{errorMessage}}", + "contextWarnings": "Zoo: Предупреждение контекста Git: {{warnings}}", + "generatingFromUnstaged": "Zoo: Генерация сообщения из несохранённых изменений", + "confirmUnstaged": "Подготовленные изменения не найдены. Сгенерировать сообщение коммита из {{count}} неподготовленных/неотслеживаемых изменений?", + "confirmUnstagedAction": "Сгенерировать из неподготовленных изменений", + "activationFailed": "Zoo: Не удалось активировать генератор сообщений: {{error}}", + "providerRegistered": "Zoo: Провайдер сообщений коммитов зарегистрирован", + "initializing": "Инициализация...", + "discoveringFiles": "Обнаружение файлов...", + "foundChanges": "Найдено {{count}} изменений", + "gettingContext": "Получение контекста Git...", + "errors": { + "connectionFailed": "Не удалось подключиться к расширению Zoo Code", + "timeout": "Время ожидания запроса истекло через 30 секунд", + "invalidResponse": "Получен неверный формат ответа от расширения", + "missingMessage": "Не получено сообщение коммита от расширения", + "noChanges": "Изменения для коммита не найдены", + "noProject": "Нет доступного проекта", + "noWorkspacePath": "Не удалось определить путь рабочего пространства для Git-репозитория", + "workspaceNotFound": "Не удалось определить путь рабочего пространства для Git-репозитория", + "processingError": "Ошибка обработки генерации сообщения коммита: {{error}}" + }, + "error": { + "title": "Ошибка", + "workspacePathNotFound": "Не удалось определить путь рабочего пространства для Git-репозитория", + "generationFailed": "Не удалось сгенерировать сообщение коммита: {{error}}", + "processingFailed": "Ошибка обработки генерации сообщения коммита: {{error}}", + "unknown": "Неизвестная ошибка" + }, + "dialogs": { + "info": "AI-сообщение коммита", + "error": "Ошибка", + "success": "Успех", + "title": "AI-сообщение коммита" + }, + "progress": { + "title": "Генерация сообщения коммита", + "analyzing": "Анализ изменений...", + "connecting": "Подключение к Zoo Code...", + "generating": "Генерация сообщения коммита..." + }, + "ui": { + "generateButton": "Сгенерировать сообщение коммита", + "generateButtonTooltip": "Генерирует сообщение коммита с помощью ИИ-анализа изменений кода" + } } } diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 6bb669b454..abda023fe0 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: Başarıyla bağlandı! Artık Zoo Code'u AI sağlayıcısı olarak kullanabilirsin.", "disconnected": "Zoo Code: Başarıyla bağlantı kesildi." } + }, + "commitMessage": { + "activated": "Zoo Code commit mesaj üreteci etkinleştirildi", + "gitNotFound": "⚠️ Git deposu bulunamadı veya git kullanılamıyor", + "gitInitError": "⚠️ Git başlatma hatası: {{error}}", + "generating": "Zoo: Commit mesajı oluşturuluyor...", + "noChanges": "Zoo: Analiz edilecek değişiklik bulunamadı", + "generated": "Zoo: Commit mesajı oluşturuldu!", + "generationFailed": "Zoo: Commit mesajı oluşturulamadı: {{errorMessage}}", + "contextWarnings": "Zoo: Git bağlam uyarısı: {{warnings}}", + "generatingFromUnstaged": "Zoo: İşlenmemiş değişiklikler kullanılarak mesaj oluşturuluyor", + "confirmUnstaged": "İşlenmiş değişiklik bulunamadı. {{count}} işlenmemiş/izlenmeyen değişiklikten commit mesajı oluşturulsun mu?", + "confirmUnstagedAction": "İşlenmemiş değişikliklerden oluştur", + "activationFailed": "Zoo: Mesaj üreteci etkinleştirilemedi: {{error}}", + "providerRegistered": "Zoo: Commit mesajı sağlayıcısı kaydedildi", + "initializing": "Başlatılıyor...", + "discoveringFiles": "Dosyalar keşfediliyor...", + "foundChanges": "{{count}} değişiklik bulundu", + "gettingContext": "Git bağlamı alınıyor...", + "errors": { + "connectionFailed": "Zoo Code uzantısına bağlanılamadı", + "timeout": "İstek 30 saniye sonra zaman aşımına uğradı", + "invalidResponse": "Uzantıdan geçersiz yanıt formatı alındı", + "missingMessage": "Uzantıdan commit mesajı alınamadı", + "noChanges": "Commit edilecek değişiklik bulunamadı", + "noProject": "Kullanılabilir proje yok", + "noWorkspacePath": "Git deposu için çalışma alanı yolu belirlenemedi", + "workspaceNotFound": "Git deposu için çalışma alanı yolu belirlenemedi", + "processingError": "Commit mesajı oluşturma işleminde hata: {{error}}" + }, + "error": { + "title": "Hata", + "workspacePathNotFound": "Git deposu için çalışma alanı yolu belirlenemedi", + "generationFailed": "Commit mesajı oluşturulamadı: {{error}}", + "processingFailed": "Commit mesajı oluşturma işleminde hata: {{error}}", + "unknown": "Bilinmeyen hata" + }, + "dialogs": { + "info": "AI Commit Mesajı", + "error": "Hata", + "success": "Başarılı", + "title": "AI Commit Mesajı" + }, + "progress": { + "title": "Commit Mesajı Oluşturuluyor", + "analyzing": "Değişiklikler analiz ediliyor...", + "connecting": "Zoo Code'a bağlanılıyor...", + "generating": "Commit mesajı oluşturuluyor..." + }, + "ui": { + "generateButton": "Commit Mesajı Oluştur", + "generateButtonTooltip": "Kod değişikliklerinizi analiz ederek AI ile commit mesajı oluşturur" + } } } diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 57b01d682f..da88f89c43 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -274,5 +274,58 @@ "connected": "Zoo Code: Kết nối thành công! Bạn có thể sử dụng Zoo Code làm nhà cung cấp AI.", "disconnected": "Zoo Code: Đã ngắt kết nối thành công." } + }, + "commitMessage": { + "activated": "Zoo Code: Trình tạo thông điệp commit đã được kích hoạt", + "gitNotFound": "⚠️ Không tìm thấy kho Git hoặc git không khả dụng", + "gitInitError": "⚠️ Lỗi khởi tạo Git: {{error}}", + "generating": "Zoo: Đang tạo thông điệp commit...", + "noChanges": "Zoo: Không tìm thấy thay đổi để phân tích", + "generated": "Zoo: Đã tạo thông điệp commit!", + "generationFailed": "Zoo: Không thể tạo thông điệp commit: {{errorMessage}}", + "contextWarnings": "Zoo: Cảnh báo ngữ cảnh Git: {{warnings}}", + "generatingFromUnstaged": "Zoo: Đang tạo thông điệp từ các thay đổi chưa lưu", + "confirmUnstaged": "Không tìm thấy thay đổi đã lưu. Tạo thông điệp commit từ {{count}} thay đổi chưa lưu/chưa theo dõi?", + "confirmUnstagedAction": "Tạo từ thay đổi chưa lưu", + "activationFailed": "Zoo: Không thể kích hoạt trình tạo thông điệp: {{error}}", + "providerRegistered": "Zoo: Nhà cung cấp thông điệp commit đã được đăng ký", + "initializing": "Đang khởi tạo...", + "discoveringFiles": "Đang tìm kiếm tệp...", + "foundChanges": "Tìm thấy {{count}} thay đổi", + "gettingContext": "Đang lấy ngữ cảnh Git...", + "errors": { + "connectionFailed": "Không thể kết nối đến tiện ích Zoo Code", + "timeout": "Yêu cầu đã hết thời gian chờ sau 30 giây", + "invalidResponse": "Nhận được định dạng phản hồi không hợp lệ từ tiện ích", + "missingMessage": "Không nhận được thông điệp commit từ tiện ích", + "noChanges": "Không tìm thấy thay đổi để commit", + "noProject": "Không có dự án nào khả dụng", + "noWorkspacePath": "Không thể xác định đường dẫn workspace cho kho Git", + "workspaceNotFound": "Không thể xác định đường dẫn workspace cho kho Git", + "processingError": "Lỗi xử lý tạo thông điệp commit: {{error}}" + }, + "error": { + "title": "Lỗi", + "workspacePathNotFound": "Không thể xác định đường dẫn workspace cho kho Git", + "generationFailed": "Không thể tạo thông điệp commit: {{error}}", + "processingFailed": "Lỗi xử lý tạo thông điệp commit: {{error}}", + "unknown": "Lỗi không xác định" + }, + "dialogs": { + "info": "Thông điệp commit AI", + "error": "Lỗi", + "success": "Thành công", + "title": "Thông điệp commit AI" + }, + "progress": { + "title": "Đang tạo thông điệp commit", + "analyzing": "Đang phân tích thay đổi...", + "connecting": "Đang kết nối đến Zoo Code...", + "generating": "Đang tạo thông điệp commit..." + }, + "ui": { + "generateButton": "Tạo thông điệp commit", + "generateButtonTooltip": "Tạo thông điệp commit bằng AI để phân tích các thay đổi mã nguồn của bạn" + } } } diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 62a176ff69..d37bcd3f8d 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -272,5 +272,58 @@ "connected": "Zoo Code: 连接成功!你现在可以使用 Zoo Code 作为 AI 提供商。", "disconnected": "Zoo Code: 已成功断开连接。" } + }, + "commitMessage": { + "activated": "Zoo Code: 提交消息生成器已激活", + "gitNotFound": "⚠️ 未找到 Git 仓库或 git 不可用", + "gitInitError": "⚠️ Git 初始化错误:{{error}}", + "generating": "Zoo:正在生成提交消息...", + "noChanges": "Zoo:未找到需要分析的更改", + "generated": "Zoo:提交消息已生成!", + "generationFailed": "Zoo:生成提交消息失败:{{errorMessage}}", + "contextWarnings": "Zoo:Git 上下文警告:{{warnings}}", + "generatingFromUnstaged": "Zoo:使用未暂存的更改生成消息", + "confirmUnstaged": "未找到已暂存的更改。是否从 {{count}} 个未暂存/未跟踪的更改生成提交消息?", + "confirmUnstagedAction": "从未暂存的更改生成", + "activationFailed": "Zoo:激活消息生成器失败:{{error}}", + "providerRegistered": "Zoo:提交消息提供者已注册", + "initializing": "正在初始化...", + "discoveringFiles": "正在发现文件...", + "foundChanges": "发现 {{count}} 个更改", + "gettingContext": "正在获取 Git 上下文...", + "errors": { + "connectionFailed": "无法连接到 Zoo Code 扩展", + "timeout": "请求超时,超过 30 秒", + "invalidResponse": "从扩展收到无效的响应格式", + "missingMessage": "未从扩展收到提交消息", + "noChanges": "未找到需要提交的更改", + "noProject": "没有可用的项目", + "noWorkspacePath": "无法确定 Git 仓库的工作区路径", + "workspaceNotFound": "无法确定 Git 仓库的工作区路径", + "processingError": "处理提交消息生成时出错:{{error}}" + }, + "error": { + "title": "错误", + "workspacePathNotFound": "无法确定 Git 仓库的工作区路径", + "generationFailed": "生成提交消息失败:{{error}}", + "processingFailed": "处理提交消息生成时出错:{{error}}", + "unknown": "未知错误" + }, + "dialogs": { + "info": "AI 提交消息", + "error": "错误", + "success": "成功", + "title": "AI 提交消息" + }, + "progress": { + "title": "正在生成提交消息", + "analyzing": "正在分析更改...", + "connecting": "正在连接 Zoo Code...", + "generating": "正在生成提交消息..." + }, + "ui": { + "generateButton": "生成提交消息", + "generateButtonTooltip": "使用 AI 分析代码更改并生成提交消息" + } } } diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 54325ad022..2ffb8464ee 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -267,5 +267,58 @@ "connected": "Zoo Code: 連線成功!你現在可以使用 Zoo Code 作為 AI 提供商。", "disconnected": "Zoo Code: 已成功中斷連線。" } + }, + "commitMessage": { + "activated": "Zoo Code:提交訊息產生器已啟用", + "gitNotFound": "⚠️ 找不到 Git 儲存庫或 git 不可用", + "gitInitError": "⚠️ Git 初始化錯誤:{{error}}", + "generating": "Zoo:正在產生提交訊息...", + "noChanges": "Zoo:找不到需要分析的變更", + "generated": "Zoo:提交訊息已產生!", + "generationFailed": "Zoo:產生提交訊息失敗:{{errorMessage}}", + "contextWarnings": "Zoo:Git 上下文警告:{{warnings}}", + "generatingFromUnstaged": "Zoo:使用未暫存的變更產生訊息", + "confirmUnstaged": "找不到已暫存的變更。是否從 {{count}} 個未暫存/未追蹤的變更產生提交訊息?", + "confirmUnstagedAction": "從未暫存的變更產生", + "activationFailed": "Zoo:啟用訊息產生器失敗:{{error}}", + "providerRegistered": "Zoo:提交訊息提供者已註冊", + "initializing": "正在初始化...", + "discoveringFiles": "正在探索檔案...", + "foundChanges": "找到 {{count}} 個變更", + "gettingContext": "正在取得 Git 上下文...", + "errors": { + "connectionFailed": "無法連線到 Zoo Code 擴充套件", + "timeout": "請求逾時,超過 30 秒", + "invalidResponse": "從擴充套件收到無效的回應格式", + "missingMessage": "未從擴充套件收到提交訊息", + "noChanges": "找不到需要提交的變更", + "noProject": "沒有可用的專案", + "noWorkspacePath": "無法確定 Git 儲存庫的工作區路徑", + "workspaceNotFound": "無法確定 Git 儲存庫的工作區路徑", + "processingError": "處理提交訊息產生時發生錯誤:{{error}}" + }, + "error": { + "title": "錯誤", + "workspacePathNotFound": "無法確定 Git 儲存庫的工作區路徑", + "generationFailed": "產生提交訊息失敗:{{error}}", + "processingFailed": "處理提交訊息產生時發生錯誤:{{error}}", + "unknown": "未知錯誤" + }, + "dialogs": { + "info": "AI 提交訊息", + "error": "錯誤", + "success": "成功", + "title": "AI 提交訊息" + }, + "progress": { + "title": "正在產生提交訊息", + "analyzing": "正在分析變更...", + "connecting": "正在連線到 Zoo Code...", + "generating": "正在產生提交訊息..." + }, + "ui": { + "generateButton": "產生提交訊息", + "generateButtonTooltip": "使用 AI 分析程式碼變更並產生提交訊息" + } } } diff --git a/src/package.json b/src/package.json index f62f421d43..175014720b 100644 --- a/src/package.json +++ b/src/package.json @@ -164,6 +164,14 @@ "command": "zoo-code.toggleAutoApprove", "title": "%command.toggleAutoApprove.title%", "category": "%configuration.title%" + }, + { + "command": "zoo-code.generateCommitMessage", + "title": "%command.generateCommitMessage.title%", + "icon": { + "light": "assets/icons/panel_light.png", + "dark": "assets/icons/panel_dark.png" + } } ], "menus": { @@ -207,6 +215,20 @@ "group": "1_actions@3" } ], + "scm/input": [ + { + "command": "zoo-code.generateCommitMessage", + "when": "scmProvider == git", + "group": "navigation" + } + ], + "scm/title": [ + { + "command": "zoo-code.generateCommitMessage", + "when": "scmProvider == git", + "group": "navigation" + } + ], "view/title": [ { "command": "zoo-code.plusButtonClicked", diff --git a/src/package.nls.json b/src/package.nls.json index 23c9b02d92..7aa9485f8d 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -24,6 +24,7 @@ "command.terminal.explainCommand.title": "Explain This Command", "command.acceptInput.title": "Accept Input/Suggestion", "command.toggleAutoApprove.title": "Toggle Auto-Approve", + "command.generateCommitMessage.title": "Generate Commit Message with Zoo", "configuration.title": "Zoo Code", "commands.allowedCommands.description": "Commands that can be auto-executed when 'Always approve execute operations' is enabled", "commands.deniedCommands.description": "Command prefixes that will be automatically denied without asking for approval. In case of conflicts with allowed commands, the longest prefix match takes precedence. Add * to deny all commands.", diff --git a/src/services/commit-message/CommitMessageGenerator.ts b/src/services/commit-message/CommitMessageGenerator.ts new file mode 100644 index 0000000000..08ea5351c2 --- /dev/null +++ b/src/services/commit-message/CommitMessageGenerator.ts @@ -0,0 +1,253 @@ +import { ContextProxy } from "../../core/config/ContextProxy" +import { ProviderSettingsManager } from "../../core/config/ProviderSettingsManager" +import { singleCompletionHandler as defaultSingleCompletionHandler } from "../../utils/single-completion-handler" +import { supportPrompt } from "../../shared/support-prompt" +import { addCustomInstructions as defaultAddCustomInstructions } from "../../core/prompts/sections/custom-instructions" +import { TelemetryService } from "@roo-code/telemetry" +import { TelemetryEventName, type ProviderSettings } from "@roo-code/types" + +import { GenerateMessageParams, PromptOptions, ProgressUpdate } from "./types/core" +import { getActiveCommitMessageProfileSettings } from "./profileSettings" +import { appendCommitMessageAttribution, createCommitMessageAttribution } from "./attribution" + +/** Provides the extension settings needed to generate commit messages. */ +export interface CommitMessageContextProxy { + /** Whether the underlying extension configuration is ready to read. */ + isInitialized: boolean + /** Returns the active provider settings used as the default generation profile. */ + getProviderSettings(): ProviderSettings + /** Reads a persisted extension setting by key. */ + getValue(key: any): unknown +} + +/** Overrides used to isolate commit message generation in tests and integrations. */ +export interface CommitMessageGeneratorDependencies { + /** Supplies the context proxy that owns provider settings and user configuration. */ + getContextProxy?: () => CommitMessageContextProxy + /** Completes the prepared commit-message prompt with the selected provider. */ + completePrompt?: (apiConfiguration: ProviderSettings, promptText: string) => Promise + /** Adds repository-specific custom instructions to the commit-message prompt. */ + addCustomInstructions?: typeof defaultAddCustomInstructions + /** Records successful commit-message generation telemetry. */ + captureGenerated?: () => void + /** Receives non-fatal generation warnings, such as profile fallback failures. */ + logger?: Pick +} + +/** Builds prompts, selects provider settings, and extracts AI generated commit messages. */ +export class CommitMessageGenerator { + private readonly providerSettingsManager: ProviderSettingsManager + private readonly dependencies: Required + private previousGitContext: string | null = null + private previousCommitMessage: string | null = null + + /** Creates a generator using the provider settings manager and optional test seams. */ + constructor( + providerSettingsManager: ProviderSettingsManager, + dependencies: CommitMessageGeneratorDependencies = {}, + ) { + this.providerSettingsManager = providerSettingsManager + this.dependencies = { + getContextProxy: dependencies.getContextProxy ?? (() => ContextProxy.instance), + completePrompt: dependencies.completePrompt ?? defaultSingleCompletionHandler, + addCustomInstructions: dependencies.addCustomInstructions ?? defaultAddCustomInstructions, + captureGenerated: + dependencies.captureGenerated ?? + (() => TelemetryService.instance.captureEvent(TelemetryEventName.COMMIT_MSG_GENERATED)), + logger: dependencies.logger ?? console, + } + } + + /** Generates a commit message for the supplied Git context. */ + async generateMessage(params: GenerateMessageParams): Promise { + const { gitContext, onProgress } = params + + try { + this.validateGitContext(gitContext) + + onProgress?.({ + message: "Generating commit message...", + percentage: 75, + }) + + const generatedMessage = await this.callAIForCommitMessage(gitContext, params.workspacePath, onProgress) + + this.previousGitContext = gitContext + this.previousCommitMessage = generatedMessage + + this.dependencies.captureGenerated() + + onProgress?.({ + message: "Commit message generated successfully", + percentage: 100, + }) + + return generatedMessage + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred" + throw new Error(`Failed to generate commit message: ${errorMessage}`) + } + } + + /** Creates the final model prompt, including custom and regeneration instructions. */ + async buildPrompt(gitContext: string, options: PromptOptions, workspacePath: string): Promise { + const { customSupportPrompts = {}, previousContext, previousMessage } = options + + const customInstructions = await this.dependencies.addCustomInstructions("", "", workspacePath, "commit", { + language: "en", + }) + + const shouldGenerateDifferentMessage = + (previousContext === gitContext || this.previousGitContext === gitContext) && + (previousMessage !== null || this.previousCommitMessage !== null) + + const targetPreviousMessage = previousMessage || this.previousCommitMessage + + if (shouldGenerateDifferentMessage && targetPreviousMessage) { + const differentMessagePrefix = `# CRITICAL INSTRUCTION: GENERATE A COMPLETELY DIFFERENT COMMIT MESSAGE +The user has requested a new commit message for the same changes. +The previous message was: "${targetPreviousMessage}" +YOU MUST create a message that is COMPLETELY DIFFERENT by: +- Using entirely different wording and phrasing +- Focusing on different aspects of the changes +- Using a different structure or format if appropriate +- Possibly using a different type or scope if justifiable +This is the MOST IMPORTANT requirement for this task. + +` + const baseTemplate = supportPrompt.get(customSupportPrompts, "COMMIT_MESSAGE") + const modifiedTemplate = + differentMessagePrefix + + baseTemplate + + ` + +FINAL REMINDER: Your message MUST be COMPLETELY DIFFERENT from the previous message: "${targetPreviousMessage}". This is a critical requirement.` + + return supportPrompt.create( + "COMMIT_MESSAGE", + { + gitContext, + customInstructions: customInstructions || "", + }, + { + ...customSupportPrompts, + COMMIT_MESSAGE: modifiedTemplate, + }, + ) + } else { + return supportPrompt.create( + "COMMIT_MESSAGE", + { + gitContext, + customInstructions: customInstructions || "", + }, + customSupportPrompts, + ) + } + } + + /** Calls the configured AI provider and returns the cleaned commit message text. */ + private async callAIForCommitMessage( + gitContextString: string, + workspacePath: string, + onProgress?: (progress: ProgressUpdate) => void, + ): Promise { + const contextProxy = this.dependencies.getContextProxy() + if (!contextProxy.isInitialized) { + throw new Error("ContextProxy not initialized. Please try again after the extension has fully loaded.") + } + const apiConfiguration = contextProxy.getProviderSettings() + const activeProfile = getActiveCommitMessageProfileSettings(contextProxy) + const commitMessageApiConfigId = activeProfile.apiConfigId + const listApiConfigMeta = (contextProxy.getValue("listApiConfigMeta") || []) as Array<{ id: string }> + const customSupportPrompts = (contextProxy.getValue("customSupportPrompts") || {}) as Record< + string, + string | undefined + > + + let configToUse: ProviderSettings = apiConfiguration + + if (commitMessageApiConfigId && listApiConfigMeta.find(({ id }) => id === commitMessageApiConfigId)) { + try { + await this.providerSettingsManager.initialize() + const { name: _, ...providerSettings } = await this.providerSettingsManager.getProfile({ + id: commitMessageApiConfigId, + }) + + if (providerSettings.apiProvider) { + configToUse = providerSettings + } + } catch (error) { + this.dependencies.logger.warn( + `Failed to load commit message API profile ${commitMessageApiConfigId}; falling back to current API configuration`, + error, + ) + } + } + + const filteredPrompts = Object.fromEntries( + Object.entries(customSupportPrompts).filter(([_, value]) => value !== undefined), + ) as Record + const profilePrompts = + activeProfile.prompt !== undefined + ? { ...filteredPrompts, COMMIT_MESSAGE: activeProfile.prompt } + : filteredPrompts + + const prompt = await this.buildPrompt(gitContextString, { customSupportPrompts: profilePrompts }, workspacePath) + + onProgress?.({ + message: "Calling AI service...", + increment: 10, + }) + + const response = await this.dependencies.completePrompt(configToUse, prompt) + + onProgress?.({ + message: "Processing AI response...", + increment: 10, + }) + + const message = this.extractCommitMessage(response) + const attribution = createCommitMessageAttribution(activeProfile.attribution, configToUse) + + return appendCommitMessageAttribution(message, attribution) + } + + /** Throws when there is no meaningful Git change data to describe. */ + private validateGitContext(gitContext: string): void { + if (!this.hasGitChanges(gitContext)) { + throw new Error("No changes to generate a commit message for") + } + } + + /** Detects whether collected Git context includes at least one changed file. */ + private hasGitChanges(gitContext: string): boolean { + const normalizedContext = gitContext.trim() + + if (!normalizedContext || normalizedContext.includes("(No changes matched selection)")) { + return false + } + + return ( + /^diff --git /m.test(normalizedContext) || + /^Binary file /m.test(normalizedContext) || + /^(Added|Modified|Deleted|Renamed|Copied|Updated|Untracked|Unknown) \((staged|unstaged)\): .+$/m.test( + normalizedContext, + ) + ) + } + + /** Cleans formatting wrappers from an AI response without enforcing message style. */ + private extractCommitMessage(response: string): string { + const cleaned = response.trim() + const withoutCodeBlocks = cleaned.replace(/^```[a-zA-Z0-9_-]*\r?\n/, "").replace(/\r?\n```$/, "") + const withoutQuotes = withoutCodeBlocks.replace(/^["'`]|["'`]$/g, "") + const normalized = withoutQuotes.trim() + + if (!normalized) { + throw new Error("AI returned an empty commit message") + } + + return normalized + } +} diff --git a/src/services/commit-message/CommitMessageProvider.ts b/src/services/commit-message/CommitMessageProvider.ts new file mode 100644 index 0000000000..44c4444da8 --- /dev/null +++ b/src/services/commit-message/CommitMessageProvider.ts @@ -0,0 +1,258 @@ +import * as path from "path" +import * as vscode from "vscode" +import { ProviderSettingsManager } from "../../core/config/ProviderSettingsManager" +import { t } from "../../i18n" +import { Package } from "../../shared/package" +import { GitChange, GitContextCollector } from "../git-context" + +import { CommitMessageGenerator } from "./CommitMessageGenerator" +import { getCommitMessageGitContextSettings, toGitContextCollectorOptions } from "./gitContextSettings" + +interface VscGenerationRequest { + /** Source control input box that should receive the generated message. */ + inputBox: { value: string } + /** Root URI supplied by VS Code for the source control command invocation. */ + rootUri?: vscode.Uri +} + +/** Registers and handles the VS Code command that writes AI commit messages into SCM input. */ +export class CommitMessageProvider implements vscode.Disposable { + private generator: CommitMessageGenerator + + /** Creates the provider and wires it to the extension settings store. */ + constructor( + private context: vscode.ExtensionContext, + private outputChannel: vscode.OutputChannel, + ) { + const providerSettingsManager = new ProviderSettingsManager(this.context) + + this.generator = new CommitMessageGenerator(providerSettingsManager) + } + + /** Registers the generate commit message command with VS Code. */ + public async activate(): Promise { + this.outputChannel.appendLine(t("common:commitMessage.activated")) + + const disposables = [ + vscode.commands.registerCommand( + `${Package.name}.generateCommitMessage`, + (vsRequest?: VscGenerationRequest) => this.handleVSCodeCommand(vsRequest), + ), + ] + this.context.subscriptions.push(...disposables) + } + + /** Handles the command invocation from VS Code's SCM UI. */ + private async handleVSCodeCommand(vsRequest?: VscGenerationRequest): Promise { + try { + const workspacePath = this.determineWorkspacePath(vsRequest?.rootUri) + const targetRepository = await this.determineTargetRepository(workspacePath) + if (!targetRepository?.rootUri) { + throw new Error("Could not determine Git repository") + } + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.SourceControl, + title: t("common:commitMessage.generating"), + cancellable: false, + }, + async (progress) => { + let lastPercentage = 0 + const reportProgress = (percentage: number, message?: string) => { + progress.report({ + increment: Math.max(0, percentage - lastPercentage), + message: message || t("common:commitMessage.generating"), + }) + lastPercentage = percentage + } + + reportProgress(5, t("common:commitMessage.initializing")) + const gitCollector = new GitContextCollector(workspacePath) + + try { + reportProgress(15, t("common:commitMessage.discoveringFiles")) + const resolution = await this.resolveCommitChanges(gitCollector) + + const gitContextSettings = getCommitMessageGitContextSettings() + + if (resolution.changes.length === 0) { + vscode.window.showInformationMessage(t("common:commitMessage.noChanges")) + return + } + reportProgress(25, t("common:commitMessage.foundChanges", { count: resolution.changes.length })) + + if (!resolution.usedStaged) { + vscode.window.showInformationMessage(t("common:commitMessage.generatingFromUnstaged")) + } + + reportProgress(40, t("common:commitMessage.gettingContext")) + const gitContextResult = await gitCollector.collectContext( + resolution.changes, + toGitContextCollectorOptions(resolution.usedStaged, gitContextSettings), + resolution.files, + ) + if (gitContextResult.warnings.length > 0) { + vscode.window.showWarningMessage( + t("common:commitMessage.contextWarnings", { + warnings: gitContextResult.warnings.join("; "), + }), + ) + } + + reportProgress(70, t("common:commitMessage.generating")) + const gitContext = this.appendExistingCommitMessageDraft( + gitContextResult.context, + targetRepository.inputBox.value, + ) + const message = await this.generator.generateMessage({ + workspacePath, + selectedFiles: resolution.files, + gitContext, + onProgress: (update) => { + if (update.percentage !== undefined) { + reportProgress(70 + update.percentage * 0.25, update.message) + } + }, + }) + + targetRepository.inputBox.value = message + reportProgress(100, t("common:commitMessage.generated")) + } finally { + gitCollector.dispose() + } + }, + ) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred" + vscode.window.showErrorMessage(t("common:commitMessage.generationFailed", { errorMessage })) + } + } + + /** Resolves staged changes, asking before falling back to unstaged worktree changes. */ + private async resolveCommitChanges(gitCollector: GitContextCollector): Promise<{ + changes: GitChange[] + files: string[] + usedStaged: boolean + }> { + let changes = await gitCollector.gatherChanges({ staged: true }) + let usedStaged = true + + if (changes.length === 0) { + const useUnstaged = await this.confirmUnstagedGeneration() + if (!useUnstaged) { + return { + changes: [], + files: [], + usedStaged, + } + } + + changes = await gitCollector.gatherChanges({ staged: false }) + usedStaged = false + } + + return { + changes, + files: changes.map((change) => change.filePath), + usedStaged, + } + } + + /** Finds the Git repository that owns the requested workspace path. */ + private async determineTargetRepository(workspacePath: string): Promise { + try { + const gitExtension = vscode.extensions.getExtension("vscode.git") + if (!gitExtension) { + return null + } + + if (!gitExtension.isActive) { + await gitExtension.activate() + } + + const gitApi = gitExtension.exports.getAPI(1) + if (!gitApi) { + return null + } + + const repositories = gitApi.repositories ?? [] + const matchingRepositories = repositories + .filter((repo: VscGenerationRequest) => + repo.rootUri ? isPathWithinRepository(workspacePath, repo.rootUri.fsPath) : false, + ) + .sort( + (a: VscGenerationRequest, b: VscGenerationRequest) => + (b.rootUri?.fsPath.length ?? 0) - (a.rootUri?.fsPath.length ?? 0), + ) + + if (matchingRepositories.length > 0) { + return matchingRepositories[0] + } + + if (repositories.length === 1) { + return repositories[0] + } + + return null + } catch (error) { + return null + } + } + + /** Derives the workspace path from the SCM resource or active workspace. */ + private determineWorkspacePath(resourceUri?: vscode.Uri): string { + if (resourceUri) { + return resourceUri.fsPath + } + + const workspaceFolders = vscode.workspace.workspaceFolders ?? [] + if (workspaceFolders.length === 1) { + return workspaceFolders[0].uri.fsPath + } + + if (workspaceFolders.length > 1) { + throw new Error("Run this command from a specific Git source control input in a multi-root workspace") + } + + throw new Error("Could not determine workspace path") + } + + /** Adds an existing commit input draft to the model context so the next message can improve it. */ + private appendExistingCommitMessageDraft(gitContext: string, existingDraft: string): string { + const normalizedDraft = existingDraft.trim() + if (!normalizedDraft) { + return gitContext + } + + return `${gitContext} + +## Existing Commit Message Draft +The Git commit input already contains this draft. Use it as guidance and generate the best final commit message for the changes. You may improve, replace, or preserve parts of it as appropriate. + +\`\`\` +${normalizedDraft} +\`\`\`` + } + + /** Confirms whether unstaged changes may be gathered when there are no staged changes. */ + private async confirmUnstagedGeneration(): Promise { + const confirmAction = t("common:commitMessage.confirmUnstagedAction") + const choice = await vscode.window.showWarningMessage( + t("common:commitMessage.useUnstagedConfirm"), + { modal: true }, + confirmAction, + ) + + return choice === confirmAction + } + + /** Keeps provider cleanup compatible with VS Code disposable registration. */ + public dispose(): void {} +} + +/** Returns true when the target path is the repository root or is contained by it. */ +export function isPathWithinRepository(targetPath: string, repositoryPath: string): boolean { + const relativePath = path.relative(path.resolve(repositoryPath), path.resolve(targetPath)) + return relativePath === "" || (!!relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath)) +} diff --git a/src/services/commit-message/__tests__/CommitMessageGeneration.integration.spec.ts b/src/services/commit-message/__tests__/CommitMessageGeneration.integration.spec.ts new file mode 100644 index 0000000000..ccdb84ce38 --- /dev/null +++ b/src/services/commit-message/__tests__/CommitMessageGeneration.integration.spec.ts @@ -0,0 +1,80 @@ +import * as os from "os" +import * as path from "path" +import { execFile } from "child_process" +import { promisify } from "util" +import { promises as fs } from "fs" +import type { ProviderSettings } from "@roo-code/types" + +import { GitContextCollector } from "../../git-context" +import { CommitMessageGenerator } from "../CommitMessageGenerator" + +const execFileAsync = promisify(execFile) + +async function runGit(cwd: string, args: string[]) { + await execFileAsync("git", args, { cwd }) +} + +describe("commit message generation flow", () => { + const defaultConfig: ProviderSettings = { apiProvider: "openai", openAiApiKey: "default-key" } + const providerSettingsManager = { + initialize: vi.fn(), + getProfile: vi.fn(), + } + const contextProxy = { + isInitialized: true, + getProviderSettings: vi.fn(() => defaultConfig), + getValue: vi.fn((key: string) => { + switch (key) { + case "listApiConfigMeta": + return [] + case "customSupportPrompts": + return {} + default: + return undefined + } + }), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("passes collected git context with untracked file diff to the LLM", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-commit-generation-")) + try { + await runGit(tempRoot, ["init"]) + const filePath = path.join(tempRoot, "src", "new.ts") + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, "export const value = 1\n") + + const gitContext = await new GitContextCollector(tempRoot).collect({ + staged: false, + includeBranch: false, + recentCommits: { include: false }, + }) + const completePrompt = vi.fn().mockResolvedValue("feat(src): add new module") + const generator = new CommitMessageGenerator(providerSettingsManager as any, { + getContextProxy: () => contextProxy, + completePrompt, + addCustomInstructions: vi.fn().mockResolvedValue(""), + captureGenerated: vi.fn(), + }) + + const message = await generator.generateMessage({ + workspacePath: tempRoot, + selectedFiles: gitContext.changes.map((change) => change.filePath), + gitContext: gitContext.context, + }) + + expect(message).toBe("feat(src): add new module") + expect(gitContext.context).toContain("diff --git a/src/new.ts b/src/new.ts") + expect(gitContext.context).toContain("+export const value = 1") + expect(completePrompt).toHaveBeenCalledWith( + defaultConfig, + expect.stringContaining("+export const value = 1"), + ) + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }) + } + }) +}) diff --git a/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts b/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts new file mode 100644 index 0000000000..60a62e9c10 --- /dev/null +++ b/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts @@ -0,0 +1,369 @@ +import type { ProviderSettings } from "@roo-code/types" + +import { CommitMessageGenerator } from "../CommitMessageGenerator" + +describe("CommitMessageGenerator", () => { + const defaultConfig: ProviderSettings = { + apiProvider: "openai", + openAiApiKey: "default-key", + openAiModelId: "gpt-4", + } + const commitConfig: ProviderSettings = { + apiProvider: "anthropic", + apiKey: "commit-key", + apiModelId: "claude-opus-4-7", + } + const providerSettingsManager = { + initialize: vi.fn(), + getProfile: vi.fn(), + } + const contextProxy = { + isInitialized: true, + getProviderSettings: vi.fn(), + getValue: vi.fn(), + } + const completePrompt = vi.fn() + const addCustomInstructions = vi.fn() + const captureGenerated = vi.fn() + const warn = vi.fn() + + /** Creates a generator with mocked provider and configuration dependencies. */ + const createGenerator = () => + new CommitMessageGenerator(providerSettingsManager as any, { + getContextProxy: () => contextProxy, + completePrompt, + addCustomInstructions: addCustomInstructions as any, + captureGenerated, + logger: { warn }, + }) + + beforeEach(() => { + vi.clearAllMocks() + contextProxy.isInitialized = true + contextProxy.getProviderSettings.mockReturnValue(defaultConfig) + contextProxy.getValue.mockImplementation((key: string) => { + switch (key) { + case "commitMessageApiConfigId": + return undefined + case "listApiConfigMeta": + return [] + case "customSupportPrompts": + return {} + default: + return undefined + } + }) + addCustomInstructions.mockResolvedValue("Follow repo commit rules.") + completePrompt.mockResolvedValue("```\nfeat(core): add commit generator\n```") + providerSettingsManager.initialize.mockResolvedValue(undefined) + providerSettingsManager.getProfile.mockResolvedValue({ name: "Commit profile", ...commitConfig }) + }) + + it("fails before progress or AI calls when git context has no changes", async () => { + const onProgress = vi.fn() + const generator = createGenerator() + + await expect( + generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: [], + gitContext: `## Git Context + +### Full Diff of Staged Changes +\`\`\`diff +\`\`\` + +### Change Summary +\`\`\` +(No changes matched selection) +\`\`\``, + onProgress, + }), + ).rejects.toThrow("No changes to generate a commit message for") + + expect(onProgress).not.toHaveBeenCalled() + expect(completePrompt).not.toHaveBeenCalled() + expect(captureGenerated).not.toHaveBeenCalled() + }) + + it("sends the full git context to the LLM and returns cleaned commit text", async () => { + const gitContext = `## Git Context + +### Full Diff of Staged Changes +\`\`\`diff +diff --git a/src/new.ts b/src/new.ts +new file mode 100644 +--- /dev/null ++++ b/src/new.ts +@@ -0,0 +1,1 @@ ++export const value = 1 +\`\`\`` + const generator = createGenerator() + + const message = await generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: ["src/new.ts"], + gitContext, + }) + + expect(message).toBe("feat(core): add commit generator") + expect(completePrompt).toHaveBeenCalledTimes(1) + const [config, prompt] = completePrompt.mock.calls[0] + expect(config).toBe(defaultConfig) + expect(prompt).toContain("# Conventional Commit Message Generator") + expect(prompt).toContain("Follow repo commit rules.") + expect(prompt).toContain(gitContext) + expect(captureGenerated).toHaveBeenCalledTimes(1) + }) + + it("uses the selected commit-message API profile when configured", async () => { + contextProxy.getValue.mockImplementation((key: string) => { + switch (key) { + case "commitMessageApiConfigId": + return "commit-profile" + case "listApiConfigMeta": + return [{ id: "commit-profile", name: "Commit profile" }] + case "customSupportPrompts": + return {} + default: + return undefined + } + }) + completePrompt.mockResolvedValue("fix(git): include untracked file diffs") + const generator = createGenerator() + + await generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: ["src/new.ts"], + gitContext: "diff --git a/src/new.ts b/src/new.ts", + }) + + expect(providerSettingsManager.initialize).toHaveBeenCalledTimes(1) + expect(providerSettingsManager.getProfile).toHaveBeenCalledWith({ id: "commit-profile" }) + expect(completePrompt).toHaveBeenCalledWith( + expect.objectContaining(commitConfig), + expect.stringContaining("diff --git a/src/new.ts b/src/new.ts"), + ) + }) + + it("uses the active commit-message profile prompt and API config", async () => { + contextProxy.getValue.mockImplementation((key: string) => { + switch (key) { + case "commitMessageProfiles": + return { + activeProfileId: "release", + profiles: [ + { + id: "default", + name: "Default", + apiConfigId: "default-profile", + }, + { + id: "release", + name: "Release", + prompt: "Release prompt\n${gitContext}\n${customInstructions}", + apiConfigId: "commit-profile", + }, + ], + } + case "commitMessageApiConfigId": + return "default-profile" + case "listApiConfigMeta": + return [{ id: "commit-profile", name: "Commit profile" }] + case "customSupportPrompts": + return { COMMIT_MESSAGE: "Legacy prompt ${gitContext}" } + default: + return undefined + } + }) + completePrompt.mockResolvedValue("chore(release): prepare notes") + const generator = createGenerator() + + await generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: ["CHANGELOG.md"], + gitContext: "diff --git a/CHANGELOG.md b/CHANGELOG.md", + }) + + expect(providerSettingsManager.getProfile).toHaveBeenCalledWith({ id: "commit-profile" }) + expect(completePrompt).toHaveBeenCalledWith( + expect.objectContaining(commitConfig), + expect.stringContaining("Release prompt"), + ) + expect(completePrompt.mock.calls[0][1]).not.toContain("Legacy prompt") + }) + + it("falls back to current API config when the selected profile cannot be loaded", async () => { + contextProxy.getValue.mockImplementation((key: string) => { + switch (key) { + case "commitMessageApiConfigId": + return "deleted-profile" + case "listApiConfigMeta": + return [{ id: "deleted-profile", name: "Deleted profile" }] + case "customSupportPrompts": + return {} + default: + return undefined + } + }) + providerSettingsManager.getProfile.mockRejectedValue(new Error("missing profile")) + const generator = createGenerator() + + await generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: ["src/new.ts"], + gitContext: "diff --git a/src/new.ts b/src/new.ts", + }) + + expect(completePrompt).toHaveBeenCalledWith(defaultConfig, expect.any(String)) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("Failed to load commit message API profile deleted-profile"), + expect.any(Error), + ) + }) + + it("asks for a different message when regenerating for the same git context", async () => { + completePrompt.mockResolvedValueOnce("feat(git): collect git context") + completePrompt.mockResolvedValueOnce("chore(git): improve diff handling") + const generator = createGenerator() + const gitContext = "diff --git a/src/file.ts b/src/file.ts" + + await generator.generateMessage({ workspacePath: "/repo", selectedFiles: ["src/file.ts"], gitContext }) + await generator.generateMessage({ workspacePath: "/repo", selectedFiles: ["src/file.ts"], gitContext }) + + const secondPrompt = completePrompt.mock.calls[1][1] + expect(secondPrompt).toContain("GENERATE A COMPLETELY DIFFERENT COMMIT MESSAGE") + expect(secondPrompt).toContain('The previous message was: "feat(git): collect git context"') + expect(secondPrompt).toContain(gitContext) + }) + + it("cleans formatting wrappers without enforcing conventional commit format", async () => { + completePrompt.mockResolvedValue(`\`\`\` +Update Git context parsing for staged-only entries + +Keep unstaged commit context focused on worktree changes. +\`\`\``) + const generator = createGenerator() + + const message = await generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: ["src/file.ts"], + gitContext: "Modified (staged): src/file.ts", + }) + + expect(message).toBe(`Update Git context parsing for staged-only entries + +Keep unstaged commit context focused on worktree changes.`) + }) + + it("fails when AI output is empty after cleanup", async () => { + completePrompt.mockResolvedValue("```\n \n```") + const generator = createGenerator() + + await expect( + generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: ["src/file.ts"], + gitContext: "Modified (staged): src/file.ts", + }), + ).rejects.toThrow("AI returned an empty commit message") + + expect(captureGenerated).not.toHaveBeenCalled() + }) + + it("appends attribution from the top-level single-profile setting", async () => { + contextProxy.getValue.mockImplementation((key: string) => { + switch (key) { + case "commitMessageAttribution": + return { enabled: true } + case "listApiConfigMeta": + return [] + case "customSupportPrompts": + return {} + default: + return undefined + } + }) + completePrompt.mockResolvedValue("feat(scm): add commit generation") + const generator = createGenerator() + + const message = await generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: ["src/new.ts"], + gitContext: "diff --git a/src/new.ts b/src/new.ts", + }) + + expect(message).toBe("feat(scm): add commit generation\n\nAssisted-by: Zoo Code:openai/gpt-4 [Zoo Code]") + }) + + it("uses the actual profile API config for attribution", async () => { + contextProxy.getValue.mockImplementation((key: string) => { + switch (key) { + case "commitMessageProfiles": + return { + activeProfileId: "release", + profiles: [ + { + id: "release", + name: "Release", + apiConfigId: "commit-profile", + attribution: { enabled: true, template: "Generated-by: ${providerModel}" }, + }, + ], + } + case "listApiConfigMeta": + return [{ id: "commit-profile", name: "Commit profile" }] + case "customSupportPrompts": + return {} + default: + return undefined + } + }) + completePrompt.mockResolvedValue("chore(release): prepare notes") + const generator = createGenerator() + + const message = await generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: ["CHANGELOG.md"], + gitContext: "diff --git a/CHANGELOG.md b/CHANGELOG.md", + }) + + expect(message).toBe("chore(release): prepare notes\n\nGenerated-by: anthropic/claude-opus-4-7") + }) + + it("uses fallback API config for attribution when profile loading fails", async () => { + contextProxy.getValue.mockImplementation((key: string) => { + switch (key) { + case "commitMessageProfiles": + return { + activeProfileId: "release", + profiles: [ + { + id: "release", + name: "Release", + apiConfigId: "missing-profile", + attribution: { enabled: true }, + }, + ], + } + case "listApiConfigMeta": + return [{ id: "missing-profile", name: "Missing profile" }] + case "customSupportPrompts": + return {} + default: + return undefined + } + }) + providerSettingsManager.getProfile.mockRejectedValue(new Error("missing profile")) + completePrompt.mockResolvedValue("fix(scm): handle profile fallback") + const generator = createGenerator() + + const message = await generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: ["src/new.ts"], + gitContext: "diff --git a/src/new.ts b/src/new.ts", + }) + + expect(message).toBe("fix(scm): handle profile fallback\n\nAssisted-by: Zoo Code:openai/gpt-4 [Zoo Code]") + }) +}) diff --git a/src/services/commit-message/__tests__/CommitMessageProvider.spec.ts b/src/services/commit-message/__tests__/CommitMessageProvider.spec.ts new file mode 100644 index 0000000000..a187db073d --- /dev/null +++ b/src/services/commit-message/__tests__/CommitMessageProvider.spec.ts @@ -0,0 +1,120 @@ +import * as path from "path" +import * as vscode from "vscode" + +import { CommitMessageProvider, isPathWithinRepository } from "../CommitMessageProvider" + +vi.mock("vscode", () => ({ + window: { + showWarningMessage: vi.fn(), + }, + workspace: { + workspaceFolders: undefined, + }, + Uri: { + file: (fsPath: string) => ({ fsPath }), + }, +})) + +describe("CommitMessageProvider", () => { + const createProvider = () => + new CommitMessageProvider( + {} as vscode.ExtensionContext, + { appendLine: vi.fn() } as unknown as vscode.OutputChannel, + ) + + beforeEach(() => { + vi.clearAllMocks() + ;(vscode.workspace as any).workspaceFolders = undefined + }) + + it("matches repository roots by path containment instead of string prefix", () => { + const root = path.parse(process.cwd()).root + const repositoryPath = path.join(root, "work", "app") + + expect(isPathWithinRepository(path.join(repositoryPath, "src", "index.ts"), repositoryPath)).toBe(true) + expect(isPathWithinRepository(repositoryPath, repositoryPath)).toBe(true) + expect(isPathWithinRepository(path.join(root, "work", "application"), repositoryPath)).toBe(false) + }) + + it("adds existing commit input to the generation context", () => { + const provider = createProvider() + const gitContext = "diff --git a/src/file.ts b/src/file.ts" + + const contextWithDraft = (provider as any).appendExistingCommitMessageDraft(gitContext, "existing message") + + expect(contextWithDraft).toContain(gitContext) + expect(contextWithDraft).toContain("## Existing Commit Message Draft") + expect(contextWithDraft).toContain("existing message") + }) + + it("does not add empty commit input to the generation context", () => { + const provider = createProvider() + const gitContext = "diff --git a/src/file.ts b/src/file.ts" + + expect((provider as any).appendExistingCommitMessageDraft(gitContext, " ")).toBe(gitContext) + }) + + it("asks before falling back to unstaged changes", async () => { + vi.mocked(vscode.window.showWarningMessage).mockResolvedValue("commitMessage.confirmUnstagedAction" as never) + const provider = createProvider() + const gitCollector = { + gatherChanges: vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ filePath: "src/file.ts" }]), + } + + const resolution = await (provider as any).resolveCommitChanges(gitCollector) + + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + "commitMessage.useUnstagedConfirm", + { modal: true }, + "commitMessage.confirmUnstagedAction", + ) + expect(gitCollector.gatherChanges).toHaveBeenNthCalledWith(1, { staged: true }) + expect(gitCollector.gatherChanges).toHaveBeenNthCalledWith(2, { staged: false }) + expect(resolution).toEqual({ + changes: [{ filePath: "src/file.ts" }], + files: ["src/file.ts"], + usedStaged: false, + }) + }) + + it("does not read unstaged changes when fallback is declined", async () => { + vi.mocked(vscode.window.showWarningMessage).mockResolvedValue(undefined) + const provider = createProvider() + const gitCollector = { + gatherChanges: vi.fn().mockResolvedValueOnce([]), + } + + const resolution = await (provider as any).resolveCommitChanges(gitCollector) + + expect(gitCollector.gatherChanges).toHaveBeenCalledTimes(1) + expect(resolution).toEqual({ changes: [], files: [], usedStaged: true }) + }) + + it("uses the SCM resource URI as the workspace path when provided", () => { + const provider = createProvider() + + expect((provider as any).determineWorkspacePath(vscode.Uri.file("/repo"))).toBe("/repo") + }) + + it("falls back to the workspace folder only when exactly one folder is open", () => { + ;(vscode.workspace as any).workspaceFolders = [{ uri: vscode.Uri.file("/single-root") }] + const provider = createProvider() + + expect((provider as any).determineWorkspacePath()).toBe("/single-root") + }) + + it("fails clearly instead of guessing in multi-root workspaces", () => { + ;(vscode.workspace as any).workspaceFolders = [ + { uri: vscode.Uri.file("/first-root") }, + { uri: vscode.Uri.file("/second-root") }, + ] + const provider = createProvider() + + expect(() => (provider as any).determineWorkspacePath()).toThrow( + "Run this command from a specific Git source control input in a multi-root workspace", + ) + }) +}) diff --git a/src/services/commit-message/__tests__/attribution.spec.ts b/src/services/commit-message/__tests__/attribution.spec.ts new file mode 100644 index 0000000000..10fe39a3f0 --- /dev/null +++ b/src/services/commit-message/__tests__/attribution.spec.ts @@ -0,0 +1,52 @@ +import type { ProviderSettings } from "@roo-code/types" + +import { + appendCommitMessageAttribution, + applyCommitMessageAttributionTemplate, + createCommitMessageAttribution, +} from "../attribution" + +describe("commit message attribution", () => { + const apiConfiguration: ProviderSettings = { + apiProvider: "anthropic", + apiModelId: "claude-opus-4-7", + } + + it("returns no attribution when disabled by default", () => { + expect(createCommitMessageAttribution(undefined, apiConfiguration)).toBe("") + }) + + it("creates the default attribution with provider and model", () => { + expect(createCommitMessageAttribution({ enabled: true }, apiConfiguration)).toBe( + "Assisted-by: Zoo Code:anthropic/claude-opus-4-7 [Zoo Code]", + ) + }) + + it("applies custom attribution placeholders", () => { + expect( + applyCommitMessageAttributionTemplate("Co-authored-by: ${agentName} (${providerModel}) [${toolName}]", { + agentName: "Zoo Code", + toolName: "Zoo Code", + provider: "openrouter", + model: "openai/gpt-4", + providerModel: "openrouter/openai/gpt-4", + }), + ).toBe("Co-authored-by: Zoo Code (openrouter/openai/gpt-4) [Zoo Code]") + }) + + it("appends attribution with exactly one blank line", () => { + expect(appendCommitMessageAttribution("feat(scm): generate commits\n", "Assisted-by: Zoo Code")).toBe( + "feat(scm): generate commits\n\nAssisted-by: Zoo Code", + ) + }) + + it("returns only trimmed attribution when message is empty", () => { + expect(appendCommitMessageAttribution(" ", "\nAssisted-by: Zoo Code\n")).toBe("Assisted-by: Zoo Code") + }) + + it("does not duplicate an existing attribution footer", () => { + const message = "feat(scm): generate commits\n\nAssisted-by: Zoo Code" + + expect(appendCommitMessageAttribution(message, "Assisted-by: Zoo Code")).toBe(message) + }) +}) diff --git a/src/services/commit-message/__tests__/profileSettings.spec.ts b/src/services/commit-message/__tests__/profileSettings.spec.ts new file mode 100644 index 0000000000..0de2cf3b6f --- /dev/null +++ b/src/services/commit-message/__tests__/profileSettings.spec.ts @@ -0,0 +1,111 @@ +import { defaultCommitMessageAttributionSettings, defaultCommitMessageGitContextSettings } from "@roo-code/types" + +import { getActiveCommitMessageProfileSettings, getCommitMessageProfileSettings } from "../profileSettings" + +describe("commit message profile settings", () => { + const createContextProxy = (values: Record) => ({ + getValue: vi.fn((key: string) => values[key]), + }) + + it("creates one default profile from old single-profile settings", () => { + const contextProxy = createContextProxy({ + customSupportPrompts: { COMMIT_MESSAGE: "Custom commit prompt ${gitContext}" }, + commitMessageApiConfigId: "commit-profile", + commitMessageGitContext: { diffContextLines: 8, includeRecentCommits: false }, + commitMessageAttribution: { enabled: true, template: "Assisted-by: ${providerModel}" }, + }) + + const settings = getCommitMessageProfileSettings(contextProxy) + + expect(settings.activeProfileId).toBe("default") + expect(settings.profiles).toHaveLength(1) + expect(settings.profiles[0]).toMatchObject({ + id: "default", + name: "Default", + prompt: "Custom commit prompt ${gitContext}", + apiConfigId: "commit-profile", + }) + expect(settings.profiles[0].gitContext).toMatchObject({ + ...defaultCommitMessageGitContextSettings, + diffContextLines: 8, + includeRecentCommits: false, + }) + expect(settings.profiles[0].attribution).toEqual({ + enabled: true, + template: "Assisted-by: ${providerModel}", + }) + }) + + it("clamps profiles to 5 and falls back to the first profile when the active id is missing", () => { + const contextProxy = createContextProxy({ + commitMessageProfiles: { + activeProfileId: "missing", + profiles: Array.from({ length: 7 }, (_, index) => ({ + id: `profile-${index + 1}`, + name: `Profile ${index + 1}`, + })), + }, + }) + + const settings = getCommitMessageProfileSettings(contextProxy) + + expect(settings.profiles).toHaveLength(5) + expect(settings.activeProfileId).toBe("profile-1") + }) + + it("clamps recent commit diff count while merging default Git context settings", () => { + const contextProxy = createContextProxy({ + commitMessageProfiles: { + activeProfileId: "detailed", + profiles: [ + { + id: "detailed", + name: "Detailed", + gitContext: { includeRecentCommitDiffs: true, recentCommitDiffCount: 9 }, + }, + ], + }, + }) + + const profile = getActiveCommitMessageProfileSettings(contextProxy) + + expect(profile.gitContext).toMatchObject({ + ...defaultCommitMessageGitContextSettings, + includeRecentCommitDiffs: true, + recentCommitDiffCount: 5, + }) + }) + + it("does not apply top-level attribution fallback to stored profiles", () => { + const contextProxy = createContextProxy({ + commitMessageAttribution: { enabled: true, template: "Assisted-by: ${providerModel}" }, + commitMessageProfiles: { + activeProfileId: "default", + profiles: [{ id: "default", name: "Default" }], + }, + }) + + const profile = getActiveCommitMessageProfileSettings(contextProxy) + + expect(profile.attribution).toEqual(defaultCommitMessageAttributionSettings) + }) + + it("normalizes stored profile attribution independently", () => { + const contextProxy = createContextProxy({ + commitMessageProfiles: { + activeProfileId: "release", + profiles: [ + { id: "default", name: "Default" }, + { id: "release", name: "Release", attribution: { enabled: true } }, + ], + }, + }) + + const profile = getActiveCommitMessageProfileSettings(contextProxy) + + expect(profile.attribution).toEqual({ + ...defaultCommitMessageAttributionSettings, + enabled: true, + }) + }) +}) diff --git a/src/services/commit-message/attribution.ts b/src/services/commit-message/attribution.ts new file mode 100644 index 0000000000..d2c30798d0 --- /dev/null +++ b/src/services/commit-message/attribution.ts @@ -0,0 +1,76 @@ +import { + getModelId, + normalizeCommitMessageAttributionSettings, + type CommitMessageAttributionSettings, + type ProviderSettings, +} from "@roo-code/types" + +const ATTRIBUTION_AGENT_NAME = "Zoo Code" +const ATTRIBUTION_TOOL_NAME = "Zoo Code" +const UNKNOWN_VALUE = "unknown" + +export interface CommitMessageAttributionTemplateValues { + /** Name of the AI agent that assisted with the commit message. */ + agentName: string + /** Name of the tool that produced the commit message. */ + toolName: string + /** Provider key used for generation. */ + provider: string + /** Model identifier used for generation. */ + model: string + /** Combined provider/model value for compact templates. */ + providerModel: string +} + +/** Creates the attribution footer for a generated commit message when enabled. */ +export function createCommitMessageAttribution( + settings: CommitMessageAttributionSettings | undefined, + apiConfiguration: ProviderSettings, +): string { + const normalized = normalizeCommitMessageAttributionSettings(settings) + if (!normalized.enabled) { + return "" + } + + const provider = apiConfiguration.apiProvider || UNKNOWN_VALUE + const model = getModelId(apiConfiguration) || UNKNOWN_VALUE + + return applyCommitMessageAttributionTemplate(normalized.template, { + agentName: ATTRIBUTION_AGENT_NAME, + toolName: ATTRIBUTION_TOOL_NAME, + provider, + model, + providerModel: `${provider}/${model}`, + }) +} + +/** Replaces supported attribution placeholders with concrete generation metadata. */ +export function applyCommitMessageAttributionTemplate( + template: string, + values: CommitMessageAttributionTemplateValues, +): string { + return template.replace( + /\$\{(agentName|toolName|provider|model|providerModel)\}/g, + (_, key) => values[key as keyof typeof values], + ) +} + +/** Appends attribution once, preserving messages that already include the same footer. */ +export function appendCommitMessageAttribution(message: string, attribution: string): string { + const cleanedMessage = message.trim() + const cleanedAttribution = attribution.trim() + + if (!cleanedAttribution) { + return cleanedMessage + } + + if (!cleanedMessage) { + return cleanedAttribution + } + + if (cleanedMessage.endsWith(cleanedAttribution)) { + return cleanedMessage + } + + return `${cleanedMessage}\n\n${cleanedAttribution}` +} diff --git a/src/services/commit-message/gitContextSettings.ts b/src/services/commit-message/gitContextSettings.ts new file mode 100644 index 0000000000..2a527853e7 --- /dev/null +++ b/src/services/commit-message/gitContextSettings.ts @@ -0,0 +1,32 @@ +import { type CommitMessageGitContextSettings } from "@roo-code/types" + +import type { GitContextCollectorOptions } from "../git-context" +import { getActiveCommitMessageProfileSettings } from "./profileSettings" + +/** Reads and normalizes the persisted Git context settings for commit message generation. */ +export function getCommitMessageGitContextSettings(): Required { + return getActiveCommitMessageProfileSettings().gitContext +} + +/** Converts commit-message settings into options consumed by the Git context collector. */ +export function toGitContextCollectorOptions( + staged: boolean, + settings: Required, +): GitContextCollectorOptions { + return { + staged, + diff: { + contextLines: settings.diffContextLines, + includeStats: settings.includeDiffStats, + }, + includeBranch: settings.includeCurrentBranch, + recentCommits: { + include: settings.includeRecentCommits, + count: settings.recentCommitCount, + includeBodies: settings.includeRecentCommitBodies, + includeStats: settings.includeRecentCommitStats, + includeDiffs: settings.includeRecentCommitDiffs, + diffCount: settings.recentCommitDiffCount, + }, + } +} diff --git a/src/services/commit-message/index.ts b/src/services/commit-message/index.ts new file mode 100644 index 0000000000..1886cdd244 --- /dev/null +++ b/src/services/commit-message/index.ts @@ -0,0 +1,22 @@ +import * as vscode from "vscode" +import { CommitMessageProvider } from "./CommitMessageProvider" +import { t } from "../../i18n" + +/** Registers the commit message provider and reports activation failures to the output channel. */ +export function registerCommitMessageProvider( + context: vscode.ExtensionContext, + outputChannel: vscode.OutputChannel, +): void { + const commitProvider = new CommitMessageProvider(context, outputChannel) + context.subscriptions.push(commitProvider) + + commitProvider + .activate() + .then(() => { + outputChannel.appendLine(t("common:commitMessage.providerRegistered")) + }) + .catch((error) => { + outputChannel.appendLine(t("common:commitMessage.activationFailed", { error: error.message })) + console.error("Commit message provider activation failed:", error) + }) +} diff --git a/src/services/commit-message/profileSettings.ts b/src/services/commit-message/profileSettings.ts new file mode 100644 index 0000000000..fd70002af5 --- /dev/null +++ b/src/services/commit-message/profileSettings.ts @@ -0,0 +1,59 @@ +import { + getActiveCommitMessageProfile, + normalizeCommitMessageProfiles, + type CommitMessageAttributionSettings, + type CommitMessageGitContextSettings, + type CommitMessageProfilesSettings, + type NormalizedCommitMessageProfile, + type NormalizedCommitMessageProfiles, +} from "@roo-code/types" + +import { ContextProxy } from "../../core/config/ContextProxy" + +export interface CommitMessageProfileContextProxy { + /** Reads persisted extension setting values by key. */ + getValue(key: any): unknown +} + +/** Reads all commit-message profiles, normalized with legacy single-profile fallbacks. */ +export function getCommitMessageProfileSettings( + contextProxy: CommitMessageProfileContextProxy = ContextProxy.instance, +): NormalizedCommitMessageProfiles { + return normalizeCommitMessageProfiles( + readCommitMessageProfiles(contextProxy), + readSingleProfileFallback(contextProxy), + ) +} + +/** Reads the active commit-message profile for generator/provider runtime decisions. */ +export function getActiveCommitMessageProfileSettings( + contextProxy: CommitMessageProfileContextProxy = ContextProxy.instance, +): NormalizedCommitMessageProfile { + return getActiveCommitMessageProfile( + readCommitMessageProfiles(contextProxy), + readSingleProfileFallback(contextProxy), + ) +} + +/** Reads the raw persisted commit-message profiles object if present. */ +function readCommitMessageProfiles( + contextProxy: CommitMessageProfileContextProxy, +): CommitMessageProfilesSettings | undefined { + return contextProxy.getValue("commitMessageProfiles") as CommitMessageProfilesSettings | undefined +} + +/** Reads legacy single-profile settings used to synthesize the default profile. */ +function readSingleProfileFallback(contextProxy: CommitMessageProfileContextProxy) { + const customSupportPrompts = (contextProxy.getValue("customSupportPrompts") || {}) as Record< + string, + string | undefined + > + + // Profile settings layer over the original single-profile keys so profiles can be removed cleanly. + return { + prompt: customSupportPrompts.COMMIT_MESSAGE, + apiConfigId: contextProxy.getValue("commitMessageApiConfigId") as string | undefined, + gitContext: contextProxy.getValue("commitMessageGitContext") as CommitMessageGitContextSettings | undefined, + attribution: contextProxy.getValue("commitMessageAttribution") as CommitMessageAttributionSettings | undefined, + } +} diff --git a/src/services/commit-message/types/core.ts b/src/services/commit-message/types/core.ts new file mode 100644 index 0000000000..9926fdb576 --- /dev/null +++ b/src/services/commit-message/types/core.ts @@ -0,0 +1,31 @@ +/** Parameters required to generate a commit message for selected Git changes. */ +export interface GenerateMessageParams { + /** Absolute workspace path used to resolve repository custom instructions. */ + workspacePath: string + /** File paths included in the Git context used for generation. */ + selectedFiles: string[] + /** Markdown Git context describing the changes to summarize. */ + gitContext: string + /** Optional progress callback for UI updates during generation. */ + onProgress?: (progress: ProgressUpdate) => void +} + +/** Prompt customization and regeneration context for commit-message prompts. */ +export interface PromptOptions { + /** User-defined support prompt templates keyed by prompt type. */ + customSupportPrompts?: Record + /** Previous Git context used to detect regeneration for the same changes. */ + previousContext?: string + /** Previous generated message to avoid repeating during regeneration. */ + previousMessage?: string +} + +/** Incremental status update emitted while generating a commit message. */ +export interface ProgressUpdate { + /** Human-readable status message for the current generation step. */ + message?: string + /** Absolute progress percentage for the current generation step. */ + percentage?: number + /** Relative progress increment for the current generation step. */ + increment?: number +} diff --git a/src/services/git-context/GitContextCollector.ts b/src/services/git-context/GitContextCollector.ts new file mode 100644 index 0000000000..f4d294feb4 --- /dev/null +++ b/src/services/git-context/GitContextCollector.ts @@ -0,0 +1,589 @@ +import * as path from "path" +import { promises as fs } from "fs" +import { spawn } from "child_process" +import type { + GitContextCollection, + GitContextCollectorOptions, + GitChange, + GitDiffContextOptions, + GitContextOptions, + GitRecentCommitContextOptions, + GitContextResult, + GitStatus, +} from "./types" + +const DEFAULT_RECENT_COMMIT_COUNT = 5 +const DEFAULT_RECENT_COMMIT_DIFF_COUNT = 1 + +/** Collects Git status, diff, and repository metadata for commit-message generation. */ +export class GitContextCollector { + /** Creates a collector scoped to one workspace repository root. */ + constructor(private workspaceRoot: string) {} + + /** Returns changed files from staged or unstaged Git state. */ + public async gatherChanges(options: GitContextCollectorOptions): Promise { + const statusOutput = await this.getStatus(options) + if (!statusOutput) { + return [] + } + + return options.staged ? this.parseNameStatus(statusOutput, true) : this.parsePorcelainStatus(statusOutput) + } + + /** Gathers changes and formats their Git context in one call. */ + public async collect(options: GitContextCollectorOptions, specificFiles?: string[]): Promise { + const changes = await this.gatherChanges(options) + const result = await this.collectContext(changes, options, specificFiles) + + return { ...result, changes } + } + + /** Runs a Git subprocess in the workspace root and returns stdout. */ + private async runGit(args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn("git", args, { + cwd: this.workspaceRoot, + stdio: ["ignore", "pipe", "pipe"], + }) + let stdout = "" + let stderr = "" + + child.stdout.setEncoding("utf8") + child.stderr.setEncoding("utf8") + child.stdout.on("data", (chunk) => (stdout += chunk)) + child.stderr.on("data", (chunk) => (stderr += chunk)) + child.on("error", reject) + child.on("close", (code) => { + if (code === 0) { + resolve(stdout) + return + } + + reject(new Error(`Git command failed (${args.join(" ")}): ${stderr.trim() || `exit code ${code}`}`)) + }) + }) + } + + /** Builds full diff text for tracked, untracked, and binary changes. */ + private async getDiffForChanges(changes: GitChange[], options: GitContextCollectorOptions): Promise { + options.onProgress?.(0) + if (changes.length === 0) { + options.onProgress?.(100) + return "" + } + + const binaryChanges = await this.findBinaryChanges(changes, options.staged) + options.onProgress?.(25) + const diffableChanges = changes.filter((change) => change.status !== "?" && !binaryChanges.has(change.filePath)) + const untrackedFiles = changes.filter((change) => change.status === "?") + const parts: string[] = [] + + if (diffableChanges.length > 0) { + const diffArgs = this.buildDiffArgs(options.staged, diffableChanges, [], options.diff) + const diff = await this.runGit(diffArgs) + if (diff.trim()) { + parts.push(diff) + } + } + options.onProgress?.(65) + + if (untrackedFiles.length > 0) { + parts.push(await this.getUntrackedFileDiffs(untrackedFiles)) + } + options.onProgress?.(85) + + if (binaryChanges.size > 0) { + parts.push( + changes + .filter((change) => binaryChanges.has(change.filePath)) + .map( + (change) => + `Binary file ${this.getReadableStatus(change.status).toLowerCase()}: ${this.getRelativePath(change.filePath)}`, + ) + .join("\n"), + ) + } + + options.onProgress?.(100) + return parts.join("\n") + } + + /** Builds diff-stat text for tracked changes and synthesized untracked files. */ + private async getDiffStats(changes: GitChange[], options: GitContextCollectorOptions): Promise { + const trackedChanges = changes.filter((change) => change.status !== "?") + const untrackedChanges = changes.filter((change) => change.status === "?") + const parts: string[] = [] + + if (trackedChanges.length > 0) { + const args = this.buildDiffArgs(options.staged, trackedChanges, ["--stat"]) + const stats = await this.runGit(args) + if (stats.trim()) { + parts.push(stats.trim()) + } + } + + for (const change of untrackedChanges) { + parts.push(await this.getUntrackedFileStat(change)) + } + + return parts.join("\n") + } + + /** Returns a Git-style stat summary for an untracked working-tree file. */ + private async getUntrackedFileStat(change: GitChange): Promise { + const relativePath = this.getRelativePath(change.filePath) + if (await this.isProbablyBinaryFile(change.filePath)) { + return `${relativePath} | Bin 0 -> ${await this.getFileSize(change.filePath)} bytes` + } + + const content = await fs.readFile(change.filePath, "utf8") + const normalizedContent = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + const lineCount = this.countTextLines(normalizedContent) + return `${relativePath} | ${lineCount} ${"+".repeat(Math.min(lineCount, 60))}` + } + + /** Returns the byte size for a file on disk. */ + private async getFileSize(filePath: string): Promise { + return (await fs.stat(filePath)).size + } + + /** Detects binary tracked changes with a single numstat invocation. */ + private async findBinaryChanges(changes: GitChange[], staged: boolean): Promise> { + const binaryFiles = new Set() + const trackedChanges = changes.filter((change) => change.status !== "?") + if (trackedChanges.length === 0) { + return binaryFiles + } + + const args = this.buildNumstatArgs(staged, trackedChanges) + const output = await this.runGit(args) + const binaryRelativePaths = output + .split("\n") + .map((line) => line.split("\t")) + .filter(([added, deleted, filePath]) => added === "-" && deleted === "-" && Boolean(filePath)) + .map(([, , ...filePathParts]) => filePathParts.join("\t")) + + for (const change of trackedChanges) { + const relativePath = this.getRelativePath(change.filePath) + if (binaryRelativePaths.includes(relativePath)) { + binaryFiles.add(change.filePath) + } + } + + return binaryFiles + } + + /** Builds path-limited numstat arguments for binary detection. */ + private buildNumstatArgs(staged: boolean, changes: GitChange[]): string[] { + const args = staged ? ["diff", "--cached", "--numstat"] : ["diff", "--numstat"] + return [...args, "--", ...changes.map((change) => this.getRelativePath(change.filePath))] + } + + /** Checks the first bytes of a file for NUL bytes. */ + private async isProbablyBinaryFile(filePath: string): Promise { + const fileHandle = await fs.open(filePath, "r") + try { + const buffer = Buffer.alloc(8000) + const { bytesRead } = await fileHandle.read(buffer, 0, buffer.length, 0) + return buffer.subarray(0, bytesRead).includes(0) + } finally { + await fileHandle.close() + } + } + + /** Builds synthesized diff text for untracked files. */ + private async getUntrackedFileDiffs(changes: GitChange[]): Promise { + const diffs: string[] = [] + + for (const change of changes) { + if (await this.isProbablyBinaryFile(change.filePath)) { + diffs.push(`Binary file added: ${this.getRelativePath(change.filePath)}`) + continue + } + + diffs.push(await this.createNewFileDiff(change.filePath)) + } + + return diffs.join("\n") + } + + /** Creates a unified new-file diff from working-tree file content. */ + private async createNewFileDiff(filePath: string): Promise { + const relativePath = this.getRelativePath(filePath) + const content = await fs.readFile(filePath, "utf8") + const normalizedContent = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + + if (normalizedContent.length === 0) { + return [ + `diff --git a/${relativePath} b/${relativePath}`, + "new file mode 100644", + "--- /dev/null", + `+++ b/${relativePath}`, + ].join("\n") + } + + const hasTrailingNewline = normalizedContent.endsWith("\n") + const lines = (hasTrailingNewline ? normalizedContent.slice(0, -1) : normalizedContent).split("\n") + const diffLines = [ + `diff --git a/${relativePath} b/${relativePath}`, + "new file mode 100644", + "--- /dev/null", + `+++ b/${relativePath}`, + `@@ -0,0 +1,${lines.length} @@`, + ...lines.map((line) => `+${line}`), + ] + + if (!hasTrailingNewline) { + diffLines.push("\\ No newline at end of file") + } + + return diffLines.join("\n") + } + + /** Returns raw Git status output for staged or unstaged collection. */ + private async getStatus(options: GitContextOptions): Promise { + return options.staged + ? this.runGit(["diff", "--name-status", "--cached", "-z"]) + : this.runGit(["status", "--porcelain=v1", "-z", "--untracked-files=all"]) + } + + /** Returns the currently checked-out branch name. */ + private async getCurrentBranch(): Promise { + return this.runGit(["branch", "--show-current"]) + } + + /** Returns recent commit summaries and optional stats or patch context. */ + private async getRecentCommits(options: GitRecentCommitContextOptions): Promise { + const count = this.clampNumber(options.count, 1, 20, DEFAULT_RECENT_COMMIT_COUNT) + const args = options.includeBodies + ? ["log", `-${count}`, "--format=commit %h%nSubject: %s%nBody:%n%b"] + : ["log", "--oneline", `-${count}`] + + if (options.includeStats) { + args.push("--stat") + } + + const parts = [await this.runGit(args)] + + if (options.includeDiffs) { + const diffCount = this.clampNumber(options.diffCount, 1, 5, DEFAULT_RECENT_COMMIT_DIFF_COUNT) + const hashes = (await this.runGit(["log", `-${diffCount}`, "--format=%H"])) + .split("\n") + .map((hash) => hash.trim()) + .filter(Boolean) + + for (const hash of hashes) { + parts.push(await this.runGit(["show", "--format=commit %h%nSubject: %s%n%b", "--patch", hash])) + } + } + + return parts.join("\n") + } + + /** Formats collected changes as Markdown context for prompt input. */ + public async collectContext( + changes: GitChange[], + options: GitContextCollectorOptions, + specificFiles?: string[], + ): Promise { + const { includeBranch = false, recentCommits } = options + let context = "## Git Context\n\n" + const warnings: string[] = [] + + const targetChanges = this.filterChanges(changes, specificFiles) + const fileInfo = specificFiles ? ` (${specificFiles.length} selected files)` : "" + const allStaged = targetChanges.every((change) => change.staged) + const allUnstaged = targetChanges.every((change) => !change.staged) + const changeDescriptor = allStaged ? "Staged" : allUnstaged ? "Unstaged" : "Selected" + + if (options.diff?.includeStats) { + const stats = await this.getDiffStats(targetChanges, options) + if (stats.trim()) { + context += `### Diff Stats${fileInfo}\n\`\`\`\n${stats.trim()}\n\`\`\`\n\n` + } + } + + const diff = await this.getDiffForChanges(targetChanges, options) + context += `### Full Diff of ${changeDescriptor} Changes${fileInfo}\n\`\`\`diff\n${diff}\n\`\`\`\n\n` + + if (targetChanges.length > 0) { + const summaryLines = targetChanges.map((change) => { + const relativePath = this.getRelativePath(change.filePath) + const scope = change.staged ? "staged" : "unstaged" + const status = this.getReadableStatus(change.status) + + if (change.oldFilePath) { + const oldRelativePath = this.getRelativePath(change.oldFilePath) + return `${status} (${scope}): ${oldRelativePath} -> ${relativePath}` + } + + return `${status} (${scope}): ${relativePath}` + }) + + context += "### Change Summary\n```\n" + summaryLines.join("\n") + "\n```\n\n" + } else { + context += "### Change Summary\n```\n(No changes matched selection)\n```\n\n" + } + + if (includeBranch || recentCommits?.include) { + context += "### Repository Context\n\n" + } + + if (includeBranch) { + try { + const currentBranch = await this.getCurrentBranch() + if (currentBranch) { + context += "**Current branch:** `" + currentBranch.trim() + "`\n\n" + } + } catch (error) { + warnings.push(`Current branch unavailable: ${this.getErrorMessage(error)}`) + } + } + + if (recentCommits?.include) { + try { + const recentCommitContext = await this.getRecentCommits(recentCommits) + if (recentCommitContext) { + context += "**Recent commits:**\n```\n" + recentCommitContext + "\n```\n" + } + } catch (error) { + warnings.push(`Recent commits unavailable: ${this.getErrorMessage(error)}`) + } + } + + if (warnings.length > 0) { + context += "\n### Git Context Warnings\n```\n" + warnings.join("\n") + "\n```\n" + } + + return { context, warnings } + } + + /** Formats collected changes and returns only the Markdown context body. */ + public async getContext( + changes: GitChange[], + options: GitContextCollectorOptions, + specificFiles?: string[], + ): Promise { + return (await this.collectContext(changes, options, specificFiles)).context + } + + /** Normalizes unknown thrown values into displayable error messages. */ + private getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) + } + + /** Parses NUL-delimited git diff --name-status output. */ + private parseNameStatus(output: string, staged: boolean): GitChange[] { + const fields = this.splitNullDelimited(output) + const changes: GitChange[] = [] + + for (let index = 0; index < fields.length; index++) { + const statusCode = fields[index] + const status = this.getChangeStatusFromCode(statusCode) + + if (status === "R" || status === "C") { + if (index + 2 >= fields.length) { + break + } + + const oldFilePath = fields[++index] + const filePath = fields[++index] + if (oldFilePath && filePath) { + changes.push({ + filePath: path.join(this.workspaceRoot, filePath), + oldFilePath: path.join(this.workspaceRoot, oldFilePath), + status, + staged, + }) + } + continue + } + + if (index + 1 >= fields.length) { + break + } + + const filePath = fields[++index] + if (filePath) { + changes.push({ + filePath: path.join(this.workspaceRoot, filePath), + status, + staged, + }) + } + } + + return changes + } + + /** Parses NUL-delimited git status --porcelain=v1 output for unstaged changes. */ + private parsePorcelainStatus(output: string): GitChange[] { + const fields = this.splitNullDelimited(output) + const changes: GitChange[] = [] + + for (let index = 0; index < fields.length; index++) { + const entry = fields[index] + if (entry.length < 4) { + continue + } + + const indexStatus = entry.charAt(0) + const workingStatus = entry.charAt(1) + const isUntracked = indexStatus === "?" && workingStatus === "?" + const worktreeStatus = workingStatus.trim() + if (!isUntracked && !worktreeStatus) { + continue + } + + const statusCode = isUntracked ? "?" : worktreeStatus + const status = this.getChangeStatusFromCode(statusCode) + const filePath = entry.substring(3) + + if (status === "R" || status === "C") { + const oldFilePath = index + 1 < fields.length ? fields[++index] : undefined + changes.push({ + filePath: path.join(this.workspaceRoot, filePath), + oldFilePath: oldFilePath ? path.join(this.workspaceRoot, oldFilePath) : undefined, + status, + staged: false, + }) + continue + } + + changes.push({ + filePath: path.join(this.workspaceRoot, filePath), + status, + staged: false, + }) + } + + return changes + } + + /** Splits NUL-delimited Git output and drops the trailing empty field. */ + private splitNullDelimited(output: string): string[] { + return output.split("\0").filter(Boolean) + } + + /** Applies exact path or basename-only file selection to collected changes. */ + private filterChanges(changes: GitChange[], specificFiles?: string[]): GitChange[] { + if (!specificFiles || specificFiles.length === 0) { + return changes + } + + return changes.filter((change) => { + const absolutePath = this.normalizePath(change.filePath) + const relativePath = this.getRelativePath(change.filePath) + return specificFiles.some((file) => { + const normalizedFile = path.normalize(file).replace(/\\/g, "/") + const absoluteFile = this.normalizePath( + path.isAbsolute(file) ? file : path.join(this.workspaceRoot, file), + ) + const isBasenameOnly = !normalizedFile.includes("/") + + return ( + absoluteFile === absolutePath || + relativePath === normalizedFile || + // Basename-only matching is intentional for SCM selections that pass only file names. + (isBasenameOnly && path.basename(relativePath) === normalizedFile) + ) + }) + }) + } + + /** Builds path-limited diff arguments for the requested change set. */ + private buildDiffArgs( + staged: boolean, + changes: GitChange[], + extraArgs: string[] = [], + diffOptions?: GitDiffContextOptions, + ): string[] { + const args = staged ? ["diff", "--cached"] : ["diff"] + const contextLines = + !extraArgs.includes("--stat") && diffOptions?.contextLines !== undefined + ? [`--unified=${this.clampNumber(diffOptions.contextLines, 0, 20, 3)}`] + : [] + const paths = Array.from( + new Set( + changes.flatMap((change) => + [change.filePath, change.oldFilePath] + .filter((filePath): filePath is string => Boolean(filePath)) + .map((filePath) => this.getRelativePath(filePath)), + ), + ), + ) + + return paths.length > 0 ? [...args, ...extraArgs, ...contextLines, "--", ...paths] : [...args, ...extraArgs] + } + + /** Clamps a numeric option to an integer range with fallback handling. */ + private clampNumber(value: number | undefined, min: number, max: number, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return fallback + } + + return Math.min(Math.max(Math.trunc(value), min), max) + } + + /** Converts an absolute file path to a slash-normalized repository-relative path. */ + private getRelativePath(filePath: string): string { + return path.relative(this.workspaceRoot, filePath).replace(/\\/g, "/") + } + + /** Converts a Git status code into the collector's status enum. */ + private getChangeStatusFromCode(code: string): GitStatus { + const status = code.charAt(0) + switch (status) { + case "M": + case "A": + case "D": + case "R": + case "C": + case "U": + case "?": + return status as GitStatus + default: + return "Unknown" + } + } + + /** Converts a status enum to a human-readable label. */ + private getReadableStatus(status: GitStatus): string { + switch (status) { + case "M": + return "Modified" + case "A": + return "Added" + case "D": + return "Deleted" + case "R": + return "Renamed" + case "C": + return "Copied" + case "U": + return "Updated" + case "?": + return "Untracked" + case "Unknown": + default: + return "Unknown" + } + } + + /** Keeps collector cleanup compatible with provider lifecycle hooks. */ + public dispose(): void {} + + /** Counts text lines while preserving blank lines and ignoring a final newline terminator. */ + private countTextLines(content: string): number { + if (content.length === 0) { + return 0 + } + + return (content.endsWith("\n") ? content.slice(0, -1) : content).split("\n").length + } + + /** Normalizes absolute paths for platform-independent comparisons. */ + private normalizePath(filePath: string): string { + return path.normalize(filePath).replace(/\\/g, "/") + } +} diff --git a/src/services/git-context/__tests__/GitContextCollector.spec.ts b/src/services/git-context/__tests__/GitContextCollector.spec.ts new file mode 100644 index 0000000000..c36cdc5770 --- /dev/null +++ b/src/services/git-context/__tests__/GitContextCollector.spec.ts @@ -0,0 +1,326 @@ +import * as os from "os" +import * as path from "path" +import { EventEmitter } from "events" +import { promises as fs } from "fs" +import { spawn } from "child_process" + +import { GitContextCollector } from "../GitContextCollector" + +vi.mock("child_process", () => ({ + spawn: vi.fn(), +})) + +const mockSpawn = vi.mocked(spawn) +const workspaceRoot = path.resolve("/repo") +const requiredContextOnly = { includeBranch: false, recentCommits: { include: false } } + +/** Queues a mocked Git subprocess response for the next spawn call. */ +function mockGitCommand(stdout: string, stderr = "", code = 0) { + mockSpawn.mockImplementationOnce((() => { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter & { setEncoding: ReturnType } + stderr: EventEmitter & { setEncoding: ReturnType } + } + + child.stdout = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }) + child.stderr = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }) + + queueMicrotask(() => { + if (stdout) { + child.stdout.emit("data", stdout) + } + + if (stderr) { + child.stderr.emit("data", stderr) + } + + child.emit("close", code) + }) + + return child + }) as unknown as typeof spawn) +} + +describe("GitContextCollector", () => { + beforeEach(() => { + mockSpawn.mockReset() + }) + + it("parses staged name-status output including renames and copies", async () => { + mockGitCommand( + ["M", "src/file.ts", "R100", "src/old.ts", "src/new.ts", "C075", "src/a.ts", "src/b.ts", ""].join("\0"), + ) + + const collector = new GitContextCollector(workspaceRoot) + const changes = await collector.gatherChanges({ staged: true }) + + expect(mockSpawn).toHaveBeenCalledWith( + "git", + ["diff", "--name-status", "--cached", "-z"], + expect.objectContaining({ cwd: workspaceRoot }), + ) + expect(changes).toEqual([ + { filePath: path.join(workspaceRoot, "src/file.ts"), status: "M", staged: true }, + { + filePath: path.join(workspaceRoot, "src/new.ts"), + oldFilePath: path.join(workspaceRoot, "src/old.ts"), + status: "R", + staged: true, + }, + { + filePath: path.join(workspaceRoot, "src/b.ts"), + oldFilePath: path.join(workspaceRoot, "src/a.ts"), + status: "C", + staged: true, + }, + ]) + }) + + it("parses staged paths with spaces, special characters, and deleted status", async () => { + mockGitCommand(["M", "src/file with spaces.ts", "D", "src/old'file.ts", ""].join("\0")) + + const collector = new GitContextCollector(workspaceRoot) + const changes = await collector.gatherChanges({ staged: true }) + + expect(mockSpawn).toHaveBeenCalledWith( + "git", + ["diff", "--name-status", "--cached", "-z"], + expect.objectContaining({ cwd: workspaceRoot }), + ) + expect(changes).toEqual([ + { filePath: path.join(workspaceRoot, "src/file with spaces.ts"), status: "M", staged: true }, + { filePath: path.join(workspaceRoot, "src/old'file.ts"), status: "D", staged: true }, + ]) + }) + + it("requests all untracked files instead of collapsed untracked directories", async () => { + mockGitCommand(["?? src/new.ts", ""].join("\0")) + + const collector = new GitContextCollector(workspaceRoot) + const changes = await collector.gatherChanges({ staged: false }) + + expect(mockSpawn).toHaveBeenCalledWith( + "git", + ["status", "--porcelain=v1", "-z", "--untracked-files=all"], + expect.objectContaining({ cwd: workspaceRoot }), + ) + expect(changes).toEqual([{ filePath: path.join(workspaceRoot, "src/new.ts"), status: "?", staged: false }]) + }) + + it("ignores staged-only entries when gathering unstaged changes", async () => { + mockGitCommand(["M src/staged.ts", ""].join("\0")) + + const collector = new GitContextCollector(workspaceRoot) + const changes = await collector.gatherChanges({ staged: false }) + + expect(mockSpawn).toHaveBeenCalledWith( + "git", + ["status", "--porcelain=v1", "-z", "--untracked-files=all"], + expect.objectContaining({ cwd: workspaceRoot }), + ) + expect(changes).toEqual([]) + }) + + it("skips malformed staged name-status entries without reading past the output", async () => { + mockGitCommand(["R100", "src/old.ts", ""].join("\0")) + + const collector = new GitContextCollector(workspaceRoot) + const changes = await collector.gatherChanges({ staged: true }) + + expect(changes).toEqual([]) + }) + + it("keeps lockfiles in git context because git state is authoritative", async () => { + mockGitCommand("1\t1\tpackage-lock.json\n") + mockGitCommand("diff --git a/package-lock.json b/package-lock.json\n") + + const collector = new GitContextCollector(workspaceRoot) + const context = await collector.getContext( + [{ filePath: path.join(workspaceRoot, "package-lock.json"), status: "M", staged: true }], + { staged: true, ...requiredContextOnly }, + ) + + expect(context).toContain("diff --git a/package-lock.json b/package-lock.json") + expect(context).toContain("Modified (staged): package-lock.json") + }) + + it("can gather changes and collect context in one reusable call", async () => { + mockGitCommand(["M", "src/file.ts", ""].join("\0")) + mockGitCommand("1\t1\tsrc/file.ts\n") + mockGitCommand("diff --git a/src/file.ts b/src/file.ts\n") + + const collector = new GitContextCollector(workspaceRoot) + const result = await collector.collect({ staged: true, ...requiredContextOnly }) + + expect(result.changes).toEqual([ + { filePath: path.join(workspaceRoot, "src/file.ts"), status: "M", staged: true }, + ]) + expect(result.context).toContain("diff --git a/src/file.ts b/src/file.ts") + expect(result.warnings).toEqual([]) + }) + + it("uses requested diff context lines and includes diff stats", async () => { + mockGitCommand("src/file.ts | 3 ++-\n") + mockGitCommand("2\t1\tsrc/file.ts\n") + mockGitCommand("diff --git a/src/file.ts b/src/file.ts\n") + + const collector = new GitContextCollector(workspaceRoot) + const context = await collector.getContext( + [{ filePath: path.join(workspaceRoot, "src/file.ts"), status: "M", staged: true }], + { staged: true, ...requiredContextOnly, diff: { contextLines: 0, includeStats: true } }, + ) + + expect(mockSpawn).toHaveBeenNthCalledWith( + 3, + "git", + ["diff", "--cached", "--unified=0", "--", "src/file.ts"], + expect.objectContaining({ cwd: workspaceRoot }), + ) + expect(context).toContain("### Diff Stats") + expect(context).toContain("src/file.ts | 3 ++-") + }) + + it("batches binary detection for tracked changes", async () => { + mockGitCommand("-\t-\tsrc/image.png\n1\t1\tsrc/file.ts\n") + mockGitCommand("diff --git a/src/file.ts b/src/file.ts\n") + + const collector = new GitContextCollector(workspaceRoot) + const context = await collector.getContext( + [ + { filePath: path.join(workspaceRoot, "src/image.png"), status: "M", staged: true }, + { filePath: path.join(workspaceRoot, "src/file.ts"), status: "M", staged: true }, + ], + { staged: true, ...requiredContextOnly }, + ) + + expect(mockSpawn).toHaveBeenNthCalledWith( + 1, + "git", + ["diff", "--cached", "--numstat", "--", "src/image.png", "src/file.ts"], + expect.objectContaining({ cwd: workspaceRoot }), + ) + expect(mockSpawn).toHaveBeenCalledTimes(2) + expect(context).toContain("Binary file modified: src/image.png") + expect(context).toContain("diff --git a/src/file.ts b/src/file.ts") + }) + + it("matches selected files by exact path or basename without suffix matching", async () => { + mockGitCommand("1\t1\tsrc/test.ts\n") + mockGitCommand("diff --git a/src/test.ts b/src/test.ts\n") + + const collector = new GitContextCollector(workspaceRoot) + const context = await collector.getContext( + [ + { filePath: path.join(workspaceRoot, "src/mytest.ts"), status: "M", staged: true }, + { filePath: path.join(workspaceRoot, "src/test.ts"), status: "M", staged: true }, + ], + { staged: true, ...requiredContextOnly }, + ["test.ts"], + ) + + expect(mockSpawn).toHaveBeenNthCalledWith( + 2, + "git", + ["diff", "--cached", "--", "src/test.ts"], + expect.objectContaining({ cwd: workspaceRoot }), + ) + expect(context).toContain("Modified (staged): src/test.ts") + expect(context).not.toContain("src/mytest.ts") + }) + + it("includes full new-file diffs for untracked text files", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-git-context-")) + try { + const filePath = path.join(tempRoot, "src", "new.ts") + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, "export const value = 1\n") + + const collector = new GitContextCollector(tempRoot) + const context = await collector.getContext([{ filePath, status: "?", staged: false }], { + staged: false, + ...requiredContextOnly, + }) + + expect(context).toContain("diff --git a/src/new.ts b/src/new.ts") + expect(context).toContain("--- /dev/null") + expect(context).toContain("+export const value = 1") + expect(context).toContain("Untracked (unstaged): src/new.ts") + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }) + } + }) + + it("counts blank lines in untracked text file stats", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-git-context-")) + try { + const filePath = path.join(tempRoot, "src", "new.ts") + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, "first\n\nthird\n") + + const collector = new GitContextCollector(tempRoot) + const context = await collector.getContext([{ filePath, status: "?", staged: false }], { + staged: false, + ...requiredContextOnly, + diff: { includeStats: true }, + }) + + expect(context).toContain("src/new.ts | 3 +++") + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }) + } + }) + + it("summarizes untracked binary files without binary payload", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-git-context-")) + try { + const filePath = path.join(tempRoot, "image.bin") + await fs.writeFile(filePath, Buffer.from([0, 1, 2, 3])) + + const collector = new GitContextCollector(tempRoot) + const context = await collector.getContext([{ filePath, status: "?", staged: false }], { + staged: false, + ...requiredContextOnly, + }) + + expect(context).toContain("Binary file added: image.bin") + expect(context).not.toContain("@@ -0,0") + expect(context).toContain("Untracked (unstaged): image.bin") + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }) + } + }) + + it("fails required diff collection instead of emitting partial context", async () => { + mockGitCommand("1\t1\tsrc/file.ts\n") + mockGitCommand("", "fatal: bad revision", 128) + + const collector = new GitContextCollector(workspaceRoot) + + await expect( + collector.getContext([{ filePath: path.join(workspaceRoot, "src/file.ts"), status: "M", staged: true }], { + staged: true, + ...requiredContextOnly, + }), + ).rejects.toThrow("fatal: bad revision") + }) + + it("returns warnings when supplemental repository context is unavailable", async () => { + mockGitCommand("1\t1\tsrc/file.ts\n") + mockGitCommand("diff --git a/src/file.ts b/src/file.ts\n") + mockGitCommand("", "fatal: branch unavailable", 128) + mockGitCommand("", "fatal: log unavailable", 128) + + const collector = new GitContextCollector(workspaceRoot) + const result = await collector.collectContext( + [{ filePath: path.join(workspaceRoot, "src/file.ts"), status: "M", staged: true }], + { staged: true, includeBranch: true, recentCommits: { include: true } }, + ) + + expect(result.warnings).toEqual([ + expect.stringContaining("Current branch unavailable"), + expect.stringContaining("Recent commits unavailable"), + ]) + expect(result.context).toContain("### Git Context Warnings") + expect(result.context).toContain("diff --git a/src/file.ts b/src/file.ts") + }) +}) diff --git a/src/services/git-context/index.ts b/src/services/git-context/index.ts new file mode 100644 index 0000000000..525a1c0bcf --- /dev/null +++ b/src/services/git-context/index.ts @@ -0,0 +1,11 @@ +export { GitContextCollector } from "./GitContextCollector" +export type { + GitChange, + GitContextCollection, + GitContextCollectorOptions, + GitDiffContextOptions, + GitContextOptions, + GitRecentCommitContextOptions, + GitContextResult, + GitStatus, +} from "./types" diff --git a/src/services/git-context/types.ts b/src/services/git-context/types.ts new file mode 100644 index 0000000000..b9a1e4786c --- /dev/null +++ b/src/services/git-context/types.ts @@ -0,0 +1,70 @@ +/** Git status code emitted by porcelain and name-status commands. */ +export type GitStatus = "M" | "A" | "D" | "R" | "C" | "U" | "?" | "Unknown" + +/** One repository file change selected for commit-message context. */ +export interface GitChange { + /** Absolute path to the changed file. */ + filePath: string + /** Absolute previous path for rename or copy changes. */ + oldFilePath?: string + /** Parsed Git status for the changed file. */ + status: GitStatus + /** Whether this change came from the index instead of the working tree. */ + staged: boolean +} + +/** Formatted Git context and non-fatal supplemental context warnings. */ +export interface GitContextResult { + /** Markdown context suitable for commit-message prompt input. */ + context: string + /** Warnings from optional branch or recent-commit collection. */ + warnings: string[] +} + +/** Combined change discovery and formatted context result. */ +export interface GitContextCollection extends GitContextResult { + /** File changes used to build the formatted context. */ + changes: GitChange[] +} + +/** Base Git collection mode options. */ +export interface GitContextOptions { + /** Collect staged changes when true, otherwise collect unstaged changes. */ + staged: boolean +} + +/** Diff formatting options for collected changes. */ +export interface GitDiffContextOptions { + /** Number of unchanged context lines around each hunk. */ + contextLines?: number + /** Include diff-stat output before the full diff. */ + includeStats?: boolean +} + +/** Recent-commit context options appended to formatted Git context. */ +export interface GitRecentCommitContextOptions { + /** Include recent commit context when true. */ + include?: boolean + /** Number of recent commits to include. */ + count?: number + /** Include commit body text when true. */ + includeBodies?: boolean + /** Include recent commit stats when true. */ + includeStats?: boolean + /** Include recent commit patches when true. */ + includeDiffs?: boolean + /** Number of recent commit patches to include. */ + diffCount?: number +} + +/** Full collector options for change discovery and context formatting. */ +export interface GitContextCollectorOptions extends GitContextOptions { + /** Receives coarse progress percentages during diff collection. */ + onProgress?: (percentage: number) => void + /** Controls full-diff and stat formatting. */ + diff?: GitDiffContextOptions + /** Include the current branch name when true. */ + includeBranch?: boolean + /** Controls recent commit context inclusion. */ + recentCommits?: GitRecentCommitContextOptions +} diff --git a/src/shared/support-prompt.ts b/src/shared/support-prompt.ts index da14c4367f..3f3c27257d 100644 --- a/src/shared/support-prompt.ts +++ b/src/shared/support-prompt.ts @@ -44,6 +44,7 @@ type SupportPromptType = | "TERMINAL_FIX" | "TERMINAL_EXPLAIN" | "NEW_TASK" + | "COMMIT_MESSAGE" const supportPromptConfigs: Record = { ENHANCE: { @@ -240,6 +241,79 @@ Please provide: NEW_TASK: { template: `\${userInput}`, }, + COMMIT_MESSAGE: { + template: `# Conventional Commit Message Generator +## System Instructions +You are an expert Git commit message generator that creates conventional commit messages based on provided Git changes. Analyze the provided git diff output and generate appropriate conventional commit messages following the specification. + +\${customInstructions} + +## CRITICAL: Commit Message Output Rules +- DO NOT include any internal status indicators or bracketed metadata (e.g. "[Status: Active]", "[Context: Missing]") +- DO NOT include any task-specific formatting or artifacts from other rules +- ONLY Generate a clean conventional commit message as specified below + +\${gitContext} + +## Conventional Commits Format +Generate commit messages following this exact structure: +\`\`\` +[optional scope]: +[optional body] +[optional footer(s)] +\`\`\` + +### Core Types (Required) +- **feat**: New feature or functionality (MINOR version bump) +- **fix**: Bug fix or error correction (PATCH version bump) + +### Additional Types (Extended) +- **docs**: Documentation changes only +- **style**: Code style changes (whitespace, formatting, semicolons, etc.) +- **refactor**: Code refactoring without feature changes or bug fixes +- **perf**: Performance improvements +- **test**: Adding or fixing tests +- **build**: Build system or external dependency changes +- **ci**: CI/CD configuration changes +- **chore**: Maintenance tasks, tooling changes +- **revert**: Reverting previous commits + +### Scope Guidelines +- Use parentheses: \`feat(api):\`, \`fix(ui):\` +- Common scopes: \`api\`, \`ui\`, \`auth\`, \`db\`, \`config\`, \`deps\`, \`docs\` +- For monorepos: package or module names +- Keep scope concise and lowercase + +### Description Rules +- Use imperative mood ("add" not "added" or "adds") +- Start with lowercase letter +- No period at the end +- Maximum 50 characters +- Be concise but descriptive + +### Body Guidelines (Optional) +- Start one blank line after description +- Explain the "what" and "why", not the "how" +- Wrap at 72 characters per line +- Use for complex changes requiring explanation + +### Footer Guidelines (Optional) +- Start one blank line after body +- **Breaking Changes**: \`BREAKING CHANGE: description\` + +## Analysis Instructions +When analyzing provided Git changes: +1. Determine Primary Type based on the nature of changes +2. Identify Scope from modified directories or modules +3. Craft Description focusing on the most significant change +4. Determine if there are Breaking Changes +5. For complex changes, include a detailed body explaining what and why +6. Add appropriate footers for issue references or breaking changes + +For significant changes, include a detailed body explaining the changes. + +Return ONLY the commit message in the conventional format, nothing else.`, + }, } as const export const supportPrompt = { diff --git a/webview-ui/src/components/settings/CommitMessagePromptSettings.tsx b/webview-ui/src/components/settings/CommitMessagePromptSettings.tsx new file mode 100644 index 0000000000..71beb70d45 --- /dev/null +++ b/webview-ui/src/components/settings/CommitMessagePromptSettings.tsx @@ -0,0 +1,555 @@ +import React from "react" +import { VSCodeCheckbox, VSCodeTextArea, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { + MAX_COMMIT_MESSAGE_PROFILES, + createCommitMessageProfileId, + createCommitMessageProfileName, + defaultCommitMessageAttributionSettings, + defaultCommitMessageGitContextSettings, + normalizeCommitMessageProfiles, + type CommitMessageAttributionSettings, + type CommitMessageGitContextSettings, + type CommitMessageProfileSettings, + type CommitMessageProfilesSettings, +} from "@roo-code/types" +import { supportPrompt } from "@roo/support-prompt" +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { + Button, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + StandardTooltip, +} from "@src/components/ui" + +interface CommitMessagePromptSettingsProps { + listApiConfigMeta: Array<{ id: string; name: string }> + customSupportPrompts: Record + setCustomSupportPrompts: (prompts: Record) => void + commitMessageApiConfigId?: string + setCommitMessageApiConfigId: (value: string) => void + commitMessageGitContext?: CommitMessageGitContextSettings + setCommitMessageGitContext: (value: CommitMessageGitContextSettings) => void + commitMessageAttribution?: CommitMessageAttributionSettings + setCommitMessageAttribution: (value: CommitMessageAttributionSettings) => void + commitMessageProfiles?: CommitMessageProfilesSettings + setCommitMessageProfiles: (value: CommitMessageProfilesSettings) => void +} + +const CommitMessagePromptSettings = ({ + listApiConfigMeta, + customSupportPrompts, + setCustomSupportPrompts, + commitMessageApiConfigId, + setCommitMessageApiConfigId, + commitMessageGitContext, + setCommitMessageGitContext, + commitMessageAttribution, + setCommitMessageAttribution, + commitMessageProfiles, + setCommitMessageProfiles, +}: CommitMessagePromptSettingsProps) => { + const { t } = useAppTranslation() + const hasStoredProfiles = Boolean(commitMessageProfiles?.profiles?.length) + const normalizedProfiles = normalizeCommitMessageProfiles(commitMessageProfiles, { + prompt: customSupportPrompts.COMMIT_MESSAGE, + apiConfigId: commitMessageApiConfigId, + gitContext: commitMessageGitContext, + attribution: commitMessageAttribution, + }) + const activeProfile = + normalizedProfiles.profiles.find((profile) => profile.id === normalizedProfiles.activeProfileId) ?? + normalizedProfiles.profiles[0] + const profilePrompt = activeProfile.prompt ?? supportPrompt.get({}, "COMMIT_MESSAGE") + const canAddProfile = normalizedProfiles.profiles.length < MAX_COMMIT_MESSAGE_PROFILES + const canDeleteProfile = normalizedProfiles.profiles.length > 1 + const attributionSettings = activeProfile.attribution + const suppressCheckboxChangesRef = React.useRef(false) + + const getRawProfiles = (): CommitMessageProfileSettings[] => { + if (hasStoredProfiles) { + return (commitMessageProfiles?.profiles ?? []).slice(0, MAX_COMMIT_MESSAGE_PROFILES) + } + + return [ + { + id: activeProfile.id, + name: activeProfile.name, + prompt: activeProfile.prompt, + apiConfigId: activeProfile.apiConfigId, + gitContext: commitMessageGitContext, + attribution: commitMessageAttribution, + }, + ] + } + + const isActiveRawProfile = (_profile: CommitMessageProfileSettings, index: number) => + normalizedProfiles.profiles[index]?.id === activeProfile.id + const getActiveRawProfile = () => getRawProfiles().find(isActiveRawProfile) + + const suppressProfileTransitionCheckboxChanges = () => { + suppressCheckboxChangesRef.current = true + window.setTimeout(() => { + suppressCheckboxChangesRef.current = false + }, 0) + } + + const persistProfiles = ( + profiles: CommitMessageProfileSettings[], + activeProfileId: string, + suppressCheckboxChanges = false, + ) => { + if (suppressCheckboxChanges) { + suppressProfileTransitionCheckboxChanges() + } + + setCommitMessageProfiles({ + activeProfileId, + profiles: profiles.slice(0, MAX_COMMIT_MESSAGE_PROFILES), + }) + } + + const updateActiveProfile = (updates: Partial) => { + if (!hasStoredProfiles) { + if ("prompt" in updates) { + const nextPrompts = { ...customSupportPrompts } + if (updates.prompt === undefined) { + delete nextPrompts.COMMIT_MESSAGE + } else { + nextPrompts.COMMIT_MESSAGE = updates.prompt + } + setCustomSupportPrompts(nextPrompts) + } + + if ("apiConfigId" in updates) { + setCommitMessageApiConfigId(updates.apiConfigId ?? "") + } + + if (updates.gitContext) { + setCommitMessageGitContext(updates.gitContext) + } + + if ("name" in updates) { + persistProfiles([{ ...getRawProfiles()[0], ...updates }], activeProfile.id, true) + } + + return + } + + const profiles = getRawProfiles().map((profile, index) => + isActiveRawProfile(profile, index) ? { ...profile, ...updates } : profile, + ) + + persistProfiles(profiles, activeProfile.id) + } + + const updateAttributionSetting = (updates: Partial) => { + const currentAttribution = hasStoredProfiles + ? (getActiveRawProfile()?.attribution ?? {}) + : (commitMessageAttribution ?? {}) + const nextAttribution = { ...currentAttribution, ...updates } + + if (!hasStoredProfiles) { + setCommitMessageAttribution(nextAttribution) + return + } + + const profiles = getRawProfiles().map((profile, index) => + isActiveRawProfile(profile, index) ? { ...profile, attribution: nextAttribution } : profile, + ) + + persistProfiles(profiles, activeProfile.id) + } + + const updateGitContextSetting = ( + key: K, + value: CommitMessageGitContextSettings[K], + ) => { + const currentGitContext = hasStoredProfiles + ? (getActiveRawProfile()?.gitContext ?? {}) + : (commitMessageGitContext ?? {}) + updateActiveProfile({ gitContext: { ...currentGitContext, [key]: value } }) + } + + const updateNumberSetting = ( + key: keyof CommitMessageGitContextSettings, + value: string, + min: number, + max: number, + ) => { + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + return + } + + updateGitContextSetting(key, Math.min(Math.max(Math.trunc(parsed), min), max) as never) + } + + const handleActiveProfileChange = (profileId: string) => { + const nextActiveProfile = normalizedProfiles.profiles.find((profile) => profile.id === profileId) + if (!nextActiveProfile) { + return + } + + persistProfiles(getRawProfiles(), nextActiveProfile.id, true) + } + + const handleAddProfile = () => { + if (!canAddProfile) { + return + } + + const newProfile: CommitMessageProfileSettings = { + id: createCommitMessageProfileId(), + name: createCommitMessageProfileName(normalizedProfiles.profiles), + gitContext: defaultCommitMessageGitContextSettings, + } + persistProfiles([...getRawProfiles(), newProfile], newProfile.id!, true) + } + + const handleDeleteProfile = () => { + if (!canDeleteProfile) { + return + } + + const profiles = getRawProfiles().filter((profile, index) => !isActiveRawProfile(profile, index)) + const nextActiveProfile = normalizeCommitMessageProfiles({ profiles }).profiles[0] + persistProfiles(profiles, nextActiveProfile.id, true) + } + + const getTextAreaValue = (event: Event | React.FormEvent) => { + return ( + (event as unknown as CustomEvent)?.detail?.target?.value ?? + ((event as any).target as HTMLTextAreaElement).value + ) + } + + return ( +
+ {/* Profile selection controls. Keep this first so users understand which profile they are editing. */} +
+
+ + +
+ {t("prompts:supportPrompts.commitMessage.profiles.description")} +
+
+ +
+ + updateActiveProfile({ name: ((event as any).target as HTMLInputElement).value }) + }> + {t("prompts:supportPrompts.commitMessage.profiles.name")} + +
+ {t("prompts:supportPrompts.commitMessage.profiles.nameDescription")} +
+
+ +
+ + + + {t("prompts:supportPrompts.commitMessage.profiles.limit", { + count: MAX_COMMIT_MESSAGE_PROFILES, + })} + +
+
+ + {/* Prompt editor for the selected commit-message profile. */} +
+
+
+ +
+ {t("prompts:supportPrompts.commitMessage.promptDescription")} +
+
+ + + +
+ updateActiveProfile({ prompt: getTextAreaValue(event) })} + rows={6} + className="w-full" + data-testid="commit-message-prompt-textarea" + /> +
+ + {/* API configuration for the selected commit-message profile. */} +
+ + +
+ {t("prompts:supportPrompts.commitMessage.apiConfigDescription")} +
+
+ + {/* Optional Git context controls. Required diff/change summary behavior is not user-toggleable. */} +
+
+
{t("prompts:supportPrompts.commitMessage.gitContext.title")}
+
+ {t("prompts:supportPrompts.commitMessage.gitContext.description")} +
+
+ +
+ + updateNumberSetting( + "diffContextLines", + ((event as any).target as HTMLInputElement).value, + 0, + 20, + ) + }> + + {t("prompts:supportPrompts.commitMessage.gitContext.contextLines")} + + +
+ {t("prompts:supportPrompts.commitMessage.gitContext.contextLinesDescription")} +
+
+ + updateGitContextSetting("includeDiffStats", checked)} + shouldIgnoreChange={() => suppressCheckboxChangesRef.current} + /> + + updateGitContextSetting("includeCurrentBranch", checked)} + shouldIgnoreChange={() => suppressCheckboxChangesRef.current} + /> + +
+ updateGitContextSetting("includeRecentCommits", checked)} + shouldIgnoreChange={() => suppressCheckboxChangesRef.current} + /> + + {activeProfile.gitContext.includeRecentCommits && ( +
+
+ + updateNumberSetting( + "recentCommitCount", + ((event as any).target as HTMLInputElement).value, + 1, + 20, + ) + }> + + {t("prompts:supportPrompts.commitMessage.gitContext.recentCommitCount")} + + +
+ {t("prompts:supportPrompts.commitMessage.gitContext.recentCommitCountDescription")} +
+
+ + updateGitContextSetting("includeRecentCommitBodies", checked)} + shouldIgnoreChange={() => suppressCheckboxChangesRef.current} + /> + + updateGitContextSetting("includeRecentCommitStats", checked)} + shouldIgnoreChange={() => suppressCheckboxChangesRef.current} + /> + + updateGitContextSetting("includeRecentCommitDiffs", checked)} + shouldIgnoreChange={() => suppressCheckboxChangesRef.current} + /> + + {activeProfile.gitContext.includeRecentCommitDiffs && ( +
+ + updateNumberSetting( + "recentCommitDiffCount", + ((event as any).target as HTMLInputElement).value, + 1, + 5, + ) + }> + + {t("prompts:supportPrompts.commitMessage.gitContext.recentCommitDiffCount")} + + +
+ {t( + "prompts:supportPrompts.commitMessage.gitContext.recentCommitDiffCountDescription", + )} +
+
+ )} +
+ )} +
+
+ + {/* Optional attribution footer appended deterministically after generation. */} +
+
+
+ {t("prompts:supportPrompts.commitMessage.attribution.title")} +
+
+ {t("prompts:supportPrompts.commitMessage.attribution.description")} +
+
+ + updateAttributionSetting({ enabled: checked })} + shouldIgnoreChange={() => suppressCheckboxChangesRef.current} + data-testid="commit-message-attribution-enabled" + /> + + {attributionSettings.enabled && ( +
+ + updateAttributionSetting({ template: getTextAreaValue(event) })} + rows={3} + className="w-full" + data-testid="commit-message-attribution-template" + /> +
+ {t("prompts:supportPrompts.commitMessage.attribution.placeholders")} +
+
+ )} +
+
+ ) +} + +interface CheckboxSettingProps { + checked: boolean + label: string + description: string + onChange: (checked: boolean) => void + shouldIgnoreChange?: () => boolean + "data-testid"?: string +} + +const CheckboxSetting = ({ + checked, + label, + description, + onChange, + shouldIgnoreChange, + "data-testid": dataTestId, +}: CheckboxSettingProps) => ( +
+ { + if (shouldIgnoreChange?.()) { + return + } + + onChange((event.target as HTMLInputElement).checked) + }}> + {label} + +
{description}
+
+) + +export default CommitMessagePromptSettings diff --git a/webview-ui/src/components/settings/PromptsSettings.tsx b/webview-ui/src/components/settings/PromptsSettings.tsx index 54babbcfcb..1f750b7201 100644 --- a/webview-ui/src/components/settings/PromptsSettings.tsx +++ b/webview-ui/src/components/settings/PromptsSettings.tsx @@ -1,5 +1,10 @@ import { useState, useEffect, FormEvent } from "react" import { VSCodeTextArea, VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react" +import type { + CommitMessageAttributionSettings, + CommitMessageGitContextSettings, + CommitMessageProfilesSettings, +} from "@roo-code/types" import { supportPrompt, SupportPromptType } from "@roo/support-prompt" @@ -19,10 +24,19 @@ import { import { SectionHeader } from "./SectionHeader" import { Section } from "./Section" import { SearchableSetting } from "./SearchableSetting" +import CommitMessagePromptSettings from "./CommitMessagePromptSettings" interface PromptsSettingsProps { customSupportPrompts: Record setCustomSupportPrompts: (prompts: Record) => void + commitMessageApiConfigId?: string + setCommitMessageApiConfigId?: (value: string) => void + commitMessageGitContext?: CommitMessageGitContextSettings + setCommitMessageGitContext?: (value: CommitMessageGitContextSettings) => void + commitMessageAttribution?: CommitMessageAttributionSettings + setCommitMessageAttribution?: (value: CommitMessageAttributionSettings) => void + commitMessageProfiles?: CommitMessageProfilesSettings + setCommitMessageProfiles?: (value: CommitMessageProfilesSettings) => void includeTaskHistoryInEnhance?: boolean setIncludeTaskHistoryInEnhance?: (value: boolean) => void } @@ -30,6 +44,14 @@ interface PromptsSettingsProps { const PromptsSettings = ({ customSupportPrompts, setCustomSupportPrompts, + commitMessageApiConfigId, + setCommitMessageApiConfigId, + commitMessageGitContext, + setCommitMessageGitContext, + commitMessageAttribution, + setCommitMessageAttribution, + commitMessageProfiles, + setCommitMessageProfiles, includeTaskHistoryInEnhance: propsIncludeTaskHistoryInEnhance, setIncludeTaskHistoryInEnhance: propsSetIncludeTaskHistoryInEnhance, }: PromptsSettingsProps) => { @@ -101,7 +123,7 @@ const PromptsSettings = ({ return (
- + {t("settings:sections.prompts")} @@ -132,117 +154,143 @@ const PromptsSettings = ({
-
- - - - -
- - { - const value = - (e as unknown as CustomEvent)?.detail?.target?.value ?? - ((e as any).target as HTMLTextAreaElement).value - updateSupportPrompt(activeSupportOption, value) - }} - rows={6} - className="w-full" - /> - - {activeSupportOption === "ENHANCE" && ( -
-
- - -
- {t("prompts:supportPrompts.enhance.apiConfigDescription")} -
+ {activeSupportOption === "COMMIT_MESSAGE" ? ( + {})} + commitMessageGitContext={commitMessageGitContext} + setCommitMessageGitContext={setCommitMessageGitContext ?? (() => {})} + commitMessageAttribution={commitMessageAttribution} + setCommitMessageAttribution={setCommitMessageAttribution ?? (() => {})} + commitMessageProfiles={commitMessageProfiles} + setCommitMessageProfiles={setCommitMessageProfiles ?? (() => {})} + /> + ) : ( + <> +
+ + + +
-
- ) => { - const target = ("target" in e ? e.target : null) as HTMLInputElement | null - - if (!target) { - return - } - - setIncludeTaskHistoryInEnhance(target.checked) - - vscode.postMessage({ - type: "updateSettings", - updatedSettings: { includeTaskHistoryInEnhance: target.checked }, - }) - }}> - - {t("prompts:supportPrompts.enhance.includeTaskHistory")} - - -
- {t("prompts:supportPrompts.enhance.includeTaskHistoryDescription")} -
-
+ { + const value = + (e as unknown as CustomEvent)?.detail?.target?.value ?? + ((e as any).target as HTMLTextAreaElement).value + updateSupportPrompt(activeSupportOption, value) + }} + rows={6} + className="w-full" + /> -
- - setTestPrompt((e.target as HTMLTextAreaElement).value)} - placeholder={t("prompts:supportPrompts.enhance.testPromptPlaceholder")} - rows={3} - className="w-full" - data-testid="test-prompt-textarea" - /> -
- + {activeSupportOption === "ENHANCE" && ( +
+
+ + +
+ {t("prompts:supportPrompts.enhance.apiConfigDescription")} +
+
+ +
+ ) => { + const target = ( + "target" in e ? e.target : null + ) as HTMLInputElement | null + + if (!target) { + return + } + + setIncludeTaskHistoryInEnhance(target.checked) + + vscode.postMessage({ + type: "updateSettings", + updatedSettings: { includeTaskHistoryInEnhance: target.checked }, + }) + }}> + + {t("prompts:supportPrompts.enhance.includeTaskHistory")} + + +
+ {t("prompts:supportPrompts.enhance.includeTaskHistoryDescription")} +
+
+ +
+ + setTestPrompt((e.target as HTMLTextAreaElement).value)} + placeholder={t("prompts:supportPrompts.enhance.testPromptPlaceholder")} + rows={3} + className="w-full" + data-testid="test-prompt-textarea" + /> +
+ +
+
-
-
+ )} + )}
diff --git a/webview-ui/src/components/settings/SectionHeader.tsx b/webview-ui/src/components/settings/SectionHeader.tsx index 4f25fd1a75..861c9ade6e 100644 --- a/webview-ui/src/components/settings/SectionHeader.tsx +++ b/webview-ui/src/components/settings/SectionHeader.tsx @@ -5,13 +5,15 @@ import { cn } from "@/lib/utils" type SectionHeaderProps = HTMLAttributes & { children: React.ReactNode description?: string + sticky?: boolean } -export const SectionHeader = ({ description, children, className, ...props }: SectionHeaderProps) => { +export const SectionHeader = ({ description, children, className, sticky = true, ...props }: SectionHeaderProps) => { return (
diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 47e087615e..eae7d11d35 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -189,6 +189,10 @@ const SettingsView = forwardRef(({ onDone, t maxImageFileSize, maxTotalImageSize, customSupportPrompts, + commitMessageApiConfigId, + commitMessageGitContext, + commitMessageAttribution, + commitMessageProfiles, profileThresholds, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs, @@ -422,6 +426,10 @@ const SettingsView = forwardRef(({ onDone, t openRouterImageGenerationSelectedModel, experiments, customSupportPrompts, + commitMessageApiConfigId, + commitMessageGitContext, + commitMessageAttribution, + commitMessageProfiles, }, }) @@ -880,6 +888,22 @@ const SettingsView = forwardRef(({ onDone, t + setCachedStateField("commitMessageApiConfigId", value) + } + commitMessageGitContext={commitMessageGitContext} + setCommitMessageGitContext={(value) => + setCachedStateField("commitMessageGitContext", value) + } + commitMessageAttribution={commitMessageAttribution} + setCommitMessageAttribution={(value) => + setCachedStateField("commitMessageAttribution", value) + } + commitMessageProfiles={commitMessageProfiles} + setCommitMessageProfiles={(value) => + setCachedStateField("commitMessageProfiles", value) + } includeTaskHistoryInEnhance={includeTaskHistoryInEnhance} setIncludeTaskHistoryInEnhance={(value) => setCachedStateField("includeTaskHistoryInEnhance", value) diff --git a/webview-ui/src/components/settings/__tests__/CommitMessagePromptSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/CommitMessagePromptSettings.spec.tsx new file mode 100644 index 0000000000..aa3a758af7 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/CommitMessagePromptSettings.spec.tsx @@ -0,0 +1,268 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { vi } from "vitest" + +import CommitMessagePromptSettings from "../CommitMessagePromptSettings" + +const mockPostMessage = vi.fn() +;(global as any).acquireVsCodeApi = () => ({ postMessage: mockPostMessage }) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string, values?: Record) => + values?.count !== undefined ? `${key} ${values.count}` : key, + }), +})) + +vi.mock("@src/components/ui", () => ({ + Button: ({ children, onClick, disabled, "data-testid": dataTestId }: any) => ( + + ), + Select: ({ children, value, onValueChange }: any) => ( +
+ + {children} +
+ ), + SelectContent: ({ children }: any) =>
{children}
, + SelectItem: ({ children, value, "data-testid": dataTestId }: any) => ( +
+ {children} +
+ ), + SelectTrigger: ({ children, "data-testid": dataTestId }: any) =>
{children}
, + SelectValue: ({ placeholder }: any) =>
{placeholder}
, + StandardTooltip: ({ children }: any) => <>{children}, +})) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeCheckbox: ({ children, checked, onChange, "data-testid": dataTestId }: any) => ( + + ), + VSCodeTextArea: ({ value, onInput, "data-testid": dataTestId }: any) => ( +