Skip to content

Commit cb54fa6

Browse files
committed
feat(scm): add commit message generator service
1 parent e173b0e commit cb54fa6

7 files changed

Lines changed: 559 additions & 0 deletions

File tree

packages/types/src/global-settings.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,32 @@ import { languagesSchema } from "./vscode.js"
2323
*/
2424
export const DEFAULT_WRITE_DELAY_MS = 1000
2525

26+
export const commitMessageGitContextSchema = z.object({
27+
diffContextLines: z.number().int().min(0).max(20).optional(),
28+
includeDiffStats: z.boolean().optional(),
29+
includeCurrentBranch: z.boolean().optional(),
30+
includeRecentCommits: z.boolean().optional(),
31+
recentCommitCount: z.number().int().min(1).max(20).optional(),
32+
includeRecentCommitBodies: z.boolean().optional(),
33+
includeRecentCommitStats: z.boolean().optional(),
34+
includeRecentCommitDiffs: z.boolean().optional(),
35+
recentCommitDiffCount: z.number().int().min(1).max(5).optional(),
36+
})
37+
38+
export type CommitMessageGitContextSettings = z.infer<typeof commitMessageGitContextSchema>
39+
40+
export const defaultCommitMessageGitContextSettings: Required<CommitMessageGitContextSettings> = {
41+
diffContextLines: 3,
42+
includeDiffStats: true,
43+
includeCurrentBranch: true,
44+
includeRecentCommits: true,
45+
recentCommitCount: 5,
46+
includeRecentCommitBodies: false,
47+
includeRecentCommitStats: false,
48+
includeRecentCommitDiffs: false,
49+
recentCommitDiffCount: 1,
50+
}
51+
2652
/**
2753
* Terminal output preview size options for persisted command output.
2854
*
@@ -232,6 +258,9 @@ export const globalSettingsSchema = z.object({
232258
* Tools in this list will be excluded from prompt generation and rejected at execution time.
233259
*/
234260
disabledTools: z.array(toolNamesSchema).optional(),
261+
262+
commitMessageApiConfigId: z.string().optional(),
263+
commitMessageGitContext: commitMessageGitContextSchema.optional(),
235264
})
236265

237266
export type GlobalSettings = z.infer<typeof globalSettingsSchema>

packages/types/src/telemetry.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ export enum TelemetryEventName {
7474
TELEMETRY_SETTINGS_CHANGED = "Telemetry Settings Changed",
7575
MODEL_CACHE_EMPTY_RESPONSE = "Model Cache Empty Response",
7676
READ_FILE_LEGACY_FORMAT_USED = "Read File Legacy Format Used",
77+
78+
COMMIT_MSG_GENERATED = "Commit Message Generated",
7779
}
7880

