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:
+143
-68
@@ -4,14 +4,23 @@ clone_or_pull(url, dest, branch=None) clones ``url`` into ``dest`` with
|
||||
FULL history the first time — ``git clone --branch <b>`` when a branch
|
||||
is pinned (phase 135: the source's branch is checked out from the
|
||||
first commit), plain ``git clone`` when not — or, for an existing
|
||||
checkout, runs the branch guard first when a branch is pinned (a
|
||||
checkout on any other branch fails loudly — the source must be removed
|
||||
and re-added to switch, phase 135), unshallows a shallow one first
|
||||
(``git fetch --unshallow``, the one-time self-heal), and then
|
||||
fast-forwards it — ``git pull --ff-only`` with no branch, or the
|
||||
single-fetch ``git fetch origin <b>`` + ``git merge --ff-only
|
||||
FETCH_HEAD`` of the pinned ref with one (``branch=None`` keeps the
|
||||
pre-phase argv byte-identical — the unit argv pins are the proof).
|
||||
checkout, brings it to the head of the tracked ref with the UNIFIED
|
||||
ff-or-reset sequence (phase 136 — it replaces both the legacy
|
||||
``git pull --ff-only`` and phase 135's fetch + ff-merge pair): the
|
||||
phase-135 HEAD guard first when a branch is pinned (a checkout on any
|
||||
other branch fails loudly — the source must be removed and re-added
|
||||
to switch), the tracked ref — the pinned branch, or the checkout's
|
||||
own branch when ``branch=None`` (a detached HEAD fails loudly, D12) —
|
||||
unshallows a shallow one first (``git fetch --unshallow``, the
|
||||
one-time self-heal), ``git fetch origin <ref>`` (a fetch FAILURE
|
||||
still fails loudly), and ``git merge --ff-only FETCH_HEAD`` — when
|
||||
the fast-forward is impossible (a force push / any divergent remote
|
||||
head) the checkout is ``git reset --hard FETCH_HEAD`` to the ref's
|
||||
head with ONE loud WARNING line and the function returns normally
|
||||
(a force push is a logged, self-healing event, NEVER a failed sync —
|
||||
the checkout is a cache of the remote head; the legacy ``git pull
|
||||
--ff-only`` is deliberately gone from ``branch=None``: every row
|
||||
gets the ff-or-reset update).
|
||||
|
||||
Auth: nothing special — an ``https://…`` URL uses the OS credential
|
||||
helper / prompts; a ``git@host:repo.git`` URL uses the machine's SSH key.
|
||||
@@ -21,14 +30,17 @@ used.
|
||||
This module is the only place the ``git`` CLI is invoked (A11: stdlib
|
||||
``subprocess`` only, no new packages) — every git command goes through
|
||||
:func:`run_git`: the clone (with ``--branch`` when a branch is
|
||||
pinned, phase 135), the branch guard's ``git rev-parse --abbrev-ref
|
||||
HEAD`` probe, the shallow probe, the one-time ``git fetch
|
||||
--unshallow``, the pinned branch's ``git fetch origin <b>`` +
|
||||
``git merge --ff-only FETCH_HEAD`` pair, and the ``git pull
|
||||
--ff-only`` in :func:`clone_or_pull`; the per-file last-commit-date
|
||||
walk in :func:`file_commit_dates` (phase 106 — it walks the
|
||||
checkout's own log, so a branch checkout yields the BRANCH's history:
|
||||
the head-of-branch semantics phase 135 pins, untouched); and the
|
||||
pinned, phase 135), the branch guard's / ref-resolution's ``git
|
||||
rev-parse --abbrev-ref HEAD`` probe, the shallow probe, the one-time
|
||||
``git fetch --unshallow``, the unified update's ``git fetch origin
|
||||
<ref>`` + ``git merge --ff-only FETCH_HEAD`` pair, and the phase-136
|
||||
force-push fallback's ``git rev-parse HEAD`` / ``git rev-parse
|
||||
FETCH_HEAD`` capture + ``git reset --hard FETCH_HEAD`` in
|
||||
:func:`clone_or_pull`; the per-file last-commit-date walk in
|
||||
:func:`file_commit_dates` (phase 106 — it walks the checkout's own
|
||||
log, so a branch checkout yields the BRANCH's history: the
|
||||
head-of-branch semantics phase 135 pins, untouched — a post-reset
|
||||
checkout yields the NEW head's dates, the same semantics); and the
|
||||
docs-push sequence in :mod:`app.core.docs_push` (phase 59).
|
||||
|
||||
Per-file last-commit dates (phase 106 D2; phase 107 D11 supersedes
|
||||
@@ -42,9 +54,18 @@ gone; ``--branch`` clones, phase 135, included), and an existing
|
||||
shallow checkout (made pre-phase 107 — i.e. every deployed one,
|
||||
live + dev included) is probed with ``git
|
||||
rev-parse --is-shallow-repository`` and, while shallow, self-healed
|
||||
with the ONE-TIME ``git fetch --unshallow`` before the usual ``git
|
||||
pull --ff-only`` (no branch) / ``git merge --ff-only FETCH_HEAD``
|
||||
(pinned branch) — no re-clone;
|
||||
with the ONE-TIME ``git fetch --unshallow`` before the unified
|
||||
update's ``git fetch origin <ref>`` + ``git merge --ff-only
|
||||
FETCH_HEAD`` (+ the phase-136 ``git reset --hard FETCH_HEAD``
|
||||
fallback on a non-ff merge) — no re-clone;
|
||||
* D11/D12, extended by phase 136 (the force-push contract): a
|
||||
non-ff merge resets to the ref's head instead of failing — the
|
||||
checkout is a cache of the remote head, and "always the head of
|
||||
the branch" is the invariant ``clone_or_pull`` enforces. A force
|
||||
push (or any divergent remote head) is a LOGGED, SELF-HEALING
|
||||
event — one WARNING line, then the run continues — never a failed
|
||||
sync; everything else still fails loud per D12 (a fetch failure,
|
||||
a broken probe/unshallow, a detached HEAD, a branch mismatch);
|
||||
* on a full-history checkout :func:`file_commit_dates` yields the
|
||||
TRUE per-file last-commit date for ALL git sources. The old
|
||||
shallow-clone behavior was the bug the owner reported 2026-09-16:
|
||||
@@ -73,7 +94,8 @@ class GitSyncError(RuntimeError):
|
||||
|
||||
|
||||
def clone_or_pull(url: str, dest: Path | str, branch: str | None = None) -> Path:
|
||||
"""Clone ``url`` into ``dest`` (full history, first run) or fast-forward it.
|
||||
"""Clone ``url`` into ``dest`` (full history, first run) or bring the
|
||||
existing checkout to the tracked ref's head (ff-or-reset, phase 136).
|
||||
|
||||
- dest without a ``.git`` (or absent) → ``git clone url dest`` (full
|
||||
history for every transport — phase 107 D11: no ``--depth``, so a
|
||||
@@ -82,35 +104,56 @@ def clone_or_pull(url: str, dest: Path | str, branch: str | None = None) -> Path
|
||||
dest`` (phase 135: the source's branch is checked out from the
|
||||
first commit — no post-clone checkout; ``branch=None`` keeps the
|
||||
two-argument argv byte-identical).
|
||||
- dest with a ``.git`` and no ``branch`` → probe ``git rev-parse
|
||||
--is-shallow-repository``; while shallow, ``git fetch
|
||||
--unshallow`` (the ONE-TIME self-heal for checkouts made shallow
|
||||
pre-phase 107 — the next sync of a deployed checkout becomes
|
||||
full-history without a re-clone), then ``git pull --ff-only``
|
||||
(refuses to merge unrelated histories — a broken checkout fails
|
||||
loudly rather than producing a dirty index).
|
||||
- dest with a ``.git`` and a pinned ``branch`` (phase 135), in
|
||||
order: the HEAD GUARD first — ``git rev-parse --abbrev-ref
|
||||
HEAD``; a checkout sitting on any other branch raises the
|
||||
remove-and-re-add :class:`GitSyncError` (naming the checkout and
|
||||
BOTH branch names — never the URL, so no credential exposure)
|
||||
BEFORE any fetch or merge (no silent branch switch: the checkout
|
||||
may serve other rows' documents, and a switch would silently
|
||||
re-map the source's file set); a matching checkout then runs the
|
||||
SAME shallow probe + one-time unshallow self-heal, and pulls the
|
||||
pinned ref with the single-fetch sequence ``git fetch origin
|
||||
<b>`` + ``git merge --ff-only FETCH_HEAD`` (a non-ff merge still
|
||||
fails loudly HERE — the force-push reset fallback is phase 136's
|
||||
extension of this sequence, not this phase's).
|
||||
- dest with a ``.git`` (ANY ``branch``) → the UNIFIED update
|
||||
sequence (phase 136 — it replaces both the legacy
|
||||
``git pull --ff-only`` and phase 135's fetch + ff-merge pair), in
|
||||
order:
|
||||
1. the tracked REF: a pinned ``branch`` keeps the HEAD GUARD
|
||||
(phase 135, UNCHANGED and first — ``git rev-parse
|
||||
--abbrev-ref HEAD``; a checkout sitting on any other branch
|
||||
raises the remove-and-re-add :class:`GitSyncError`, naming
|
||||
the checkout and BOTH branch names — never the URL, so no
|
||||
credential exposure — BEFORE any update; no silent branch
|
||||
switch, since the checkout may serve other rows' documents)
|
||||
and ``ref = branch``; ``branch=None`` resolves the checkout's
|
||||
OWN branch (the pre-phase default-branch behavior) — and a
|
||||
DETACHED checkout (the result ``"HEAD"``) raises the
|
||||
remove-and-re-add :class:`GitSyncError` naming the checkout
|
||||
(D12 fail-loud: 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).
|
||||
2. the shallow probe + one-time ``git fetch --unshallow``
|
||||
self-heal (UNCHANGED — phase 107 D11).
|
||||
3. ``git fetch origin <ref>`` — a fetch FAILURE (network/auth,
|
||||
the branch gone from the remote, git missing) STILL raises
|
||||
:class:`GitSyncError` (a loud failure — a force push is NOT a
|
||||
fetch failure; the reset is scoped to the merge step only).
|
||||
4. try ``git merge --ff-only FETCH_HEAD``.
|
||||
5. ON MERGE FAILURE — the reset fallback (phase 136): capture
|
||||
``git rev-parse HEAD`` (old) and ``git rev-parse FETCH_HEAD``
|
||||
(new), run ``git reset --hard FETCH_HEAD``, log ONE WARNING
|
||||
line (PLAN §9: ``git sync: divergent remote head on <dest>
|
||||
(force push) — reset <old[:7]> → <new[:7]> to
|
||||
origin/<ref>`` — the checkout PATH, never the URL,
|
||||
credential-safety) carrying the merge's stderr for
|
||||
diagnosability, and RETURN the dest normally (the sync
|
||||
proceeds — a force push is a logged, self-healing event,
|
||||
NEVER a failed sync; the merge's stderr rides the log only —
|
||||
there is no user-facing error). The checkout is a CACHE of
|
||||
the remote head (the app owns it; the KB is rebuilt from it
|
||||
on the same run), so "always the head of the branch" is the
|
||||
invariant this sequence enforces.
|
||||
|
||||
Returns the destination path. Raises :class:`GitSyncError` when git is
|
||||
missing or a git invocation exits non-zero (with git's stderr in the
|
||||
message, so the caller can name the failing repo + reason). D12
|
||||
fail-loud: a failed probe/unshallow/pull/merge propagates exactly
|
||||
like a clone failure — never a silent fallback to tip dates or
|
||||
mtimes (a continued shallow checkout would silently re-serve the
|
||||
uniform tip date, i.e. the bug D11 fixes) and never a silent branch
|
||||
switch (phase 135 — the fail-loud guard, above).
|
||||
message, so the caller can name the failing repo + reason) — EXCEPT
|
||||
the non-ff merge, which takes the reset fallback of step 5 instead
|
||||
of failing (phase 136). D12 fail-loud still governs everything the
|
||||
reset does not cover: a failed probe/unshallow/fetch propagates
|
||||
exactly like a clone failure — never a silent fallback to tip
|
||||
dates or mtimes (a continued shallow checkout would silently
|
||||
re-serve the uniform tip date, i.e. the bug D11 fixes) and never a
|
||||
silent branch switch (phase 135 — the fail-loud guard, step 1).
|
||||
"""
|
||||
dest = Path(dest)
|
||||
if not dest.exists() or not (dest / ".git").exists():
|
||||
@@ -120,26 +163,58 @@ def clone_or_pull(url: str, dest: Path | str, branch: str | None = None) -> Path
|
||||
argv += ["--branch", branch]
|
||||
argv += [url, str(dest)]
|
||||
run_git(argv, cwd=dest.parent)
|
||||
elif branch is None:
|
||||
_unshallow_if_shallow(dest)
|
||||
run_git(["git", "pull", "--ff-only"], cwd=dest)
|
||||
else:
|
||||
# Phase 135 HEAD guard (first — before the probe, any fetch,
|
||||
# or any merge): a mismatched checkout fails loudly. The
|
||||
# message names the checkout and both branch names; it never
|
||||
# carries the URL (the only place a token could ride).
|
||||
current = run_git(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=dest).strip()
|
||||
if current != branch:
|
||||
raise GitSyncError(
|
||||
f"checkout {dest} is on branch '{current}' but the source "
|
||||
f"specifies branch '{branch}' — remove and re-add the "
|
||||
"source to switch branches"
|
||||
)
|
||||
# existing checkout (the fresh-clone path above is untouched):
|
||||
if branch is not None:
|
||||
# Phase 135 HEAD guard, UNCHANGED (runs first, before any
|
||||
# update): a mismatched checkout fails loudly. The message
|
||||
# names the checkout and both branch names; it never
|
||||
# carries the URL (the only place a token could ride).
|
||||
current = run_git(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=dest).strip()
|
||||
if current != branch:
|
||||
raise GitSyncError(
|
||||
f"checkout {dest} is on branch '{current}' but the source "
|
||||
f"specifies branch '{branch}' — remove and re-add the "
|
||||
"source to switch branches"
|
||||
)
|
||||
ref = branch
|
||||
else:
|
||||
# branch=None: the tracked ref is the checkout's OWN branch
|
||||
# (the pre-phase default-branch behavior). A detached HEAD
|
||||
# (the result "HEAD") is a genuinely broken checkout — the
|
||||
# app never creates one — and fails loud (D12), not a
|
||||
# force-push case.
|
||||
ref = run_git(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=dest).strip()
|
||||
if ref == "HEAD": # detached — the app never creates one (D12 fail-loud)
|
||||
raise GitSyncError(
|
||||
f"checkout {dest} is in a detached-HEAD state — remove and "
|
||||
"re-add the source to rebuild it"
|
||||
)
|
||||
_unshallow_if_shallow(dest)
|
||||
# The single-fetch ff pull of the pinned ref (phase 136
|
||||
# extends this sequence with its reset fallback).
|
||||
run_git(["git", "fetch", "origin", branch], cwd=dest)
|
||||
run_git(["git", "merge", "--ff-only", "FETCH_HEAD"], cwd=dest)
|
||||
# A fetch FAILURE (network/auth, the branch gone from the
|
||||
# remote, git missing) STILL raises — a force push is not a
|
||||
# fetch failure; the reset below is scoped to the merge step.
|
||||
run_git(["git", "fetch", "origin", ref], cwd=dest)
|
||||
try:
|
||||
run_git(["git", "merge", "--ff-only", "FETCH_HEAD"], cwd=dest)
|
||||
except GitSyncError as ff:
|
||||
# A divergent remote head (a force push, or any non-ff
|
||||
# state): the checkout is a cache of the remote head —
|
||||
# reset it to the tracked ref's head and continue. The ONE
|
||||
# WARNING line (PLAN §9) is the only observable side effect
|
||||
# besides the checkout state: no user-facing error, no
|
||||
# status change — the run continues. The merge's stderr
|
||||
# rides the log (diagnosability) but never a user-facing
|
||||
# error (there is no error). The log carries the checkout
|
||||
# PATH, never the URL (credential-safety).
|
||||
old = run_git(["git", "rev-parse", "HEAD"], cwd=dest).strip()
|
||||
new = run_git(["git", "rev-parse", "FETCH_HEAD"], cwd=dest).strip()
|
||||
run_git(["git", "reset", "--hard", "FETCH_HEAD"], cwd=dest)
|
||||
logger.warning(
|
||||
"git sync: divergent remote head on %s (force push) — reset %s → %s to "
|
||||
"origin/%s (%s)",
|
||||
dest, old[:7], new[:7], ref, ff,
|
||||
)
|
||||
return dest
|
||||
|
||||
|
||||
@@ -148,10 +223,10 @@ def _unshallow_if_shallow(dest: Path) -> None:
|
||||
|
||||
Probe ``git rev-parse --is-shallow-repository``; while shallow,
|
||||
``git fetch --unshallow`` (a pre-phase-107 deployed checkout
|
||||
becomes full-history on its next sync without a re-clone). BOTH
|
||||
pull paths of :func:`clone_or_pull` (no branch / pinned branch)
|
||||
run this before their pull — the argv sequence is the pre-phase
|
||||
one in both (the unit argv pins are the proof).
|
||||
becomes full-history on its next sync without a re-clone). The
|
||||
UNIFIED existing-checkout update of :func:`clone_or_pull` (phase
|
||||
136 — one sequence for both ``branch=None`` and a pinned branch)
|
||||
runs this before its fetch (the unit argv pins are the proof).
|
||||
"""
|
||||
shallow = (
|
||||
run_git(["git", "rev-parse", "--is-shallow-repository"], cwd=dest)
|
||||
|
||||
Reference in New Issue
Block a user