"""CLI interface for Git Commit AI.""" import sys import click from git_commit_ai.core.cache import CacheManager, get_cache_manager from git_commit_ai.core.config import Config, get_config from git_commit_ai.core.conventional import ( ConventionalCommitParser, ConventionalCommitFixer, validate_commit_message, ) from git_commit_ai.core.git_handler import GitError, GitHandler, get_git_handler from git_commit_ai.core.ollama_client import OllamaClient, OllamaError, get_client @click.group() @click.option( "--config", type=click.Path(exists=True, dir_okay=False), help="Path to config.yaml file", ) @click.pass_context def main(ctx: click.Context, config: str) -> None: """Git Commit AI - Generate intelligent commit messages with local LLM.""" ctx.ensure_object(dict) cfg = get_config(config) if config else get_config() ctx.obj["config"] = cfg @main.command() @click.option( "--conventional/--no-conventional", default=None, help="Generate conventional commit format messages", ) @click.option( "--model", default=None, help="Ollama model to use", ) @click.option( "--base-url", default=None, help="Ollama API base URL", ) @click.option( "--interactive/--no-interactive", default=None, help="Interactive mode for selecting messages", ) @click.option( "--show-diff", is_flag=True, default=None, help="Show the diff being analyzed", ) @click.option( "--auto-fix", is_flag=True, default=False, help="Auto-fix conventional commit format issues", ) @click.pass_obj def generate( ctx: dict, conventional: bool | None, model: str | None, base_url: str | None, interactive: bool | None, show_diff: bool, auto_fix: bool, ) -> None: """Generate commit message suggestions for staged changes.""" config: Config = ctx.get("config", get_config()) if conventional is None: conventional = config.conventional_by_default if interactive is None: interactive = config.interactive if show_diff is None: show_diff = config.show_diff git_handler = get_git_handler() if not git_handler.is_repository(): click.echo(click.style("Error: Not in a git repository", fg="red"), err=True) click.echo("Please run this command from within a git repository.", err=True) sys.exit(1) if not git_handler.is_staged(): click.echo(click.style("No staged changes found.", fg="yellow")) click.echo("Please stage your changes first with 'git add '", err=True) sys.exit(1) diff = git_handler.get_staged_changes() if show_diff: click.echo("\nStaged diff:") click.echo("-" * 50) click.echo(diff[:2000] + "..." if len(diff) > 2000 else diff) click.echo("-" * 50) cache_manager = get_cache_manager(config) cached = cache_manager.get(diff, conventional=conventional, model=model or config.ollama_model) if cached: messages = cached click.echo(click.style("Using cached suggestions", fg="cyan")) else: ollama_client = get_client(config) if model: ollama_client.model = model if base_url: ollama_client.base_url = base_url if not ollama_client.is_available(): click.echo(click.style("Error: Ollama server is not available", fg="red"), err=True) click.echo(f"Please ensure Ollama is running at {ollama_client.base_url}", err=True) sys.exit(1) if not ollama_client.check_model_exists(): click.echo(click.style(f"Model '{ollama_client.model}' not found", fg="yellow"), err=True) if click.confirm("Would you like to pull this model?"): if ollama_client.pull_model(): click.echo(click.style("Model pulled successfully", fg="green")) else: click.echo(click.style("Failed to pull model", fg="red"), err=True) sys.exit(1) else: available = ollama_client.list_models() if available: click.echo("Available models:", err=True) for m in available[:10]: click.echo(f" - {m.get('name', 'unknown')}", err=True) sys.exit(1) try: commit_history = git_handler.get_commit_history(max_commits=3) context = "\n".join(f"- {c['hash']}: {c['message']}" for c in commit_history) response = ollama_client.generate_commit_message( diff=diff, context=context if context else None, conventional=conventional, model=model ) messages = [m.strip() for m in response.split("\n") if m.strip() and not m.strip().lower().startswith("suggestion")] if len(messages) == 1: single = messages[0].split("1.", "2.", "3.") if len(single) > 1: messages = [s.strip() for s in single if s.strip()] messages = messages[:config.num_suggestions] cache_manager.set(diff, messages, conventional=conventional, model=model or config.ollama_model) except OllamaError as e: click.echo(click.style(f"Error generating commit message: {e}", fg="red"), err=True) sys.exit(1) if not messages: click.echo(click.style("No suggestions generated", fg="yellow"), err=True) sys.exit(1) if conventional and auto_fix: fixed_messages = [] for msg in messages: is_valid, errors = validate_commit_message(msg) if not is_valid: fixed = ConventionalCommitFixer.fix(msg, diff) fixed_messages.append(fixed) else: fixed_messages.append(msg) messages = fixed_messages click.echo("\n" + click.style("Suggested commit messages:", fg="green")) for i, msg in enumerate(messages, 1): click.echo(f" {i}. {msg}") if conventional: click.echo() for i, msg in enumerate(messages, 1): is_valid, errors = validate_commit_message(msg) if is_valid: click.echo(click.style(f" {i}. [Valid conventional format]", fg="green")) else: click.echo(click.style(f" {i}. [Format issues: {', '.join(errors)}]", fg="yellow")) if interactive: choice = click.prompt("\nSelect a message (number) or press Enter to see all:", type=int, default=0, show_default=False) if 1 <= choice <= len(messages): selected = messages[choice - 1] click.echo(f"\nSelected: {selected}") click.echo(f"\nTo commit, run:") click.echo(f' git commit -m "{selected}"') else: click.echo(f"\nTo use the first suggestion, run:") click.echo(click.style(f' git commit -m "{messages[0]}"', fg="cyan")) @main.command() @click.option("--model", help="Ollama model to check") @click.pass_obj def status(ctx: dict, model: str | None) -> None: """Check Ollama and repository status.""" config: Config = ctx.get("config", get_config()) click.echo("Git Commit AI Status") click.echo("=" * 40) git_handler = get_git_handler() click.echo(f"\nGit Repository: {'Yes' if git_handler.is_repository() else 'No'}") if git_handler.is_repository(): click.echo(f"Staged Changes: {'Yes' if git_handler.is_staged() else 'No'}") ollama_client = get_client(config) if model: ollama_client.model = model click.echo(f"\nOllama:") click.echo(f" Base URL: {ollama_client.base_url}") click.echo(f" Model: {ollama_client.model}") if ollama_client.is_available(): click.echo(f" Status: {click.style('Running', fg='green')}") if ollama_client.check_model_exists(): click.echo(f" Model: {click.style('Available', fg='green')}") else: click.echo(f" Model: {click.style('Not found', fg='yellow')}") available = ollama_client.list_models() if available: click.echo(" Available models:") for m in available[:5]: click.echo(f" - {m.get('name', 'unknown')}") else: click.echo(f" Status: {click.style('Not running', fg='red')}") click.echo(f" Start Ollama with: {click.style('ollama serve', fg='cyan')}") cache_manager = get_cache_manager(config) stats = cache_manager.get_stats() click.echo(f"\nCache:") click.echo(f" Enabled: {'Yes' if stats['enabled'] else 'No'}") click.echo(f" Entries: {stats['entries']}") if stats['entries'] > 0: click.echo(f" Size: {stats['size_bytes'] // 1024} KB") @main.command() @click.pass_obj def models(ctx: dict) -> None: """List available Ollama models.""" config: Config = ctx.get("config", get_config()) ollama_client = get_client(config) if not ollama_client.is_available(): click.echo(click.style("Error: Ollama server is not available", fg="red"), err=True) sys.exit(1) models = ollama_client.list_models() if models: click.echo("Available models:") for m in models: name = m.get("name", "unknown") size = m.get("size", 0) size_mb = size / (1024 * 1024) if size else 0 click.echo(f" {name} ({size_mb:.1f} MB)") else: click.echo("No models found.") @main.command() @click.option("--model", help="Model to pull") @click.pass_obj def pull(ctx: dict, model: str | None) -> None: """Pull an Ollama model.""" config: Config = ctx.get("config", get_config()) ollama_client = get_client(config) model = model or config.ollama_model if not ollama_client.is_available(): click.echo(click.style("Error: Ollama server is not available", fg="red"), err=True) sys.exit(1) with click.progressbar(length=100, label=f"Pulling {model}", show_percent=True, show_pos=True) as progress: success = ollama_client.pull_model(model) if success: progress.update(100) click.echo(click.style(f"\nModel {model} pulled successfully", fg="green")) else: click.echo(click.style(f"\nFailed to pull model {model}", fg="red"), err=True) sys.exit(1) @main.command() @click.option("--force", is_flag=True, help="Force cleanup without confirmation") @click.pass_obj def cache(ctx: dict, force: bool) -> None: """Manage cache.""" config: Config = ctx.get("config", get_config()) cache_manager = get_cache_manager(config) stats = cache_manager.get_stats() click.echo("Cache Status:") click.echo(f" Enabled: {'Yes' if stats['enabled'] else 'No'}") click.echo(f" Entries: {stats['entries']}") click.echo(f" Expired: {stats['expired']}") click.echo(f" Size: {stats['size_bytes'] // 1024} KB") if stats['entries'] > 0: if force or click.confirm("\nClear all cache entries?"): cleared = cache_manager.clear() click.echo(f"Cleared {cleared} entries") @main.command() @click.argument("message") @click.option("--auto-fix", is_flag=True, help="Attempt to auto-fix format issues") def validate(message: str, auto_fix: bool) -> None: """Validate a commit message format.""" is_valid, errors = validate_commit_message(message) if is_valid: click.echo(click.style("Valid commit message", fg="green")) else: click.echo(click.style("Invalid commit message:", fg="red")) for error in errors: click.echo(f" - {error}") if auto_fix: fixed = ConventionalCommitFixer.fix(message, "") if fixed != message: click.echo() click.echo(click.style(f"Suggested fix: {fixed}", fg="cyan")) sys.exit(0 if is_valid else 1) if __name__ == "__main__": main()