Skip to content

Commit 53b5824

Browse files
committed
feat(scm): integrate commit generation with source control
1 parent cb54fa6 commit 53b5824

11 files changed

Lines changed: 377 additions & 1 deletion

File tree

packages/build/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const commandsSchema = z.array(
3131
command: z.string(),
3232
title: z.string(),
3333
category: z.string().optional(),
34-
icon: z.string().optional(),
34+
icon: z.union([z.string(), z.object({ light: z.string(), dark: z.string() })]).optional(),
3535
}),
3636
)
3737

packages/types/src/vscode-extension-host.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,8 @@ export type ExtensionState = Pick<
283283
| "customModePrompts"
284284
| "customSupportPrompts"
285285
| "enhancementApiConfigId"
286+
| "commitMessageApiConfigId"
287+
| "commitMessageGitContext"
286288
| "customCondensingPrompt"
287289
| "codebaseIndexConfig"
288290
| "codebaseIndexModels"

src/core/webview/ClineProvider.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2057,6 +2057,8 @@ export class ClineProvider
20572057
customModePrompts,
20582058
customSupportPrompts,
20592059
enhancementApiConfigId,
2060+
commitMessageApiConfigId,
2061+
commitMessageGitContext,
20602062
autoApprovalEnabled,
20612063
customModes,
20622064
experiments,
@@ -2209,6 +2211,8 @@ export class ClineProvider
22092211
customModePrompts: customModePrompts ?? {},
22102212
customSupportPrompts: customSupportPrompts ?? {},
22112213
enhancementApiConfigId,
2214+
commitMessageApiConfigId,
2215+
commitMessageGitContext,
22122216
autoApprovalEnabled: autoApprovalEnabled ?? false,
22132217
customModes,
22142218
experiments: experiments ?? experimentDefault,
@@ -2415,6 +2419,8 @@ export class ClineProvider
24152419
customModePrompts: stateValues.customModePrompts ?? {},
24162420
customSupportPrompts: stateValues.customSupportPrompts ?? {},
24172421
enhancementApiConfigId: stateValues.enhancementApiConfigId,
2422+
commitMessageApiConfigId: stateValues.commitMessageApiConfigId,
2423+
commitMessageGitContext: stateValues.commitMessageGitContext,
24182424
experiments: stateValues.experiments ?? experimentDefault,
24192425
autoApprovalEnabled: stateValues.autoApprovalEnabled ?? false,
24202426
customModes,

src/extension.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
import { initializeI18n } from "./i18n"
5151
import { initializeModelCacheRefresh } from "./api/providers/fetchers/modelCache"
5252
import { initZooCodeAuth } from "./services/zoo-code-auth"
53+
import { registerCommitMessageProvider } from "./services/commit-message"
5354

