diff --git a/src/gitmate/cli.py b/src/gitmate/cli.py index ebd46b9..a89e326 100644 --- a/src/gitmate/cli.py +++ b/src/gitmate/cli.py @@ -8,6 +8,7 @@ from gitmate import config as config_mod from gitmate.config import API_KEY_ACCOUNT +from gitmate.diff_extractor import DiffExtractor, GitCommandError app = typer.Typer( help="AI-assisted git commit messages, PR summaries, and changelogs.", @@ -49,6 +50,56 @@ def doc() -> None: _stub("doc", "not scheduled yet") +@app.command(name="debug-diff") +def debug_diff( + base: str | None = typer.Option( + None, "--base", help="Compare branch base against HEAD (e.g. --base main)." + ), + from_ref: str | None = typer.Option(None, "--from-ref", help="Range start ref."), + to_ref: str | None = typer.Option(None, "--to-ref", help="Range end ref."), + summary: bool = typer.Option( + False, "--summary", help="Show per-file stats only, no patch text." + ), +) -> None: + """Print the cleaned, filtered diff (defaults to staged changes).""" + if base is not None and (from_ref is not None or to_ref is not None): + raise typer.BadParameter("--base cannot be combined with --from-ref/--to-ref.") + if (from_ref is None) != (to_ref is None): + raise typer.BadParameter("--from-ref and --to-ref must be given together.") + try: + cfg = config_mod.load_config() + extractor = DiffExtractor(extra_ignores=cfg.ignore_globs) + if base is not None: + diffs = extractor.branch_comparison(base) + elif from_ref is not None and to_ref is not None: + diffs = extractor.rev_range(from_ref, to_ref) + else: + diffs = extractor.staged() + except (GitCommandError, config_mod.ConfigError) as exc: + console.print(f"[red]error:[/red] {exc}") + raise typer.Exit(1) from None + if not diffs: + console.print("No changes.") + return + table = Table(title="diff", show_header=True) + table.add_column("file") + table.add_column("status") + table.add_column("+", justify="right") + table.add_column("-", justify="right") + table.add_column("note") + for diff in diffs: + name = diff.path if diff.old_path is None else f"{diff.old_path} -> {diff.path}" + note = "binary, patch skipped" if diff.is_binary else "" + table.add_row(name, diff.status, str(diff.additions), str(diff.deletions), note) + console.print(table) + if summary: + return + for diff in diffs: + if diff.patch_text: + console.print(f"[bold]{diff.path}[/bold]") + console.print(diff.patch_text, markup=False, highlight=False, crop=False) + + @config_app.command("set-key") def config_set_key() -> None: """Store the LLM API key in the OS credential store.""" @@ -62,7 +113,11 @@ def config_set_key() -> None: @config_app.command("show") def config_show() -> None: """Print current settings (never prints the secret itself).""" - cfg = config_mod.load_config() + try: + cfg = config_mod.load_config() + except config_mod.ConfigError as exc: + console.print(f"[red]error:[/red] {exc}") + raise typer.Exit(1) from None key_set = secret_store.get_secret(API_KEY_ACCOUNT) is not None table = Table(title="gitmate config", show_header=False) table.add_column("field") @@ -71,6 +126,7 @@ def config_show() -> None: table.add_row("model", cfg.model) table.add_row("commit_style", cfg.commit_style) table.add_row("budget_cap_usd", str(cfg.budget_cap_usd) if cfg.budget_cap_usd else "none") + table.add_row("ignore_globs", ", ".join(cfg.ignore_globs) if cfg.ignore_globs else "none") table.add_row("api_key", "set" if key_set else "not set") console.print(table) @@ -78,7 +134,11 @@ def config_show() -> None: @config_app.command("set") def config_set(field: str, value: str) -> None: """Set one field: model, commit_style, or budget_cap_usd (empty clears).""" - cfg = config_mod.load_config() + try: + cfg = config_mod.load_config() + except config_mod.ConfigError as exc: + console.print(f"[red]error:[/red] {exc}") + raise typer.Exit(1) from None match field: case "model": cfg.model = value diff --git a/src/gitmate/config.py b/src/gitmate/config.py index d0d9aa9..bbfa73f 100644 --- a/src/gitmate/config.py +++ b/src/gitmate/config.py @@ -4,7 +4,7 @@ import os import tomllib -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Any, Protocol @@ -32,6 +32,7 @@ class GitmateConfig: model: str = DEFAULT_MODEL commit_style: str = DEFAULT_COMMIT_STYLE budget_cap_usd: float | None = None + ignore_globs: list[str] = field(default_factory=list) class SecretStore(Protocol): @@ -110,7 +111,10 @@ def load_config(path: Path | None = None) -> GitmateConfig: raise ConfigError(f"cannot parse {resolved}: 'budget_cap_usd' must be a number") else: cap = float(budget) - return GitmateConfig(model=model, commit_style=style, budget_cap_usd=cap) + globs = raw.get("ignore_globs", []) + if not isinstance(globs, list) or not all(isinstance(g, str) for g in globs): + raise ConfigError(f"cannot parse {resolved}: 'ignore_globs' must be a string list") + return GitmateConfig(model=model, commit_style=style, budget_cap_usd=cap, ignore_globs=globs) def save_config(cfg: GitmateConfig, path: Path | None = None) -> None: @@ -120,4 +124,6 @@ def save_config(cfg: GitmateConfig, path: Path | None = None) -> None: data: dict[str, Any] = {"model": cfg.model, "commit_style": cfg.commit_style} if cfg.budget_cap_usd is not None: data["budget_cap_usd"] = cfg.budget_cap_usd + if cfg.ignore_globs: + data["ignore_globs"] = cfg.ignore_globs resolved.write_text(tomli_w.dumps(data), encoding="utf-8") diff --git a/src/gitmate/diff_extractor.py b/src/gitmate/diff_extractor.py new file mode 100644 index 0000000..dde9f27 --- /dev/null +++ b/src/gitmate/diff_extractor.py @@ -0,0 +1,323 @@ +"""Extract structured diffs from git via subprocess; no GitPython, no raw strings.""" + +from __future__ import annotations + +import subprocess +from collections.abc import Iterable +from dataclasses import dataclass +from fnmatch import fnmatchcase +from pathlib import Path, PurePosixPath +from typing import Literal, Protocol + +FileStatus = Literal["added", "modified", "deleted", "renamed"] + +#: Built-in noise filter; users extend (never shrink) it via `ignore_globs`. +DEFAULT_IGNORE_GLOBS: tuple[str, ...] = ( + "package-lock.json", + "poetry.lock", + "Cargo.lock", + "*.min.js", + "*.bundle.js", + "*.map", + "dist/*", + "*.snap", + "*.lock", +) + + +class GitCommandError(Exception): + """Raised when a git invocation fails for any reason.""" + + +class NotAGitRepo(GitCommandError): + """Raised when the target directory is not inside a git repository.""" + + +@dataclass +class FileDiff: + """One file's diff; every downstream layer works off this, not raw text.""" + + path: str + old_path: str | None + status: FileStatus + additions: int + deletions: int + patch_text: str + is_binary: bool + + +class GitRunner(Protocol): + """Runs git so tests can substitute a fake; production shells out for real.""" + + def run(self, args: list[str], cwd: Path) -> str: + """Run `git ` in cwd, returning stdout or raising GitCommandError.""" + ... + + +class SubprocessGitRunner: + """GitRunner that calls the real git binary with a timeout.""" + + def __init__(self, timeout: int = 30) -> None: + self._timeout = timeout + + def run(self, args: list[str], cwd: Path) -> str: + """Run the real git binary; map failures to typed exceptions.""" + if not cwd.exists(): + raise NotAGitRepo(f"{cwd} does not exist.") + try: + proc = subprocess.run( + ["git", *args], + cwd=cwd, + capture_output=True, + text=True, + timeout=self._timeout, + check=False, + ) + except FileNotFoundError as exc: + raise GitCommandError("git binary not found on PATH.") from exc + except subprocess.TimeoutExpired as exc: + raise GitCommandError(f"git {' '.join(args)} timed out.") from exc + if proc.returncode != 0: + err = proc.stderr.strip() + if "not a git repository" in err: + raise NotAGitRepo(f"{cwd} is not inside a git repository.") + raise GitCommandError(f"git {' '.join(args)} failed: {err}") + return proc.stdout + + +def matches_any(path: str, patterns: Iterable[str]) -> bool: + """Gitignore-flavored match: dir prefixes, bare basenames, else fnmatch.""" + posix = PurePosixPath(path).as_posix() + name = PurePosixPath(path).name + for pat in patterns: + if pat.endswith("/*"): + prefix = pat[:-1] + if posix == prefix[:-1] or posix.startswith(prefix): + return True + elif pat.endswith("/"): + if posix == pat[:-1] or posix.startswith(pat): + return True + elif "/" not in pat: + if fnmatchcase(name, pat): + return True + elif fnmatchcase(posix, pat): + return True + return False + + +_STATUS_MAP: dict[str, FileStatus] = { + "A": "added", + "M": "modified", + "D": "deleted", + "R": "renamed", + "T": "modified", + "C": "added", + "U": "modified", +} + + +def _parse_numstat(output: str) -> tuple[dict[str, tuple[int, int, str | None]], set[str]]: + """Parse `git diff -M -z --numstat`, keyed by new path with binary set. + + With -z, records are NUL-separated and a rename carries no inline path at + all: the `added\\tdeleted\\t` prefix is followed by an empty field, then + the old and new paths as two further NUL-terminated fields. This avoids + the `{old => new}` pretty-printing entirely, so no arrow-parsing is needed. + """ + counts: dict[str, tuple[int, int, str | None]] = {} + binary: set[str] = set() + fields = output.split("\0") + i = 0 + while i < len(fields): + field = fields[i] + i += 1 + if not field: + continue + parts = field.split("\t") + if len(parts) < 3: + raise GitCommandError(f"cannot parse numstat record: {field!r}.") + added_s, deleted_s, rest = parts[0], parts[1], "\t".join(parts[2:]) + old_path: str | None + if rest == "": + # Rename record: old and new paths follow as two extra fields. + if i + 1 >= len(fields) or not fields[i] or not fields[i + 1]: + raise GitCommandError(f"cannot parse numstat rename: {field!r}.") + old, key = fields[i], fields[i + 1] + old_path = old + i += 2 + else: + key, old_path = rest, None + if added_s == "-" or deleted_s == "-": + counts[key] = (0, 0, old_path) + binary.add(key) + continue + try: + added, deleted = int(added_s), int(deleted_s) + except ValueError as exc: + raise GitCommandError(f"cannot parse numstat counts: {field!r}.") from exc + counts[key] = (added, deleted, old_path) + return counts, binary + + +def _parse_name_status(output: str) -> dict[str, tuple[FileStatus, str | None]]: + """Parse `git diff -M --name-status`; clean `R100\\told\\tnew` rename rows.""" + statuses: dict[str, tuple[FileStatus, str | None]] = {} + for line in output.splitlines(): + if not line: + continue + parts = line.split("\t") + code = parts[0][0] + if code not in _STATUS_MAP: + raise GitCommandError(f"unknown git status code: {line!r}.") + status = _STATUS_MAP[code] + if code in ("R", "C"): + if len(parts) != 3: + raise GitCommandError(f"cannot parse rename/copy row: {line!r}.") + _, old, new = parts + statuses[new] = (status, old) + else: + if len(parts) != 2: + raise GitCommandError(f"cannot parse status row: {line!r}.") + statuses[parts[1]] = (status, None) + return statuses + + +def _strip_git_prefix(path: str) -> str: + """Strip git's a//b/ prefixes and optional C-style quoting.""" + if len(path) >= 2 and path.startswith('"') and path.endswith('"'): + path = path[1:-1] + if path.startswith(("a/", "b/")): + return path[2:] + return path + + +def _parse_patch(output: str) -> dict[str, str]: + """Split `--patch` output per file, keyed by new path (old on deletion).""" + chunks: dict[str, list[str]] = {} + order: list[str] = [] + current: str | None = None + minus: str | None = None + chunk: list[str] = [] + + def flush() -> None: + nonlocal current, minus + if current is not None or minus is not None: + for cl in chunk: + if cl.startswith(("rename to ", "copy to ")): + current = _strip_git_prefix(cl.split(" ", 2)[2].rstrip("\n")) + elif cl.startswith(("rename from ", "copy from ")): + minus = _strip_git_prefix(cl.split(" ", 2)[2].rstrip("\n")) + key = current if current is not None and current != "/dev/null" else minus + if key is not None: + if key not in chunks: + chunks[key] = [] + order.append(key) + chunks[key].extend(chunk) + + for line in output.splitlines(keepends=True): + if line.startswith("diff --git "): + flush() + rest = line[len("diff --git ") :].rstrip("\n") + if len(rest) >= 5 and rest.startswith("a/"): + mid = (len(rest) - 1) // 2 + if rest[mid : mid + 3] == " b/" and rest[2:mid] == rest[mid + 3 :]: + minus = _strip_git_prefix(rest[2:mid]) + current = _strip_git_prefix(rest[mid + 3 :]) + else: + halves = rest.rsplit(" ", 1) + minus = _strip_git_prefix(halves[0].strip()) if len(halves) == 2 else None + current = _strip_git_prefix(halves[1].strip()) if len(halves) == 2 else None + else: + halves = rest.rsplit(" ", 1) + minus = _strip_git_prefix(halves[0].strip()) if len(halves) == 2 else None + current = _strip_git_prefix(halves[1].strip()) if len(halves) == 2 else None + chunk = [line] + elif current is None and minus is None: + continue + else: + chunk.append(line) + flush() + # Re-key via the ---/+++ lines: +++ names the new file (/dev/null on delete). + fixed: dict[str, str] = {} + for key in order: + text = "".join(chunks[key]) + for text_line in text.splitlines(): + if text_line.startswith("+++ "): + plus = _strip_git_prefix(text_line[4:].strip()) + if plus == "/dev/null": + for prev in text.splitlines(): + if prev.startswith("--- "): + key = _strip_git_prefix(prev[4:].strip()) + else: + key = plus + break + fixed[key] = text + return fixed + + +class DiffExtractor: + """Three git-diff modes sharing one parse/merge/filter pipeline.""" + + def __init__( + self, + repo: Path | None = None, + runner: GitRunner | None = None, + extra_ignores: Iterable[str] = (), + ) -> None: + self._repo = repo if repo is not None else Path.cwd() + self._runner = runner if runner is not None else SubprocessGitRunner() + self._ignores = (*DEFAULT_IGNORE_GLOBS, *extra_ignores) + + def staged(self) -> list[FileDiff]: + """Diff of staged changes (the `git commit` path).""" + return self._extract(["diff", "--staged"]) + + def branch_comparison(self, base: str) -> list[FileDiff]: + """Three-dot `base...HEAD` diff (the PR-summary path).""" + return self._extract(["diff", f"{base}...HEAD"]) + + def rev_range(self, from_ref: str, to_ref: str) -> list[FileDiff]: + """Two-dot `from..to` diff (the changelog path).""" + return self._extract(["diff", f"{from_ref}..{to_ref}"]) + + def _extract(self, base_args: list[str]) -> list[FileDiff]: + """Run the three git calls and merge them keyed by path, never by order.""" + self._runner.run(["rev-parse", "--git-dir"], self._repo) + git_flags = [ + "-c", + "core.quotepath=false", + "-c", + "diff.mnemonicPrefix=false", + *base_args, + "--src-prefix=a/", + "--dst-prefix=b/", + "--no-color", + "--no-ext-diff", + "--no-textconv", + ] + numstat = self._runner.run([*git_flags, "-M", "-z", "--numstat"], self._repo) + patch = self._runner.run([*git_flags, "-M", "--patch"], self._repo) + names = self._runner.run([*git_flags, "-M", "--name-status"], self._repo) + counts, binary = _parse_numstat(numstat) + statuses = _parse_name_status(names) + patches = _parse_patch(patch) + diffs: list[FileDiff] = [] + for path in list(counts) + [p for p in statuses if p not in counts]: + if matches_any(path, self._ignores): + continue + added, deleted, num_old = counts.get(path, (0, 0, None)) + status, name_old = statuses.get(path, ("modified", None)) + old_path = name_old if name_old is not None else num_old + is_binary = path in binary + diffs.append( + FileDiff( + path=path, + old_path=old_path, + status=status, + additions=added, + deletions=deleted, + patch_text="" if is_binary else patches.get(path, ""), + is_binary=is_binary, + ) + ) + return diffs diff --git a/tests/conftest.py b/tests/conftest.py index e1bb4d8..98e2317 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,8 @@ -"""Shared fixtures: isolated config dir and in-memory secret store.""" +"""Shared fixtures: isolated config dir, in-memory secrets, scratch git repos.""" from __future__ import annotations +import subprocess from pathlib import Path import pytest @@ -18,9 +19,50 @@ def tmp_config_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: return cfg_dir -@pytest.fixture +@pytest.fixture(autouse=True) def mem_store(monkeypatch: pytest.MonkeyPatch) -> InMemorySecretStore: - """Swap the CLI's secret store for an in-memory fake.""" + """Swap the CLI's secret store for an in-memory fake across all tests.""" store = InMemorySecretStore() monkeypatch.setattr(cli, "secret_store", store) return store + + +def _git(repo: Path, *args: str) -> None: + """Run git in repo, raising on failure.""" + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + + +@pytest.fixture +def git_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Scratch repo with real commits; rename detection forced off by default. + + `diff.renames=false` proves our explicit `-M` flag does the work, not the + ambient git config of the machine running the tests. + """ + repo = tmp_path / "repo" + repo.mkdir() + _git(repo, "init", "-b", "main") + _git(repo, "config", "user.name", "Test") + _git(repo, "config", "user.email", "test@example.com") + _git(repo, "config", "diff.renames", "false") + _git(repo, "config", "diff.mnemonicPrefix", "true") + _git(repo, "commit", "--allow-empty", "-m", "init") + monkeypatch.chdir(repo) + return repo + + +def commit_file(repo: Path, name: str, content: str, message: str) -> None: + """Write text content to name, stage, and commit it in repo.""" + full = repo / name + full.parent.mkdir(parents=True, exist_ok=True) + full.write_text(content, encoding="utf-8") + _git(repo, "add", name) + _git(repo, "commit", "-m", message) + + +def stage_file(repo: Path, name: str, data: bytes) -> None: + """Write raw bytes to name and stage it in repo (no commit).""" + full = repo / name + full.parent.mkdir(parents=True, exist_ok=True) + full.write_bytes(data) + _git(repo, "add", name) diff --git a/tests/test_cli.py b/tests/test_cli.py index 6e4b9f1..915f925 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,10 +4,18 @@ from pathlib import Path +import pytest from typer.testing import CliRunner from gitmate import cli -from gitmate.config import API_KEY_ACCOUNT, InMemorySecretStore, load_config +from gitmate.config import ( + API_KEY_ACCOUNT, + GitmateConfig, + InMemorySecretStore, + config_path, + load_config, + save_config, +) runner = CliRunner() @@ -15,7 +23,7 @@ def test_help_lists_all_commands() -> None: result = runner.invoke(cli.app, ["--help"]) assert result.exit_code == 0 - for name in ("commit", "pr-summary", "changelog", "doc", "config"): + for name in ("commit", "pr-summary", "changelog", "doc", "config", "debug-diff"): assert name in result.output @@ -53,3 +61,97 @@ def test_config_set_field_persists(tmp_config_dir: Path) -> None: def test_config_set_unknown_field_fails(tmp_config_dir: Path) -> None: result = runner.invoke(cli.app, ["config", "set", "nope", "x"]) assert result.exit_code != 0 + + +def test_debug_diff_shows_table_and_patch(tmp_config_dir: Path, git_repo: Path) -> None: + from conftest import commit_file, stage_file + + commit_file(git_repo, "a.py", "l1\n", "add a") + stage_file(git_repo, "a.py", b"l1\nl2\n") + result = runner.invoke(cli.app, ["debug-diff"]) + assert result.exit_code == 0 + assert "a.py" in result.output + assert "@@" in result.output + + +def test_debug_diff_summary_omits_patch(tmp_config_dir: Path, git_repo: Path) -> None: + from conftest import commit_file, stage_file + + commit_file(git_repo, "a.py", "l1\n", "add a") + stage_file(git_repo, "a.py", b"l1\nl2\n") + result = runner.invoke(cli.app, ["debug-diff", "--summary"]) + assert result.exit_code == 0 + assert "a.py" in result.output + assert "@@" not in result.output + + +def test_debug_diff_branch_mode(tmp_config_dir: Path, git_repo: Path) -> None: + import subprocess + + from conftest import commit_file + + commit_file(git_repo, "b.py", "v1\n", "base") + subprocess.run(["git", "checkout", "-b", "f"], cwd=git_repo, check=True) + commit_file(git_repo, "b.py", "v1\nv2\n", "bump") + result = runner.invoke(cli.app, ["debug-diff", "--base", "main"]) + assert result.exit_code == 0 + assert "b.py" in result.output + + +def test_debug_diff_conflicting_modes_fail(tmp_config_dir: Path, git_repo: Path) -> None: + result = runner.invoke(cli.app, ["debug-diff", "--base", "main", "--from-ref", "v1"]) + assert result.exit_code != 0 + result = runner.invoke(cli.app, ["debug-diff", "--from-ref", "v1"]) + assert result.exit_code != 0 + + +def test_config_show_displays_ignore_globs( + tmp_config_dir: Path, mem_store: InMemorySecretStore +) -> None: + result = runner.invoke(cli.app, ["config", "show"]) + assert result.exit_code == 0 + assert "ignore_globs" in result.output + assert "none" in result.output + + save_config(GitmateConfig(ignore_globs=["*.lock", "dist/*"])) + result2 = runner.invoke(cli.app, ["config", "show"]) + assert result2.exit_code == 0 + assert "*.lock, dist/*" in result2.output + + +def test_debug_diff_malformed_config_clean_error(tmp_config_dir: Path, git_repo: Path) -> None: + config_path().parent.mkdir(parents=True, exist_ok=True) + config_path().write_text("model = [invalid toml\n", encoding="utf-8") + result = runner.invoke(cli.app, ["debug-diff"]) + assert result.exit_code == 1 + assert "error:" in result.output + assert "Traceback" not in result.output + + +def test_debug_diff_not_a_git_repo_clean_error( + tmp_config_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + non_repo = tmp_path / "not_a_repo" + non_repo.mkdir() + monkeypatch.chdir(non_repo) + result = runner.invoke(cli.app, ["debug-diff"]) + assert result.exit_code == 1 + assert "error:" in result.output + assert "is not inside a git repository" in result.output + assert "Traceback" not in result.output + + +def test_config_show_malformed_config_clean_error(tmp_config_dir: Path) -> None: + config_path().parent.mkdir(parents=True, exist_ok=True) + config_path().write_text("model = [invalid toml\n", encoding="utf-8") + result = runner.invoke(cli.app, ["config", "show"]) + assert result.exit_code == 1 + assert "error:" in result.output + assert "Traceback" not in result.output + + +def test_debug_diff_git_command_error_clean_error(tmp_config_dir: Path, git_repo: Path) -> None: + result = runner.invoke(cli.app, ["debug-diff", "--base", "nonexistent-branch"]) + assert result.exit_code == 1 + assert "error:" in result.output + assert "Traceback" not in result.output diff --git a/tests/test_config.py b/tests/test_config.py index da8f9c7..6e47760 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -58,3 +58,15 @@ def test_memory_store_round_trip() -> None: assert store.get_secret("api-key") is None store.set_secret("api-key", "s3cr3t") assert store.get_secret("api-key") == "s3cr3t" + + +def test_ignore_globs_round_trip(tmp_config_dir: Path) -> None: + save_config(GitmateConfig(ignore_globs=["*.gen.py"])) + assert load_config() == GitmateConfig(ignore_globs=["*.gen.py"]) + + +def test_ignore_globs_wrong_type_raises(tmp_config_dir: Path) -> None: + config_path().parent.mkdir(parents=True, exist_ok=True) + config_path().write_text('ignore_globs = "*.gen.py"\n', encoding="utf-8") + with pytest.raises(ConfigError): + load_config() diff --git a/tests/test_diff_extractor.py b/tests/test_diff_extractor.py new file mode 100644 index 0000000..2f5951a --- /dev/null +++ b/tests/test_diff_extractor.py @@ -0,0 +1,180 @@ +"""Tests for DiffExtractor against scratch repos with real git (never mocked).""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest +from conftest import commit_file, stage_file + +from gitmate.diff_extractor import ( + DiffExtractor, + GitCommandError, + NotAGitRepo, + _parse_name_status, + matches_any, +) + + +def test_staged_modify_reports_counts(git_repo: Path) -> None: + commit_file(git_repo, "a.py", "l1\nl2\n", "add a") + stage_file(git_repo, "a.py", b"l1\nl2\nl3\nl4\n") + (diff,) = DiffExtractor(git_repo).staged() + assert (diff.path, diff.status) == ("a.py", "modified") + assert (diff.additions, diff.deletions) == (2, 0) + assert "+l3" in diff.patch_text and not diff.is_binary + + +def test_staged_new_file_is_added(git_repo: Path) -> None: + stage_file(git_repo, "new.py", b"x = 1\n") + (diff,) = DiffExtractor(git_repo).staged() + assert (diff.path, diff.status, diff.old_path) == ("new.py", "added", None) + assert diff.additions == 1 + + +def test_staged_delete(git_repo: Path) -> None: + commit_file(git_repo, "gone.py", "x\n", "add gone") + subprocess.run(["git", "rm", "gone.py"], cwd=git_repo, check=True, capture_output=True) + (diff,) = DiffExtractor(git_repo).staged() + assert (diff.path, diff.status) == ("gone.py", "deleted") + + +def test_rename_detected_without_ambient_config(git_repo: Path) -> None: + commit_file(git_repo, "old.py", "x = 1\n", "add old") + subprocess.run(["git", "mv", "old.py", "new.py"], cwd=git_repo, check=True) + (diff,) = DiffExtractor(git_repo).staged() + assert (diff.path, diff.status, diff.old_path) == ("new.py", "renamed", "old.py") + + +def test_rename_with_edits_keeps_counts(git_repo: Path) -> None: + commit_file(git_repo, "old.py", "x = 1\ny = 2\n", "add old") + subprocess.run(["git", "mv", "old.py", "new.py"], cwd=git_repo, check=True) + (git_repo / "new.py").write_text("x = 1\ny = 2\nz = 3\n", encoding="utf-8") + subprocess.run(["git", "add", "new.py"], cwd=git_repo, check=True) + (diff,) = DiffExtractor(git_repo).staged() + assert (diff.path, diff.status, diff.old_path) == ("new.py", "renamed", "old.py") + assert diff.additions >= 1 + + +def test_binary_flagged_and_patch_dropped(git_repo: Path) -> None: + stage_file(git_repo, "img.bin", b"\x00\x01\x02binary\xff") + (diff,) = DiffExtractor(git_repo).staged() + assert diff.path == "img.bin" and diff.is_binary + assert diff.patch_text == "" + + +def test_noise_filtered_including_nested_paths(git_repo: Path) -> None: + stage_file(git_repo, "package-lock.json", b"{}\n") + stage_file(git_repo, "pkg/package-lock.json", b"{}\n") + stage_file(git_repo, "dist/assets/bundle.js", b"var x;\n") + stage_file(git_repo, "app.min.js", b"var x;\n") + stage_file(git_repo, "real.py", b"y = 2\n") + (diff,) = DiffExtractor(git_repo).staged() + assert diff.path == "real.py" + + +def test_user_ignores_extend_defaults(git_repo: Path) -> None: + stage_file(git_repo, "foo.gen.py", b"gen\n") + stage_file(git_repo, "package-lock.json", b"{}\n") + stage_file(git_repo, "keep.py", b"ok\n") + diffs = DiffExtractor(git_repo, extra_ignores=["*.gen.py"]).staged() + assert [d.path for d in diffs] == ["keep.py"] + + +def test_matcher_rules() -> None: + assert matches_any("dist/assets/b.js", ["dist/*"]) + assert matches_any("pkg/package-lock.json", ["package-lock.json"]) + assert matches_any("a/b/c.min.js", ["*.min.js"]) + assert not matches_any("src/app.py", ["dist/*", "*.min.js"]) + assert matches_any("out/log.txt", ["out/"]) + + +def test_branch_comparison(git_repo: Path) -> None: + commit_file(git_repo, "base.py", "v1\n", "base") + subprocess.run(["git", "checkout", "-b", "feature"], cwd=git_repo, check=True) + commit_file(git_repo, "base.py", "v1\nv2\n", "bump") + (diff,) = DiffExtractor(git_repo).branch_comparison("main") + assert (diff.path, diff.additions) == ("base.py", 1) + + +def test_rev_range(git_repo: Path) -> None: + commit_file(git_repo, "c.py", "1\n", "one") + subprocess.run(["git", "tag", "v1"], cwd=git_repo, check=True) + commit_file(git_repo, "c.py", "1\n2\n", "two") + subprocess.run(["git", "tag", "v2"], cwd=git_repo, check=True) + (diff,) = DiffExtractor(git_repo).rev_range("v1", "v2") + assert (diff.path, diff.additions) == ("c.py", 1) + + +def test_not_a_repo(tmp_path: Path) -> None: + with pytest.raises(NotAGitRepo): + DiffExtractor(tmp_path / "nope").staged() + + +def test_empty_diff(git_repo: Path) -> None: + assert DiffExtractor(git_repo).staged() == [] + + +def test_bad_ref_raises(git_repo: Path) -> None: + with pytest.raises(GitCommandError): + DiffExtractor(git_repo).branch_comparison("does-not-exist") + + +def test_filename_with_spaces_staged(git_repo: Path) -> None: + commit_file(git_repo, "my cool file.py", "line 1\n", "initial") + stage_file(git_repo, "my cool file.py", b"line 1\nline 2\n") + stage_file(git_repo, "brand new file.py", b"print('hello')\n") + diffs = DiffExtractor(git_repo).staged() + diff_map = {d.path: d for d in diffs} + assert "my cool file.py" in diff_map + assert diff_map["my cool file.py"].status == "modified" + assert diff_map["my cool file.py"].additions == 1 + assert "+line 2" in diff_map["my cool file.py"].patch_text + assert "brand new file.py" in diff_map + assert diff_map["brand new file.py"].status == "added" + assert "+print('hello')" in diff_map["brand new file.py"].patch_text + + +def test_filename_with_spaces_binary(git_repo: Path) -> None: + stage_file(git_repo, "bin space.bin", b"\x00\x01\x02\xffbinary\x00") + (diff,) = DiffExtractor(git_repo).staged() + assert diff.path == "bin space.bin" + assert diff.is_binary + assert diff.patch_text == "" + + +def test_non_ascii_filename(git_repo: Path) -> None: + stage_file(git_repo, "café.txt", b"coffee\n") + (diff,) = DiffExtractor(git_repo).staged() + assert diff.path == "café.txt" + assert diff.status == "added" + assert diff.additions == 1 + assert not diff.is_binary + assert "+coffee" in diff.patch_text + + +def test_mnemonic_prefix_ambient_config(git_repo: Path) -> None: + # Ambient config already sets diff.mnemonicPrefix=true in git_repo fixture + commit_file(git_repo, "prefix_test.py", "v1\n", "initial") + stage_file(git_repo, "prefix_test.py", b"v1\nv2\n") + (diff,) = DiffExtractor(git_repo).staged() + assert diff.path == "prefix_test.py" + assert "+v2" in diff.patch_text + assert diff.status == "modified" + + +def test_pure_rename_with_spaces(git_repo: Path) -> None: + commit_file(git_repo, "old name.py", "content\n", "add old") + subprocess.run(["git", "mv", "old name.py", "new name.py"], cwd=git_repo, check=True) + (diff,) = DiffExtractor(git_repo).staged() + assert diff.path == "new name.py" + assert diff.old_path == "old name.py" + assert diff.status == "renamed" + assert "rename from old name.py" in diff.patch_text + assert "rename to new name.py" in diff.patch_text + + +def test_copy_status_row() -> None: + statuses = _parse_name_status("C100\told.py\tnew.py\n") + assert statuses["new.py"] == ("added", "old.py")