Suggested commit message

This commit is contained in:
AndyMik90
2025-12-20 12:20:02 +01:00
parent 08aa2ff02b
commit cdda3ff277
9 changed files with 516 additions and 4 deletions
@@ -500,6 +500,21 @@ export function registerWorktreeHandlers(
debug('Merge result. isStageOnly:', isStageOnly, 'newStatus:', newStatus, 'staged:', staged);
// Read suggested commit message if staging succeeded
let suggestedCommitMessage: string | undefined;
if (staged) {
const commitMsgPath = path.join(specDir, 'suggested_commit_message.txt');
try {
if (existsSync(commitMsgPath)) {
const { readFileSync } = require('fs');
suggestedCommitMessage = readFileSync(commitMsgPath, 'utf-8').trim();
debug('Read suggested commit message:', suggestedCommitMessage?.substring(0, 100));
}
} catch (e) {
debug('Failed to read suggested commit message:', e);
}
}
// Persist the status change to implementation_plan.json
const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
try {
@@ -531,7 +546,8 @@ export function registerWorktreeHandlers(
success: true,
message,
staged,
projectPath: staged ? project.path : undefined
projectPath: staged ? project.path : undefined,
suggestedCommitMessage
}
});
} else {
@@ -120,6 +120,7 @@ function TaskDetailModalContent({ open, task, onOpenChange }: { open: boolean; t
state.setWorkspaceError(null);
state.setStagedSuccess(result.data.message || 'Changes staged in main project');
state.setStagedProjectPath(result.data.projectPath);
state.setSuggestedCommitMessage(result.data.suggestedCommitMessage);
} else {
onOpenChange(false);
}
@@ -393,6 +394,7 @@ function TaskDetailModalContent({ open, task, onOpenChange }: { open: boolean; t
stageOnly={state.stageOnly}
stagedSuccess={state.stagedSuccess}
stagedProjectPath={state.stagedProjectPath}
suggestedCommitMessage={state.suggestedCommitMessage}
mergePreview={state.mergePreview}
isLoadingPreview={state.isLoadingPreview}
showConflictDialog={state.showConflictDialog}
@@ -84,6 +84,7 @@ export function TaskDetailPanel({ task, onClose }: TaskDetailPanelProps) {
state.setWorkspaceError(null);
state.setStagedSuccess(result.data.message || 'Changes staged in main project');
state.setStagedProjectPath(result.data.projectPath);
state.setSuggestedCommitMessage(result.data.suggestedCommitMessage);
} else {
console.warn('[TaskDetailPanel] Full merge success, closing panel');
onClose();
@@ -196,6 +197,7 @@ export function TaskDetailPanel({ task, onClose }: TaskDetailPanelProps) {
stageOnly={state.stageOnly}
stagedSuccess={state.stagedSuccess}
stagedProjectPath={state.stagedProjectPath}
suggestedCommitMessage={state.suggestedCommitMessage}
mergePreview={state.mergePreview}
isLoadingPreview={state.isLoadingPreview}
showConflictDialog={state.showConflictDialog}
@@ -26,6 +26,7 @@ interface TaskReviewProps {
stageOnly: boolean;
stagedSuccess: string | null;
stagedProjectPath: string | undefined;
suggestedCommitMessage: string | undefined;
mergePreview: { files: string[]; conflicts: MergeConflict[]; summary: MergeStats; gitConflicts?: GitConflictInfo; uncommittedChanges?: { hasChanges: boolean; files: string[]; count: number } | null } | null;
isLoadingPreview: boolean;
showConflictDialog: boolean;
@@ -64,6 +65,7 @@ export function TaskReview({
stageOnly,
stagedSuccess,
stagedProjectPath,
suggestedCommitMessage,
mergePreview,
isLoadingPreview,
showConflictDialog,
@@ -88,6 +90,7 @@ export function TaskReview({
stagedSuccess={stagedSuccess}
stagedProjectPath={stagedProjectPath}
task={task}
suggestedCommitMessage={suggestedCommitMessage}
/>
)}
@@ -30,6 +30,7 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
const [stageOnly, setStageOnly] = useState(task.status === 'human_review');
const [stagedSuccess, setStagedSuccess] = useState<string | null>(null);
const [stagedProjectPath, setStagedProjectPath] = useState<string | undefined>(undefined);
const [suggestedCommitMessage, setSuggestedCommitMessage] = useState<string | undefined>(undefined);
const [phaseLogs, setPhaseLogs] = useState<TaskLogs | null>(null);
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const [expandedPhases, setExpandedPhases] = useState<Set<TaskLogPhase>>(new Set());
@@ -279,6 +280,7 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
stageOnly,
stagedSuccess,
stagedProjectPath,
suggestedCommitMessage,
phaseLogs,
isLoadingLogs,
expandedPhases,
@@ -318,6 +320,7 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
setStageOnly,
setStagedSuccess,
setStagedProjectPath,
setSuggestedCommitMessage,
setPhaseLogs,
setIsLoadingLogs,
setExpandedPhases,
@@ -1,11 +1,14 @@
import { GitMerge, ExternalLink } from 'lucide-react';
import { useState } from 'react';
import { GitMerge, ExternalLink, Copy, Check, Sparkles } from 'lucide-react';
import { Button } from '../../ui/button';
import { Textarea } from '../../ui/textarea';
import type { Task } from '../../../../shared/types';
interface StagedSuccessMessageProps {
stagedSuccess: string;
stagedProjectPath: string | undefined;
task: Task;
suggestedCommitMessage?: string;
}
/**
@@ -14,8 +17,23 @@ interface StagedSuccessMessageProps {
export function StagedSuccessMessage({
stagedSuccess,
stagedProjectPath,
task
task,
suggestedCommitMessage
}: StagedSuccessMessageProps) {
const [commitMessage, setCommitMessage] = useState(suggestedCommitMessage || '');
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
if (!commitMessage) return;
try {
await navigator.clipboard.writeText(commitMessage);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
return (
<div className="rounded-xl border border-success/30 bg-success/10 p-4">
<h3 className="font-medium text-sm text-foreground mb-2 flex items-center gap-2">
@@ -25,6 +43,47 @@ export function StagedSuccessMessage({
<p className="text-sm text-muted-foreground mb-3">
{stagedSuccess}
</p>
{/* Commit Message Section */}
{suggestedCommitMessage && (
<div className="bg-background/50 rounded-lg p-3 mb-3">
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<Sparkles className="h-3 w-3 text-purple-400" />
AI-generated commit message
</p>
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
className="h-6 px-2 text-xs"
disabled={!commitMessage}
>
{copied ? (
<>
<Check className="h-3 w-3 mr-1 text-success" />
Copied!
</>
) : (
<>
<Copy className="h-3 w-3 mr-1" />
Copy
</>
)}
</Button>
</div>
<Textarea
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
className="font-mono text-xs min-h-[100px] bg-background/80 resize-y"
placeholder="Commit message..."
/>
<p className="text-[10px] text-muted-foreground mt-1.5">
Edit as needed, then copy and use with <code className="bg-background px-1 rounded">git commit -m "..."</code>
</p>
</div>
)}
<div className="bg-background/50 rounded-lg p-3 mb-3">
<p className="text-xs text-muted-foreground mb-2">Next steps:</p>
<ol className="text-xs text-muted-foreground space-y-1 list-decimal list-inside">
+2
View File
@@ -356,6 +356,8 @@ export interface WorktreeMergeResult {
staged?: boolean;
alreadyStaged?: boolean;
projectPath?: string;
// AI-generated commit message suggestion (for stage-only mode)
suggestedCommitMessage?: string;
// New conflict info from smart merge
conflicts?: MergeConflict[];
stats?: MergeStats;
+77 -1
View File
@@ -189,7 +189,83 @@ def handle_merge_command(
Returns:
True if merge succeeded, False otherwise
"""
return merge_existing_build(project_dir, spec_name, no_commit=no_commit)
success = merge_existing_build(project_dir, spec_name, no_commit=no_commit)
# Generate commit message suggestion if staging succeeded (no_commit mode)
if success and no_commit:
_generate_and_save_commit_message(project_dir, spec_name)
return success
def _generate_and_save_commit_message(project_dir: Path, spec_name: str) -> None:
"""
Generate a commit message suggestion and save it for the UI.
Args:
project_dir: Project root directory
spec_name: Name of the spec
"""
try:
from commit_message import generate_commit_message_sync
# Get diff summary for context
diff_summary = ""
files_changed = []
try:
result = subprocess.run(
["git", "diff", "--staged", "--stat"],
cwd=project_dir,
capture_output=True,
text=True,
)
if result.returncode == 0:
diff_summary = result.stdout.strip()
# Get list of changed files
result = subprocess.run(
["git", "diff", "--staged", "--name-only"],
cwd=project_dir,
capture_output=True,
text=True,
)
if result.returncode == 0:
files_changed = [
f.strip() for f in result.stdout.strip().split("\n") if f.strip()
]
except Exception as e:
debug_warning(MODULE, f"Could not get diff summary: {e}")
# Generate commit message
debug(MODULE, "Generating commit message suggestion...")
commit_message = generate_commit_message_sync(
project_dir=project_dir,
spec_name=spec_name,
diff_summary=diff_summary,
files_changed=files_changed,
)
if commit_message:
# Save to spec directory for UI to read
spec_dir = project_dir / ".auto-claude" / "specs" / spec_name
if not spec_dir.exists():
spec_dir = project_dir / "auto-claude" / "specs" / spec_name
if spec_dir.exists():
commit_msg_file = spec_dir / "suggested_commit_message.txt"
commit_msg_file.write_text(commit_message, encoding="utf-8")
debug_success(
MODULE, f"Saved commit message suggestion to {commit_msg_file}"
)
else:
debug_warning(MODULE, f"Spec directory not found: {spec_dir}")
else:
debug_warning(MODULE, "No commit message generated")
except ImportError:
debug_warning(MODULE, "commit_message module not available")
except Exception as e:
debug_warning(MODULE, f"Failed to generate commit message: {e}")
def handle_review_command(project_dir: Path, spec_name: str) -> None:
+349
View File
@@ -0,0 +1,349 @@
"""
Commit Message Generator
========================
Generates high-quality commit messages using Claude Haiku.
Features:
- Conventional commits format (feat/fix/refactor/etc)
- GitHub issue references (Fixes #123)
- Context-aware descriptions from spec metadata
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
import sys
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__)
# Map task categories to conventional commit types
CATEGORY_TO_COMMIT_TYPE = {
"feature": "feat",
"bug_fix": "fix",
"bug": "fix",
"refactoring": "refactor",
"refactor": "refactor",
"documentation": "docs",
"docs": "docs",
"testing": "test",
"test": "test",
"performance": "perf",
"perf": "perf",
"security": "security",
"chore": "chore",
"style": "style",
"ci": "ci",
"build": "build",
}
SYSTEM_PROMPT = """You are a Git expert who writes clear, concise commit messages following conventional commits format.
Rules:
1. First line: type(scope): description (max 72 chars total)
2. Leave blank line after first line
3. Body: 1-3 sentences explaining WHAT changed and WHY
4. If GitHub issue number provided, end with "Fixes #N" on its own line
5. Be specific about the changes, not generic
6. Use imperative mood ("Add feature" not "Added feature")
Types: feat, fix, refactor, docs, test, perf, chore, style, ci, build
Example output:
feat(auth): add OAuth2 login flow
Implement OAuth2 authentication with Google and GitHub providers.
Add token refresh logic and secure storage.
Fixes #42"""
def _get_spec_context(spec_dir: Path) -> dict:
"""
Extract context from spec files for commit message generation.
Returns dict with:
- title: Feature/task title
- category: Task category (feature, bug_fix, etc)
- description: Brief description
- github_issue: GitHub issue number if linked
"""
context = {
"title": "",
"category": "chore",
"description": "",
"github_issue": None,
}
# Try to read spec.md for title
spec_file = spec_dir / "spec.md"
if spec_file.exists():
try:
content = spec_file.read_text(encoding="utf-8")
# Extract title from first H1 or H2
title_match = re.search(r"^#+ (.+)$", content, re.MULTILINE)
if title_match:
context["title"] = title_match.group(1).strip()
# Look for overview/description section
overview_match = re.search(
r"## Overview\s*\n(.+?)(?=\n##|\Z)", content, re.DOTALL
)
if overview_match:
context["description"] = overview_match.group(1).strip()[:200]
except Exception as e:
logger.debug(f"Could not read spec.md: {e}")
# Try to read requirements.json for metadata
req_file = spec_dir / "requirements.json"
if req_file.exists():
try:
req_data = json.loads(req_file.read_text(encoding="utf-8"))
if not context["title"] and req_data.get("feature"):
context["title"] = req_data["feature"]
if req_data.get("workflow_type"):
context["category"] = req_data["workflow_type"]
if req_data.get("task_description") and not context["description"]:
context["description"] = req_data["task_description"][:200]
except Exception as e:
logger.debug(f"Could not read requirements.json: {e}")
# Try to read implementation_plan.json for GitHub issue
plan_file = spec_dir / "implementation_plan.json"
if plan_file.exists():
try:
plan_data = json.loads(plan_file.read_text(encoding="utf-8"))
# Check for GitHub metadata
metadata = plan_data.get("metadata", {})
if metadata.get("githubIssueNumber"):
context["github_issue"] = metadata["githubIssueNumber"]
# Fallback title
if not context["title"]:
context["title"] = plan_data.get("feature") or plan_data.get("title", "")
except Exception as e:
logger.debug(f"Could not read implementation_plan.json: {e}")
return context
def _build_prompt(
spec_context: dict,
diff_summary: str,
files_changed: list[str],
) -> str:
"""Build the prompt for Claude."""
commit_type = CATEGORY_TO_COMMIT_TYPE.get(
spec_context.get("category", "").lower(), "chore"
)
github_ref = ""
if spec_context.get("github_issue"):
github_ref = f"\nGitHub Issue: #{spec_context['github_issue']} (include 'Fixes #{spec_context['github_issue']}' at the end)"
# Truncate file list if too long
if len(files_changed) > 20:
files_display = "\n".join(files_changed[:20]) + f"\n... and {len(files_changed) - 20} more files"
else:
files_display = "\n".join(files_changed) if files_changed else "(no files listed)"
prompt = f"""Generate a commit message for this change.
Task: {spec_context.get('title', 'Unknown task')}
Type: {commit_type}
Files changed: {len(files_changed)}
{github_ref}
Description: {spec_context.get('description', 'No description available')}
Changed files:
{files_display}
Diff summary:
{diff_summary[:2000] if diff_summary else '(no diff available)'}
Generate ONLY the commit message, nothing else. Follow the format exactly:
type(scope): short description
Body explaining changes.
Fixes #N (if applicable)"""
return prompt
async def _call_claude_haiku(prompt: str) -> str:
"""Call Claude Haiku with low thinking for fast commit message generation."""
from core.auth import ensure_claude_code_oauth_token, get_auth_token
if not get_auth_token():
logger.warning("No authentication token found")
return ""
ensure_claude_code_oauth_token()
try:
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
except ImportError:
logger.warning("claude_agent_sdk not installed")
return ""
client = ClaudeSDKClient(
options=ClaudeAgentOptions(
model="claude-haiku-4-5-20251001",
system_prompt=SYSTEM_PROMPT,
allowed_tools=[],
max_turns=1,
max_thinking_tokens=1024, # Low thinking for speed
)
)
try:
async with client:
await client.query(prompt)
response_text = ""
async for msg in client.receive_response():
msg_type = type(msg).__name__
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content:
if hasattr(block, "text"):
response_text += block.text
logger.info(f"Generated commit message: {len(response_text)} chars")
return response_text.strip()
except Exception as e:
logger.error(f"Claude SDK call failed: {e}")
print(f" [WARN] Commit message generation failed: {e}", file=sys.stderr)
return ""
def generate_commit_message_sync(
project_dir: Path,
spec_name: str,
diff_summary: str = "",
files_changed: list[str] | None = None,
github_issue: int | None = None,
) -> str:
"""
Generate a commit message synchronously.
Args:
project_dir: Project root directory
spec_name: Spec identifier (e.g., "001-add-feature")
diff_summary: Git diff stat or summary
files_changed: List of changed file paths
github_issue: GitHub issue number if linked (overrides spec metadata)
Returns:
Generated commit message or fallback message
"""
# Find spec directory
spec_dir = project_dir / ".auto-claude" / "specs" / spec_name
if not spec_dir.exists():
# Try alternative location
spec_dir = project_dir / "auto-claude" / "specs" / spec_name
# Get context from spec files
spec_context = _get_spec_context(spec_dir) if spec_dir.exists() else {}
# Override with provided github_issue
if github_issue:
spec_context["github_issue"] = github_issue
# Build prompt
prompt = _build_prompt(
spec_context,
diff_summary,
files_changed or [],
)
# Call Claude
try:
result = asyncio.run(_call_claude_haiku(prompt))
if result:
return result
except Exception as e:
logger.error(f"Failed to generate commit message: {e}")
# Fallback message
commit_type = CATEGORY_TO_COMMIT_TYPE.get(
spec_context.get("category", "").lower(), "chore"
)
title = spec_context.get("title", spec_name)
fallback = f"{commit_type}: {title}"
if github_issue or spec_context.get("github_issue"):
issue_num = github_issue or spec_context.get("github_issue")
fallback += f"\n\nFixes #{issue_num}"
return fallback
async def generate_commit_message(
project_dir: Path,
spec_name: str,
diff_summary: str = "",
files_changed: list[str] | None = None,
github_issue: int | None = None,
) -> str:
"""
Generate a commit message asynchronously.
Args:
project_dir: Project root directory
spec_name: Spec identifier (e.g., "001-add-feature")
diff_summary: Git diff stat or summary
files_changed: List of changed file paths
github_issue: GitHub issue number if linked (overrides spec metadata)
Returns:
Generated commit message or fallback message
"""
# Find spec directory
spec_dir = project_dir / ".auto-claude" / "specs" / spec_name
if not spec_dir.exists():
spec_dir = project_dir / "auto-claude" / "specs" / spec_name
# Get context from spec files
spec_context = _get_spec_context(spec_dir) if spec_dir.exists() else {}
# Override with provided github_issue
if github_issue:
spec_context["github_issue"] = github_issue
# Build prompt
prompt = _build_prompt(
spec_context,
diff_summary,
files_changed or [],
)
# Call Claude
try:
result = await _call_claude_haiku(prompt)
if result:
return result
except Exception as e:
logger.error(f"Failed to generate commit message: {e}")
# Fallback message
commit_type = CATEGORY_TO_COMMIT_TYPE.get(
spec_context.get("category", "").lower(), "chore"
)
title = spec_context.get("title", spec_name)
fallback = f"{commit_type}: {title}"
if github_issue or spec_context.get("github_issue"):
issue_num = github_issue or spec_context.get("github_issue")
fallback += f"\n\nFixes #{issue_num}"
return fallback