diff --git a/git_commit_ai/cli/cli.py b/git_commit_ai/cli/cli.py new file mode 100644 index 0000000..cf070fc --- /dev/null +++ b/git_commit_ai/cli/cli.py @@ -0,0 +1,343 @@ +"""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()