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..13f0152be7 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -23,6 +23,32 @@ import { languagesSchema } from "./vscode.js" */ export const DEFAULT_WRITE_DELAY_MS = 1000 +export const commitMessageGitContextSchema = z.object({ + diffContextLines: z.number().int().min(0).max(20).optional(), + includeDiffStats: z.boolean().optional(), + includeCurrentBranch: z.boolean().optional(), + includeRecentCommits: z.boolean().optional(), + recentCommitCount: z.number().int().min(1).max(20).optional(), + includeRecentCommitBodies: z.boolean().optional(), + includeRecentCommitStats: z.boolean().optional(), + includeRecentCommitDiffs: z.boolean().optional(), + recentCommitDiffCount: z.number().int().min(1).max(5).optional(), +}) + +export type CommitMessageGitContextSettings = z.infer + +export const defaultCommitMessageGitContextSettings: Required = { + diffContextLines: 3, + includeDiffStats: true, + includeCurrentBranch: true, + includeRecentCommits: true, + recentCommitCount: 5, + includeRecentCommitBodies: false, + includeRecentCommitStats: false, + includeRecentCommitDiffs: false, + recentCommitDiffCount: 1, +} + /** * Terminal output preview size options for persisted command output. * @@ -232,6 +258,9 @@ export const globalSettingsSchema = z.object({ * Tools in this list will be excluded from prompt generation and rejected at execution time. */ disabledTools: z.array(toolNamesSchema).optional(), + + commitMessageApiConfigId: z.string().optional(), + commitMessageGitContext: commitMessageGitContextSchema.optional(), }) export type GlobalSettings = z.infer diff --git a/packages/types/src/telemetry.ts b/packages/types/src/telemetry.ts index 402cd571c8..1e9d80f202 100644 --- a/packages/types/src/telemetry.ts +++ b/packages/types/src/telemetry.ts @@ -74,6 +74,8 @@ export enum TelemetryEventName { TELEMETRY_SETTINGS_CHANGED = "Telemetry Settings Changed", MODEL_CACHE_EMPTY_RESPONSE = "Model Cache Empty Response", READ_FILE_LEGACY_FORMAT_USED = "Read File Legacy Format Used", + + COMMIT_MSG_GENERATED = "Commit Message Generated", } /** @@ -206,6 +208,7 @@ export const rooCodeTelemetryEventSchema = z.discriminatedUnion("type", [ TelemetryEventName.MODE_SETTINGS_CHANGED, TelemetryEventName.CUSTOM_MODE_CREATED, TelemetryEventName.READ_FILE_LEGACY_FORMAT_USED, + TelemetryEventName.COMMIT_MSG_GENERATED, ]), properties: telemetryPropertiesSchema, }), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index c09f22aed7..8e7b4afd94 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -283,6 +283,8 @@ export type ExtensionState = Pick< | "customModePrompts" | "customSupportPrompts" | "enhancementApiConfigId" + | "commitMessageApiConfigId" + | "commitMessageGitContext" | "customCondensingPrompt" | "codebaseIndexConfig" | "codebaseIndexModels" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 3f5af94cae..db16a18673 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2057,6 +2057,8 @@ export class ClineProvider customModePrompts, customSupportPrompts, enhancementApiConfigId, + commitMessageApiConfigId, + commitMessageGitContext, autoApprovalEnabled, customModes, experiments, @@ -2209,6 +2211,8 @@ export class ClineProvider customModePrompts: customModePrompts ?? {}, customSupportPrompts: customSupportPrompts ?? {}, enhancementApiConfigId, + commitMessageApiConfigId, + commitMessageGitContext, autoApprovalEnabled: autoApprovalEnabled ?? false, customModes, experiments: experiments ?? experimentDefault, @@ -2415,6 +2419,8 @@ export class ClineProvider customModePrompts: stateValues.customModePrompts ?? {}, customSupportPrompts: stateValues.customSupportPrompts ?? {}, enhancementApiConfigId: stateValues.enhancementApiConfigId, + commitMessageApiConfigId: stateValues.commitMessageApiConfigId, + commitMessageGitContext: stateValues.commitMessageGitContext, experiments: stateValues.experiments ?? experimentDefault, autoApprovalEnabled: stateValues.autoApprovalEnabled ?? false, customModes, diff --git a/src/extension.ts b/src/extension.ts index 44c1243528..58ee685144 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -50,6 +50,7 @@ import { import { initializeI18n } from "./i18n" import { initializeModelCacheRefresh } from "./api/providers/fetchers/modelCache" import { initZooCodeAuth } from "./services/zoo-code-auth" +import { registerCommitMessageProvider } from "./services/commit-message" /** * Built using https://github.com/microsoft/vscode-webview-ui-toolkit @@ -256,6 +257,14 @@ export async function activate(context: vscode.ExtensionContext) { registerCommands({ context, outputChannel, provider }) + try { + registerCommitMessageProvider(context, outputChannel) + } catch (error) { + outputChannel.appendLine( + `Failed to register commit message provider: ${error instanceof Error ? error.message : String(error)}`, + ) + } + /** * We use the text document content provider API to show the left side for diff * view by creating a virtual document for the original content. This makes it diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 157a87c5dc..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/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..bd293f8a36 --- /dev/null +++ b/src/services/commit-message/CommitMessageGenerator.ts @@ -0,0 +1,247 @@ +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" + +/** 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 commitMessageApiConfigId = contextProxy.getValue("commitMessageApiConfigId") as string | undefined + const listApiConfigMeta = (contextProxy.getValue("listApiConfigMeta") || []) as Array<{ id: string }> + const customSupportPrompts = (contextProxy.getValue("customSupportPrompts") || {}) as Record< + string, + string | undefined + > + + let configToUse: ProviderSettings = apiConfiguration + + if (commitMessageApiConfigId && listApiConfigMeta.find(({ id }) => id === commitMessageApiConfigId)) { + try { + await this.providerSettingsManager.initialize() + const { name: _, ...providerSettings } = await this.providerSettingsManager.getProfile({ + id: commitMessageApiConfigId, + }) + + if (providerSettings.apiProvider) { + configToUse = providerSettings + } + } catch (error) { + this.dependencies.logger.warn( + `Failed to load commit message API profile ${commitMessageApiConfigId}; falling back to current API configuration`, + error, + ) + } + } + + const filteredPrompts = Object.fromEntries( + Object.entries(customSupportPrompts).filter(([_, value]) => value !== undefined), + ) as Record + + const prompt = await this.buildPrompt( + gitContextString, + { customSupportPrompts: filteredPrompts }, + workspacePath, + ) + + onProgress?.({ + message: "Calling AI service...", + increment: 10, + }) + + const response = await this.dependencies.completePrompt(configToUse, prompt) + + onProgress?.({ + message: "Processing AI response...", + increment: 10, + }) + + return this.extractCommitMessage(response) + } + + /** 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..23533bf406 --- /dev/null +++ b/src/services/commit-message/CommitMessageProvider.ts @@ -0,0 +1,257 @@ +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..188533d11e --- /dev/null +++ b/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts @@ -0,0 +1,218 @@ +import type { ProviderSettings } from "@roo-code/types" + +import { CommitMessageGenerator } from "../CommitMessageGenerator" + +describe("CommitMessageGenerator", () => { + const defaultConfig: ProviderSettings = { apiProvider: "openai", openAiApiKey: "default-key" } + const commitConfig: ProviderSettings = { apiProvider: "anthropic", apiKey: "commit-key" } + const providerSettingsManager = { + initialize: vi.fn(), + getProfile: vi.fn(), + } + const contextProxy = { + isInitialized: true, + getProviderSettings: vi.fn(), + getValue: vi.fn(), + } + const completePrompt = vi.fn() + const addCustomInstructions = vi.fn() + const captureGenerated = vi.fn() + const warn = vi.fn() + + /** 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("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() + }) +}) 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/gitContextSettings.ts b/src/services/commit-message/gitContextSettings.ts new file mode 100644 index 0000000000..eec746dba3 --- /dev/null +++ b/src/services/commit-message/gitContextSettings.ts @@ -0,0 +1,71 @@ +import { defaultCommitMessageGitContextSettings, type CommitMessageGitContextSettings } from "@roo-code/types" + +import { ContextProxy } from "../../core/config/ContextProxy" +import type { GitContextCollectorOptions } from "../git-context" + +/** Reads and normalizes the persisted Git context settings for commit message generation. */ +export function getCommitMessageGitContextSettings(): Required { + const rawSettings = ContextProxy.instance.getValue("commitMessageGitContext") as + | CommitMessageGitContextSettings + | undefined + + return normalizeCommitMessageGitContextSettings(rawSettings) +} + +export function normalizeCommitMessageGitContextSettings( + settings?: CommitMessageGitContextSettings, +): Required { + return { + ...defaultCommitMessageGitContextSettings, + ...settings, + diffContextLines: clamp( + settings?.diffContextLines, + 0, + 20, + defaultCommitMessageGitContextSettings.diffContextLines, + ), + recentCommitCount: clamp( + settings?.recentCommitCount, + 1, + 20, + defaultCommitMessageGitContextSettings.recentCommitCount, + ), + recentCommitDiffCount: clamp( + settings?.recentCommitDiffCount, + 1, + 5, + defaultCommitMessageGitContextSettings.recentCommitDiffCount, + ), + } +} + +/** 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, + }, + } +} + +function clamp(value: number | undefined, min: number, max: number, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return fallback + } + + return Math.min(Math.max(Math.trunc(value), min), max) +} diff --git a/src/services/commit-message/index.ts b/src/services/commit-message/index.ts new file mode 100644 index 0000000000..b9019840b3 --- /dev/null +++ b/src/services/commit-message/index.ts @@ -0,0 +1,19 @@ +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().catch((error) => { + outputChannel.appendLine(t("common:commitMessage.activationFailed", { error: error.message })) + console.error("Commit message provider activation failed:", error) + }) + + outputChannel.appendLine(t("common:commitMessage.providerRegistered")) +} 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 = {