344 lines
12 KiB
Python
344 lines
12 KiB
Python
"""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 <files>'", 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()
|