7981
/**
@@ -206,6 +208,7 @@ export const rooCodeTelemetryEventSchema = z.discriminatedUnion("type", [
206208
TelemetryEventName.MODE_SETTINGS_CHANGED,
207209
TelemetryEventName.CUSTOM_MODE_CREATED,
208210
TelemetryEventName.READ_FILE_LEGACY_FORMAT_USED,
211+
TelemetryEventName.COMMIT_MSG_GENERATED,
209212
]),
210213
properties: telemetryPropertiesSchema,
211214
}),
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { ContextProxy } from "../../core/config/ContextProxy"
2+
import { ProviderSettingsManager } from "../../core/config/ProviderSettingsManager"
3+
import { singleCompletionHandler as defaultSingleCompletionHandler } from "../../utils/single-completion-handler"
4+
import { supportPrompt } from "../../shared/support-prompt"
5+
import { addCustomInstructions as defaultAddCustomInstructions } from "../../core/prompts/sections/custom-instructions"
6+
import { TelemetryService } from "@roo-code/telemetry"
7+
import { TelemetryEventName, type ProviderSettings } from "@roo-code/types"
8+
9+
import { GenerateMessageParams, PromptOptions, ProgressUpdate } from "./types/core"
10+
11+
export interface CommitMessageContextProxy {
12+
isInitialized: boolean
13+
getProviderSettings(): ProviderSettings
14+
getValue(key: any): unknown
15+
}
16+
17+
export interface CommitMessageGeneratorDependencies {
18+
getContextProxy?: () => CommitMessageContextProxy
19+
completePrompt?: (apiConfiguration: ProviderSettings, promptText: string) => Promise<string>
20+
addCustomInstructions?: typeof defaultAddCustomInstructions
21+
captureGenerated?: () => void
22+
logger?: Pick<Console, "warn">
23+
}
24+
25+
export class CommitMessageGenerator {
26+
private readonly providerSettingsManager: ProviderSettingsManager
27+
private readonly dependencies: Required<CommitMessageGeneratorDependencies>
28+
private previousGitContext: string | null = null
29+
private previousCommitMessage: string | null = null
30+
31+
constructor(
32+
providerSettingsManager: ProviderSettingsManager,
33+
dependencies: CommitMessageGeneratorDependencies = {},
34+
) {
35+
this.providerSettingsManager = providerSettingsManager
36+
this.dependencies = {
37+
getContextProxy: dependencies.getContextProxy ?? (() => ContextProxy.instance),
38+
completePrompt: dependencies.completePrompt ?? defaultSingleCompletionHandler,
39+
addCustomInstructions: dependencies.addCustomInstructions ?? defaultAddCustomInstructions,
40+
captureGenerated:
41+
dependencies.captureGenerated ??
42+
(() => TelemetryService.instance.captureEvent(TelemetryEventName.COMMIT_MSG_GENERATED)),
43+
logger: dependencies.logger ?? console,
44+
}
45+
}
46+
47+
async generateMessage(params: GenerateMessageParams): Promise<string> {
48+
const { gitContext, onProgress } = params
49+
50+
try {
51+
onProgress?.({
52+
message: "Generating commit message...",
53+
percentage: 75,
54+
})
55+
56+
const generatedMessage = await this.callAIForCommitMessage(gitContext, params.workspacePath, onProgress)
57+
58+
this.previousGitContext = gitContext
59+
this.previousCommitMessage = generatedMessage
60+
61+
this.dependencies.captureGenerated()
62+
63+
onProgress?.({
64+
message: "Commit message generated successfully",
65+
percentage: 100,
66+
})
67+
68+
return generatedMessage
69+
} catch (error) {
70+
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"
71+
throw new Error(`Failed to generate commit message: ${errorMessage}`)
72+
}
73+
}
74+
75+
async buildPrompt(gitContext: string, options: PromptOptions, workspacePath: string): Promise<string> {
76+
const { customSupportPrompts = {}, previousContext, previousMessage } = options
77+
78+
const customInstructions = await this.dependencies.addCustomInstructions("", "", workspacePath, "commit", {
79+
language: "en",
80+
})
81+
82+
const shouldGenerateDifferentMessage =
83+
(previousContext === gitContext || this.previousGitContext === gitContext) &&
84+
(previousMessage !== null || this.previousCommitMessage !== null)
85+
86+
const targetPreviousMessage = previousMessage || this.previousCommitMessage
87+
88+
if (shouldGenerateDifferentMessage && targetPreviousMessage) {
89+
const differentMessagePrefix = `# CRITICAL INSTRUCTION: GENERATE A COMPLETELY DIFFERENT COMMIT MESSAGE
90+
The user has requested a new commit message for the same changes.
91+
The previous message was: "${targetPreviousMessage}"
92+
YOU MUST create a message that is COMPLETELY DIFFERENT by:
93+
- Using entirely different wording and phrasing
94+
- Focusing on different aspects of the changes
95+
- Using a different structure or format if appropriate
96+
- Possibly using a different type or scope if justifiable
97+
This is the MOST IMPORTANT requirement for this task.
98+
99+
`
100+
const baseTemplate = supportPrompt.get(customSupportPrompts, "COMMIT_MESSAGE")
101+
const modifiedTemplate =
102+
differentMessagePrefix +
103+
baseTemplate +
104+
`
105+
106+
FINAL REMINDER: Your message MUST be COMPLETELY DIFFERENT from the previous message: "${targetPreviousMessage}". This is a critical requirement.`
107+
108+
return supportPrompt.create(
109+
"COMMIT_MESSAGE",
110+
{
111+
gitContext,
112+
customInstructions: customInstructions || "",
113+
},
114+
{
115+
...customSupportPrompts,
116+
COMMIT_MESSAGE: modifiedTemplate,
117+
},
118+
)
119+
} else {
120+
return supportPrompt.create(
121+
"COMMIT_MESSAGE",
122+
{
123+
gitContext,
124+
customInstructions: customInstructions || "",
125+
},
126+
customSupportPrompts,
127+
)
128+
}
129+
}
130+
131+
private async callAIForCommitMessage(
132+
gitContextString: string,
133+
workspacePath: string,
134+
onProgress?: (progress: ProgressUpdate) => void,
135+
): Promise<string> {
136+
const contextProxy = this.dependencies.getContextProxy()
137+
if (!contextProxy.isInitialized) {
138+
throw new Error("ContextProxy not initialized. Please try again after the extension has fully loaded.")
139+
}
140+
const apiConfiguration = contextProxy.getProviderSettings()
141+
const commitMessageApiConfigId = contextProxy.getValue("commitMessageApiConfigId") as string | undefined
142+
const listApiConfigMeta = (contextProxy.getValue("listApiConfigMeta") || []) as Array<{ id: string }>
143+
const customSupportPrompts = (contextProxy.getValue("customSupportPrompts") || {}) as Record<
144+
string,
145+
string | undefined
146+
>
147+
148+
let configToUse: ProviderSettings = apiConfiguration
149+
150+
if (commitMessageApiConfigId && listApiConfigMeta.find(({ id }) => id === commitMessageApiConfigId)) {
151+
try {
152+
await this.providerSettingsManager.initialize()
153+
const { name: _, ...providerSettings } = await this.providerSettingsManager.getProfile({
154+
id: commitMessageApiConfigId,
155+
})
156+
157+
if (providerSettings.apiProvider) {
158+
configToUse = providerSettings
159+
}
160+
} catch (error) {
161+
this.dependencies.logger.warn(
162+
`Failed to load commit message API profile ${commitMessageApiConfigId}; falling back to current API configuration`,
163+
error,
164+
)
165+
}
166+
}
167+
168+
const filteredPrompts = Object.fromEntries(
169+
Object.entries(customSupportPrompts).filter(([_, value]) => value !== undefined),
170+
) as Record<string, string>
171+
172+
const prompt = await this.buildPrompt(
173+
gitContextString,
174+
{ customSupportPrompts: filteredPrompts },
175+
workspacePath,
176+
)
177+
178+
onProgress?.({
179+
message: "Calling AI service...",
180+
increment: 10,
181+
})
182+
183+
const response = await this.dependencies.completePrompt(configToUse, prompt)
184+
185+
onProgress?.({
186+
message: "Processing AI response...",
187+
increment: 10,
188+
})
189+
190+
return this.extractCommitMessage(response)
191+
}
192+
193+
private extractCommitMessage(response: string): string {
194+
const cleaned = response.trim()
195+
const withoutCodeBlocks = cleaned.replace(/```[a-z]*\n|```/g, "")
196+
const withoutQuotes = withoutCodeBlocks.replace(/^["'`]|["'`]$/g, "")
197+
return withoutQuotes.trim()
198+
}
199+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import * as os from "os"
2+
import * as path from "path"
3+
import { execFile } from "child_process"
4+
import { promisify } from "util"
5+
import { promises as fs } from "fs"
6+
import type { ProviderSettings } from "@roo-code/types"
7+
8+
import { GitContextCollector } from "../../git-context"
9+
import { CommitMessageGenerator } from "../CommitMessageGenerator"
10+
11+
const execFileAsync = promisify(execFile)
12+
13+
async function runGit(cwd: string, args: string[]) {
14+
await execFileAsync("git", args, { cwd })
15+
}
16+
17+
describe("commit message generation flow", () => {
18+
const defaultConfig: ProviderSettings = { apiProvider: "openai", openAiApiKey: "default-key" }
19+
const providerSettingsManager = {
20+
initialize: vi.fn(),
21+
getProfile: vi.fn(),
22+
}
23+
const contextProxy = {
24+
isInitialized: true,
25+
getProviderSettings: vi.fn(() => defaultConfig),
26+
getValue: vi.fn((key: string) => {
27+
switch (key) {
28+
case "listApiConfigMeta":
29+
return []
30+
case "customSupportPrompts":
31+
return {}
32+
default:
33+
return undefined
34+
}
35+
}),
36+
}
37+
38+
beforeEach(() => {
39+
vi.clearAllMocks()
40+
})
41+
42+
it("passes collected git context with untracked file diff to the LLM", async () => {
43+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-commit-generation-"))
44+
try {
45+
await runGit(tempRoot, ["init"])
46+
const filePath = path.join(tempRoot, "src", "new.ts")
47+
await fs.mkdir(path.dirname(filePath), { recursive: true })
48+
await fs.writeFile(filePath, "export const value = 1\n")
49+
50+
const gitContext = await new GitContextCollector(tempRoot).collect({
51+
staged: false,
52+
includeBranch: false,
53+
recentCommits: { include: false },
54+
})
55+
const completePrompt = vi.fn().mockResolvedValue("feat(src): add new module")
56+
const generator = new CommitMessageGenerator(providerSettingsManager as any, {
57+
getContextProxy: () => contextProxy,
58+
completePrompt,
59+
addCustomInstructions: vi.fn().mockResolvedValue(""),
60+
captureGenerated: vi.fn(),
61+
})
62+
63+
const message = await generator.generateMessage({
64+
workspacePath: tempRoot,
65+
selectedFiles: gitContext.changes.map((change) => change.filePath),
66+
gitContext: gitContext.context,
67+
})
68+
69+
expect(message).toBe("feat(src): add new module")
70+
expect(gitContext.context).toContain("diff --git a/src/new.ts b/src/new.ts")
71+
expect(gitContext.context).toContain("+export const value = 1")
72+
expect(completePrompt).toHaveBeenCalledWith(
73+
defaultConfig,
74+
expect.stringContaining("+export const value = 1"),
75+
)
76+
} finally {
77+
await fs.rm(tempRoot, { recursive: true, force: true })
78+
}
79+
})
80+
})

0 commit comments

Comments
 (0)