5455
/**
5556
* Built using https://github.com/microsoft/vscode-webview-ui-toolkit
@@ -256,6 +257,14 @@ export async function activate(context: vscode.ExtensionContext) {
256257

257258
registerCommands({ context, outputChannel, provider })
258259

260+
try {
261+
registerCommitMessageProvider(context, outputChannel)
262+
} catch (error) {
263+
outputChannel.appendLine(
264+
`Failed to register commit message provider: ${error instanceof Error ? error.message : String(error)}`,
265+
)
266+
}
267+
259268
/**
260269
* We use the text document content provider API to show the left side for diff
261270
* view by creating a virtual document for the original content. This makes it

src/i18n/locales/en/common.json

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,5 +259,56 @@
259259
"connected": "Zoo Code: Successfully connected! You can now use Zoo Code as your AI provider.",
260260
"disconnected": "Zoo Code: Disconnected successfully."
261261
}
262+
},
263+
"commitMessage": {
264+
"activated": "Zoo Code commit message generator activated",
265+
"gitNotFound": "⚠️ Git repository not found or git not available",
266+
"gitInitError": "⚠️ Git initialization error: {{error}}",
267+
"generating": "Zoo: Generating commit message...",
268+
"noChanges": "Zoo: No changes found to analyze",
269+
"generated": "Zoo: Commit message generated!",
270+
"generationFailed": "Zoo: Failed to generate commit message: {{errorMessage}}",
271+
"contextWarnings": "Zoo: Git context warning: {{warnings}}",
272+
"generatingFromUnstaged": "Zoo: Generating message using unstaged changes",
273+
"activationFailed": "Zoo: Failed to activate message generator: {{error}}",
274+
"providerRegistered": "Zoo: Commit message provider registered",
275+
"initializing": "Initializing...",
276+
"discoveringFiles": "Discovering files...",
277+
"foundChanges": "Found {{count}} changes",
278+
"gettingContext": "Getting git context...",
279+
"errors": {
280+
"connectionFailed": "Failed to connect to Zoo Code extension",
281+
"timeout": "Request timed out after 30 seconds",
282+
"invalidResponse": "Invalid response format received from extension",
283+
"missingMessage": "No commit message received from extension",
284+
"noChanges": "No changes found to commit",
285+
"noProject": "No project available",
286+
"noWorkspacePath": "Could not determine workspace path for Git repository",
287+
"workspaceNotFound": "Could not determine workspace path for Git repository",
288+
"processingError": "Error processing commit message generation: {{error}}"
289+
},
290+
"error": {
291+
"title": "Error",
292+
"workspacePathNotFound": "Could not determine workspace path for Git repository",
293+
"generationFailed": "Failed to generate commit message: {{error}}",
294+
"processingFailed": "Error processing commit message generation: {{error}}",
295+
"unknown": "Unknown error"
296+
},
297+
"dialogs": {
298+
"info": "AI Commit Message",
299+
"error": "Error",
300+
"success": "Success",
301+
"title": "AI Commit Message"
302+
},
303+
"progress": {
304+
"title": "Generating Commit Message",
305+
"analyzing": "Analyzing changes...",
306+
"connecting": "Connecting to Zoo Code...",
307+
"generating": "Generating commit message..."
308+
},
309+
"ui": {
310+
"generateButton": "Generate Commit Message",
311+
"generateButtonTooltip": "Generates commit message using AI to analyze your code changes"
312+
}
262313
}
263314
}

src/package.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,14 @@
164164
"command": "zoo-code.toggleAutoApprove",
165165
"title": "%command.toggleAutoApprove.title%",
166166
"category": "%configuration.title%"
167+
},
168+
{
169+
"command": "zoo-code.generateCommitMessage",
170+
"title": "%command.generateCommitMessage.title%",
171+
"icon": {
172+
"light": "assets/icons/panel_light.png",
173+
"dark": "assets/icons/panel_dark.png"
174+
}
167175
}
168176
],
169177
"menus": {
@@ -207,6 +215,19 @@
207215
"group": "1_actions@3"
208216
}
209217
],
218+
"scm/input": [
219+
{
220+
"command": "zoo-code.generateCommitMessage",
221+
"group": "navigation"
222+
}
223+
],
224+
"scm/title": [
225+
{
226+
"command": "zoo-code.generateCommitMessage",
227+
"when": "scmProvider == git",
228+
"group": "navigation"
229+
}
230+
],
210231
"view/title": [
211232
{
212233
"command": "zoo-code.plusButtonClicked",

src/package.nls.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"command.terminal.explainCommand.title": "Explain This Command",
2525
"command.acceptInput.title": "Accept Input/Suggestion",
2626
"command.toggleAutoApprove.title": "Toggle Auto-Approve",
27+
"command.generateCommitMessage.title": "Generate Commit Message with Zoo",
2728
"configuration.title": "Zoo Code",
2829
"commands.allowedCommands.description": "Commands that can be auto-executed when 'Always approve execute operations' is enabled",
2930
"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.",
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import * as vscode from "vscode"
2+
import { ProviderSettingsManager } from "../../core/config/ProviderSettingsManager"
3+
import { t } from "../../i18n"
4+
import { Package } from "../../shared/package"
5+
import { GitChange, GitContextCollector } from "../git-context"
6+
7+
import { CommitMessageGenerator } from "./CommitMessageGenerator"
8+
import { getCommitMessageGitContextSettings, toGitContextCollectorOptions } from "./gitContextSettings"
9+
10+
interface VscGenerationRequest {
11+
inputBox: { value: string }
12+
rootUri?: vscode.Uri
13+
}
14+
15+
export class CommitMessageProvider implements vscode.Disposable {
16+
private generator: CommitMessageGenerator
17+
18+
constructor(
19+
private context: vscode.ExtensionContext,
20+
private outputChannel: vscode.OutputChannel,
21+
) {
22+
const providerSettingsManager = new ProviderSettingsManager(this.context)
23+
24+
this.generator = new CommitMessageGenerator(providerSettingsManager)
25+
}
26+
27+
public async activate(): Promise<void> {
28+
this.outputChannel.appendLine(t("common:commitMessage.activated"))
29+
30+
const disposables = [
31+
vscode.commands.registerCommand(
32+
`${Package.name}.generateCommitMessage`,
33+
(vsRequest?: VscGenerationRequest) => this.handleVSCodeCommand(vsRequest),
34+
),
35+
]
36+
this.context.subscriptions.push(...disposables)
37+
}
38+
39+
private async handleVSCodeCommand(vsRequest?: VscGenerationRequest): Promise<void> {
40+
try {
41+
const workspacePath = this.determineWorkspacePath(vsRequest?.rootUri)
42+
const targetRepository = await this.determineTargetRepository(workspacePath)
43+
if (!targetRepository?.rootUri) {
44+
throw new Error("Could not determine Git repository")
45+
}
46+
47+
await vscode.window.withProgress(
48+
{
49+
location: vscode.ProgressLocation.SourceControl,
50+
title: t("common:commitMessage.generating"),
51+
cancellable: false,
52+
},
53+
async (progress) => {
54+
let lastPercentage = 0
55+
const reportProgress = (percentage: number, message?: string) => {
56+
progress.report({
57+
increment: Math.max(0, percentage - lastPercentage),
58+
message: message || t("common:commitMessage.generating"),
59+
})
60+
lastPercentage = percentage
61+
}
62+
63+
reportProgress(5, t("common:commitMessage.initializing"))
64+
const gitCollector = new GitContextCollector(workspacePath)
65+
66+
try {
67+
reportProgress(15, t("common:commitMessage.discoveringFiles"))
68+
const resolution = await this.resolveCommitChanges(gitCollector)
69+
const gitContextSettings = getCommitMessageGitContextSettings()
70+
71+
if (resolution.changes.length === 0) {
72+
vscode.window.showInformationMessage(t("common:commitMessage.noChanges"))
73+
return
74+
}
75+
76+
reportProgress(25, t("common:commitMessage.foundChanges", { count: resolution.changes.length }))
77+
78+
if (!resolution.usedStaged) {
79+
vscode.window.showInformationMessage(t("common:commitMessage.generatingFromUnstaged"))
80+
}
81+
82+
reportProgress(40, t("common:commitMessage.gettingContext"))
83+
const gitContextResult = await gitCollector.collectContext(
84+
resolution.changes,
85+
toGitContextCollectorOptions(resolution.usedStaged, gitContextSettings),
86+
resolution.files,
87+
)
88+
if (gitContextResult.warnings.length > 0) {
89+
vscode.window.showWarningMessage(
90+
t("common:commitMessage.contextWarnings", {
91+
warnings: gitContextResult.warnings.join("; "),
92+
}),
93+
)
94+
}
95+
96+
reportProgress(70, t("common:commitMessage.generating"))
97+
const message = await this.generator.generateMessage({
98+
workspacePath,
99+
selectedFiles: resolution.files,
100+
gitContext: gitContextResult.context,
101+
onProgress: (update) => {
102+
if (update.percentage !== undefined) {
103+
reportProgress(70 + update.percentage * 0.25, update.message)
104+
}
105+
},
106+
})
107+
108+
targetRepository.inputBox.value = message
109+
reportProgress(100, t("common:commitMessage.generated"))
110+
} finally {
111+
gitCollector.dispose()
112+
}
113+
},
114+
)
115+
} catch (error) {
116+
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"
117+
vscode.window.showErrorMessage(t("common:commitMessage.generationFailed", { errorMessage }))
118+
}
119+
}
120+
121+
private async resolveCommitChanges(gitCollector: GitContextCollector): Promise<{
122+
changes: GitChange[]
123+
files: string[]
124+
usedStaged: boolean
125+
}> {
126+
let changes = await gitCollector.gatherChanges({ staged: true })
127+
let usedStaged = true
128+
129+
if (changes.length === 0) {
130+
changes = await gitCollector.gatherChanges({ staged: false })
131+
usedStaged = false
132+
}
133+
134+
return {
135+
changes,
136+
files: changes.map((change) => change.filePath),
137+
usedStaged,
138+
}
139+
}
140+
141+
private async determineTargetRepository(workspacePath: string): Promise<VscGenerationRequest | null> {
142+
try {
143+
const gitExtension = vscode.extensions.getExtension("vscode.git")
144+
if (!gitExtension) {
145+
return null
146+
}
147+
148+
if (!gitExtension.isActive) {
149+
await gitExtension.activate()
150+
}
151+
152+
const gitApi = gitExtension.exports.getAPI(1)
153+
if (!gitApi) {
154+
return null
155+
}
156+
157+
for (const repo of gitApi.repositories ?? []) {
158+
if (repo.rootUri && workspacePath.startsWith(repo.rootUri.fsPath)) {
159+
return repo
160+
}
161+
}
162+
163+
return gitApi.repositories[0] ?? null
164+
} catch (error) {
165+
return null
166+
}
167+
}
168+
169+
private determineWorkspacePath(resourceUri?: vscode.Uri): string {
170+
if (resourceUri) {
171+
return resourceUri.fsPath
172+
}
173+
174+
const workspaceFolders = vscode.workspace.workspaceFolders
175+
if (workspaceFolders && workspaceFolders.length > 0) {
176+
return workspaceFolders[0].uri.fsPath
177+
}
178+
179+
throw new Error("Could not determine workspace path")
180+
}
181+
182+
public dispose(): void {}
183+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import * as path from "path"
2+
3+
import { isPathWithinRepository } from "../CommitMessageProvider"
4+
5+
vi.mock("vscode", () => ({}))
6+
7+
describe("CommitMessageProvider", () => {
8+
it("matches repository roots by path containment instead of string prefix", () => {
9+
const root = path.parse(process.cwd()).root
10+
const repositoryPath = path.join(root, "work", "app")
11+
12+
expect(isPathWithinRepository(path.join(repositoryPath, "src", "index.ts"), repositoryPath)).toBe(true)
13+
expect(isPathWithinRepository(repositoryPath, repositoryPath)).toBe(true)
14+
expect(isPathWithinRepository(path.join(root, "work", "application"), repositoryPath)).toBe(false)
15+
})
16+
})

0 commit comments

Comments
 (0)