"""Phase 136 (task 02) E2E (Playwright): a force push no longer breaks the sync — the checkout resets to the head of the tracked branch. Source: ``TODO.md:5`` — the owner's verbatim repro: "Force pushes break brain of reese git pull. Brain of reese should always have the head of the branch specified, so if pull fails due to force pushes it should just reset origin/." Run in isolation (DB must be up: ``podman compose up -d db``; git on PATH — a documented environment prerequisite, phase 28): uv run pytest tests/e2e/test_force_push_resync.py -v --no-cov The owner's exact flow is pinned end to end: sync (clone + import) → force-push the origin (its history rewritten to an UNRELATED root commit) → sync again → SUCCESS, and the KB holds the NEW head's content. Pre-phase, that second sync landed ``failed``: the legacy ``git pull --ff-only`` (the ``branch=None`` env row) refused the divergent head and the run died in the ``failed`` state with a user-facing error. This module is the regression pin — the pin has TEETH: against the pre-phase ``clone_or_pull`` (verified by temporarily reverting the phase-136 change on a scratch branch, then restoring), run 2 lands ``failed`` and the terminal-state assertion fails, exactly as the pre-phase behavior demanded. Per-module app env (the ``test_sync_button.py`` module-app pattern): this module's app boots on its own port (a same-port second uvicorn dies on bind and would drive the wrong server) with ``BOR_GIT_SOURCES=file://`` (an env-fallback git row — ``branch=None``, the default-branch path the owner's repro exercises; the registry is truncated per test, so the fallback is the effective source list) and a dedicated ``BOR_SOURCES_DIR``. The session app (no git sources) is never started in this isolated run, so no port clash. The fixture (module-scoped, real ``git`` — the ``test_sync_button.sync_git_repo`` pattern): one branch (``main``), one commit, ``notes/doc.md`` with ``Marker: RESE-FPPUSH-V1-5e8a``. ``_force_push_new_head`` rewrites the origin IN PLACE to a NEW ORPHAN root commit (the branch ref deleted and recreated at the new commit — full divergence, not just a new commit on top): the ``file://`` equivalent of a force push, whose OBSERVABLE contract is that the origin's single branch now points at a commit with UNRELATED history (no common ancestor with the old head — asserted) and the new content. The app's next ``git fetch`` sees the divergent head. Test → contract mapping (two tests): 1. ``test_sync_survives_a_force_push`` — the owner's exact flow, through the UI: run 1 (clone + import: "1 added", the catalog holds ``doc.md``) → the force push (V2) → run 2 (``state == "success"`` — NOT ``failed``; the UI settles to "Synced HH:MM" with the error banner HIDDEN and no error modal — a reset is a LOGGED event, not a user-facing failure; the ``#sync-progress`` box shows "0 added · 1 updated" (phase 150 — the single sync text surface) and the document modal serves the V2 sentinel — the reset's content flowed through the import; the counts are the secondary signal, the content is primary) → run 3 (idempotent: "0 added · 1 unchanged" — the checkout is stably at the new head; the reset was not a one-off fluke, and "unchanged" rules out drift back to V1, which would have re-reported as "updated"). 2. ``test_failed_pull_is_not_the_outcome`` — the negative pin at the API seam (the task's PREFERRED direct-API leg, kept as its own test rather than folded into test 1 as step 4b: it costs one small API-only sync against the same module app — cheap while the fixture exists — and keeps the UI test focused): a FRESH force push (V3 — its own orphan root: test 1's checkout already sits at V2, so a second rewrite is needed to make THIS sync the post-force-push one) → ``POST /api/sync`` (202) + poll → ``state == "success"`` (the same contract as run 2 without the UI), and the KB row's content carries the V3 sentinel. Purely the status endpoint + the DB: the UI's 2 s poll loop starts only on a click or a boot-while-running, so it does NOT adopt an API-triggered run (the house rule — the status object, not the request, is authoritative — the test_sync_button API-trigger pattern asserts the endpoint, never the label). Wall-time guard: the whole module < 120 s (app boot + four small syncs against the mock LLM — no ``slow_llm`` proxy needed: the point is the TERMINAL state, not a running window). """ from __future__ import annotations import os import re import subprocess import sys import time import uuid from collections.abc import Iterator from datetime import datetime from pathlib import Path from typing import Any import pytest from playwright.sync_api import Page, expect from sqlalchemy import text from app.db import SessionLocal from e2e.auth_helpers import login from e2e.conftest import ( ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http, ) from e2e.sync_helpers import counts_line REPO = Path(__file__).resolve().parents[2] # Phase 79 (task 04, full inventory): the conftest session app owns its # port in a combined run — this module app binds its own port instead # (a same-port second uvicorn dies on bind and would drive the wrong # server). Env-overridable. APP_PORT = int(os.environ.get("E2E_APP_PORT_FPPUSH", "8135")) APP_URL = f"http://127.0.0.1:{APP_PORT}" #: The fixture repo's directory name — the source name the import #: records for it (the repo_name basename rule, phase 28). FIXTURE_NAME = "fppush-fixture" FIXTURE_DOC = "notes/doc.md" #: The per-version sentinels inside the fixture doc — the V2/V3 #: sentinels in the KB after their force push are the reset's #: observable end-to-end effect (the NEW head's content is what the #: KB serves). SENTINEL_V1 = "RESE-FPPUSH-V1-5e8a" SENTINEL_V2 = "RESE-FPPUSH-V2-2b7f" SENTINEL_V3 = "RESE-FPPUSH-V3-4a9d" V1_DOC = ( "# Force-push fixture note\n" "\n" "One small note that exists only to pin the force-push resync end\n" "to end: a real git clone, a real import, a force-pushed origin,\n" "and the reset back to the head of the tracked branch.\n" "\n" f"Marker: {SENTINEL_V1}\n" ) V2_DOC = ( "# Force-push fixture note (rewritten root)\n" "\n" "The origin's history was rewritten to a NEW orphan root commit —\n" "the old commit is gone and the histories share no ancestor. The\n" "next sync must reset to this head, not fail.\n" "\n" f"Marker: {SENTINEL_V2}\n" ) V3_DOC = ( "# Force-push fixture note (rewritten root, second time)\n" "\n" "A second unrelated rewrite — the API-level resync pin needs THIS\n" "sync to be the post-force-push one, so the origin gets a fresh\n" "orphan root again.\n" "\n" f"Marker: {SENTINEL_V3}\n" ) #: "Synced HH:MM" — the local-time last-result label (sources.js's #: fmtSyncTime), any hour/minute. SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}") #: The module's wall budget (the task's wall-time guard): app boot + #: four small syncs against the mock LLM stay under 120 s. A breach is #: a regression canary (the sync pipeline slowed), not a flake #: tolerance. WALL_BUDGET_S = 120.0 # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- def _git(cwd: Path, *args: str) -> None: """Run git in *cwd*; a non-zero exit fails the fixture loudly.""" proc = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True) if proc.returncode != 0: raise AssertionError(f"git {' '.join(args)} failed: {proc.stderr.strip()}") def _rev_parse(repo: Path, *args: str) -> str: """``git rev-parse`` in *repo* — stripped stdout, loud on failure.""" proc = subprocess.run( ["git", "rev-parse", *args], cwd=repo, capture_output=True, text=True ) if proc.returncode != 0: raise AssertionError( f"git rev-parse {' '.join(args)} failed: {proc.stderr.strip()}" ) return proc.stdout.strip() def _commit(repo: Path, message: str) -> None: """A fixture commit (never signed — the house ``commit.gpgsign`` flag, the test_sync_button.py idiom).""" _git( repo, "-c", "user.email=e@x", "-c", "user.name=t", "-c", "commit.gpgsign=false", "commit", "-qm", message, ) @pytest.fixture(scope="module") def fppush_repo(tmp_path_factory: pytest.TempPathFactory) -> Path: """A real one-commit, one-branch git repo the sync must clone (the ``test_sync_button.sync_git_repo`` pattern): ``main``, one commit, ``notes/doc.md`` with ``Marker: RESE-FPPUSH-V1-5e8a``. Built under ``tmp_path_factory`` (``tmp_path`` is function-scoped while the module-scoped app fixture needs the repo for the module's lifetime) via real ``git`` subprocesses. ``init -b main`` pins the default branch NAME (the system's ``init.defaultBranch`` may differ). """ root = tmp_path_factory.mktemp("bor_fppush") repo = root / FIXTURE_NAME (repo / "notes").mkdir(parents=True) (repo / "notes" / "doc.md").write_text(V1_DOC, encoding="utf-8") _git(repo, "init", "-q", "-b", "main") _git(repo, "add", "-A") _commit(repo, "one") assert (repo / ".git").is_dir() return repo @pytest.fixture(scope="module") def app_server(mock_llm: int, fppush_repo: Path) -> Iterator[str]: """The real app under test — per-module env: the sync's subject is a real ``file://`` git source (env-fallback row — ``branch=None``, the default-branch path the owner's repro exercises) with its own checkout dir (the conftest session app boots without ``BOR_GIT_SOURCES`` and is never started in this isolated run).""" env = dict(os.environ) env.pop("DEBUGPY", None) env["BOR_ENVIRONMENT"] = "e2e" env["BOR_STATIC_DIR"] = str(REPO / "frontend") env["BOR_LLM_BASE_URL"] = ( "https://aipi.reeseapps.com/v1" if USE_REAL_LLM else f"http://127.0.0.1:{mock_llm}/v1" ) # Mock-calibrated threshold (conftest pattern) — keeps every story # suite's deterministic gate behavior; production default stays 0.62. env["BOR_RELEVANCE_THRESHOLD"] = "0.30" env.setdefault( "BOR_DATABASE_URL", "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese", ) # Phase 16: admin auth must be set or create_app() refuses to boot. env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD env["BOR_SESSION_SECRET"] = SESSION_SECRET # This suite's subject: one real local repo via the env fallback # (the registry is truncated per test, so the fallback is the # effective source list — the test_sync_button.py idiom). env["BOR_GIT_SOURCES"] = f"file://{fppush_repo}" env["BOR_SOURCES_DIR"] = str(fppush_repo.parent / "checkouts") proc = subprocess.Popen( [sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"], cwd=REPO, env=env, ) try: _wait_http(f"{APP_URL}/api/health") yield APP_URL finally: proc.terminate() try: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() @pytest.fixture(scope="module") def app_url(app_server: str) -> str: return app_server def _truncate_kb() -> None: """Fresh KB + registry per test (the E2E isolation pattern): each test's sync counts are its own. ``git_sources`` too — the shared Postgres may carry leftover registry rows from other suites, and ``effective_sources`` prefers the DB list over this app's ``BOR_GIT_SOURCES`` env fallback. ``sources_meta`` (the KB generation counter) resets with the KB — the bump is an upsert, so the sync re-creates the row.""" with SessionLocal() as db: db.execute(text( "TRUNCATE chunks, documents, query_log, kb_overview, " "git_sources, sources_meta" )) db.commit() @pytest.fixture(autouse=True) def _clean_kb(db_ready: None) -> Iterator[None]: _truncate_kb() yield _truncate_kb() @pytest.fixture(scope="module", autouse=True) def _wall_time_guard() -> Iterator[None]: """The module's wall-time guard (the task's 120 s budget): the whole module — app boot + four small syncs of a one-file repo against the mock LLM — stays under 120 s. A breach is a regression canary (the sync pipeline slowed), not a flake tolerance.""" start = time.monotonic() yield elapsed = time.monotonic() - start assert elapsed < WALL_BUDGET_S, ( f"the module took {elapsed:.1f}s — over the {WALL_BUDGET_S:.0f}s wall " "budget (the small-fixture syncs against the mock LLM are a " "speed canary)" ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _force_push_new_head(repo: Path, content: str) -> None: """Rewrite the origin's history to a NEW root commit (a full divergence — not just a new commit on top): delete the branch ref and recreate it at a new orphan commit with *content*. The ``file://`` equivalent of a force push: the app's next ``git fetch`` sees the divergent head. The OBSERVABLE contract is asserted here — the branch now points at a commit with UNRELATED history (no ``merge-base`` with the old head) and the new content. The fixture repo is left with EXACTLY ONE branch (the original, at the new root), so the helper is repeatable (the second test force-pushes again). """ branch = _rev_parse(repo, "--abbrev-ref", "HEAD") old_head = _rev_parse(repo, "HEAD") # A unique orphan branch name per call (a repeatable helper must # not collide with a leftover ref); it is deleted again at the end # so the fixture is left with exactly one branch. orphan = f"fppush-root-{uuid.uuid4().hex[:8]}" _git(repo, "checkout", "-q", "--orphan", orphan) (repo / "notes" / "doc.md").write_text(content, encoding="utf-8") _git(repo, "add", "-A") _commit(repo, "fppush: rewritten root") new_head = _rev_parse(repo, "HEAD") # The force push proper: the branch ref is DELETED and recreated # at the orphan root — the old history is no longer reachable from # any ref (a real force push's observable effect). _git(repo, "update-ref", "-d", f"refs/heads/{branch}") _git(repo, "update-ref", f"refs/heads/{branch}", new_head) _git(repo, "checkout", "-q", "-B", branch, new_head) _git(repo, "update-ref", "-d", f"refs/heads/{orphan}") # The OBSERVABLE contract: the branch at the new head, and # UNRELATED history (no common ancestor with the old root). assert _rev_parse(repo, branch) == new_head proc = subprocess.run( ["git", "merge-base", old_head, new_head], cwd=repo, capture_output=True, text=True, ) assert proc.returncode != 0, ( "the rewrite must be UNRELATED history (no common ancestor); " f"merge-base found: {proc.stdout.strip()!r}" ) def _go_top(page: Page) -> None: """Back to the catalog's top level (no-op when already there — the breadcrumb is hidden at the top, the test_sync_button.py idiom).""" if not page.locator("#kb-crumb").is_hidden(): page.locator("#kb-crumb a.kb-crumb-link").first.click() def _expect_fixture_row(page: Page) -> None: """The REAL clone was imported — the fixture path is listed. The catalog is the drill-down tree (phase 97), so drill source → folder ``notes`` first (the ``test_sync_button._expect_fixture_row`` pattern).""" _go_top(page) page.click(f'#folders-tbody a.folder-link:text-is("{FIXTURE_NAME}")') page.click('#folders-tbody a.folder-link:text-is("notes")') expect(page.locator("#docs-tbody tr", has_text=FIXTURE_DOC)).to_have_count(1) def _started_at(body: dict[str, Any]) -> float: """The status body's ``started_at`` as a wall-clock epoch (the server stamps it in UTC; this process runs on the same host — same clock, the test_git_source_branch idiom).""" started = body.get("started_at") if not started: return 0.0 try: return datetime.fromisoformat(str(started)).timestamp() except ValueError: return 0.0 def _wait_sync_state( page: Page, app_url: str, *, clicked_at: float, timeout_s: float = 90.0, settle_ui: bool = True, ) -> dict[str, Any]: """Poll the (cookie-authenticated) status endpoint until the current run reaches a TERMINAL state, then settle the UI. The ``test_sync_button._wait_sync_done`` pattern — the terminal set is ``{"success", "failed"}`` (a cancel is never issued in this module) — plus the UI-level settle wait on ``#sync-label`` (``settle_ui=True``): a success waits for the "Synced HH:MM" last-result label (the UI's 2 s poll loop settles it; the never-stale contract, §7.4), a ``failed`` terminal returns WITHOUT settling (the label never leaves "Syncing…" on a failure — the caller's terminal-state assertion is what pins it). ``settle_ui=False`` (the API-triggered negative pin) skips the label settle: the UI's poll loop starts only on a click or a boot-while-running, so an API-triggered run is never adopted visually — the status endpoint is the authoritative surface. The module app is shared across the test's sequential runs, so the PREVIOUS run's terminal lives in the app's memory until this run supersedes it: a terminal is accepted only after a running tick was observed or its ``started_at`` post-dates the click (the stale-terminal freshness gate, the ``test_git_source_branch`` idiom) — the caller captures ``clicked_at`` just before the trigger (the click or the ``POST /api/sync``). """ deadline = time.monotonic() + timeout_s saw_running = False body: dict[str, Any] = {} while time.monotonic() < deadline: r = page.request.get(f"{app_url}/api/sync/status") assert r.status == 200, r.text body = r.json() state = body["state"] if state == "running": saw_running = True elif state in ("success", "failed") and ( saw_running or _started_at(body) >= clicked_at - 1.0 ): if state == "success" and settle_ui: expect(page.locator("#sync-label")).to_have_text( SYNCED_LABEL, timeout=15_000 ) return body time.sleep(0.25) raise AssertionError(f"sync did not reach a terminal state: {body}") # --------------------------------------------------------------------------- # 1. THE owner's exact flow: sync → force push → sync again → success # with the NEW head's content # --------------------------------------------------------------------------- def test_sync_survives_a_force_push( page: Page, app_url: str, db_ready: None, fppush_repo: Path ) -> None: """The owner's exact reproduction, through the UI: run 1 (clone + import) → force-push the origin (an orphan-root rewrite, V2) → run 2 (``state == "success"`` — NOT the pre-phase ``failed``; the error banner stays hidden and no error modal exists — a reset is a logged event, not a user-facing failure) with the KB serving the NEW head's content → run 3 (idempotent at the new head).""" page.set_default_timeout(30_000) login(page, app_url) # lands on /sources.html (the button's home) btn = page.locator("#sync-btn") expect(btn).to_be_visible() expect(page.locator("#sync-label")).to_have_text("Sync sources") # --- run 1: clone + import (the V1 head) -------------------------- clicked_at = time.time() btn.click() expect(btn).to_be_disabled() expect(page.locator("#sync-label")).to_have_text("Syncing…") body = _wait_sync_state(page, app_url, clicked_at=clicked_at) assert body["state"] == "success", body # The fixture doc (one A9-format file) is the only change — the # counts line rides the box (phase 150). expect(page.locator("#sync-progress-text")).to_have_text(counts_line("1 added")) # The REAL clone was imported: the fixture path is in the Sources # table (the drill-down tree, the test_sync_button pattern). _expect_fixture_row(page) # --- the force push (server-side — the test owns the origin) ------ _force_push_new_head(fppush_repo, V2_DOC) # --- run 2: the resync — SUCCESS, the reset is not a failure ------ expect(btn).to_be_enabled() # never stale — re-enabled at the terminal clicked_at = time.time() btn.click() expect(btn).to_be_disabled() expect(page.locator("#sync-label")).to_have_text("Syncing…") body = _wait_sync_state(page, app_url, clicked_at=clicked_at) # THE regression pin: pre-phase, the --ff-only pull refused the # divergent head and this terminal was "failed" (the run died in # the failed state with a user-facing error). assert body["state"] == "success", body expect(btn).to_be_enabled() # A reset is a LOGGED event, not a user-facing failure: the error # banner stays hidden and no error modal was ever created (it is # created on demand, so count 0 is the absence proof)… expect(page.locator("#sync-error-banner")).to_be_hidden() expect(page.locator(".sync-modal-backdrop")).to_have_count(0) # …and the digest delta re-indexed the file as updated (the counts # are the secondary signal — the content assertion below is # primary). The counts line rides the box (phase 150). expect(page.locator("#sync-progress-text")).to_have_text(counts_line("0 added · 1 updated")) # The KB holds the NEW head: the document modal serves the V2 # sentinel — the reset's content flowed through the import. _expect_fixture_row(page) doc_row = page.locator("#docs-tbody tr", has_text=FIXTURE_DOC) doc_row.locator("td:nth-child(2) a.doc-link").click() expect(page.locator(".doc-modal")).to_be_visible() expect(page.locator("#doc-modal-content")).to_contain_text(SENTINEL_V2) page.keyboard.press("Escape") expect(page.locator(".doc-modal")).to_be_hidden() # --- run 3: idempotent — the checkout is stably at the new head --- expect(btn).to_be_enabled() clicked_at = time.time() btn.click() expect(btn).to_be_disabled() expect(page.locator("#sync-label")).to_have_text("Syncing…") body = _wait_sync_state(page, app_url, clicked_at=clicked_at) assert body["state"] == "success", body # "1 unchanged" means the content hash did not move — drift back # to V1 would have re-reported as "1 updated" (the no-drift pin). # The counts line rides the box (phase 150). expect(page.locator("#sync-progress-text")).to_have_text(counts_line("0 added · 1 unchanged")) # --------------------------------------------------------------------------- # 2. The negative pin at the API seam: after a force push, POST # /api/sync + poll → state == "success" (no UI) # --------------------------------------------------------------------------- def test_failed_pull_is_not_the_outcome( page: Page, app_url: str, db_ready: None, fppush_repo: Path ) -> None: """The API-level negative pin (no UI — the task's PREFERRED direct-API leg, its own test rather than folded into test 1 as step 4b so the UI test stays focused): a FRESH force push (V3 — its own orphan root: test 1's checkout already sits at V2, so a second rewrite is needed to make THIS sync the post-force-push one) → ``POST /api/sync`` (202) + poll → ``state == "success"`` — the same contract as run 2 without the UI — and the KB row's content carries the V3 sentinel. Purely the status endpoint + the DB (no page navigation, no UI assertion): the UI's 2 s poll loop starts only on a click or a boot-while-running, so it does NOT adopt an API-triggered run — the status endpoint is the authoritative surface (the house rule, and the test_sync_button API-trigger pattern).""" r = page.request.post( f"{app_url}/api/login", data={"password": ADMIN_PASSWORD} ) assert r.status == 204, r.text _force_push_new_head(fppush_repo, V3_DOC) clicked_at = time.time() r = page.request.post(f"{app_url}/api/sync") assert r.status == 202, r.text body = _wait_sync_state( page, app_url, clicked_at=clicked_at, settle_ui=False ) assert body["state"] == "success", body # Fresh after the per-test truncate: the (reset) checkout's file # is re-added from the NEW head. assert body["detail"]["added"] == 1, body # The NEW head's content is what the KB serves (the sentinel in # the stored document). with SessionLocal() as db: content = db.execute( text( "SELECT content FROM documents " "WHERE source = :src AND path = :path" ), {"src": FIXTURE_NAME, "path": FIXTURE_DOC}, ).scalar_one() assert SENTINEL_V3 in content