feat(worktree): add AI commit message generation feature

- Implemented a new endpoint to generate commit messages based on git diffs.
- Updated worktree routes to include the AI commit message generation functionality.
- Enhanced the UI to support automatic generation of commit messages when the commit dialog opens, based on user settings.
- Added settings for enabling/disabling AI-generated commit messages and configuring the model used for generation.
This commit is contained in:
Shirone
2026-01-12 20:38:00 +01:00
parent 8b19266c9a
commit 5e4f5f86cd
10 changed files with 162 additions and 42 deletions

View File

@@ -217,7 +217,7 @@ app.use('/api/sessions', createSessionsRoutes(agentService));
app.use('/api/features', createFeaturesRoutes(featureLoader)); app.use('/api/features', createFeaturesRoutes(featureLoader));
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService)); app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService)); app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
app.use('/api/worktree', createWorktreeRoutes(events)); app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
app.use('/api/git', createGitRoutes()); app.use('/api/git', createGitRoutes());
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService)); app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
app.use('/api/models', createModelsRoutes()); app.use('/api/models', createModelsRoutes());

View File

@@ -40,8 +40,12 @@ import {
createDeleteInitScriptHandler, createDeleteInitScriptHandler,
createRunInitScriptHandler, createRunInitScriptHandler,
} from './routes/init-script.js'; } from './routes/init-script.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createWorktreeRoutes(events: EventEmitter): Router { export function createWorktreeRoutes(
events: EventEmitter,
settingsService?: SettingsService
): Router {
const router = Router(); const router = Router();
router.post('/info', validatePathParams('projectPath'), createInfoHandler()); router.post('/info', validatePathParams('projectPath'), createInfoHandler());
@@ -69,7 +73,7 @@ export function createWorktreeRoutes(events: EventEmitter): Router {
'/generate-commit-message', '/generate-commit-message',
validatePathParams('worktreePath'), validatePathParams('worktreePath'),
requireGitRepoOnly, requireGitRepoOnly,
createGenerateCommitMessageHandler() createGenerateCommitMessageHandler(settingsService)
); );
router.post( router.post(
'/push', '/push',

View File

@@ -1,7 +1,8 @@
/** /**
* POST /worktree/generate-commit-message endpoint - Generate an AI commit message from git diff * 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. * Uses the configured model (via phaseModels.commitMessageModel) to generate a concise,
* conventional commit message from git changes. Defaults to Claude Haiku for speed.
*/ */
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
@@ -9,12 +10,26 @@ import { exec } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import { query } from '@anthropic-ai/claude-agent-sdk'; import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils'; import { createLogger } from '@automaker/utils';
import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver'; import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { mergeCommitMessagePrompts } from '@automaker/prompts';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getErrorMessage, logError } from '../common.js'; import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('GenerateCommitMessage'); const logger = createLogger('GenerateCommitMessage');
const execAsync = promisify(exec); const execAsync = promisify(exec);
/**
* Get the effective system prompt for commit message generation.
* Uses custom prompt from settings if enabled, otherwise falls back to default.
*/
async function getSystemPrompt(settingsService?: SettingsService): Promise<string> {
const settings = await settingsService?.getGlobalSettings();
const prompts = mergeCommitMessagePrompts(settings?.promptCustomization?.commitMessage);
return prompts.systemPrompt;
}
interface GenerateCommitMessageRequestBody { interface GenerateCommitMessageRequestBody {
worktreePath: string; worktreePath: string;
} }
@@ -29,23 +44,6 @@ interface GenerateCommitMessageErrorResponse {
error: string; 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( async function extractTextFromStream(
stream: AsyncIterable<{ stream: AsyncIterable<{
type: string; type: string;
@@ -73,10 +71,9 @@ async function extractTextFromStream(
return responseText; return responseText;
} }
export function createGenerateCommitMessageHandler(): ( export function createGenerateCommitMessageHandler(
req: Request, settingsService?: SettingsService
res: Response ): (req: Request, res: Response) => Promise<void> {
) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => { return async (req: Request, res: Response): Promise<void> => {
try { try {
const { worktreePath } = req.body as GenerateCommitMessageRequestBody; const { worktreePath } = req.body as GenerateCommitMessageRequestBody;
@@ -136,21 +133,66 @@ export function createGenerateCommitMessageHandler(): (
const userPrompt = `Generate a commit message for these changes:\n\n\`\`\`diff\n${truncatedDiff}\n\`\`\``; const userPrompt = `Generate a commit message for these changes:\n\n\`\`\`diff\n${truncatedDiff}\n\`\`\``;
const stream = query({ // Get model from phase settings
prompt: userPrompt, const settings = await settingsService?.getGlobalSettings();
options: { const phaseModelEntry =
model: CLAUDE_MODEL_MAP.haiku, settings?.phaseModels?.commitMessageModel || DEFAULT_PHASE_MODELS.commitMessageModel;
systemPrompt: SYSTEM_PROMPT, const { model } = resolvePhaseModel(phaseModelEntry);
logger.info(`Using model for commit message: ${model}`);
// Get the effective system prompt (custom or default)
const systemPrompt = await getSystemPrompt(settingsService);
let message: string;
// Route to appropriate provider based on model type
if (isCursorModel(model)) {
// Use Cursor provider for Cursor models
logger.info(`Using Cursor provider for model: ${model}`);
const provider = ProviderFactory.getProviderForModel(model);
const bareModel = stripProviderPrefix(model);
const cursorPrompt = `${systemPrompt}\n\n${userPrompt}`;
let responseText = '';
for await (const msg of provider.executeQuery({
prompt: cursorPrompt,
model: bareModel,
cwd: worktreePath,
maxTurns: 1, maxTurns: 1,
allowedTools: [], allowedTools: [],
permissionMode: 'default', readOnly: true,
}, })) {
}); if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
}
}
const message = await extractTextFromStream(stream); message = responseText.trim();
} else {
// Use Claude SDK for Claude models
const stream = query({
prompt: userPrompt,
options: {
model,
systemPrompt,
maxTurns: 1,
allowedTools: [],
permissionMode: 'default',
},
});
message = await extractTextFromStream(stream);
}
if (!message || message.trim().length === 0) { if (!message || message.trim().length === 0) {
logger.warn('Received empty response from Claude'); logger.warn('Received empty response from model');
const response: GenerateCommitMessageErrorResponse = { const response: GenerateCommitMessageErrorResponse = {
success: false, success: false,
error: 'Failed to generate commit message - empty response', error: 'Failed to generate commit message - empty response',

View File

@@ -13,6 +13,7 @@ import { Label } from '@/components/ui/label';
import { GitCommit, Loader2, Sparkles } from 'lucide-react'; import { GitCommit, Loader2, Sparkles } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
interface WorktreeInfo { interface WorktreeInfo {
path: string; path: string;
@@ -39,6 +40,7 @@ export function CommitWorktreeDialog({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const enableAiCommitMessages = useAppStore((state) => state.enableAiCommitMessages);
const handleCommit = async () => { const handleCommit = async () => {
if (!worktree || !message.trim()) return; if (!worktree || !message.trim()) return;
@@ -83,19 +85,24 @@ export function CommitWorktreeDialog({
} }
}; };
// Generate AI commit message when dialog opens // Generate AI commit message when dialog opens (if enabled)
useEffect(() => { useEffect(() => {
if (open && worktree) { if (open && worktree) {
// Reset state // Reset state
setMessage(''); setMessage('');
setError(null); setError(null);
// Only generate AI commit message if enabled
if (!enableAiCommitMessages) {
return;
}
setIsGenerating(true); setIsGenerating(true);
const generateMessage = async () => { const generateMessage = async () => {
try { try {
const api = getElectronAPI(); const api = getElectronAPI();
if (!api?.worktree?.generateCommitMessage) { if (!api?.worktree?.generateCommitMessage) {
setError('AI commit message generation not available');
setIsGenerating(false); setIsGenerating(false);
return; return;
} }
@@ -120,7 +127,7 @@ export function CommitWorktreeDialog({
generateMessage(); generateMessage();
} }
}, [open, worktree]); }, [open, worktree, enableAiCommitMessages]);
if (!worktree) return null; if (!worktree) return null;

View File

@@ -44,6 +44,8 @@ export function SettingsView() {
setEnableDependencyBlocking, setEnableDependencyBlocking,
skipVerificationInAutoMode, skipVerificationInAutoMode,
setSkipVerificationInAutoMode, setSkipVerificationInAutoMode,
enableAiCommitMessages,
setEnableAiCommitMessages,
useWorktrees, useWorktrees,
setUseWorktrees, setUseWorktrees,
muteDoneSound, muteDoneSound,
@@ -182,11 +184,13 @@ export function SettingsView() {
skipVerificationInAutoMode={skipVerificationInAutoMode} skipVerificationInAutoMode={skipVerificationInAutoMode}
defaultPlanningMode={defaultPlanningMode} defaultPlanningMode={defaultPlanningMode}
defaultRequirePlanApproval={defaultRequirePlanApproval} defaultRequirePlanApproval={defaultRequirePlanApproval}
enableAiCommitMessages={enableAiCommitMessages}
onDefaultSkipTestsChange={setDefaultSkipTests} onDefaultSkipTestsChange={setDefaultSkipTests}
onEnableDependencyBlockingChange={setEnableDependencyBlocking} onEnableDependencyBlockingChange={setEnableDependencyBlocking}
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode} onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
onDefaultPlanningModeChange={setDefaultPlanningMode} onDefaultPlanningModeChange={setDefaultPlanningMode}
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval} onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
onEnableAiCommitMessagesChange={setEnableAiCommitMessages}
/> />
); );
case 'worktrees': case 'worktrees':

View File

@@ -10,6 +10,7 @@ import {
ScrollText, ScrollText,
ShieldCheck, ShieldCheck,
FastForward, FastForward,
Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
@@ -28,11 +29,13 @@ interface FeatureDefaultsSectionProps {
skipVerificationInAutoMode: boolean; skipVerificationInAutoMode: boolean;
defaultPlanningMode: PlanningMode; defaultPlanningMode: PlanningMode;
defaultRequirePlanApproval: boolean; defaultRequirePlanApproval: boolean;
enableAiCommitMessages: boolean;
onDefaultSkipTestsChange: (value: boolean) => void; onDefaultSkipTestsChange: (value: boolean) => void;
onEnableDependencyBlockingChange: (value: boolean) => void; onEnableDependencyBlockingChange: (value: boolean) => void;
onSkipVerificationInAutoModeChange: (value: boolean) => void; onSkipVerificationInAutoModeChange: (value: boolean) => void;
onDefaultPlanningModeChange: (value: PlanningMode) => void; onDefaultPlanningModeChange: (value: PlanningMode) => void;
onDefaultRequirePlanApprovalChange: (value: boolean) => void; onDefaultRequirePlanApprovalChange: (value: boolean) => void;
onEnableAiCommitMessagesChange: (value: boolean) => void;
} }
export function FeatureDefaultsSection({ export function FeatureDefaultsSection({
@@ -41,11 +44,13 @@ export function FeatureDefaultsSection({
skipVerificationInAutoMode, skipVerificationInAutoMode,
defaultPlanningMode, defaultPlanningMode,
defaultRequirePlanApproval, defaultRequirePlanApproval,
enableAiCommitMessages,
onDefaultSkipTestsChange, onDefaultSkipTestsChange,
onEnableDependencyBlockingChange, onEnableDependencyBlockingChange,
onSkipVerificationInAutoModeChange, onSkipVerificationInAutoModeChange,
onDefaultPlanningModeChange, onDefaultPlanningModeChange,
onDefaultRequirePlanApprovalChange, onDefaultRequirePlanApprovalChange,
onEnableAiCommitMessagesChange,
}: FeatureDefaultsSectionProps) { }: FeatureDefaultsSectionProps) {
return ( return (
<div <div
@@ -251,6 +256,34 @@ export function FeatureDefaultsSection({
</p> </p>
</div> </div>
</div> </div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* AI Commit Messages Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="enable-ai-commit-messages"
checked={enableAiCommitMessages}
onCheckedChange={(checked) => onEnableAiCommitMessagesChange(checked === true)}
className="mt-1"
data-testid="enable-ai-commit-messages-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="enable-ai-commit-messages"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Sparkles className="w-4 h-4 text-brand-500" />
Generate AI commit messages
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, opening the commit dialog will automatically generate a commit message
using AI based on your staged or unstaged changes. You can configure the model used in
Model Defaults.
</p>
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -28,6 +28,11 @@ const QUICK_TASKS: PhaseConfig[] = [
label: 'Image Descriptions', label: 'Image Descriptions',
description: 'Analyzes and describes context images', description: 'Analyzes and describes context images',
}, },
{
key: 'commitMessageModel',
label: 'Commit Messages',
description: 'Generates git commit messages from diffs',
},
]; ];
const VALIDATION_TASKS: PhaseConfig[] = [ const VALIDATION_TASKS: PhaseConfig[] = [

View File

@@ -1538,11 +1538,10 @@ function createMockWorktreeAPI(): WorktreeAPI {
}, },
generateCommitMessage: async (worktreePath: string) => { generateCommitMessage: async (worktreePath: string) => {
console.log('[Mock] Generating commit message:', { worktreePath }); console.log('[Mock] Generating commit message for:', worktreePath);
return { return {
success: true, success: true,
message: message: 'feat: Add mock commit message generation',
'feat: Add new feature implementation\n\nThis is a mock AI-generated commit message.',
}; };
}, },

View File

@@ -536,6 +536,7 @@ export interface AppState {
defaultSkipTests: boolean; // Default value for skip tests when creating new features defaultSkipTests: boolean; // Default value for skip tests when creating new features
enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true) enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true)
skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running) skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running)
enableAiCommitMessages: boolean; // When true, auto-generate commit messages using AI when opening commit dialog
planUseSelectedWorktreeBranch: boolean; // When true, Plan dialog creates features on the currently selected worktree branch planUseSelectedWorktreeBranch: boolean; // When true, Plan dialog creates features on the currently selected worktree branch
addFeatureUseSelectedWorktreeBranch: boolean; // When true, Add Feature dialog defaults to custom mode with selected worktree branch addFeatureUseSelectedWorktreeBranch: boolean; // When true, Add Feature dialog defaults to custom mode with selected worktree branch
@@ -932,6 +933,7 @@ export interface AppActions {
setDefaultSkipTests: (skip: boolean) => void; setDefaultSkipTests: (skip: boolean) => void;
setEnableDependencyBlocking: (enabled: boolean) => void; setEnableDependencyBlocking: (enabled: boolean) => void;
setSkipVerificationInAutoMode: (enabled: boolean) => Promise<void>; setSkipVerificationInAutoMode: (enabled: boolean) => Promise<void>;
setEnableAiCommitMessages: (enabled: boolean) => Promise<void>;
setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>; setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>; setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise<void>;
@@ -1218,6 +1220,7 @@ const initialState: AppState = {
defaultSkipTests: true, // Default to manual verification (tests disabled) defaultSkipTests: true, // Default to manual verification (tests disabled)
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI) enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
skipVerificationInAutoMode: false, // Default to disabled (require dependencies to be verified) skipVerificationInAutoMode: false, // Default to disabled (require dependencies to be verified)
enableAiCommitMessages: true, // Default to enabled (auto-generate commit messages)
planUseSelectedWorktreeBranch: true, // Default to enabled (Plan creates features on selected worktree branch) planUseSelectedWorktreeBranch: true, // Default to enabled (Plan creates features on selected worktree branch)
addFeatureUseSelectedWorktreeBranch: false, // Default to disabled (Add Feature uses normal defaults) addFeatureUseSelectedWorktreeBranch: false, // Default to disabled (Add Feature uses normal defaults)
useWorktrees: true, // Default to enabled (git worktree isolation) useWorktrees: true, // Default to enabled (git worktree isolation)
@@ -1848,6 +1851,17 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
await syncSettingsToServer(); await syncSettingsToServer();
}, },
setEnableAiCommitMessages: async (enabled) => {
const previous = get().enableAiCommitMessages;
set({ enableAiCommitMessages: enabled });
// Sync to server settings file
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
const ok = await syncSettingsToServer();
if (!ok) {
logger.error('Failed to sync enableAiCommitMessages setting to server - reverting');
set({ enableAiCommitMessages: previous });
}
},
setPlanUseSelectedWorktreeBranch: async (enabled) => { setPlanUseSelectedWorktreeBranch: async (enabled) => {
const previous = get().planUseSelectedWorktreeBranch; const previous = get().planUseSelectedWorktreeBranch;
set({ planUseSelectedWorktreeBranch: enabled }); set({ planUseSelectedWorktreeBranch: enabled });

View File

@@ -157,6 +157,10 @@ export interface PhaseModelConfig {
// Memory tasks - for learning extraction and memory operations // Memory tasks - for learning extraction and memory operations
/** Model for extracting learnings from completed agent sessions */ /** Model for extracting learnings from completed agent sessions */
memoryExtractionModel: PhaseModelEntry; memoryExtractionModel: PhaseModelEntry;
// Quick tasks - commit messages
/** Model for generating git commit messages from diffs */
commitMessageModel: PhaseModelEntry;
} }
/** Keys of PhaseModelConfig for type-safe access */ /** Keys of PhaseModelConfig for type-safe access */
@@ -398,6 +402,10 @@ export interface GlobalSettings {
/** Priority for ntfy notifications (1-5, default: 3) */ /** Priority for ntfy notifications (1-5, default: 3) */
ntfyPriority: 1 | 2 | 3 | 4 | 5; ntfyPriority: 1 | 2 | 3 | 4 | 5;
// AI Commit Message Generation
/** Enable AI-generated commit messages when opening commit dialog (default: true) */
enableAiCommitMessages: boolean;
// AI Model Selection (per-phase configuration) // AI Model Selection (per-phase configuration)
/** Phase-specific AI model configuration */ /** Phase-specific AI model configuration */
phaseModels: PhaseModelConfig; phaseModels: PhaseModelConfig;
@@ -669,6 +677,9 @@ export const DEFAULT_PHASE_MODELS: PhaseModelConfig = {
// Memory - use fast model for learning extraction (cost-effective) // Memory - use fast model for learning extraction (cost-effective)
memoryExtractionModel: { model: 'haiku' }, memoryExtractionModel: { model: 'haiku' },
// Commit messages - use fast model for speed
commitMessageModel: { model: 'haiku' },
}; };
/** Current version of the global settings schema */ /** Current version of the global settings schema */
@@ -724,6 +735,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
ntfyTopic: '', ntfyTopic: '',
ntfyAuthToken: undefined, ntfyAuthToken: undefined,
ntfyPriority: 3, ntfyPriority: 3,
enableAiCommitMessages: true,
phaseModels: DEFAULT_PHASE_MODELS, phaseModels: DEFAULT_PHASE_MODELS,
enhancementModel: 'sonnet', enhancementModel: 'sonnet',
validationModel: 'opus', validationModel: 'opus',