diff --git a/src/cli.py b/src/cli.py new file mode 100644 index 0000000..44cddb3 --- /dev/null +++ b/src/cli.py @@ -0,0 +1,244 @@ +"""CLI interface for local-commit-message-generator.""" + +import sys +from pathlib import Path +from typing import Optional + +import click + +from . import __version__ +from .config import ( + DEFAULT_CONFIG, + ConfigError, + ensure_config_exists, + get_config_path, + load_config, + save_config, +) +from .generator import GenerationError, generate_commit_message +from .hooks import HookManager, handle_hook_invocation + + +def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> None: + """Print version information.""" + if not value or ctx.resilient_parsing: + return + click.echo(f"local-commit-message-generator v{__version__}") + ctx.exit() + + +@click.group() +@click.option( + "--version", + is_flag=True, + callback=print_version, + expose_value=False, + help="Show version information." +) +@click.option( + "--repo", + default=None, + help="Path to git repository." +) +@click.pass_context +def main(ctx: click.Context, repo: Optional[str]) -> None: + """Generate conventional commit messages from staged git changes.""" + ctx.ensure_object(dict) + ctx.obj["repo"] = repo + + +@main.command() +@click.pass_context +def generate(ctx: click.Context) -> None: + """Generate a commit message from staged changes.""" + repo = ctx.obj.get("repo") + + try: + message = generate_commit_message(repo_path=repo) + click.echo(message) + except GenerationError as e: + click.echo(f"Error: {e}", err=True) + click.echo("Make sure you have staged changes with 'git add'.", err=True) + sys.exit(1) + except Exception as e: + click.echo(f"Unexpected error: {e}", err=True) + sys.exit(1) + + +@main.command() +@click.pass_context +def hook(ctx: click.Context) -> None: + """Handle prepare-commit-msg hook invocation. + + This is called automatically by git when the hook is installed. + Do not call this directly. + """ + repo = ctx.obj.get("repo") + args = sys.argv[1:] if sys.argv else [] + + try: + message = handle_hook_invocation(args, repo) + if message: + click.echo(message) + except Exception: + pass + + +@main.command() +@click.option( + "--path", + "config_path", + default=None, + help="Path to config file." +) +@click.pass_context +def install_hook(ctx: click.Context, config_path: Optional[str]) -> None: + """Install prepare-commit-msg git hook.""" + repo = ctx.obj.get("repo") + repo_path = Path(repo) if repo else Path.cwd() + + manager = HookManager(repo_path) + result = manager.install_hook() + + if result.success: + click.secho(result.message, fg="green") + click.echo("Hook is now active. Commit messages will be auto-generated.") + else: + click.secho(result.message, fg="red", err=True) + sys.exit(1) + + +@main.command() +@click.pass_context +def uninstall_hook(ctx: click.Context) -> None: + """Uninstall prepare-commit-msg git hook.""" + repo = ctx.obj.get("repo") + repo_path = Path(repo) if repo else Path.cwd() + + manager = HookManager(repo_path) + result = manager.uninstall_hook() + + if result.success: + click.secho(result.message, fg="green") + else: + click.secho(result.message, fg="red", err=True) + sys.exit(1) + + +@main.command() +@click.pass_context +def status(ctx: click.Context) -> None: + """Check git repository and staged changes status.""" + repo = ctx.obj.get("repo") + + try: + from .analyzer import ChangeAnalyzer + analyzer = ChangeAnalyzer(repo) + change_set = analyzer.get_staged_changes() + + if change_set.has_changes: + click.secho(f"Staged changes: {change_set.total_count}", fg="green") + for change in change_set.changes[:10]: + emoji = { + "added": "+", + "deleted": "-", + "modified": "~", + "renamed": "R", + }.get(change.change_type.value, "?") + click.echo(f" {emoji} {change.path}") + if change_set.total_count > 10: + click.echo(f" ... and {change_set.total_count - 10} more") + else: + click.secho("No staged changes", fg="yellow") + click.echo("Run 'git add' to stage changes.") + + except ValueError as e: + click.secho(str(e), fg="red", err=True) + sys.exit(1) + + +@main.group() +def config() -> None: + """Manage configuration.""" + pass + + +@config.command("show") +@click.pass_context +def config_show(ctx: click.Context) -> None: + """Show current configuration.""" + try: + ensure_config_exists() + config = load_config() + click.echo("Current Configuration:") + click.echo("-" * 40) + for key, value in config.items(): + if key == "type_rules": + click.echo("type_rules:") + for type_, patterns in value.items(): + click.echo(f" {type_}: {patterns}") + else: + click.echo(f"{key}: {value}") + except ConfigError as e: + click.secho(f"Error: {e}", fg="red", err=True) + sys.exit(1) + + +@config.command("set-template") +@click.argument("template", nargs=-1) +@click.pass_context +def config_set_template(ctx: click.Context, template: tuple) -> None: + """Set the commit message template.""" + try: + ensure_config_exists() + config = load_config() + template_str = " ".join(template) if template else "" + config["template"] = template_str + save_config(config) + click.secho(f"Template updated to: {template_str}", fg="green") + except ConfigError as e: + click.secho(f"Error: {e}", fg="red", err=True) + sys.exit(1) + + +@config.command("reset") +@click.pass_context +def config_reset(ctx: click.Context) -> None: + """Reset configuration to defaults.""" + try: + config_path = get_config_path() + if config_path.exists(): + config_path.unlink() + save_config(DEFAULT_CONFIG.copy()) + click.secho("Configuration reset to defaults.", fg="green") + except ConfigError as e: + click.secho(f"Error: {e}", fg="red", err=True) + sys.exit(1) + + +@main.command() +@click.pass_context +def preview(ctx: click.Context) -> None: + """Preview the commit message without printing.""" + repo = ctx.obj.get("repo") + + try: + from .generator import get_commit_message_preview + message, has_changes = get_commit_message_preview(repo) + + if has_changes: + click.secho("Preview:", fg="cyan") + click.echo("-" * 40) + click.echo(message) + click.echo("-" * 40) + else: + click.secho("No staged changes to preview.", fg="yellow") + + except Exception as e: + click.secho(f"Error: {e}", fg="red", err=True) + sys.exit(1) + + +def cli_entrypoint() -> None: + """Entry point for the CLI.""" + main()