phase: 136_force_push_reset
Build and Push Containers / build-and-push-app (push) Successful in 2m25s
Build and Push Containers / build-and-push-db (push) Successful in 12s

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:
2026-09-28 22:38:54 -04:00
parent d3f8c7f2c8
commit 0035bdf533
17 changed files with 1757 additions and 143 deletions
+387 -3
View File
@@ -33,25 +33,46 @@ and the ``_resolve_sources`` seam with a ``branch='release'`` row
(importing file B, not file A) + an env-origin row without a branch
(the default branch, byte-identical pre-phase behavior). Skipped, not
failed, on a machine without the git CLI (the house pattern).
Phase 136 (the ff-or-reset) extends the REAL-git section: the owner's
force-push repro (the origin's history REWRITTEN to a new root commit
between two ``clone_or_pull`` runs — normal divergence AND fully
unrelated history) now resets the checkout to the origin's new head
instead of failing (the loud WARNING line pinned alongside), a
branched row's force push lands ``origin/<branch>``'s head (main's
content not involved), ``file_commit_dates`` over the post-reset
checkout reflects the NEW head's dates, and the SYNC PIPELINE END-
TO-END over the API seam (``POST /api/sync`` + the status poll — the
seam ``test_sync_api.py`` exercises) lands ``success`` after a force
push, with the checkout (and therefore the KB) holding the new head.
"""
from __future__ import annotations
import asyncio
import logging
import os
import re
import subprocess
import time
from collections.abc import Iterator
from datetime import datetime
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.api import sync as sync_api
from app.config import Settings
from app.db import SessionLocal
from app.main import app as fastapi_app
from app.models import GitSource
from app.rag import git_sources as git_sources_resolver
from app.rag.importer import ImportSummary
from scripts import import_docs
from scripts.git_sync import GitSyncError, clone_or_pull
from scripts.git_sync import GitSyncError, clone_or_pull, file_commit_dates
from tests.conftest import ADMIN_PASSWORD
from tests.fakes import FakeEmbedder
@@ -788,14 +809,22 @@ def two_branch_repo(tmp_path: Path) -> Path:
return repo
def _git(cwd: Path, *argv: str) -> None:
def _git(cwd: Path, *argv: str, date: str | None = None) -> None:
"""Run one git command for the test harness (fixture setup); a
non-zero exit fails the fixture, not the test under test."""
non-zero exit fails the fixture, not the test under test.
``date`` pins the commit date (``GIT_COMMITTER_DATE`` =
``GIT_AUTHOR_DATE``) — deterministic per-file dates for the
phase-136 ``file_commit_dates`` pin (scratch commits would
otherwise share the wall clock's second)."""
env = os.environ.copy()
env["GIT_AUTHOR_NAME"] = "T"
env["GIT_AUTHOR_EMAIL"] = "t@example.com"
env["GIT_COMMITTER_NAME"] = "T"
env["GIT_COMMITTER_EMAIL"] = "t@example.com"
if date is not None:
env["GIT_AUTHOR_DATE"] = date
env["GIT_COMMITTER_DATE"] = date
proc = subprocess.run(
["git", *argv], cwd=cwd, env=env, capture_output=True, text=True, check=False
)
@@ -947,3 +976,358 @@ def test_resolve_sources_row_without_branch_clones_default_branch(
assert sources == [root]
assert (root / "a.md").is_file() # main's file — the default branch
assert not (root / "b.md").exists()
# --- phase 136: the ff-or-reset (real git, the force-push repro) -----------
@pytest.fixture()
def single_branch_repo(tmp_path: Path) -> Path:
"""A real-git scratch origin on ``main`` with one file (``a.md``)
(the phase-136 base shape — the owner's single-branch source;
the root commit's date is pinned for the deterministic
``file_commit_dates`` pin below)."""
repo = tmp_path / "origin"
repo.mkdir()
_git(repo, "init", "-q", "-b", "main")
_git(repo, "config", "commit.gpgsign", "false")
(repo / "a.md").write_text("# A\noriginal content\n", encoding="utf-8")
_git(repo, "add", "a.md")
_git(repo, "commit", "-q", "-m", "root: add a.md", date="2026-01-05T10:00:00+00:00")
return repo
def _force_push_new_root(
origin: Path,
files: dict[str, str | None],
message: str,
*,
branch: str = "main",
date: str | None = None,
) -> None:
"""Rewrite the origin's ``branch`` to a NEW ROOT commit (the
phase-136 force push): ``git checkout --orphan`` + commit, so the
branch now points at a root with NO shared ancestry with the old
history — the strongest divergence, exactly what ``--ff-only``
refuses. ``files`` maps name → new content (the orphan commit
holds exactly that set — the old files are dropped)."""
_git(origin, "checkout", "--orphan", "rewritten")
for p in sorted(origin.iterdir()):
if p.is_file() and p.name != ".git":
p.unlink()
for name, content in files.items():
if content is not None:
(origin / name).write_text(content, encoding="utf-8")
_git(origin, "add", "-A")
_git(origin, "commit", "-q", "-m", message, date=date)
_git(origin, "branch", "-D", branch)
_git(origin, "branch", "-m", "rewritten", branch)
_git(origin, "checkout", "-q", "main")
@pytest.mark.skipif(not GIT, reason="git CLI not available")
def test_clone_or_pull_normal_ff_updates_without_reset(
tmp_path: Path, single_branch_repo: Path
) -> None:
"""Phase 136: a normal second commit (a true fast-forward) → the
unified sequence ff-merges: the checkout's HEAD is the origin's
head, the second commit's message is the HEAD subject, NO merge
commit (a pure ff), and the working tree holds the new content —
the reset tail never runs on a clean ff."""
origin = single_branch_repo
url = f"file://{origin}"
dest = tmp_path / "bor" / "origin"
clone_or_pull(url, dest)
(origin / "b.md").write_text("# B\nsecond commit\n", encoding="utf-8")
_git(origin, "add", "b.md")
_git(origin, "commit", "-q", "-m", "second: add b.md", date="2026-02-01T09:00:00+00:00")
clone_or_pull(url, dest)
dest_head = _git_out(dest, "rev-parse", "HEAD").strip()
origin_head = _git_out(origin, "rev-parse", "main").strip()
assert dest_head == origin_head
subjects = _git_out(dest, "log", "--format=%s").splitlines()
assert subjects[0] == "second: add b.md" # the second commit's message is the HEAD
assert subjects == ["second: add b.md", "root: add a.md"] # pure ff: no merge commit
assert (dest / "b.md").read_text(encoding="utf-8") == "# B\nsecond commit\n"
@pytest.mark.skipif(not GIT, reason="git CLI not available")
def test_clone_or_pull_force_push_resets_to_the_new_head(
tmp_path: Path, single_branch_repo: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Phase 136 (the owner's repro, TODO L5): after the first clone,
the origin's history is REWRITTEN to a NEW ROOT commit (same
file, new content — the branch now diverges; pre-phase, the next
sync died on the non-ff ``git pull --ff-only`` → the whole sync
landed ``failed``) → ``clone_or_pull`` NO LONGER raises: the
checkout is reset to the origin's NEW head (the checkout's HEAD ==
the origin's head), the working tree holds the NEW content, and
ONE WARNING line (PLAN §9) is the only side effect — no
user-facing error (the log carries the checkout path, never the
URL)."""
origin = single_branch_repo
url = f"file://{origin}"
dest = tmp_path / "bor" / "origin"
new_content = "# A\nrewritten content\n"
clone_or_pull(url, dest)
_force_push_new_root(
origin, {"a.md": new_content}, "force: rewritten root",
date="2026-03-10T12:00:00+00:00",
)
with caplog.at_level(logging.WARNING, logger="scripts.git_sync"):
result = clone_or_pull(url, dest) # NO exception (the pre-phase break)
assert result == dest
dest_head = _git_out(dest, "rev-parse", "HEAD").strip()
origin_head = _git_out(origin, "rev-parse", "main").strip()
assert dest_head == origin_head
assert (dest / "a.md").read_text(encoding="utf-8") == new_content
# ONE WARNING line: the checkout path + the tracked ref, never the URL.
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert len(warnings) == 1
msg = warnings[0].getMessage()
assert "git sync: divergent remote head on " in msg
assert str(dest) in msg
assert "to origin/main" in msg
assert url not in msg # credential-safety: the log never carries the URL
@pytest.mark.skipif(not GIT, reason="git CLI not available")
def test_clone_or_pull_unrelated_history_force_push_resets(
tmp_path: Path, single_branch_repo: Path
) -> None:
"""Phase 136: the rewritten origin has a DIFFERENT root commit
with a fully UNRELATED tree (a different file, NO shared ancestry
— the ff merge of an unrelated history is exactly what
``--ff-only`` refuses) → the SAME reset path handles it: the
checkout's HEAD is the origin's new head and the working tree IS
the new tree (the old file GONE — the reset is a hard one, not a
merge)."""
origin = single_branch_repo
url = f"file://{origin}"
dest = tmp_path / "bor" / "origin"
clone_or_pull(url, dest)
_force_push_new_root(
origin, {"z.md": "# Z\nunrelated root\n"}, "force: unrelated root",
date="2026-03-11T12:00:00+00:00",
)
result = clone_or_pull(url, dest) # NO exception — the same reset path
assert result == dest
dest_head = _git_out(dest, "rev-parse", "HEAD").strip()
origin_head = _git_out(origin, "rev-parse", "main").strip()
assert dest_head == origin_head
assert (dest / "z.md").read_text(encoding="utf-8") == "# Z\nunrelated root\n"
assert not (dest / "a.md").exists() # the hard reset dropped the old tree's file
@pytest.mark.skipif(not GIT, reason="git CLI not available")
def test_clone_or_pull_branched_force_push_resets_to_the_branch_head(
tmp_path: Path, two_branch_repo: Path
) -> None:
"""Phase 136 + phase 135: a two-branch origin (the phase-135
shape), a row-shaped call ``clone_or_pull(url, dest,
"release")`` after a force push of ONLY ``release`` → the reset
lands ``origin/release``'s head (the owner's verbatim "reset
origin/<branch>") and ``main``'s content is not involved (file A
is still absent — the checkout holds the branch's tree only)."""
origin = two_branch_repo
url = f"file://{origin}"
dest = tmp_path / "bor" / "origin"
forced = "# B\nrelease branch content FORCED\n"
clone_or_pull(url, dest, "release")
_force_push_new_root(
origin, {"b.md": forced}, "release: force-pushed root",
branch="release", date="2026-03-15T08:00:00+00:00",
)
clone_or_pull(url, dest, "release") # NO exception — the reset landed
assert _git_out(dest, "rev-parse", "--abbrev-ref", "HEAD").strip() == "release"
dest_head = _git_out(dest, "rev-parse", "HEAD").strip()
release_head = _git_out(origin, "rev-parse", "release").strip()
assert dest_head == release_head
assert (dest / "b.md").read_text(encoding="utf-8") == forced
assert not (dest / "a.md").exists() # main's content is not involved
@pytest.mark.skipif(not GIT, reason="git CLI not available")
def test_file_commit_dates_after_reset_reflects_the_new_head(
tmp_path: Path, single_branch_repo: Path
) -> None:
"""Phase 136: ``file_commit_dates`` over the post-reset checkout
yields the NEW head's per-file dates — the new root commit's date
is what the walk returns for its files (the head-of-branch dates
the owner asked for: the KB is rebuilt from the reset checkout on
the same run, so its document dates are the NEW history's, not
the old one's)."""
origin = single_branch_repo
url = f"file://{origin}"
dest = tmp_path / "bor" / "origin"
clone_or_pull(url, dest)
old_dates = file_commit_dates(dest)
assert old_dates["a.md"] == datetime.fromisoformat("2026-01-05T10:00:00+00:00")
_force_push_new_root(
origin, {"a.md": "# A\nrewritten content\n"}, "force: rewritten root",
date="2026-03-10T12:00:00+00:00",
)
clone_or_pull(url, dest)
new_dates = file_commit_dates(dest)
assert set(new_dates) == {"a.md"} # the new tree's files only
assert new_dates["a.md"] == datetime.fromisoformat("2026-03-10T12:00:00+00:00")
assert new_dates["a.md"] != old_dates["a.md"] # the NEW head dates the file
# --- phase 136: the sync pipeline end to end (the API seam) ---------------
def _reset_sources_meta() -> None:
"""Pin the single-row ``sources_meta`` counter to the
migration-0010 seed (0) — the runner bumps it through its own
short-lived ``SessionLocal`` (the ``test_sync_api`` pattern)."""
session = SessionLocal()
try:
session.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1"))
session.commit()
finally:
session.close()
@pytest.fixture()
def fresh_sync_state() -> Iterator[None]:
"""Phase 136 (the API-seam pin): the sync runner's module-level
status object + task + cancel event are process-global — reset
them around the test (the ``test_sync_api`` autouse pattern, the
full suite runs both files in one process)."""
sync_api._status = sync_api.SyncStatus()
sync_api._task = None
sync_api._cancel_event = asyncio.Event()
yield
sync_api._status = sync_api.SyncStatus()
sync_api._task = None
sync_api._cancel_event = asyncio.Event()
@pytest.fixture()
def clean_sync_seam(db: Session) -> Iterator[None]:
"""Phase 136 (the API-seam pin): the real sync runner writes
global tables — ``git_sources`` (seeded), ``documents``/
``chunks`` (the real import), and ``folder_summaries`` (the real
generator) — truncated around the test; the ``sources_meta``
counter is pinned to 0 before and after. Skips like the ``db``
fixture when Postgres is down."""
db.execute(text("TRUNCATE git_sources, chunks, documents, folder_summaries"))
db.commit()
_reset_sources_meta()
yield
db.execute(text("TRUNCATE git_sources, chunks, documents, folder_summaries"))
db.commit()
_reset_sources_meta()
@pytest.fixture()
def sync_client() -> Iterator[TestClient]:
"""Context-managed TestClient — one app event loop across
requests (the background sync task must survive between the POST
and the polls — the ``test_sync_api`` pattern; a TestClient
without the context manager would cancel the task on request
exit)."""
with TestClient(fastapi_app) as client:
yield client
def _poll_sync(client: TestClient, want: str, timeout: float = 10.0) -> dict:
"""Poll ``GET /api/sync/status`` until ``state == want``
(terminal). Any state other than ``running`` before the deadline
fails loudly — an unexpected ``failed`` must never be masked by
the wait (the pre-phase force push landed exactly that)."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
body = client.get("/api/sync/status").json()
if body["state"] == want:
return body
assert body["state"] == "running", (
f"unexpected state {body['state']!r} while waiting for {want!r}: {body}"
)
time.sleep(0.05)
raise AssertionError(f"sync did not reach {want!r} within {timeout}s")
@pytest.mark.skipif(not GIT, reason="git CLI not available")
def test_sync_pipeline_survives_a_force_push(
sync_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
db: Session,
tmp_path: Path,
single_branch_repo: Path,
fresh_sync_state: None,
clean_sync_seam: None,
) -> None:
"""Phase 136, the SYNC PIPELINE END-TO-END (the seam
``tests/integration/test_sync_api.py`` already exercises — the
pre-phase force push landed the button sync in ``failed``; this
test is the API-seam regression pin that it now lands
``success``): a real-git ``file://`` origin + a seeded git row;
sync #1 clones and imports the original content; the origin's
history is REWRITTEN to a new root commit (the owner's exact
repro); sync #2 → ``state == "success"`` (NOT ``failed``), the
rewritten file is re-imported (``updated=1``), and the checkout
(and therefore the KB) holds the NEW head. The REAL
``clone_or_pull`` runs (no fake — the reset is what is under
test) over the real import + real overview/folder-summary steps
(deterministic in-process ``FakeEmbedder``, no network)."""
origin = single_branch_repo
url = f"file://{origin}"
db.add(GitSource(url=url, kind="git"))
db.commit()
# The resolver's env fallback pinned empty (the DB row wins — the
# dev ``.env`` never leaks in; the ``test_sync_api`` pattern).
monkeypatch.setattr(
git_sources_resolver,
"get_settings",
lambda: Settings(_env_file=None, git_sources=""), # pyright: ignore[reportCallIssue]
)
monkeypatch.setattr(
sync_api,
"get_settings",
lambda: Settings(_env_file=None, sources_dir=str(tmp_path / "bor")), # pyright: ignore[reportCallIssue]
)
monkeypatch.setattr(sync_api, "LLMClient", lambda: FakeEmbedder()) # probe passes against it
r = sync_client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
# Sync #1: the fresh clone imports the original content.
assert sync_client.post("/api/sync").status_code == 202
body1 = _poll_sync(sync_client, "success")
assert body1["detail"]["added"] == 1
# The owner's repro: force push the origin (a NEW ROOT commit).
new_content = "# A\nrewritten content\n"
_force_push_new_root(origin, {"a.md": new_content}, "force: rewritten root")
# Sync #2: pre-phase this landed ``failed`` (the non-ff
# ``git pull --ff-only`` died) — the reset is now self-healing
# and the run SUCCEEDS.
assert sync_client.post("/api/sync").status_code == 202
body2 = _poll_sync(sync_client, "success") # NOT ``failed`` — the regression pin
assert body2["error"] is None
assert body2["detail"]["updated"] == 1 # the rewritten file is re-imported
# The checkout (and therefore the KB) holds the NEW head.
root = tmp_path / "bor" / "origin"
root_head = _git_out(root, "rev-parse", "HEAD").strip()
origin_head = _git_out(origin, "rev-parse", "main").strip()
assert root_head == origin_head
assert (root / "a.md").read_text(encoding="utf-8") == new_content