From beffd28b5aaa05dcff671cc63f314c45b26e1ace Mon Sep 17 00:00:00 2001 From: nazarli-shabnam Date: Sat, 25 Jul 2026 13:09:19 +0400 Subject: [PATCH 1/2] test(git): add coverage for git_explain.git subprocess wrappers git.py parses raw git diff/ls-files output (including rename normalization) but had no tests. Cover repo-root/inside-repo checks, staged/unstaged/untracked detection, rename normalization, non-ASCII paths, and the combined-diff / diff-for-paths output shapes. Closes #17 --- tests/test_git.py | 209 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 tests/test_git.py diff --git a/tests/test_git.py b/tests/test_git.py new file mode 100644 index 0000000..4cebb2e --- /dev/null +++ b/tests/test_git.py @@ -0,0 +1,209 @@ +import subprocess + +import pytest + +from git_explain.git import ( + ensure_git_repo, + get_combined_diff, + get_diff_for_paths, + get_repo_root, + get_staged_changes, + get_staged_diff_for_paths, + get_untracked_changes, + get_unstaged_changes, + repo_has_commits, +) + + +def _git(cwd, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + cwd=cwd, + check=True, + capture_output=True, + text=True, + ) + + +def _init_repo(repo) -> None: + repo.mkdir() + _git(repo, "init") + _git(repo, "config", "user.email", "test@example.com") + _git(repo, "config", "user.name", "Test User") + # Emit UTF-8 paths as-is instead of octal-escaped/quoted (git's default + # for non-ASCII paths), matching the UTF-8 assumption in git_explain.git. + _git(repo, "config", "core.quotepath", "false") + + +def test_get_repo_root_raises_outside_a_repo(tmp_path) -> None: + outside = tmp_path / "not_a_repo" + outside.mkdir() + with pytest.raises(RuntimeError): + get_repo_root(outside) + + +def test_ensure_git_repo_raises_outside_a_repo(tmp_path) -> None: + outside = tmp_path / "not_a_repo" + outside.mkdir() + with pytest.raises(RuntimeError): + ensure_git_repo(outside) + + +def test_get_repo_root_and_ensure_git_repo_return_root(tmp_path) -> None: + repo = tmp_path / "repo" + _init_repo(repo) + assert get_repo_root(repo).resolve() == repo.resolve() + assert ensure_git_repo(repo).resolve() == repo.resolve() + + +def test_repo_has_commits_before_and_after_first_commit(tmp_path) -> None: + repo = tmp_path / "repo" + _init_repo(repo) + assert repo_has_commits(repo) is False + + (repo / "a.txt").write_text("hello\n", encoding="utf-8") + _git(repo, "add", "a.txt") + _git(repo, "commit", "-m", "init") + assert repo_has_commits(repo) is True + + +def test_get_staged_changes(tmp_path) -> None: + repo = tmp_path / "repo" + _init_repo(repo) + (repo / "a.txt").write_text("hello\n", encoding="utf-8") + _git(repo, "add", "a.txt") + assert get_staged_changes(repo) == [("A", "a.txt")] + + +def test_get_unstaged_changes(tmp_path) -> None: + repo = tmp_path / "repo" + _init_repo(repo) + (repo / "a.txt").write_text("hello\n", encoding="utf-8") + _git(repo, "add", "a.txt") + _git(repo, "commit", "-m", "init") + + (repo / "a.txt").write_text("hello world\n", encoding="utf-8") + assert get_unstaged_changes(repo) == [("M", "a.txt")] + + +def test_get_untracked_changes(tmp_path) -> None: + repo = tmp_path / "repo" + _init_repo(repo) + (repo / "new.txt").write_text("new\n", encoding="utf-8") + assert get_untracked_changes(repo) == [("A", "new.txt")] + + +def test_rename_is_normalized_to_single_r_status(tmp_path) -> None: + repo = tmp_path / "repo" + _init_repo(repo) + (repo / "old.txt").write_text("same content\n", encoding="utf-8") + _git(repo, "add", "old.txt") + _git(repo, "commit", "-m", "init") + + _git(repo, "mv", "old.txt", "new.txt") + staged = get_staged_changes(repo) + assert staged == [("R", "new.txt")] + + +def test_copy_without_detection_flags_shows_as_two_adds(tmp_path) -> None: + # get_staged_changes() calls `git diff --name-status` without -C, so git + # never detects copies (unlike renames, copy detection needs an explicit + # flag) — both the original and the copy show up as separate adds. + repo = tmp_path / "repo" + _init_repo(repo) + (repo / "old.txt").write_text("x\n" * 20, encoding="utf-8") + _git(repo, "add", "old.txt") + _git(repo, "commit", "-m", "init") + + (repo / "copy.txt").write_text((repo / "old.txt").read_text(encoding="utf-8")) + _git(repo, "add", "copy.txt") + assert get_staged_changes(repo) == [("A", "copy.txt")] + + +def test_non_ascii_path_round_trips(tmp_path) -> None: + repo = tmp_path / "repo" + _init_repo(repo) + fname = "café.txt" + (repo / fname).write_text("hello\n", encoding="utf-8") + assert get_untracked_changes(repo) == [("A", fname)] + + +def test_get_combined_diff_sections(tmp_path) -> None: + repo = tmp_path / "repo" + _init_repo(repo) + (repo / "tracked.txt").write_text("hello\n", encoding="utf-8") + _git(repo, "add", "tracked.txt") + _git(repo, "commit", "-m", "init") + + (repo / "tracked.txt").write_text("hello world\n", encoding="utf-8") + (repo / "staged.txt").write_text("staged\n", encoding="utf-8") + _git(repo, "add", "staged.txt") + (repo / "untracked.txt").write_text("new\n", encoding="utf-8") + + combined, root = get_combined_diff(repo) + assert root.resolve() == repo.resolve() + assert "## Meta\nhas_commits: true" in combined + assert "## Staged\nA staged.txt" in combined + assert "## Unstaged\nM tracked.txt" in combined + assert "## Untracked\nA untracked.txt" in combined + + +def test_get_combined_diff_empty_repo_has_no_commits(tmp_path) -> None: + repo = tmp_path / "repo" + _init_repo(repo) + combined, _root = get_combined_diff(repo) + assert "has_commits: false" in combined + + +def test_get_diff_for_paths_includes_untracked_file_content(tmp_path) -> None: + repo = tmp_path / "repo" + _init_repo(repo) + (repo / "new.txt").write_text("brand new content\n", encoding="utf-8") + + diff = get_diff_for_paths(["new.txt"], repo) + assert "Untracked (new file): new.txt" in diff + assert "brand new content" in diff + + +def test_get_diff_for_paths_includes_staged_and_unstaged_sections(tmp_path) -> None: + repo = tmp_path / "repo" + _init_repo(repo) + (repo / "a.txt").write_text("line1\n", encoding="utf-8") + _git(repo, "add", "a.txt") + _git(repo, "commit", "-m", "init") + + (repo / "a.txt").write_text("line1\nline2\n", encoding="utf-8") + _git(repo, "add", "a.txt") + (repo / "a.txt").write_text("line1\nline2\nline3\n", encoding="utf-8") + + diff = get_diff_for_paths(["a.txt"], repo) + assert "## Staged diff" in diff + assert "## Unstaged diff" in diff + + +def test_get_diff_for_paths_empty_paths_returns_empty_string(tmp_path) -> None: + repo = tmp_path / "repo" + _init_repo(repo) + assert get_diff_for_paths([], repo) == "" + + +def test_get_staged_diff_for_paths(tmp_path) -> None: + repo = tmp_path / "repo" + _init_repo(repo) + (repo / "a.txt").write_text("line1\n", encoding="utf-8") + _git(repo, "add", "a.txt") + _git(repo, "commit", "-m", "init") + + (repo / "a.txt").write_text("line1\nline2\n", encoding="utf-8") + _git(repo, "add", "a.txt") + (repo / "a.txt").write_text("line1\nline2\nline3\n", encoding="utf-8") + + staged_diff = get_staged_diff_for_paths(["a.txt"], repo) + assert "+line2" in staged_diff + assert "line3" not in staged_diff + + +def test_get_staged_diff_for_paths_empty_paths_returns_empty_string(tmp_path) -> None: + repo = tmp_path / "repo" + _init_repo(repo) + assert get_staged_diff_for_paths([], repo) == "" From c55ca931b152f0019d4e05fbe9f7a6ca818e26c6 Mon Sep 17 00:00:00 2001 From: nazarli-shabnam Date: Sat, 25 Jul 2026 14:19:31 +0400 Subject: [PATCH 2/2] ci: pin ruff to 0.12.3 to stop lint from drifting with upstream releases The lint job's `pip install ruff` had no version pin, decoupled from pyproject.toml's own ruff>=0.8.0. A newer ruff release enables new default-on lint rules, breaking CI on pre-existing code unrelated to this change. Pin to the last known-good version in both places. --- .github/workflows/ci.yml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 653a008..37e7881 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: cache: "pip" - name: Install Ruff - run: pip install ruff + run: pip install ruff==0.12.3 - name: Ruff check run: ruff check . diff --git a/pyproject.toml b/pyproject.toml index 496a42a..bb37a87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ dependencies = [ [project.optional-dependencies] dev = [ "pytest>=8.0.0", - "ruff>=0.8.0", + "ruff==0.12.3", ] [project.scripts]