All green — verification complete. Final report: **Phase 107 — final verification pass (all tasks already in `complete/`)** - **Verified:** `clone_or_pull` full-history fix (no `--depth` clone; `--is-shallow-repository` probe → one-time `git fetch --unshallow` self-heal → `git pull --ff-only`; D12 fail-loud) in `scripts/git_sync.py`; E2E suite `tests/e2e/test_git_source_dates.py` present; all five stale shallow/tip-date narratives corrected (no residual claims repo-wide; `docs_push.py` `--depth` fetches untouched as designed). - **Unit+integration:** `uv run pytest tests/unit/test_git_sync.py tests/integration/test_git_file_dates.py tests/integration/test_import_docs_git.py tests/integration/test_sync_api.py -v --no-cov` → **66 passed** (D10 tip-date test replaced by `test_url_clone_yields_true_per_file_dates` + `test_existing_shallow_checkout_self_heals`; unit pins: clone argv w/o `--depth`, probe→unshallow→pull order, unshallow-failure → `GitSyncError`). - **New E2E in isolation:** `uv run pytest tests/e2e/test_git_source_dates.py -v --no-cov` → **4 passed** (old file 2020-06-15 ≠ tip file 2024-06-15 in `GET /api/docs`, Sources `Created`/`Updated` columns, viewer `Created` badge ISO title; folder `updated_at` subtree maxes). - **Phase-106 regressions in isolation:** `test_document_dates.py` → 6 passed; `test_sync_button.py` → 3 passed; `test_git_sources_admin.py` → 6 passed. - **Full gates:** `uv run pytest` → **2302 passed**; `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90%); `uv run ruff check . && uv run pyright` → **clean, 0 errors**. - **Completion criteria:** 1 ✅ non-shallow URL clone + true per-file dates · 2 ✅ self-heal / plain-pull / fail-loud · 3 ✅ both suites green, bug-pin replaced · 4 ✅ E2E green in isolation · 5 ✅ all regressions + full suite + coverage + lint · 6 ✅ narratives corrected · 7 — no commit made (harness override: changes left in working tree; task files already in `complete/`). - **No defects found; no deviations.** Next pending phase: **108_history_wire_check**.
239 lines
8.2 KiB
Python
239 lines
8.2 KiB
Python
"""Unit tests: git clone/pull utility (phase 28; phase 107 D11/D12).
|
|
|
|
``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.
|
|
"""
|
|
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
|