diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index a00e0bfe..537a9acd 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -17,6 +17,7 @@ import { createDeleteHandler } from './routes/delete.js'; import { createCreatePRHandler } from './routes/create-pr.js'; import { createPRInfoHandler } from './routes/pr-info.js'; import { createCommitHandler } from './routes/commit.js'; +import { createGenerateCommitMessageHandler } from './routes/generate-commit-message.js'; import { createPushHandler } from './routes/push.js'; import { createPullHandler } from './routes/pull.js'; import { createCheckoutBranchHandler } from './routes/checkout-branch.js'; @@ -64,6 +65,12 @@ export function createWorktreeRoutes(events: EventEmitter): Router { requireGitRepoOnly, createCommitHandler() ); + router.post( + '/generate-commit-message', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createGenerateCommitMessageHandler() + ); router.post( '/push', validatePathParams('worktreePath'), diff --git a/apps/server/src/routes/worktree/routes/generate-commit-message.ts b/apps/server/src/routes/worktree/routes/generate-commit-message.ts new file mode 100644 index 00000000..69e44058 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/generate-commit-message.ts @@ -0,0 +1,178 @@ +/** + * POST /worktree/generate-commit-message endpoint - Generate an AI commit message from git diff + * + * Uses Claude Haiku to generate a concise, conventional commit message from git changes. + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { createLogger } from '@automaker/utils'; +import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver'; +import { getErrorMessage, logError } from '../common.js'; + +const logger = createLogger('GenerateCommitMessage'); +const execAsync = promisify(exec); + +interface GenerateCommitMessageRequestBody { + worktreePath: string; +} + +interface GenerateCommitMessageSuccessResponse { + success: true; + message: string; +} + +interface GenerateCommitMessageErrorResponse { + success: false; + error: string; +} + +const SYSTEM_PROMPT = `You are a git commit message generator. Your task is to create a clear, concise commit message based on the git diff provided. + +Rules: +- Output ONLY the commit message, nothing else +- First line should be a short summary (50 chars or less) in imperative mood +- Start with a conventional commit type if appropriate (feat:, fix:, refactor:, docs:, etc.) +- Keep it concise and descriptive +- Focus on WHAT changed and WHY (if clear from the diff), not HOW +- No quotes, backticks, or extra formatting +- If there are multiple changes, provide a brief summary on the first line + +Examples: +- feat: Add dark mode toggle to settings +- fix: Resolve login validation edge case +- refactor: Extract user authentication logic +- docs: Update installation instructions`; + +async function extractTextFromStream( + stream: AsyncIterable<{ + type: string; + subtype?: string; + result?: string; + message?: { + content?: Array<{ type: string; text?: string }>; + }; + }> +): Promise { + let responseText = ''; + + for await (const msg of stream) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + responseText += block.text; + } + } + } else if (msg.type === 'result' && msg.subtype === 'success') { + responseText = msg.result || responseText; + } + } + + return responseText; +} + +export function createGenerateCommitMessageHandler(): ( + req: Request, + res: Response +) => Promise { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as GenerateCommitMessageRequestBody; + + if (!worktreePath || typeof worktreePath !== 'string') { + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'worktreePath is required and must be a string', + }; + res.status(400).json(response); + return; + } + + logger.info(`Generating commit message for worktree: ${worktreePath}`); + + // Get git diff of staged and unstaged changes + let diff = ''; + try { + // First try to get staged changes + const { stdout: stagedDiff } = await execAsync('git diff --cached', { + cwd: worktreePath, + maxBuffer: 1024 * 1024 * 5, // 5MB buffer + }); + + // If no staged changes, get unstaged changes + if (!stagedDiff.trim()) { + const { stdout: unstagedDiff } = await execAsync('git diff', { + cwd: worktreePath, + maxBuffer: 1024 * 1024 * 5, // 5MB buffer + }); + diff = unstagedDiff; + } else { + diff = stagedDiff; + } + } catch (error) { + logger.error('Failed to get git diff:', error); + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'Failed to get git changes', + }; + res.status(500).json(response); + return; + } + + if (!diff.trim()) { + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'No changes to commit', + }; + res.status(400).json(response); + return; + } + + // Truncate diff if too long (keep first 10000 characters to avoid token limits) + const truncatedDiff = + diff.length > 10000 ? diff.substring(0, 10000) + '\n\n[... diff truncated ...]' : diff; + + const userPrompt = `Generate a commit message for these changes:\n\n\`\`\`diff\n${truncatedDiff}\n\`\`\``; + + const stream = query({ + prompt: userPrompt, + options: { + model: CLAUDE_MODEL_MAP.haiku, + systemPrompt: SYSTEM_PROMPT, + maxTurns: 1, + allowedTools: [], + permissionMode: 'default', + }, + }); + + const message = await extractTextFromStream(stream); + + if (!message || message.trim().length === 0) { + logger.warn('Received empty response from Claude'); + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'Failed to generate commit message - empty response', + }; + res.status(500).json(response); + return; + } + + logger.info(`Generated commit message: ${message.trim().substring(0, 100)}...`); + + const response: GenerateCommitMessageSuccessResponse = { + success: true, + message: message.trim(), + }; + res.json(response); + } catch (error) { + logError(error, 'Generate commit message failed'); + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: getErrorMessage(error), + }; + res.status(500).json(response); + } + }; +} diff --git a/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx index 8e905c5e..492f671f 100644 --- a/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Dialog, DialogContent, @@ -10,7 +10,7 @@ import { import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; -import { GitCommit, Loader2 } from 'lucide-react'; +import { GitCommit, Loader2, Sparkles } from 'lucide-react'; import { getElectronAPI } from '@/lib/electron'; import { toast } from 'sonner'; @@ -37,6 +37,7 @@ export function CommitWorktreeDialog({ }: CommitWorktreeDialogProps) { const [message, setMessage] = useState(''); const [isLoading, setIsLoading] = useState(false); + const [isGenerating, setIsGenerating] = useState(false); const [error, setError] = useState(null); const handleCommit = async () => { @@ -82,6 +83,45 @@ export function CommitWorktreeDialog({ } }; + // Generate AI commit message when dialog opens + useEffect(() => { + if (open && worktree) { + // Reset state + setMessage(''); + setError(null); + setIsGenerating(true); + + const generateMessage = async () => { + try { + const api = getElectronAPI(); + if (!api?.worktree?.generateCommitMessage) { + setError('AI commit message generation not available'); + setIsGenerating(false); + return; + } + + const result = await api.worktree.generateCommitMessage(worktree.path); + + if (result.success && result.message) { + setMessage(result.message); + } else { + // Don't show error toast, just log it and leave message empty + console.warn('Failed to generate commit message:', result.error); + setMessage(''); + } + } catch (err) { + // Don't show error toast for generation failures + console.warn('Error generating commit message:', err); + setMessage(''); + } finally { + setIsGenerating(false); + } + }; + + generateMessage(); + } + }, [open, worktree]); + if (!worktree) return null; return ( @@ -106,10 +146,20 @@ export function CommitWorktreeDialog({
- +