phase: 136_force_push_reset
Everything is verified green and consistent. Final report: **Phase 136 — final verification pass (all tasks already complete in `complete/136_force_push_reset/`):** - **Verified implemented:** unified ff-or-reset update sequence in `scripts/git_sync.py::clone_or_pull` (phase-135 HEAD guard → shallow self-heal → `fetch origin <ref>` → `merge --ff-only` → on non-ff: SHA capture + `reset --hard FETCH_HEAD` + one WARNING line, returns normally); detached-HEAD fail-loud; `branch=None` legacy `git pull` deliberately gone - **Completion criteria — all met:** 1. ✅ Force push no longer fails sync (integration `test_clone_or_pull_force_push_resets_to_the_new_head` + API-seam `test_sync_pipeline_survives_a_force_push` + E2E run 2 asserts `success` and KB serves the V2 sentinel) 2. ✅ Unrelated-history force push handled by the same reset (`test_clone_or_pull_unrelated_history_force_push_resets`) 3. ✅ Broken fetch still fails loud (fetch-failure unit pins raise with no merge/reset argv; `test_main_git_failure_aborts_before_import`) 4. ✅ WARNING line pinned: one line, 7-char SHAs, checkout path, `origin/<ref>`, no URL (unit exact-string pin + integration caplog pin) 5. ✅ `uv run pytest --cov=app --cov-report=term-missing` → **3231 passed, app/ 99%**; `uv run pytest tests/e2e/test_force_push_resync.py -v --no-cov` → **2 passed in isolation** (10.6 s) 6. ✅ Phase-135 pins preserved (mismatch guard, `--branch` clone argv, `branch=None` fresh-clone argv all green in the 53-test run) - **Test/lint results:** `uv run pytest tests/unit/test_git_sync.py tests/integration/test_import_docs_git.py -v` → 53 passed; full suite 3231 passed (72 s); `uv run ruff check . && uv run pyright` → clean (0 errors) - **Documentation:** README.md git-sources section already updated this phase to the ff-or-reset contract; all `.env.example` vars documented; pages table matches router; AGENTS.md rules and quick-reference commands verified accurate (rule 5 correctly documents the phase-100 §7.1 supersession) — **no changes needed** - **Notable:** E2E "teeth" verification (fails on pre-phase code) is recorded in the task report; nothing was committed per harness protocol - **Next pending phase:** `todo/137_pdf_image_context`
This commit is contained in:
+234
-65
@@ -1,19 +1,27 @@
|
||||
"""Unit tests: git clone/pull utility (phase 28; phase 107 D11/D12;
|
||||
phase 135 adds the branch pin — the argv matrix).
|
||||
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 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 <b>`` → ``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.
|
||||
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
|
||||
|
||||
@@ -80,27 +88,45 @@ def test_clone_or_pull_clones_when_dest_has_no_git_dir(
|
||||
assert call["text"] is True
|
||||
|
||||
|
||||
def test_clone_or_pull_pulls_when_git_dir_exists(
|
||||
def test_clone_or_pull_existing_checkout_ff_updates_the_tracked_ref(
|
||||
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)."""
|
||||
"""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", "--is-shallow-repository"): (0, "false\n", "")},
|
||||
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 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
|
||||
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(
|
||||
@@ -108,24 +134,33 @@ def test_clone_or_pull_unshallows_existing_shallow_checkout(
|
||||
) -> 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
|
||||
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) == 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"]
|
||||
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
|
||||
|
||||
@@ -134,14 +169,15 @@ 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)."""
|
||||
: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"),
|
||||
},
|
||||
@@ -153,9 +189,10 @@ def test_clone_or_pull_unshallow_failure_propagates(
|
||||
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 pull never ran
|
||||
] # the fetch/merge/reset never ran
|
||||
|
||||
|
||||
def test_clone_or_pull_creates_missing_parent_before_clone(
|
||||
@@ -209,21 +246,36 @@ def test_failing_git_raises_error_carrying_stderr(
|
||||
):
|
||||
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.
|
||||
# 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)
|
||||
_fake_run(
|
||||
calls = _fake_run(
|
||||
monkeypatch,
|
||||
returncode=1,
|
||||
stderr="error: cannot pull with rebase",
|
||||
responses={("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", "")},
|
||||
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 pull --ff-only failed \(exit 1\): error: cannot pull with rebase"
|
||||
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
|
||||
@@ -248,48 +300,65 @@ def test_run_git_captures_and_returns_stdout(
|
||||
# --- phase 135: the branch pin (the argv matrix) ----------------------------
|
||||
|
||||
|
||||
def test_branch_none_argv_is_byte_identical_pre_phase_sequence(
|
||||
def test_branch_none_argv_pins_the_unified_phase_136_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."""
|
||||
"""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)
|
||||
# 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
|
||||
# existing, not shallow — the unified ff-or-reset sequence
|
||||
(dest / ".git").mkdir(parents=True)
|
||||
calls = _fake_run(
|
||||
monkeypatch,
|
||||
responses={("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", "")},
|
||||
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", "pull", "--ff-only"],
|
||||
["git", "fetch", "origin", "main"],
|
||||
["git", "merge", "--ff-only", "FETCH_HEAD"],
|
||||
]
|
||||
|
||||
# existing, shallow (the one-time D11 self-heal between probe and pull)
|
||||
# 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", "pull", "--ff-only"],
|
||||
["git", "fetch", "origin", "main"],
|
||||
["git", "merge", "--ff-only", "FETCH_HEAD"],
|
||||
]
|
||||
|
||||
|
||||
@@ -466,14 +535,23 @@ def test_existing_checkout_branch_fetch_failure_propagates_before_merge(
|
||||
] # the merge never ran
|
||||
|
||||
|
||||
def test_existing_checkout_branch_non_ff_merge_fails_loud(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
def test_existing_checkout_branch_non_ff_merge_resets_to_the_ref_head(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> 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."""
|
||||
"""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={
|
||||
@@ -481,20 +559,111 @@ def test_existing_checkout_branch_non_ff_merge_fails_loud(
|
||||
("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",
|
||||
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 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")
|
||||
with caplog.at_level(logging.WARNING, logger="scripts.git_sync"):
|
||||
result = clone_or_pull(url, dest, "release") # NO raise — the run continues
|
||||
|
||||
assert len(calls) == 4 # all four argv ran; the merge failed loud
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user