All gates green. Final report: **Phase 173 — final verification pass (all tasks already complete in `complete/`)** - Verified the full implementation is in the working tree: `docs_user`/`docs_token` settings + catalogue rows (task 01), `docs_repo` PUT 422 guard + `docs_token` write-only pin (task 02), `run_git(env)` + `push_document(user, token)` credential env (task 03), `settings_migrate.py` wired into `create_app()` (task 04), E2E suite (task 05). - **Completion criteria:** 1. GET `/api/admin/settings` lists `docs_user` (plain) + `docs_token` (secret, `value=null`), token never in any body — ✅ `test_docs_token_is_write_only_masked_end_to_end` + E2E sentinel-absent-from-DOM 2. PUT `docs_repo` with `://user:pass@` → 422 naming the fields; clean URL passes — ✅ integration + E2E UI-side 3. Token set → `GIT_CURL_USERNAME`/`GIT_CURL_PASSWORD`/`GIT_TERMINAL_PROMPT=0` on every git step; empty → `env=None` — ✅ mocked-git unit tests 4. Legacy env split once at startup, idempotent, one INFO line, `.env` untouched — ✅ `test_settings_migrate.py` (4 shapes) + `test_docs_push_migrate.py` 5. Full suite green, coverage >90%, lint clean — ✅ 6. E2E isolated: `test_docs_push_creds.py` 4/4; regression set (panel + layout + response_to_docs) 21/21 — ✅ 7. Commit + phase move — harness's job (per instructions, not mine). 8. README/AGENTS consistent — ✅ (below) - Tests/lint: `uv run pytest` exit 0 (4112 passed) · `uv run pytest --cov=app --cov-report=term-missing` TOTAL 99% · `uv run ruff check .` clean · `uv run pyright` 0 errors - Docs: README.md (incl. Docs-push config section, guard/migration paragraph, `.env.example` block) and AGENTS.md verified consistent in full; fixed two stale docstrings in `app/config.py` + `app/core/settings_catalog.py` (`is_set` → the API's actual `has_value` field). - No live-infrastructure changes (no live step in this phase). No next pending phase — `todo/` held only 173.
700 lines
28 KiB
Python
700 lines
28 KiB
Python
"""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 <ref>`` (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 <ref>`` + ``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
|
|
|
|
|
|
def test_run_git_default_env_is_none_and_reaches_subprocess(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Phase 173 (C5 byte-identity pin): the default ``env=None`` is
|
|
passed through verbatim to ``subprocess.run`` — the pre-phase call
|
|
shape, so every sync-path invocation is unchanged."""
|
|
calls = _fake_run(monkeypatch, stdout="ok")
|
|
|
|
assert git_sync.run_git(["git", "version"], cwd=tmp_path) == "ok"
|
|
assert len(calls) == 1
|
|
assert "env" in calls[0]
|
|
assert calls[0]["env"] is None
|
|
|
|
|
|
def test_run_git_explicit_env_reaches_subprocess_verbatim(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Phase 173 (C2): an explicit env dict reaches ``subprocess.run``
|
|
verbatim — ``run_git`` neither merges over nor mutates the parent
|
|
environment (the env-replacement trap is the caller's contract;
|
|
the docs-push sequence does its own ``os.environ`` merge)."""
|
|
calls = _fake_run(monkeypatch, stdout="ok")
|
|
env = {"A": "b"}
|
|
|
|
assert git_sync.run_git(["git", "version"], cwd=tmp_path, env=env) == "ok"
|
|
assert len(calls) == 1
|
|
assert calls[0]["env"] is env # the SAME object — passed through verbatim
|
|
assert calls[0]["env"] == {"A": "b"}
|
|
|
|
|
|
# --- 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 <branch>`` →
|
|
``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 <b>
|
|
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 <b>``
|
|
→ ``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 <b>`` 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/<branch>"), 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)
|