"""Unit tests: git clone/pull utility (phase 28; phase 107 D11/D12; phase 135 adds the branch pin — the argv matrix). ``clone_or_pull`` dispatches to ``git clone`` (full history, fresh dest) or a shallow-probe + optional ``git fetch --unshallow`` + ``git pull --ff-only`` (existing checkout) with ``subprocess`` fully mocked — the real git CLI is never invoked, so the tests run anywhere. Phase 135: with a pinned ``branch`` the fresh clone gains ``--branch`` and the existing checkout runs the HEAD guard → probe → (unshallow) → ``fetch origin `` → ``merge --ff-only FETCH_HEAD`` sequence; ``branch=None`` (and the two-argument call) keeps the pre-phase argv BYTE-IDENTICAL — the argv pins below are the regression gate for every existing deployment. """ from __future__ import annotations import subprocess from pathlib import Path import pytest import scripts.git_sync as git_sync from scripts.git_sync import GitSyncError, clone_or_pull class _FakeProc: def __init__(self, returncode: int = 0, stdout: str = "", stderr: str = "") -> None: self.returncode = returncode self.stdout = stdout self.stderr = stderr def _fake_run( monkeypatch: pytest.MonkeyPatch, returncode: int = 0, stdout: str = "", stderr: str = "", missing_git: bool = False, responses: dict[tuple[str, ...], tuple[int, str, str]] | None = None, ) -> list[dict[str, object]]: """Monkeypatch scripts.git_sync.subprocess.run; record each call. ``responses`` dispatches by the full argv (clone / shallow-probe / ``fetch --unshallow`` / pull) — a per-command ``(returncode, stdout, stderr)``; any argv not in the map gets the default ``returncode``/``stdout``/``stderr``. """ calls: list[dict[str, object]] = [] def fake_run(argv: list[str], cwd: Path | None = None, **kwargs: object) -> _FakeProc: calls.append({"argv": list(argv), "cwd": cwd, **kwargs}) if missing_git: raise FileNotFoundError("git") if responses is not None: rc, out, err = responses.get(tuple(argv), (returncode, stdout, stderr)) return _FakeProc(rc, out, err) return _FakeProc(returncode, stdout, stderr) monkeypatch.setattr(subprocess, "run", fake_run) return calls def test_clone_or_pull_clones_when_dest_has_no_git_dir( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """First run: dest absent → ``git clone`` (full history — NO ``--depth``, phase 107 D11) from the parent dir.""" url = "https://github.com/user/homelab.git" dest = tmp_path / "homelab" calls = _fake_run(monkeypatch) result = clone_or_pull(url, dest) assert result == dest assert len(calls) == 1 call = calls[0] assert call["argv"] == ["git", "clone", url, str(dest)] assert call["cwd"] == dest.parent assert call["capture_output"] is True assert call["text"] is True def test_clone_or_pull_pulls_when_git_dir_exists( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Steady state: dest/.git present and the probe says NOT shallow (``false``) → straight to ``git pull --ff-only`` in-place (one cheap local probe, no network fetch).""" dest = tmp_path / "homelab" (dest / ".git").mkdir(parents=True) calls = _fake_run( monkeypatch, responses={("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", "")}, ) result = clone_or_pull("https://github.com/user/homelab.git", dest) assert result == dest assert len(calls) == 2 assert calls[0]["argv"] == ["git", "rev-parse", "--is-shallow-repository"] assert calls[0]["cwd"] == dest assert calls[1]["argv"] == ["git", "pull", "--ff-only"] assert calls[1]["cwd"] == dest def test_clone_or_pull_unshallows_existing_shallow_checkout( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Existing shallow checkout (probe ``true`` — the one-time D11 self-heal of deployed checkouts) → ``git fetch --unshallow`` BEFORE the usual ``git pull --ff-only`` (order pinned), all in dest.""" dest = tmp_path / "homelab" (dest / ".git").mkdir(parents=True) calls = _fake_run( monkeypatch, responses={ ("git", "rev-parse", "--is-shallow-repository"): (0, "true\n", ""), ("git", "fetch", "--unshallow"): (0, "Fetching full history\n", ""), }, ) clone_or_pull("https://github.com/user/homelab.git", dest) assert len(calls) == 3 assert calls[0]["argv"] == ["git", "rev-parse", "--is-shallow-repository"] assert calls[1]["argv"] == ["git", "fetch", "--unshallow"] assert calls[2]["argv"] == ["git", "pull", "--ff-only"] for call in calls: assert call["cwd"] == dest def test_clone_or_pull_unshallow_failure_propagates( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """D12 fail-loud: a failed ``git fetch --unshallow`` raises :class:`GitSyncError` (with git's stderr) and the pull NEVER runs — continuing a failed self-heal would silently re-serve the uniform tip date (the bug D11 fixes).""" dest = tmp_path / "homelab" (dest / ".git").mkdir(parents=True) calls = _fake_run( monkeypatch, responses={ ("git", "rev-parse", "--is-shallow-repository"): (0, "true\n", ""), ("git", "fetch", "--unshallow"): (128, "", "fatal: could not fetch\n"), }, ) with pytest.raises( GitSyncError, match=r"git fetch --unshallow failed \(exit 128\): fatal: could not fetch" ): clone_or_pull("https://github.com/user/homelab.git", dest) assert [call["argv"] for call in calls] == [ ["git", "rev-parse", "--is-shallow-repository"], ["git", "fetch", "--unshallow"], ] # the pull never ran def test_clone_or_pull_creates_missing_parent_before_clone( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The (nested) parent of the destination is mkdir'd before git runs.""" dest = tmp_path / "nested" / "deeper" / "homelab" assert not dest.parent.exists() calls = _fake_run(monkeypatch) clone_or_pull("https://github.com/user/homelab.git", dest) assert dest.parent.is_dir() assert calls[0]["argv"] == [ "git", "clone", "https://github.com/user/homelab.git", str(dest), ] def test_existing_dir_without_git_dir_is_treated_as_fresh( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """A leftover non-repo dir at the dest still takes the clone path — git fails loudly (non-zero exit) if the dir is not empty.""" dest = tmp_path / "homelab" dest.mkdir() calls = _fake_run(monkeypatch) clone_or_pull("https://github.com/user/homelab.git", dest) assert calls[0]["argv"] == [ "git", "clone", "https://github.com/user/homelab.git", str(dest), ] def test_failing_git_raises_error_carrying_stderr( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Non-zero exit → GitSyncError naming the subcommand, exit code, stderr.""" dest = tmp_path / "homelab" _fake_run(monkeypatch, returncode=128, stderr="fatal: repository not found\n") with pytest.raises( GitSyncError, match=r"git clone .* failed \(exit 128\): fatal: repository not found", ): clone_or_pull("https://example.com/nope.git", dest) # Same for the pull path (broken checkout, e.g. diverged history) — # the probe answers "false" (not shallow) so the pull runs. (dest / ".git").mkdir(parents=True) _fake_run( monkeypatch, returncode=1, stderr="error: cannot pull with rebase", responses={("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", "")}, ) with pytest.raises( GitSyncError, match=r"git pull --ff-only failed \(exit 1\): error: cannot pull with rebase" ): clone_or_pull("https://example.com/homelab.git", dest) def test_missing_git_raises_named_error( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """No git binary on PATH → GitSyncError telling the user to install it.""" _fake_run(monkeypatch, missing_git=True) with pytest.raises(GitSyncError, match="git was not found on PATH"): clone_or_pull("https://example.com/homelab.git", tmp_path / "homelab") def test_run_git_captures_and_returns_stdout( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """run_git returns the captured stdout on success (git output is not lost).""" calls = _fake_run(monkeypatch, stdout="From example.com\n + abc..def main") assert git_sync.run_git(["git", "status"], cwd=tmp_path) == "From example.com\n + abc..def main" assert len(calls) == 1 # --- phase 135: the branch pin (the argv matrix) ---------------------------- def test_branch_none_argv_is_byte_identical_pre_phase_sequence( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The regression gate: ``branch=None`` (explicit, the call sites' form for every pre-phase-135 row) produces the EXACT pre-phase argv — fresh: one ``git clone`` without ``--branch``; existing (not shallow): probe → ``git pull --ff-only``; existing (shallow): probe → ``fetch --unshallow`` → ``git pull --ff-only``. A future refactor that rewrites the default path breaks this pin.""" url = "https://github.com/user/homelab.git" dest = tmp_path / "homelab" # fresh (dest absent) calls = _fake_run(monkeypatch) clone_or_pull(url, dest, None) assert [call["argv"] for call in calls] == [["git", "clone", url, str(dest)]] # existing, not shallow (dest / ".git").mkdir(parents=True) calls = _fake_run( monkeypatch, responses={("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", "")}, ) clone_or_pull(url, dest, None) assert [call["argv"] for call in calls] == [ ["git", "rev-parse", "--is-shallow-repository"], ["git", "pull", "--ff-only"], ] # existing, shallow (the one-time D11 self-heal between probe and pull) calls = _fake_run( monkeypatch, responses={ ("git", "rev-parse", "--is-shallow-repository"): (0, "true\n", ""), ("git", "fetch", "--unshallow"): (0, "", ""), }, ) clone_or_pull(url, dest, None) assert [call["argv"] for call in calls] == [ ["git", "rev-parse", "--is-shallow-repository"], ["git", "fetch", "--unshallow"], ["git", "pull", "--ff-only"], ] def test_fresh_clone_with_branch_uses_clone_branch( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Phase 135: fresh dest + pinned branch → ``git clone --branch url dest`` (the branch is checked out from the first commit — no post-clone checkout; still full history, no ``--depth``).""" url = "https://github.com/user/homelab.git" dest = tmp_path / "homelab" calls = _fake_run(monkeypatch) result = clone_or_pull(url, dest, "release") assert result == dest assert [call["argv"] for call in calls] == [ ["git", "clone", "--branch", "release", url, str(dest)] ] assert calls[0]["cwd"] == dest.parent def test_existing_checkout_matching_branch_fetches_and_ff_merges_the_ref( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Phase 135: existing checkout ALREADY on the pinned branch → the full sequence in order: the HEAD guard (``rev-parse --abbrev-ref HEAD``) → the shallow probe → the single-fetch ``fetch origin `` → ``merge --ff-only FETCH_HEAD`` (not ``git pull`` — the default path's argv is reserved for ``branch=None``).""" dest = tmp_path / "homelab" (dest / ".git").mkdir(parents=True) calls = _fake_run( monkeypatch, responses={ ("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "release\n", ""), ("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", ""), ("git", "fetch", "origin", "release"): (0, " * branch release -> FETCH_HEAD", ""), ("git", "merge", "--ff-only", "FETCH_HEAD"): (0, "Already up to date.\n", ""), }, ) result = clone_or_pull("https://github.com/user/homelab.git", dest, "release") assert result == dest assert [call["argv"] for call in calls] == [ ["git", "rev-parse", "--abbrev-ref", "HEAD"], ["git", "rev-parse", "--is-shallow-repository"], ["git", "fetch", "origin", "release"], ["git", "merge", "--ff-only", "FETCH_HEAD"], ] for call in calls: assert call["cwd"] == dest def test_existing_shallow_checkout_matching_branch_unshallows_before_fetch( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Phase 135 + phase 107 D11: the branch path runs the SAME one-time self-heal — a shallow matching checkout is unshallowed BETWEEN the guard/probe and the branch fetch (order pinned).""" dest = tmp_path / "homelab" (dest / ".git").mkdir(parents=True) calls = _fake_run( monkeypatch, responses={ ("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "release\n", ""), ("git", "rev-parse", "--is-shallow-repository"): (0, "true\n", ""), ("git", "fetch", "--unshallow"): (0, "", ""), ("git", "fetch", "origin", "release"): (0, "", ""), ("git", "merge", "--ff-only", "FETCH_HEAD"): (0, "", ""), }, ) clone_or_pull("https://github.com/user/homelab.git", dest, "release") assert [call["argv"] for call in calls] == [ ["git", "rev-parse", "--abbrev-ref", "HEAD"], ["git", "rev-parse", "--is-shallow-repository"], ["git", "fetch", "--unshallow"], ["git", "fetch", "origin", "release"], ["git", "merge", "--ff-only", "FETCH_HEAD"], ] def test_existing_checkout_branch_mismatch_fails_loud_before_any_fetch( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Phase 135 (ASSUMPTION B5, D12): a checkout on a DIFFERENT branch raises the remove-and-re-add ``GitSyncError`` — naming the checkout and BOTH branch names — and the guard is FIRST: no shallow probe, no fetch, no merge argv was invoked (no silent branch switch).""" url = "https://github.com/user/homelab.git" dest = tmp_path / "homelab" (dest / ".git").mkdir(parents=True) calls = _fake_run( monkeypatch, responses={("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", "")}, ) with pytest.raises(GitSyncError) as excinfo: clone_or_pull(url, dest, "release") msg = str(excinfo.value) assert f"checkout {dest}" in msg # the checkout is named assert "'main'" in msg # the checkout's branch assert "'release'" in msg # the source's pinned branch assert "remove and re-add the source to switch branches" in msg # credential safety: the message carries NO URL (the only place a # token could ride) — even for a plain URL. assert url not in msg assert "github" not in msg # the guard is first: exactly ONE argv (the guard probe) ran. assert [call["argv"] for call in calls] == [ ["git", "rev-parse", "--abbrev-ref", "HEAD"] ] def test_branch_mismatch_message_never_contains_url_even_with_token( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Phase 135 credential-safety: even when the CLONE URL carries the injected token (the phase-121 ``clone_url_for`` form), the guard message is URL-free — the checkout path and the two branch names only.""" url = "https://x-access-token:ghp_supersecret@git.example.com/user/repo.git" dest = tmp_path / "repo" (dest / ".git").mkdir(parents=True) calls = _fake_run( monkeypatch, responses={("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", "")}, ) with pytest.raises(GitSyncError) as excinfo: clone_or_pull(url, dest, "release") msg = str(excinfo.value) assert url not in msg assert "ghp_supersecret" not in msg assert "git.example.com" not in msg assert f"checkout {dest} is on branch 'main' but the source " \ "specifies branch 'release'" in msg assert [call["argv"] for call in calls] == [ ["git", "rev-parse", "--abbrev-ref", "HEAD"] ] def test_existing_checkout_branch_fetch_failure_propagates_before_merge( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Phase 135 D12 fail-loud: a failed ``fetch origin `` raises (with git's stderr) and the merge NEVER runs.""" dest = tmp_path / "homelab" (dest / ".git").mkdir(parents=True) calls = _fake_run( monkeypatch, responses={ ("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "release\n", ""), ("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", ""), ("git", "fetch", "origin", "release"): (128, "", "fatal: couldn't find remote ref\n"), }, ) with pytest.raises( GitSyncError, match=r"git fetch origin release failed \(exit 128\): fatal: couldn't find remote ref", ): clone_or_pull("https://github.com/user/homelab.git", dest, "release") assert [call["argv"] for call in calls] == [ ["git", "rev-parse", "--abbrev-ref", "HEAD"], ["git", "rev-parse", "--is-shallow-repository"], ["git", "fetch", "origin", "release"], ] # the merge never ran def test_existing_checkout_branch_non_ff_merge_fails_loud( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Phase 135: a non-ff ``merge --ff-only FETCH_HEAD`` (diverged / force-pushed origin) fails loudly HERE with git's stderr — the reset fallback is phase 136, not a silent fix in this phase.""" dest = tmp_path / "homelab" (dest / ".git").mkdir(parents=True) calls = _fake_run( monkeypatch, responses={ ("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "release\n", ""), ("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", ""), ("git", "fetch", "origin", "release"): (0, "", ""), ("git", "merge", "--ff-only", "FETCH_HEAD"): ( 1, "", "fatal: Not possible to fast-forward, aborting.\n", ), }, ) with pytest.raises( GitSyncError, match=( r"git merge --ff-only FETCH_HEAD failed \(exit 1\): " r"fatal: Not possible to fast-forward" ), ): clone_or_pull("https://github.com/user/homelab.git", dest, "release") assert len(calls) == 4 # all four argv ran; the merge failed loud