"""Unit tests: git clone/pull utility (phase 28; phase 107 D11/D12; phase 135 adds the branch pin; phase 136 replaces the existing- checkout update with the UNIFIED ff-or-reset sequence — the argv matrix). ``clone_or_pull`` dispatches to ``git clone`` (full history, fresh dest) or the UNIFIED existing-checkout update (phase 136 — one sequence for both branch shapes) with ``subprocess`` fully mocked — the real git CLI is never invoked, so the tests run anywhere: ref resolution (``rev-parse --abbrev-ref HEAD`` — the phase-135 HEAD guard for a pinned ``branch``, the checkout's own branch + the detached-HEAD fail-loud for ``branch=None``) → the shallow probe → optional ``git fetch --unshallow`` (the one-time D11 self-heal) → ``git fetch origin `` (a fetch failure STILL raises) → ``git merge --ff-only FETCH_HEAD`` → on a non-ff merge: the ``rev-parse HEAD`` / ``rev-parse FETCH_HEAD`` capture + ``git reset --hard FETCH_HEAD`` + one WARNING line, then RETURN (a force push never fails a sync — phase 136's fix). The fresh-clone argv (with/without ``--branch``) is the pre-phase one — the argv pins below are the regression gate for every existing deployment. """ from __future__ import annotations import logging 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_existing_checkout_ff_updates_the_tracked_ref( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Phase 136 steady state (``branch=None``): dest/.git present → the UNIFIED sequence — the checkout's OWN branch is resolved (``rev-parse --abbrev-ref HEAD`` → ``main``), the shallow probe says NOT shallow (``false``), and ``fetch origin main`` + ``merge --ff-only FETCH_HEAD`` ff-update the ref (the legacy ``git pull --ff-only`` is deliberately gone — every row gets the ff-or-reset update). A SUCCESSFUL merge leaves NO ``reset`` argv and NO ``rev-parse HEAD`` / ``rev-parse FETCH_HEAD`` capture pair (the capture is only on failure).""" dest = tmp_path / "homelab" (dest / ".git").mkdir(parents=True) calls = _fake_run( monkeypatch, responses={ ("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", ""), ("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", ""), ("git", "fetch", "origin", "main"): (0, "", ""), ("git", "merge", "--ff-only", "FETCH_HEAD"): (0, "Already up to date.\n", ""), }, ) result = clone_or_pull("https://github.com/user/homelab.git", dest) 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", "main"], ["git", "merge", "--ff-only", "FETCH_HEAD"], ] for call in calls: assert call["cwd"] == dest # ff succeeded — the exact 4-step sequence above already excludes # the reset tail (and the rev-parse HEAD/FETCH_HEAD capture pair, # which is only on failure). assert ["git", "reset", "--hard", "FETCH_HEAD"] not in [c["argv"] for c in calls] 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`` BETWEEN the ref-resolution probe and the unified update's ``fetch origin `` + ``merge --ff-only FETCH_HEAD`` (order pinned — the self-heal still fires in front of the fetch), all in dest.""" dest = tmp_path / "homelab" (dest / ".git").mkdir(parents=True) calls = _fake_run( monkeypatch, responses={ ("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", ""), ("git", "rev-parse", "--is-shallow-repository"): (0, "true\n", ""), ("git", "fetch", "--unshallow"): (0, "Fetching full history\n", ""), ("git", "fetch", "origin", "main"): (0, "", ""), ("git", "merge", "--ff-only", "FETCH_HEAD"): (0, "", ""), }, ) clone_or_pull("https://github.com/user/homelab.git", dest) assert len(calls) == 5 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", "main"], ["git", "merge", "--ff-only", "FETCH_HEAD"], ] 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 update 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", "--abbrev-ref", "HEAD"): (0, "main\n", ""), ("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", "--abbrev-ref", "HEAD"], ["git", "rev-parse", "--is-shallow-repository"], ["git", "fetch", "--unshallow"], ] # the fetch/merge/reset 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 unified update path — but the reset fallback is # scoped to the MERGE step: a genuinely broken FETCH (dead # network, the branch deleted upstream, git missing) STILL fails # loud, with no merge and no reset argv. (dest / ".git").mkdir(parents=True) calls = _fake_run( monkeypatch, returncode=1, stderr="fatal: couldn't find remote ref main", responses={ ("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", ""), ("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", ""), ("git", "fetch", "origin", "main"): ( 128, "", "fatal: couldn't find remote ref main\n" ), }, ) with pytest.raises( GitSyncError, match=r"git fetch origin main failed \(exit 128\): fatal: couldn't find remote ref main", ): clone_or_pull("https://example.com/homelab.git", dest) assert [call["argv"] for call in calls] == [ ["git", "rev-parse", "--abbrev-ref", "HEAD"], ["git", "rev-parse", "--is-shallow-repository"], ["git", "fetch", "origin", "main"], ] # the merge (and therefore any reset) never ran 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_pins_the_unified_phase_136_sequence( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The ``branch=None`` argv pins (explicit ``None`` is the call sites' form for every row without a branch pin). FRESH: one ``git clone`` without ``--branch`` — the pre-phase argv, UNCHANGED (the regression gate for every existing deployment). EXISTING: the legacy ``git pull --ff-only`` is deliberately GONE — that is the fix (phase 136): every row, branched or not, gets the ff-or-reset update. The pinned sequence: ref resolution (``rev- parse --abbrev-ref HEAD``) → the shallow probe → (``fetch --unshallow`` while shallow) → ``fetch origin `` → ``merge --ff-only FETCH_HEAD``. A future refactor that re-introduces ``git pull`` (or re-splits the two branch shapes) breaks this pin.""" url = "https://github.com/user/homelab.git" dest = tmp_path / "homelab" # fresh (dest absent) — the pre-phase argv, byte-identical 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 — the unified ff-or-reset sequence (dest / ".git").mkdir(parents=True) calls = _fake_run( monkeypatch, responses={ ("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", ""), ("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", "--abbrev-ref", "HEAD"], ["git", "rev-parse", "--is-shallow-repository"], ["git", "fetch", "origin", "main"], ["git", "merge", "--ff-only", "FETCH_HEAD"], ] # existing, shallow (the one-time D11 self-heal between probe # and fetch — still in front of the fetch) calls = _fake_run( monkeypatch, responses={ ("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", ""), ("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", "--abbrev-ref", "HEAD"], ["git", "rev-parse", "--is-shallow-repository"], ["git", "fetch", "--unshallow"], ["git", "fetch", "origin", "main"], ["git", "merge", "--ff-only", "FETCH_HEAD"], ] 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_resets_to_the_ref_head( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """Phase 136: a non-ff ``merge --ff-only FETCH_HEAD`` (diverged / force-pushed origin) NO LONGER fails the sync — the sequence captures the old/new SHAs, resets the checkout hard to ``FETCH_HEAD`` (the branch's ref — the owner's verbatim "reset origin/"), logs ONE WARNING line (PLAN §9), and RETURNS the dest (no raise — the run continues). The WARNING is the only observable side effect besides the checkout state: no user-facing error, no status change.""" url = "https://github.com/user/homelab.git" dest = tmp_path / "homelab" (dest / ".git").mkdir(parents=True) old, new = "a" * 40, "b" * 40 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" ), ("git", "rev-parse", "HEAD"): (0, old + "\n", ""), ("git", "rev-parse", "FETCH_HEAD"): (0, new + "\n", ""), ("git", "reset", "--hard", "FETCH_HEAD"): (0, "", ""), }, ) with caplog.at_level(logging.WARNING, logger="scripts.git_sync"): result = clone_or_pull(url, dest, "release") # NO raise — the run continues 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"], ["git", "rev-parse", "HEAD"], ["git", "rev-parse", "FETCH_HEAD"], ["git", "reset", "--hard", "FETCH_HEAD"], ] for call in calls: assert call["cwd"] == dest # ONE WARNING line (PLAN §9): the checkout PATH (never the URL — # credential-safety), both short SHAs, the tracked ref, and the # merge's stderr (diagnosability, log-only — never user-facing). warnings = [r for r in caplog.records if r.levelno == logging.WARNING] assert len(warnings) == 1 msg = warnings[0].getMessage() assert msg == ( f"git sync: divergent remote head on {dest} (force push) — reset " f"{old[:7]} → {new[:7]} to origin/release " "(git merge --ff-only FETCH_HEAD failed (exit 1): " "fatal: Not possible to fast-forward, aborting.)" ) assert url not in msg # credential-safety: never the URL assert "github" not in msg def test_null_branch_non_ff_merge_resets_and_returns( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Phase 136 (``branch=None`` — the legacy ``git pull --ff-only`` path, the owner's reported break): a non-ff merge of the checkout's OWN branch takes the SAME reset tail — ``rev-parse HEAD`` → ``rev-parse FETCH_HEAD`` → ``reset --hard FETCH_HEAD`` — and RETURNS the dest (no raise; pre-phase, this exact shape was the ``fatal: Not possible to fast-forward`` that failed the whole sync). The reset targets the checkout's branch ref (``fetch origin main``).""" dest = tmp_path / "homelab" (dest / ".git").mkdir(parents=True) old, new = "c" * 40, "d" * 40 calls = _fake_run( monkeypatch, responses={ ("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", ""), ("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", ""), ("git", "fetch", "origin", "main"): (0, "", ""), ("git", "merge", "--ff-only", "FETCH_HEAD"): ( 1, "", "fatal: Not possible to fast-forward, aborting.\n" ), ("git", "rev-parse", "HEAD"): (0, old + "\n", ""), ("git", "rev-parse", "FETCH_HEAD"): (0, new + "\n", ""), ("git", "reset", "--hard", "FETCH_HEAD"): (0, "", ""), }, ) result = clone_or_pull("https://github.com/user/homelab.git", dest, None) assert result == dest # the function RETURNS the dest (no raise) assert [call["argv"] for call in calls] == [ ["git", "rev-parse", "--abbrev-ref", "HEAD"], ["git", "rev-parse", "--is-shallow-repository"], ["git", "fetch", "origin", "main"], ["git", "merge", "--ff-only", "FETCH_HEAD"], ["git", "rev-parse", "HEAD"], ["git", "rev-parse", "FETCH_HEAD"], ["git", "reset", "--hard", "FETCH_HEAD"], ] def test_null_branch_detached_head_fails_loud( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Phase 136 D12 fail-loud: ``branch=None`` on a DETACHED checkout (the ref resolution returns ``"HEAD"``) raises the remove-and- re-add ``GitSyncError`` naming the checkout — the app never CREATES a detached checkout (a fresh clone always lands on a branch), so this is a genuinely broken checkout, not a force-push case: no shallow probe, no fetch, no merge, NO reset argv.""" dest = tmp_path / "homelab" (dest / ".git").mkdir(parents=True) calls = _fake_run( monkeypatch, responses={("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "HEAD\n", "")}, ) with pytest.raises(GitSyncError) as excinfo: clone_or_pull("https://github.com/user/homelab.git", dest, None) msg = str(excinfo.value) assert f"checkout {dest} is in a detached-HEAD state" in msg assert "remove and re-add the source to rebuild it" in msg assert [call["argv"] for call in calls] == [ ["git", "rev-parse", "--abbrev-ref", "HEAD"] ] # the guard is first: exactly ONE argv ran (no fetch, no reset)