feat: add generate commit message feature for staged changes

- Add generateCommitMessage command to package.json with SCM menu integration
- Implement git utility functions to get staged diff and files
- Create command handler that uses AI to generate conventional commit messages
- Add localization support for the new command
- Add sparkle icon to the SCM title bar for easy access

This feature allows users to generate AI-powered commit messages based on their staged changes, following conventional commit format.
This commit is contained in:
Roo Code 2025-10-27 16:08:37 +00:00
parent b92a22b0c1
commit 4d29f89afe
5 changed files with 197 additions and 0 deletions

View file

@ -54,6 +54,7 @@ export const commandIds = [
"acceptInput",
"focusPanel",
"toggleAutoApprove",
"generateCommitMessage",
] as const
export type CommandId = (typeof commandIds)[number]

View file

@ -9,6 +9,8 @@ import { getCommand } from "../utils/commands"
import { ClineProvider } from "../core/webview/ClineProvider"
import { ContextProxy } from "../core/config/ContextProxy"
import { focusPanel } from "../utils/focusPanel"
import { getStagedDiff, getStagedFiles } from "../utils/git"
import { getWorkspacePath } from "../utils/path"
import { registerHumanRelayCallback, unregisterHumanRelayCallback, handleHumanRelayResponse } from "./humanRelay"
import { handleNewTask } from "./handleTask"
@ -233,6 +235,119 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt
action: "toggleAutoApprove",
})
},
generateCommitMessage: async () => {
const cwd = getWorkspacePath()
if (!cwd) {
vscode.window.showErrorMessage("No workspace folder open")
return
}
// Check if there are staged changes
const stagedFiles = await getStagedFiles(cwd)
if (stagedFiles.length === 0) {
vscode.window.showInformationMessage(
"No staged changes found. Please stage your changes first using 'git add'.",
)
return
}
// Get the staged diff
const stagedDiff = await getStagedDiff(cwd)
if (
stagedDiff.startsWith("Failed") ||
stagedDiff.startsWith("Git is not installed") ||
stagedDiff.startsWith("Not a git repository")
) {
vscode.window.showErrorMessage(stagedDiff)
return
}
// Get the visible provider
const visibleProvider = getVisibleProviderOrLog(outputChannel)
if (!visibleProvider) {
vscode.window.showErrorMessage("Roo Code is not active. Please open the Roo Code sidebar first.")
return
}
// Show progress notification
await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: "Generating commit message...",
cancellable: false,
},
async () => {
try {
// Create a task to generate the commit message
const prompt = `Generate a concise and descriptive commit message for the following staged changes. Follow conventional commit format (e.g., feat:, fix:, docs:, style:, refactor:, test:, chore:). The message should be clear and explain what changes were made and why.
Staged files:
${stagedFiles.join("\n")}
Diff of staged changes (truncated for context):
${stagedDiff}
Please provide ONLY the commit message, without any additional explanation or formatting. The message should be on a single line unless a body is needed for complex changes.`
// Create a task and wait for the response
const task = await visibleProvider.createTask(prompt)
// Wait for the task to complete with a simpler approach
await new Promise<void>((resolve) => {
let checkCount = 0
const maxChecks = 300 // 30 seconds with 100ms intervals
const checkInterval = setInterval(() => {
checkCount++
const messages = task.clineMessages
// Look for a message with type "say" and say "completion_result"
const completionMessage = messages.find(
(msg) => msg.type === "say" && msg.say === "completion_result",
)
if (completionMessage || checkCount >= maxChecks) {
clearInterval(checkInterval)
if (completionMessage && completionMessage.text) {
// Extract the commit message from the completion result
const commitMessage = completionMessage.text.trim()
if (commitMessage) {
// Get the SCM input box and set the message
const gitExtension = vscode.extensions.getExtension("vscode.git")?.exports
if (gitExtension) {
const git = gitExtension.getAPI(1)
const repo = git.repositories[0]
if (repo) {
repo.inputBox.value = commitMessage
vscode.window.showInformationMessage(
"Commit message generated successfully!",
)
}
}
}
} else if (checkCount >= maxChecks) {
vscode.window.showErrorMessage("Timeout generating commit message")
}
// Clear the task
visibleProvider.clearTask()
resolve()
}
}, 100)
})
} catch (error) {
vscode.window.showErrorMessage(
`Failed to generate commit message: ${error instanceof Error ? error.message : String(error)}`,
)
if (visibleProvider) {
visibleProvider.clearTask()
}
}
},
)
},
})
export const openClineInNewTab = async ({ context, outputChannel }: Omit<RegisterCommandOptions, "provider">) => {

View file

@ -179,6 +179,12 @@
"command": "roo-cline.toggleAutoApprove",
"title": "%command.toggleAutoApprove.title%",
"category": "%configuration.title%"
},
{
"command": "roo-cline.generateCommitMessage",
"title": "%command.generateCommitMessage.title%",
"category": "%configuration.title%",
"icon": "$(sparkle)"
}
],
"menus": {
@ -305,6 +311,13 @@
"group": "overflow@4",
"when": "activeWebviewPanelId == roo-cline.TabPanelProvider"
}
],
"scm/title": [
{
"command": "roo-cline.generateCommitMessage",
"group": "navigation",
"when": "scmProvider == git"
}
]
},
"keybindings": [

View file

@ -27,6 +27,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",
"configuration.title": "Roo 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.",

View file

@ -355,3 +355,70 @@ export async function getWorkingState(cwd: string): Promise<string> {
return `Failed to get working state: ${error instanceof Error ? error.message : String(error)}`
}
}
/**
* Gets the staged changes (diff) for commit message generation
* @param cwd The working directory
* @returns The staged diff or an error message
*/
export async function getStagedDiff(cwd: string): Promise<string> {
try {
const isInstalled = await checkGitInstalled()
if (!isInstalled) {
return "Git is not installed"
}
const isRepo = await checkGitRepo(cwd)
if (!isRepo) {
return "Not a git repository"
}
// Get the staged diff
const { stdout: diff } = await execAsync("git diff --cached", { cwd })
if (!diff.trim()) {
return "No staged changes found. Please stage your changes first using 'git add'."
}
// Limit the output to prevent overwhelming the AI
const lineLimit = GIT_OUTPUT_LINE_LIMIT
return truncateOutput(diff, lineLimit)
} catch (error) {
console.error("Error getting staged diff:", error)
return `Failed to get staged diff: ${error instanceof Error ? error.message : String(error)}`
}
}
/**
* Gets a summary of staged files for commit message generation
* @param cwd The working directory
* @returns List of staged files with their status
*/
export async function getStagedFiles(cwd: string): Promise<string[]> {
try {
const isInstalled = await checkGitInstalled()
if (!isInstalled) {
return []
}
const isRepo = await checkGitRepo(cwd)
if (!isRepo) {
return []
}
// Get list of staged files
const { stdout } = await execAsync("git diff --cached --name-status", { cwd })
if (!stdout.trim()) {
return []
}
return stdout
.trim()
.split("\n")
.filter((line) => line.length > 0)
} catch (error) {
console.error("Error getting staged files:", error)
return []
}
}