From 0acb8576bf8cca97048c394362cbfdf81c0e51a9 Mon Sep 17 00:00:00 2001 From: Amar K P Date: Thu, 17 Sep 2026 17:49:18 +0530 Subject: [PATCH] feat: implement Phase 5 Output & Human-in-the-Loop --- src/gitmate/cli.py | 20 +- src/gitmate/committer.py | 344 ++++++++++++++++++++++++++ src/gitmate/config.py | 11 + src/gitmate/fallback.py | 28 ++- src/gitmate/metrics.py | 188 +++++++++++++++ tests/test_cli.py | 80 ++++++- tests/test_committer.py | 503 +++++++++++++++++++++++++++++++++++++++ tests/test_config.py | 34 +++ tests/test_fallback.py | 75 +++++- tests/test_metrics.py | 214 +++++++++++++++++ 10 files changed, 1485 insertions(+), 12 deletions(-) create mode 100644 src/gitmate/committer.py create mode 100644 src/gitmate/metrics.py create mode 100644 tests/test_committer.py create mode 100644 tests/test_metrics.py diff --git a/src/gitmate/cli.py b/src/gitmate/cli.py index 1d73892..336cbe3 100644 --- a/src/gitmate/cli.py +++ b/src/gitmate/cli.py @@ -29,9 +29,15 @@ def _stub(name: str, milestone: str) -> None: @app.command() -def commit() -> None: - """Generate a commit message for the staged diff.""" - _stub("commit", "coming in Phase 5") +def commit( + yes: bool = typer.Option(False, "--yes", "-y", help="Commit without interactive confirmation."), +) -> None: + """Generate a commit message for the staged diff and review before committing.""" + from gitmate.committer import commit_flow + + code = commit_flow(yes=yes, console=console, secret_store=secret_store) + if code != 0: + raise typer.Exit(code) @app.command() @@ -169,6 +175,14 @@ def config_set(field: str, value: str) -> None: setattr(cfg, field, int_val) elif field == "cache_dir": cfg.cache_dir = None if value in ("", "none") else value.strip() or None + elif field == "allow_noninteractive_commit": + val_clean = value.strip().lower() + if val_clean == "true": + cfg.allow_noninteractive_commit = True + elif val_clean == "false": + cfg.allow_noninteractive_commit = False + else: + raise typer.BadParameter(f"'{field}' must be 'true' or 'false'.") elif field in ("model", "commit_style"): stripped = value.strip() if not stripped: diff --git a/src/gitmate/committer.py b/src/gitmate/committer.py new file mode 100644 index 0000000..297a5bf --- /dev/null +++ b/src/gitmate/committer.py @@ -0,0 +1,344 @@ +"""Interactive commit review loop, guardrails, editor handling, and git execution.""" + +from __future__ import annotations + +import logging +import os +import shlex +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +from rich.console import Console +from rich.panel import Panel + +from gitmate import config as config_mod +from gitmate.config import GitmateConfig +from gitmate.diff_extractor import DiffExtractor, GitCommandError +from gitmate.fallback import generate_commit_message +from gitmate.metrics import record_invocation +from gitmate.providers.base import LLMProvider +from gitmate.token_budget import TokenCounter + +logger = logging.getLogger("gitmate.committer") + +GIT_COMMENT_BLOCK = ( + "\n\n# Please enter the commit message for your changes. Lines starting\n" + "# with '#' will be ignored, and an empty message aborts the commit.\n" +) + + +def _safe_record_telemetry( + command: str, + model: str, + tokens_in: int | None, + tokens_out: int | None, + cache_hit: bool, + latency_ms: int | None, + fallback_used: bool, +) -> None: + """Record invocation to metrics database without propagating errors.""" + try: + record_invocation( + command=command, + model=model, + tokens_in=tokens_in, + tokens_out=tokens_out, + cache_hit=cache_hit, + latency_ms=latency_ms, + fallback_used=fallback_used, + ) + except Exception as exc: # noqa: BLE001 + logger.warning("Failed to record telemetry: %s", exc) + + +def _resolve_editor() -> str: + """Resolve the preferred editor command from environment or fallback.""" + for var in ("GIT_EDITOR", "VISUAL", "EDITOR"): + val = os.environ.get(var) + if val and val.strip(): + return val.strip() + if shutil.which("nano"): + return "nano" + return "vi" + + +def _render_panel(console: Console, message: str, model: str, is_fallback: bool) -> None: + """Display the proposed commit message in a styled Rich Panel.""" + status_badge = ( + "[yellow bold]⚠ FALLBACK TEMPLATE[/yellow bold]" + if is_fallback + else f"[green bold]{model}[/green bold]" + ) + panel = Panel( + message, + title=f"Proposed Commit Message [dim]({status_badge})[/dim]", + border_style="yellow" if is_fallback else "green", + padding=(1, 2), + expand=False, + ) + console.print(panel) + + +def _execute_commit(message: str, console: Console | None = None, cwd: Path | None = None) -> int: + """Commit using a temporary file with git commit -F, ensuring cleanup in finally.""" + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + prefix="gitmate_commit_", + delete=False, + ) as tf: + tf.write(message) + tf.flush() + temp_path = Path(tf.name) + + repo_dir = cwd if cwd is not None else Path.cwd() + try: + proc = subprocess.run( + ["git", "commit", "-F", str(temp_path)], + cwd=repo_dir, + text=True, + capture_output=True, + check=False, + ) + if proc.returncode == 0: + if console is not None and proc.stdout.strip(): + console.print(proc.stdout.strip()) + else: + if console is not None: + err_msg = proc.stderr.strip() or proc.stdout.strip() + if err_msg: + console.print(f"[red]{err_msg}[/red]") + return proc.returncode + except FileNotFoundError: + if console is not None: + console.print("[red]error:[/red] git binary not found on PATH.") + return 1 + finally: + try: + if temp_path.exists(): + temp_path.unlink() + except OSError: + pass + + +def _launch_editor( + initial_message: str, + console: Console, + cwd: Path | None = None, +) -> tuple[bool, str]: + """Launch user editor pre-populated with message and git comment lines. + + Returns (success, result_message). + - On non-zero exit code: returns (False, initial_message). + - On exit code 0: strips '#' comments and whitespace; if empty, returns (True, ""). + - On exit code 0 with content: returns (True, cleaned_message). + """ + content = initial_message.rstrip() + GIT_COMMENT_BLOCK + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + prefix="gitmate_edit_", + suffix=".txt", + delete=False, + ) as tf: + tf.write(content) + tf.flush() + temp_path = Path(tf.name) + + editor_cmd = _resolve_editor() + cmd_parts = shlex.split(editor_cmd) + repo_dir = cwd if cwd is not None else Path.cwd() + + try: + proc = subprocess.run( + [*cmd_parts, str(temp_path)], + cwd=repo_dir, + check=False, + ) + if proc.returncode != 0: + console.print( + f"[yellow]Edit aborted by editor (exit code {proc.returncode}). " + "Keeping existing proposed message.[/yellow]" + ) + return False, initial_message + + raw_text = temp_path.read_text(encoding="utf-8") + cleaned_lines = [ + line.rstrip() for line in raw_text.splitlines() if not line.strip().startswith("#") + ] + cleaned_message = "\n".join(cleaned_lines).strip() + if not cleaned_message: + console.print("[red]Aborting commit due to empty commit message.[/red]") + return True, "" + return True, cleaned_message + except FileNotFoundError: + console.print(f"[red]error:[/red] Editor command '{cmd_parts[0]}' not found on PATH.") + return False, initial_message + finally: + try: + if temp_path.exists(): + temp_path.unlink() + except OSError: + pass + + +def commit_flow( + yes: bool = False, + cfg: GitmateConfig | None = None, + diff_extractor: DiffExtractor | None = None, + console: Console | None = None, + provider: LLMProvider | None = None, + secret_store: config_mod.SecretStore | None = None, + counter: TokenCounter | None = None, +) -> int: + """Execute the commit command review loop. Returns process exit code (0 or 1).""" + con = console if console is not None else Console() + active_cfg = cfg if cfg is not None else config_mod.load_config() + + extractor = ( + diff_extractor + if diff_extractor is not None + else DiffExtractor(extra_ignores=active_cfg.ignore_globs) + ) + repo_dir = extractor._repo + + # 1. Staged diff check + try: + staged_diffs = extractor.staged() + except GitCommandError as exc: + con.print(f"[red]error:[/red] {exc}") + return 1 + + if not staged_diffs: + con.print("No staged changes to commit. Use 'git add' to stage files first.") + return 0 + + # 2. TTY and non-interactive guardrail checks + if not sys.stdin.isatty() and not yes: + con.print( + "[red]error:[/red] Interactive review requires a TTY terminal. " + "Use --yes for non-interactive commit (requires 'allow_noninteractive_commit' in config)." + ) + return 1 + + if yes and not active_cfg.allow_noninteractive_commit: + con.print( + "[red]error:[/red] Non-interactive commit (--yes) is disabled by default.\n" + "To enable, run: gitmate config set allow_noninteractive_commit true" + ) + return 1 + + active_counter = counter + if active_counter is None and provider is not None: + + class _SimpleCounter(TokenCounter): + def count_tokens(self, text: str, model: str) -> int: + return max(1, len(text) // 4) + + active_counter = _SimpleCounter() + + # 3. Initial generation & telemetry recording + t0 = time.perf_counter() + gen_result = generate_commit_message( + diffs=staged_diffs, + cfg=active_cfg, + provider=provider, + counter=active_counter, + console=con, + secret_store=secret_store, + bypass_cache=False, + ) + latency_ms = int((time.perf_counter() - t0) * 1000) + + _safe_record_telemetry( + command="commit", + model=gen_result.model, + tokens_in=gen_result.input_tokens, + tokens_out=gen_result.output_tokens, + cache_hit=gen_result.cache_hit, + latency_ms=latency_ms, + fallback_used=gen_result.is_fallback, + ) + + current_message = gen_result.text + current_model = gen_result.model + current_is_fallback = gen_result.is_fallback + + # 4. Fast path: --yes with allow_noninteractive_commit enabled + if yes: + code = _execute_commit(current_message, console=con, cwd=repo_dir) + return code + + # 5. Interactive review loop + _render_panel(con, current_message, current_model, current_is_fallback) + + while True: + try: + raw_input = con.input( + "[bold cyan]?[/bold cyan] [bold][a]ccept / [e]dit / [r]egenerate / [c]ancel:[/bold] " + ) + except (EOFError, KeyboardInterrupt): + con.print("\nCommit cancelled.") + return 0 + + choice = raw_input.strip().lower() + if not choice: + con.print( + "[yellow]Please select an option: [a]ccept, [e]dit, [r]egenerate, or [c]ancel.[/yellow]" + ) + continue + + if choice in ("a", "accept"): + code = _execute_commit(current_message, console=con, cwd=repo_dir) + return code + + elif choice in ("e", "edit"): + success, edited_msg = _launch_editor(current_message, console=con, cwd=repo_dir) + if success: + if not edited_msg: + return 1 + current_message = edited_msg + _render_panel(con, current_message, current_model, current_is_fallback) + + elif choice in ("r", "regenerate"): + con.print("Regenerating commit message with fresh API call...") + t0 = time.perf_counter() + gen_result = generate_commit_message( + diffs=staged_diffs, + cfg=active_cfg, + provider=provider, + counter=active_counter, + console=con, + secret_store=secret_store, + bypass_cache=True, + ) + latency_ms = int((time.perf_counter() - t0) * 1000) + + _safe_record_telemetry( + command="commit", + model=gen_result.model, + tokens_in=gen_result.input_tokens, + tokens_out=gen_result.output_tokens, + cache_hit=False, + latency_ms=latency_ms, + fallback_used=gen_result.is_fallback, + ) + + current_message = gen_result.text + current_model = gen_result.model + current_is_fallback = gen_result.is_fallback + _render_panel(con, current_message, current_model, current_is_fallback) + + elif choice in ("c", "cancel"): + con.print("Commit cancelled.") + return 0 + + else: + con.print( + f"[yellow]Invalid selection '{raw_input}'. " + "Please choose [a]ccept, [e]dit, [r]egenerate, or [c]ancel.[/yellow]" + ) diff --git a/src/gitmate/config.py b/src/gitmate/config.py index 4e3bf5f..7e1f292 100644 --- a/src/gitmate/config.py +++ b/src/gitmate/config.py @@ -63,6 +63,7 @@ class GitmateConfig: reserved_output_tokens: int = DEFAULT_RESERVED_OUTPUT_TOKENS template_overhead: int = DEFAULT_TEMPLATE_OVERHEAD cache_dir: str | None = None + allow_noninteractive_commit: bool = False class SecretStore(Protocol): @@ -201,6 +202,13 @@ def load_config(path: Path | None = None) -> GitmateConfig: else: cache_dir = cache_dir_raw + allow_noninteractive_raw = raw.get("allow_noninteractive_commit", False) + if not isinstance(allow_noninteractive_raw, bool): + raise ConfigError( + f"cannot parse {resolved}: 'allow_noninteractive_commit' must be a boolean" + ) + allow_noninteractive = allow_noninteractive_raw + return GitmateConfig( model=model, commit_style=style, @@ -210,6 +218,7 @@ def load_config(path: Path | None = None) -> GitmateConfig: reserved_output_tokens=reserved, template_overhead=overhead, cache_dir=cache_dir, + allow_noninteractive_commit=allow_noninteractive, ) @@ -230,4 +239,6 @@ def save_config(cfg: GitmateConfig, path: Path | None = None) -> None: data["template_overhead"] = cfg.template_overhead if cfg.cache_dir is not None: data["cache_dir"] = cfg.cache_dir + if cfg.allow_noninteractive_commit: + data["allow_noninteractive_commit"] = cfg.allow_noninteractive_commit resolved.write_text(tomli_w.dumps(data), encoding="utf-8") diff --git a/src/gitmate/fallback.py b/src/gitmate/fallback.py index c66c8ce..8bac2a5 100644 --- a/src/gitmate/fallback.py +++ b/src/gitmate/fallback.py @@ -13,7 +13,7 @@ from gitmate.diff_extractor import FileDiff from gitmate.prompt import load_template, render_commit_prompt from gitmate.providers.base import LLMProvider, ProviderUnavailable -from gitmate.providers.cache import CachedProvider +from gitmate.providers.cache import CachedProvider, cache_key from gitmate.providers.gemini import GeminiProvider from gitmate.token_budget import ( BudgetStrategy, @@ -53,6 +53,7 @@ class GenerationResult: fallback_reason: str | None = None input_tokens: int | None = None output_tokens: int | None = None + cache_hit: bool = False def _extract_scope(path_str: str) -> str | None: @@ -221,6 +222,7 @@ def generate_commit_message( counter: TokenCounter | None = None, console: Console | None = None, secret_store: config_mod.SecretStore | None = None, + bypass_cache: bool = False, ) -> GenerationResult: """Orchestrate commit message generation with graceful fallback on provider failure.""" if not diffs: @@ -239,14 +241,20 @@ def generate_commit_message( active_provider: LLMProvider if provider is not None: - active_provider = provider + if bypass_cache and isinstance(provider, CachedProvider): + active_provider = provider._provider + else: + active_provider = provider else: base_provider = GeminiProvider(api_key=api_key) - active_provider = CachedProvider( - provider=base_provider, - cache_dir=cfg.cache_dir, - template_version=version_key, - ) + if bypass_cache: + active_provider = base_provider + else: + active_provider = CachedProvider( + provider=base_provider, + cache_dir=cfg.cache_dir, + template_version=version_key, + ) # 1. Budget evaluation with fallback on counter/budget failure active_counter = counter if counter is not None else GeminiTokenCounter(api_key=api_key) @@ -308,6 +316,11 @@ def generate_commit_message( summary_note=summary_note, ) + cache_hit = False + if not bypass_cache and isinstance(active_provider, CachedProvider): + key = cache_key(prompt_text, cfg.model, active_provider._template_version) + cache_hit = active_provider._cache.get(key, default=None) is not None + resp = active_provider.generate(prompt=prompt_text, model=cfg.model) if resp.input_tokens is not None: total_input_tokens += resp.input_tokens @@ -320,6 +333,7 @@ def generate_commit_message( model=resp.model, input_tokens=total_input_tokens or None, output_tokens=total_output_tokens or None, + cache_hit=cache_hit, ) except ProviderUnavailable as exc: diff --git a/src/gitmate/metrics.py b/src/gitmate/metrics.py new file mode 100644 index 0000000..e7cc77b --- /dev/null +++ b/src/gitmate/metrics.py @@ -0,0 +1,188 @@ +"""Per-generation telemetry recording to SQLite.""" + +from __future__ import annotations + +import contextlib +import logging +import os +import sqlite3 +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path + +logger = logging.getLogger("gitmate.metrics") + +APP_NAME = "gitmate" +DEFAULT_DB_FILENAME = "metrics.db" +METRICS_DIR_ENV_VAR = "GITMATE_METRICS_DIR" + +CREATE_TABLE_SQL = """ +CREATE TABLE IF NOT EXISTS invocations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + command TEXT NOT NULL, + model TEXT NOT NULL, + tokens_in INTEGER, + tokens_out INTEGER, + cache_hit INTEGER NOT NULL, + latency_ms INTEGER, + fallback_used INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_invocations_timestamp ON invocations(timestamp); +CREATE INDEX IF NOT EXISTS idx_invocations_command ON invocations(command); +""" + + +@dataclass(frozen=True) +class InvocationRecord: + """A recorded telemetry row representing an LLM invocation or fallback.""" + + id: int + timestamp: str + command: str + model: str + tokens_in: int | None + tokens_out: int | None + cache_hit: bool + latency_ms: int | None + fallback_used: bool + + +def get_metrics_db_path(db_path: Path | str | None = None) -> Path: + """Resolve the SQLite metrics database file path. + + Order of precedence: + 1. Explicit db_path argument (if provided; appends metrics.db if path is a directory). + 2. GITMATE_METRICS_DIR environment variable directory. + 3. XDG_DATA_HOME environment variable / gitmate / metrics.db. + 4. Default: ~/.local/share/gitmate/metrics.db. + """ + if db_path is not None: + p = Path(db_path).expanduser() + if p.is_dir(): + return p / DEFAULT_DB_FILENAME + return p + + override_dir = os.environ.get(METRICS_DIR_ENV_VAR) + if override_dir: + return Path(override_dir).expanduser() / DEFAULT_DB_FILENAME + + xdg_data = os.environ.get("XDG_DATA_HOME") + base = Path(xdg_data).expanduser() if xdg_data else Path.home() / ".local" / "share" + return base / APP_NAME / DEFAULT_DB_FILENAME + + +def init_db(db_path: Path | str | None = None) -> Path: + """Initialize the metrics database and invocations table if not present. + + Returns the resolved database path. + """ + target = get_metrics_db_path(db_path) + target.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(target, timeout=5.0) + with contextlib.closing(conn): + conn.executescript(CREATE_TABLE_SQL) + return target + + +def record_invocation( + command: str, + model: str, + tokens_in: int | None, + tokens_out: int | None, + cache_hit: bool, + latency_ms: int | None, + fallback_used: bool, + db_path: Path | str | None = None, + timestamp: str | None = None, +) -> None: + """Record an invocation into SQLite database at db_path or default. + + Non-blocking: write errors or filesystem issues are logged as warnings + and never raise exceptions to disrupt git workflow. + """ + try: + target = get_metrics_db_path(db_path) + target.parent.mkdir(parents=True, exist_ok=True) + ts = timestamp if timestamp is not None else datetime.now(UTC).isoformat() + + conn = sqlite3.connect(target, timeout=5.0) + with contextlib.closing(conn): + conn.executescript(CREATE_TABLE_SQL) + with conn: + conn.execute( + """ + INSERT INTO invocations ( + timestamp, command, model, tokens_in, tokens_out, + cache_hit, latency_ms, fallback_used + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + ts, + command, + model, + tokens_in, + tokens_out, + 1 if cache_hit else 0, + latency_ms, + 1 if fallback_used else 0, + ), + ) + except Exception as exc: # noqa: BLE001 + logger.warning("Failed to record invocation metrics: %s", exc) + + +def get_invocations( + db_path: Path | str | None = None, + limit: int | None = None, +) -> list[InvocationRecord]: + """Retrieve recorded invocations from the metrics database. + + Returns an empty list if the database file does not exist or errors occur. + """ + try: + target = get_metrics_db_path(db_path) + if not target.exists(): + return [] + conn = sqlite3.connect(target, timeout=5.0) + with contextlib.closing(conn): + conn.row_factory = sqlite3.Row + query = ( + "SELECT id, timestamp, command, model, tokens_in, tokens_out, " + "cache_hit, latency_ms, fallback_used FROM invocations ORDER BY id ASC" + ) + if limit is not None: + query += f" LIMIT {int(limit)}" + cursor = conn.cursor() + cursor.execute(query) + rows = cursor.fetchall() + return [ + InvocationRecord( + id=row["id"], + timestamp=row["timestamp"], + command=row["command"], + model=row["model"], + tokens_in=row["tokens_in"], + tokens_out=row["tokens_out"], + cache_hit=bool(row["cache_hit"]), + latency_ms=row["latency_ms"], + fallback_used=bool(row["fallback_used"]), + ) + for row in rows + ] + except Exception as exc: # noqa: BLE001 + logger.warning("Failed to retrieve invocations from metrics DB: %s", exc) + return [] + + +def clear_metrics(db_path: Path | str | None = None) -> None: + """Remove all records from invocations table (helper for tests).""" + try: + target = get_metrics_db_path(db_path) + if not target.exists(): + return + conn = sqlite3.connect(target, timeout=5.0) + with contextlib.closing(conn), conn: + conn.execute("DELETE FROM invocations") + except Exception as exc: # noqa: BLE001 + logger.warning("Failed to clear metrics: %s", exc) diff --git a/tests/test_cli.py b/tests/test_cli.py index d4cee13..4705f4c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -28,7 +28,7 @@ def test_help_lists_all_commands() -> None: def test_stubs_report_not_implemented() -> None: - for name in ("commit", "pr-summary", "changelog", "doc"): + for name in ("pr-summary", "changelog", "doc"): result = runner.invoke(cli.app, [name]) assert result.exit_code == 0 assert "not implemented" in result.output @@ -215,3 +215,81 @@ def test_config_set_budget_fields_validation(tmp_config_dir: Path) -> None: res = runner.invoke(cli.app, ["config", "set", "max_context_tokens", "2000"]) assert res.exit_code != 0 assert "must be greater than" in res.output + + +def test_commit_cli_no_staged_diff_exits_zero(git_repo: Path, tmp_config_dir: Path) -> None: + result = runner.invoke(cli.app, ["commit"]) + assert result.exit_code == 0 + assert "No staged changes" in result.output + + +def test_commit_cli_non_tty_without_yes_exits_one(git_repo: Path, tmp_config_dir: Path) -> None: + from conftest import stage_file + + stage_file(git_repo, "a.py", b"x = 1\n") + result = runner.invoke(cli.app, ["commit"]) + assert result.exit_code == 1 + assert "Interactive review requires a TTY terminal" in result.output + + +def test_commit_cli_yes_disabled_by_default(git_repo: Path, tmp_config_dir: Path) -> None: + from conftest import stage_file + + stage_file(git_repo, "a.py", b"x = 1\n") + result = runner.invoke(cli.app, ["commit", "--yes"]) + assert result.exit_code == 1 + assert "Non-interactive commit (--yes) is disabled by default" in result.output + + +def test_commit_cli_yes_enabled_in_config( + git_repo: Path, tmp_config_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from conftest import stage_file + + import gitmate.committer as committer_mod + from gitmate.fallback import GenerationResult + + stage_file(git_repo, "a.py", b"x = 1\n") + save_config(GitmateConfig(allow_noninteractive_commit=True)) + + monkeypatch.setattr( + committer_mod, + "generate_commit_message", + lambda *args, **kwargs: GenerationResult( + text="feat: non-interactive cli commit", + is_fallback=False, + model="gemini-3.5-flash-lite", + ), + ) + + result = runner.invoke(cli.app, ["commit", "--yes"]) + assert result.exit_code == 0 + + +def test_config_set_allow_noninteractive_commit(tmp_config_dir: Path) -> None: + # Set True + res = runner.invoke(cli.app, ["config", "set", "allow_noninteractive_commit", "true"]) + assert res.exit_code == 0 + assert load_config().allow_noninteractive_commit is True + + # Set False + res = runner.invoke(cli.app, ["config", "set", "allow_noninteractive_commit", "false"]) + assert res.exit_code == 0 + assert load_config().allow_noninteractive_commit is False + + # Case insensitive + res = runner.invoke(cli.app, ["config", "set", "allow_noninteractive_commit", "TRUE"]) + assert res.exit_code == 0 + assert load_config().allow_noninteractive_commit is True + + # Invalid value + res = runner.invoke(cli.app, ["config", "set", "allow_noninteractive_commit", "invalid"]) + assert res.exit_code != 0 + assert "must be 'true' or 'false'" in res.output + + +def test_config_show_displays_allow_noninteractive_commit(tmp_config_dir: Path) -> None: + res = runner.invoke(cli.app, ["config", "show"]) + assert res.exit_code == 0 + assert "allow_noninteractive_commit" in res.output + assert "False" in res.output diff --git a/tests/test_committer.py b/tests/test_committer.py new file mode 100644 index 0000000..a58ac24 --- /dev/null +++ b/tests/test_committer.py @@ -0,0 +1,503 @@ +"""Tests for commit review flow, non-interactive guardrails, editor handling, and temp files.""" + +from __future__ import annotations + +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any + +import pytest +from conftest import stage_file +from rich.console import Console + +from gitmate.committer import _resolve_editor, commit_flow +from gitmate.config import GitmateConfig +from gitmate.diff_extractor import DiffExtractor, GitCommandError +from gitmate.metrics import get_invocations +from gitmate.providers.base import LLMProvider, LLMResponse + + +class FakeProvider(LLMProvider): + """In-memory LLM provider recording invocations and returning programmed responses.""" + + def __init__( + self, responses: list[str] | str = "feat(core): initial generated message" + ) -> None: + if isinstance(responses, str): + self.responses = [responses] + else: + self.responses = list(responses) + self.call_count = 0 + self.calls: list[tuple[str, str]] = [] + + def generate(self, prompt: str, model: str) -> LLMResponse: + self.calls.append((prompt, model)) + idx = min(self.call_count, len(self.responses) - 1) + resp = self.responses[idx] + self.call_count += 1 + return LLMResponse( + text=resp, + model=model, + input_tokens=100, + output_tokens=20, + ) + + +@pytest.fixture +def metrics_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Isolate telemetry in temp directory.""" + m_dir = tmp_path / "metrics" + m_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("GITMATE_METRICS_DIR", str(m_dir)) + return m_dir + + +def _get_git_log_messages(repo: Path) -> list[str]: + """Retrieve commit subjects and bodies from git log.""" + res = subprocess.run( + ["git", "log", "--format=%B%x00"], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + return [msg.strip() for msg in res.stdout.split("\x00") if msg.strip()] + + +# --- 1. Empty Staged Diff --- + + +def test_empty_staged_diff_exits_zero(git_repo: Path, metrics_dir: Path) -> None: + provider = FakeProvider() + code = commit_flow(provider=provider) + assert code == 0 + assert provider.call_count == 0 + assert len(get_invocations()) == 0 + + +# --- 2. Non-TTY Guardrail --- + + +def test_non_tty_without_yes_exits_one( + git_repo: Path, metrics_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + stage_file(git_repo, "file.txt", b"content\n") + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + + provider = FakeProvider() + code = commit_flow(yes=False, provider=provider) + assert code == 1 + assert provider.call_count == 0 + assert len(get_invocations()) == 0 + + +# --- 3. Non-interactive --yes Guardrails --- + + +def test_yes_with_allow_noninteractive_disabled_exits_one( + git_repo: Path, metrics_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + stage_file(git_repo, "file.txt", b"content\n") + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + + cfg = GitmateConfig(allow_noninteractive_commit=False) + provider = FakeProvider() + code = commit_flow(yes=True, cfg=cfg, provider=provider) + assert code == 1 + assert provider.call_count == 0 + assert len(get_invocations()) == 0 + + +def test_yes_with_allow_noninteractive_enabled_commits_directly( + git_repo: Path, metrics_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + stage_file(git_repo, "file.txt", b"content\n") + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + + cfg = GitmateConfig(allow_noninteractive_commit=True) + provider = FakeProvider("feat(auto): unattended commit") + code = commit_flow(yes=True, cfg=cfg, provider=provider) + assert code == 0 + assert provider.call_count == 1 + + messages = _get_git_log_messages(git_repo) + assert messages[0] == "feat(auto): unattended commit" + + invocations = get_invocations() + assert len(invocations) == 1 + assert invocations[0].command == "commit" + + +# --- 4. Interactive Review: [a]ccept --- + + +def test_interactive_accept_commits_message( + git_repo: Path, metrics_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + stage_file(git_repo, "foo.py", b"print('foo')\n") + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + + con = Console() + monkeypatch.setattr(con, "input", lambda prompt="": "a") + + provider = FakeProvider("feat(foo): add foo script") + code = commit_flow(console=con, provider=provider) + assert code == 0 + + messages = _get_git_log_messages(git_repo) + assert messages[0] == "feat(foo): add foo script" + + invocations = get_invocations() + assert len(invocations) == 1 + assert invocations[0].command == "commit" + + +# --- 5. Interactive Review: [c]ancel --- + + +def test_interactive_cancel_exits_zero_without_commit( + git_repo: Path, metrics_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + stage_file(git_repo, "bar.py", b"print('bar')\n") + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + + con = Console() + monkeypatch.setattr(con, "input", lambda prompt="": "c") + + provider = FakeProvider("feat(bar): candidate message") + code = commit_flow(console=con, provider=provider) + assert code == 0 + + # No new commit created (only initial commit from git_repo fixture) + messages = _get_git_log_messages(git_repo) + assert len(messages) == 1 + assert messages[0] == "init" + + # Telemetry IS recorded on generation attempt even if cancelled! + invocations = get_invocations() + assert len(invocations) == 1 + assert invocations[0].command == "commit" + + +# --- 6. Interactive Review: Empty Input & EOF --- + + +def test_interactive_empty_input_reprompts( + git_repo: Path, metrics_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + stage_file(git_repo, "baz.py", b"print('baz')\n") + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + + inputs = iter(["", " ", "a"]) + con = Console() + monkeypatch.setattr(con, "input", lambda prompt="": next(inputs)) + + provider = FakeProvider("feat(baz): add baz") + code = commit_flow(console=con, provider=provider) + assert code == 0 + + messages = _get_git_log_messages(git_repo) + assert messages[0] == "feat(baz): add baz" + + +def test_interactive_eof_or_interrupt_cancels_cleanly( + git_repo: Path, metrics_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + stage_file(git_repo, "baz.py", b"print('baz')\n") + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + + def _raise_eof(prompt: str = "") -> str: + raise EOFError() + + con = Console() + monkeypatch.setattr(con, "input", _raise_eof) + + code = commit_flow(console=con, provider=FakeProvider()) + assert code == 0 + messages = _get_git_log_messages(git_repo) + assert len(messages) == 1 # No commit created + + +# --- 7. Interactive Review: [r]egenerate --- + + +def test_interactive_regenerate_bypasses_cache_and_logs_telemetry( + git_repo: Path, metrics_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + stage_file(git_repo, "regen.py", b"def regen(): pass\n") + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + + inputs = iter(["r", "a"]) + con = Console() + monkeypatch.setattr(con, "input", lambda prompt="": next(inputs)) + + provider = FakeProvider( + [ + "feat: first proposal", + "feat(regen): better regenerated proposal", + ] + ) + code = commit_flow(console=con, provider=provider) + assert code == 0 + assert provider.call_count == 2 + + messages = _get_git_log_messages(git_repo) + assert messages[0] == "feat(regen): better regenerated proposal" + + invocations = get_invocations() + assert len(invocations) == 2 + assert invocations[0].cache_hit is False + assert invocations[1].cache_hit is False + + +# --- 8. Interactive Review: [e]dit with Compound $EDITOR --- + + +def test_interactive_edit_compound_editor_and_comment_stripping( + git_repo: Path, metrics_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + stage_file(git_repo, "edit.py", b"val = 1\n") + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + + # Compound editor: python -c "