"""Unit tests: git clone/pull utility (phase 28). ``clone_or_pull`` dispatches to ``git clone --depth 1`` (fresh dest) or ``git pull --ff-only`` (existing checkout) with ``subprocess`` fully mocked — the real git CLI is never invoked, so the tests run anywhere. """ 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, ) -> list[dict[str, object]]: """Monkeypatch scripts.git_sync.subprocess.run; record each call.""" 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") 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 --depth 1`` 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", "--depth", "1", 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: """Subsequent run: dest/.git present → ``git pull --ff-only`` in-place.""" dest = tmp_path / "homelab" (dest / ".git").mkdir(parents=True) calls = _fake_run(monkeypatch) result = clone_or_pull("https://github.com/user/homelab.git", dest) assert result == dest assert calls[0]["argv"] == ["git", "pull", "--ff-only"] assert calls[0]["cwd"] == dest 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() _fake_run(monkeypatch) clone_or_pull("https://github.com/user/homelab.git", dest) assert dest.parent.is_dir() 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", "--depth", "1", "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 --depth 1 .* 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). (dest / ".git").mkdir(parents=True) _fake_run(monkeypatch, returncode=1, stderr="error: cannot pull with rebase") 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_captures_and_returns_stdout( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """_run 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", "status"], cwd=tmp_path) == "From example.com\n + abc..def main" assert len(calls) == 1