"""Integration: phase 106 task 03 — ``file_commit_dates`` against real git scratch repos (D2; phase 107 D11 supersedes phase 106 D10). Builds scratch repositories with controlled ``GIT_COMMITTER_DATE``s (the 2026-09-13 verification recipe: file ``a.md`` committed once in 2020, file ``b.md`` committed in 2020 and touched again in 2024, a ``docs/deep.md`` subdirectory file committed once in 2020) and pins the VERIFIED checkout behavior (phase 107): * a LOCAL-PATH ``clone_or_pull`` checkout is FULL history → TRUE per-file last-commit dates (first-sighting wins: ``a.md`` 2020, ``b.md`` 2024, ``docs/deep.md`` 2020); * a URL-TRANSPORT (``file://``) ``clone_or_pull`` checkout is FULL history TOO (D11: no ``--depth`` on the clone) → the SAME true per-file dates — the regression pin for the 2026-09-16 bug (pre-fix, D10's shallow ``--depth 1`` clone returned the TIP date for EVERY file, uniform per repo); * an EXISTING shallow checkout (made ``--depth 1`` directly by the test harness — simulating every pre-phase-107 deployed checkout) self-heals on the next ``clone_or_pull``: the ``--is-shallow-repository`` probe finds it shallow → ``git fetch --unshallow`` → ``git pull --ff-only`` → no longer shallow, true per-file dates; * fail-soft: a directory without ``.git``, an empty repo (no commits), a git failure, and a malformed log output all yield ``{}`` — a date walk must never break a sync (the importer, task 04, falls back to file mtimes). Phase 186 adds the two pins for the walk moving off ``run_git`` onto the streaming site :func:`scripts.git_sync.stream_git_lines` (the incident of 2026-10-15: a full-history ``git log`` held as ONE string plus the parser's ``splitlines()`` copy, tens of MB of transient peak per source): ``test_streamed_walk_returns_the_buffered_dates`` proves the streamed dict EQUALS the buffered one on the four cases that matter (commit-1-only file, commit-2-modified file, subdirectory file, never-committed file → the mtime fallback), and ``test_file_commit_dates_fail_soft_unchanged`` pins that both fail-soft arms and their exact warning lines survived the move. DB-free by design: ``file_commit_dates`` takes a path, no session. Skipped (not failed) on a machine without the git CLI (the ``test_doc_drafts_api.py`` guard). """ from __future__ import annotations import os import subprocess from datetime import UTC, datetime from pathlib import Path import pytest from scripts.git_sync import ( GitSyncError, _parse_commit_dates, # pyright: ignore[reportPrivateUsage] clone_or_pull, file_commit_dates, run_git, ) def _git_available() -> bool: try: proc = subprocess.run(["git", "--version"], capture_output=True, check=False) return proc.returncode == 0 except (FileNotFoundError, OSError): return False #: Real ``git`` in the test environment — skipped cleanly without it #: (the ``test_doc_drafts_api.py`` house pattern). GIT = _git_available() pytestmark = pytest.mark.skipif(not GIT, reason="git CLI not available") #: The two controlled commit dates (the 2026-09-13 verification recipe). DATE_A = datetime(2020, 1, 2, 3, 4, 6, tzinfo=UTC) # commit one (2020) DATE_B = datetime(2024, 6, 15, 10, 0, 0, tzinfo=UTC) # commit two = the tip (2024) def _git(cwd: Path, *argv: str, when: datetime | None = None) -> None: """Run one git command for the test harness (fixture setup); a non-zero exit fails the fixture, not the test under test.""" env = os.environ.copy() if when is not None: iso = when.isoformat() env["GIT_AUTHOR_DATE"] = iso env["GIT_COMMITTER_DATE"] = iso env["GIT_AUTHOR_NAME"] = "T" env["GIT_AUTHOR_EMAIL"] = "t@example.com" env["GIT_COMMITTER_NAME"] = "T" env["GIT_COMMITTER_EMAIL"] = "t@example.com" proc = subprocess.run( ["git", *argv], cwd=cwd, env=env, capture_output=True, text=True, check=False ) assert proc.returncode == 0, f"git {' '.join(argv)} failed: {proc.stderr}" @pytest.fixture() def scratch_repo(tmp_path: Path) -> Path: """The 2026-09-13 recipe: commit one (2020-01-02) adds ``a.md``, ``b.md``, ``docs/deep.md``; commit two (2024-06-15, the tip) touches ONLY ``b.md``.""" repo = tmp_path / "repo" repo.mkdir() _git(repo, "init", "-q") _git(repo, "config", "user.email", "t@example.com") _git(repo, "config", "user.name", "T") _git(repo, "config", "commit.gpgsign", "false") (repo / "docs").mkdir() (repo / "a.md").write_text("# A\nstable since 2020\n", encoding="utf-8") (repo / "b.md").write_text("# B\nfirst version\n", encoding="utf-8") (repo / "docs" / "deep.md").write_text("# Deep\nalso 2020\n", encoding="utf-8") _git(repo, "add", "-A", when=DATE_A) _git(repo, "commit", "-qm", "one", when=DATE_A) (repo / "b.md").write_text("# B\nupdated 2024\n", encoding="utf-8") _git(repo, "add", "-A", when=DATE_B) _git(repo, "commit", "-qm", "two", when=DATE_B) return repo def test_local_clone_yields_true_per_file_dates(scratch_repo: Path, tmp_path: Path) -> None: """(a) LOCAL-PATH ``clone_or_pull`` → full history (no ``--depth`` at all, phase 107 D11) → TRUE per-file last-commit dates: the first (newest) sighting of each path wins — ``b.md`` the 2024 touch, the rest the 2020 commit.""" dest = tmp_path / "local" clone_or_pull(str(scratch_repo), dest) assert (dest / ".git").exists() # a real checkout assert file_commit_dates(dest) == { "a.md": DATE_A, "b.md": DATE_B, # touched again by the tip commit "docs/deep.md": DATE_A, } def test_url_clone_yields_true_per_file_dates(scratch_repo: Path, tmp_path: Path) -> None: """(b) URL-TRANSPORT ``clone_or_pull`` (``file://`` through the REAL function — the 2026-09-16 regression pin): the checkout is FULL history (no ``--depth`` — phase 107 D11 supersedes phase 106 D10) → NOT shallow, and TRUE per-file last-commit dates: the 2020 files stay 2020 and ``b.md`` (touched again at the tip) gets the 2024 tip date. PRE-FIX this returned DATE_B (the uniform tip date) for all three files — the exact bug the owner reported. """ dest = tmp_path / "url-clone" clone_or_pull(f"file://{scratch_repo}", dest) assert (dest / ".git").exists() # a real checkout assert ( run_git(["git", "rev-parse", "--is-shallow-repository"], cwd=dest).strip() == "false" ) # a full-history checkout, not shallow assert file_commit_dates(dest) == { "a.md": DATE_A, "b.md": DATE_B, # touched again by the tip commit "docs/deep.md": DATE_A, } def test_existing_shallow_checkout_self_heals(scratch_repo: Path, tmp_path: Path) -> None: """(c) an EXISTING shallow checkout — built ``--depth 1`` directly by the harness, simulating every pre-phase-107 deployed checkout (live + dev homelab included) — self-heals on the next ``clone_or_pull`` (D11): the ``--is-shallow-repository`` probe finds it shallow → the ONE-TIME ``git fetch --unshallow`` restores the full history (no re-clone) → the usual ``--ff-only`` pull. Pre- heal: shallow + the uniform tip date for all files; post-heal: full history + true per-file dates.""" dest = tmp_path / "shallow" _git(tmp_path, "clone", "-q", "--depth", "1", f"file://{scratch_repo}", str(dest)) # Pre-heal: the deployed state the fix must cure. assert ( run_git(["git", "rev-parse", "--is-shallow-repository"], cwd=dest).strip() == "true" ) assert file_commit_dates(dest) == { "a.md": DATE_B, "b.md": DATE_B, "docs/deep.md": DATE_B, } clone_or_pull(f"file://{scratch_repo}", dest) # Post-heal: full history and true per-file dates, same as (b). assert ( run_git(["git", "rev-parse", "--is-shallow-repository"], cwd=dest).strip() == "false" ) assert file_commit_dates(dest) == { "a.md": DATE_A, "b.md": DATE_B, "docs/deep.md": DATE_A, } def test_directory_without_dotgit_fails_soft(tmp_path: Path) -> None: """(c) a plain directory (no ``.git``) → ``git log`` exits non-zero → ``{}`` (fail-soft, no raise) — the importer falls back to file mtimes.""" plain = tmp_path / "notarepo" plain.mkdir() (plain / "a.md").write_text("# A\nnot a git repo\n", encoding="utf-8") assert file_commit_dates(plain) == {} def test_nonexistent_directory_fails_soft(tmp_path: Path) -> None: """(c) a missing checkout directory → ``{}`` without even invoking git (no raise).""" assert file_commit_dates(tmp_path / "gone") == {} def test_empty_repo_fails_soft(tmp_path: Path) -> None: """(c) an initialized repo with NO commits → ``git log`` fails (nothing to log) → ``{}`` (a cloned-but-empty source must not break the sync).""" empty = tmp_path / "emptyrepo" empty.mkdir() _git(empty, "init", "-q") _git(empty, "config", "commit.gpgsign", "false") assert file_commit_dates(empty) == {} def test_git_error_fails_soft( scratch_repo: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """(c) a ``GitSyncError`` from the walk (git missing/failed) → ``{}`` + a logged warning naming the fallback — the fail-soft contract (pinned). Phase 186 moved the walk off ``run_git`` onto :func:`stream_git_lines`, so that is the site the stub replaces.""" def boom(argv: list[str], cwd: Path) -> str: raise GitSyncError("git log failed (exit 128): fatal: bad object") monkeypatch.setattr("scripts.git_sync.stream_git_lines", boom) with caplog.at_level("WARNING"): assert file_commit_dates(scratch_repo) == {} assert any("file_commit_dates" in record.message for record in caplog.records) def test_malformed_log_output_fails_soft( scratch_repo: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """(c) ANY parse anomaly (a commit date ``fromisoformat`` cannot read) → ``{}`` (fail-soft) — the walk is all-or-nothing: a partially parsed date map would be worse than none.""" monkeypatch.setattr( "scripts.git_sync.stream_git_lines", lambda argv, cwd: iter(["@@not-a-date", "b.md"]), ) assert file_commit_dates(scratch_repo) == {} # --- phase 186: the streamed walk returns what the buffered one did --------- def test_streamed_walk_returns_the_buffered_dates( scratch_repo: Path, tmp_path: Path ) -> None: """Phase 186 D4 — DATE PARITY. The same checkout is walked twice: through the shipped streaming path (``file_commit_dates``) and through the pre-phase buffered path (``run_git`` → one ``str`` → ``_parse_commit_dates``). The two dicts are EQUAL, and they carry the four cases the phase names: (i) a file only in commit 1 (``a.md``) → commit 1's date; (ii) a file modified in commit 2 (``b.md``) → commit 2's date, NOT commit 1's — first-sighting-wins over a newest-first walk; (iii) a file in a subdirectory (``docs/deep.md``) → its own last-commit date, keyed repo-relative with ``/``; (iv) a file in the working tree that git never committed → no entry at all, so the importer's mtime fallback applies (asserted on the LOOKUP here — the resulting date is the importer's contract, tested in ``test_importer_dates.py``).""" dest = tmp_path / "clone" clone_or_pull(str(scratch_repo), dest) (dest / "never-committed.md").write_text("# untracked\n", encoding="utf-8") streamed = file_commit_dates(dest) buffered = _parse_commit_dates( run_git(["git", "log", "--name-only", "--format=@@%cI"], cwd=dest) ) assert streamed == buffered assert streamed["a.md"] == DATE_A # (i) only in commit 1 assert streamed["b.md"] == DATE_B # (ii) modified in commit 2 assert streamed["docs/deep.md"] == DATE_A # (iii) subdirectory assert "never-committed.md" not in streamed # (iv) mtime fallback assert streamed.get("never-committed.md") is None def test_file_commit_dates_fail_soft_unchanged( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """Phase 186 D3: both fail-soft arms and their EXACT warning lines survive the switch to streaming — (a) a checkout that is not a directory (git is never invoked), (b) a directory that is not a repository (the stream raises the ``run_git``-shaped ``GitSyncError`` mid-call). Both return ``{}``; neither raises. The wording is byte-pinned because the ops dashboards grep it.""" missing = tmp_path / "gone" not_a_dir = tmp_path / "a-file" not_a_dir.write_text("# not a directory\n", encoding="utf-8") plain = tmp_path / "notarepo" plain.mkdir() (plain / "a.md").write_text("# A\n", encoding="utf-8") with caplog.at_level("WARNING", logger="scripts.git_sync"): assert file_commit_dates(missing) == {} assert file_commit_dates(not_a_dir) == {} assert file_commit_dates(plain) == {} messages = [r.getMessage() for r in caplog.records if r.name == "scripts.git_sync"] assert len(messages) == 3 assert messages[0] == ( f"file_commit_dates: {missing} is not a directory — no git dates " "(the importer will fall back to file mtimes)" ) assert messages[1] == ( f"file_commit_dates: {not_a_dir} is not a directory — no git dates " "(the importer will fall back to file mtimes)" ) assert messages[2].startswith(f"file_commit_dates: git log failed for {plain}: git log ") assert "failed (exit " in messages[2] assert messages[2].endswith( "— the importer will fall back to file mtimes" ) # --- the pure parser (canned git output — no git, no DB) ------------------- def test_parser_first_sighting_wins_newest_first() -> None: """The walk is newest-first, so the FIRST sighting of a path is its last-commit date: ``b.md`` appears under both commits and keeps the 2024 (newest) date; the 2020 ``a.md`` keeps 2020. Blank lines (git's commit separators) are skipped.""" output = "\n".join( [ "@@2024-06-15T10:00:00+00:00", "", "b.md", "@@2020-01-02T03:04:06+00:00", "", "a.md", "b.md", ] ) assert _parse_commit_dates(output) == {"a.md": DATE_A, "b.md": DATE_B} def test_parser_normalizes_paths() -> None: """Path lines are whitespace-split (defensively - git's name-only output is one path per line), backslash-normalized to ``/``, and a leading ``/`` is stripped (repo-relative POSIX keys); the line is stripped first.""" output = "\n".join( [ "@@2024-06-15T10:00:00+00:00", "", "docs\\deep.md", "/rooted.md", " padded.md ", ] ) assert _parse_commit_dates(output) == { "docs/deep.md": DATE_B, "rooted.md": DATE_B, "padded.md": DATE_B, } def test_parser_rejects_path_before_header() -> None: """A path line before ANY commit header is a malformed walk → ``ValueError`` (the caller's fail-soft path turns it into ``{}``).""" with pytest.raises(ValueError, match="before any commit header"): _parse_commit_dates("stray.md\n@@2024-06-15T10:00:00+00:00\n") def test_parser_rejects_bad_date() -> None: """A commit date ``fromisoformat`` cannot read → ``ValueError`` (ISO-strict ``%cI`` always parses — this is the anomaly guard).""" with pytest.raises(ValueError): _parse_commit_dates("@@yesterday\nb.md\n")