diff --git a/.agents/phases/todo/136_force_push_reset/00_phase.md b/.agents/phases/complete/136_force_push_reset/00_phase.md similarity index 100% rename from .agents/phases/todo/136_force_push_reset/00_phase.md rename to .agents/phases/complete/136_force_push_reset/00_phase.md diff --git a/.agents/phases/todo/136_force_push_reset/01_ff_or_reset.md b/.agents/phases/complete/136_force_push_reset/01_ff_or_reset.md similarity index 100% rename from .agents/phases/todo/136_force_push_reset/01_ff_or_reset.md rename to .agents/phases/complete/136_force_push_reset/01_ff_or_reset.md diff --git a/.agents/phases/todo/136_force_push_reset/02_e2e_force_push_resync.md b/.agents/phases/complete/136_force_push_reset/02_e2e_force_push_resync.md similarity index 100% rename from .agents/phases/todo/136_force_push_reset/02_e2e_force_push_resync.md rename to .agents/phases/complete/136_force_push_reset/02_e2e_force_push_resync.md diff --git a/.agents/reports/136_force_push_reset/136_force_push_reset__00_phase.a1.err b/.agents/reports/136_force_push_reset/136_force_push_reset__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/136_force_push_reset/136_force_push_reset__00_phase.a1.md b/.agents/reports/136_force_push_reset/136_force_push_reset__00_phase.a1.md new file mode 100644 index 0000000..fa23c48 --- /dev/null +++ b/.agents/reports/136_force_push_reset/136_force_push_reset__00_phase.a1.md @@ -0,0 +1,16 @@ +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 ` → `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/`, 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` diff --git a/.agents/reports/136_force_push_reset/136_force_push_reset__00_phase.a1.validate b/.agents/reports/136_force_push_reset/136_force_push_reset__00_phase.a1.validate new file mode 100644 index 0000000..85e1feb --- /dev/null +++ b/.agents/reports/136_force_push_reset/136_force_push_reset__00_phase.a1.validate @@ -0,0 +1,116 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 11%] +........................................................................ [ 13%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 31%] +........................................................................ [ 33%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 69%] +........................................................................ [ 71%] +........................................................................ [ 73%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 98%] +............................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 268 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 179 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 256 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 133 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 412 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 56 0 100% +app/rag/importer.py 595 9 98% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 109 0 100% +app/rag/prompts.py 144 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 49 0 100% +app/schemas.py 422 0 100% +-------------------------------------------------- +TOTAL 5334 22 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/136_force_push_reset/136_force_push_reset__01_ff_or_reset.a1.err b/.agents/reports/136_force_push_reset/136_force_push_reset__01_ff_or_reset.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/136_force_push_reset/136_force_push_reset__01_ff_or_reset.a1.md b/.agents/reports/136_force_push_reset/136_force_push_reset__01_ff_or_reset.a1.md new file mode 100644 index 0000000..7da5f6d --- /dev/null +++ b/.agents/reports/136_force_push_reset/136_force_push_reset__01_ff_or_reset.a1.md @@ -0,0 +1,17 @@ +Task 01 complete. Report: + +**Implemented** +- `scripts/git_sync.py::clone_or_pull` — unified existing-checkout sequence: phase-135 HEAD guard (pinned branch) or ref-resolution to the checkout's own branch (`branch=None`, detached-HEAD fails loud, D12) → unshallow self-heal → `git fetch origin ` (fetch failure still raises) → `merge --ff-only FETCH_HEAD` → on non-ff: `rev-parse HEAD`/`FETCH_HEAD` capture + `reset --hard FETCH_HEAD` + one loud WARNING (checkout path, short SHAs, `origin/`, merge stderr — never the URL), then returns normally. Legacy `git pull --ff-only` deliberately gone. Module + function docstrings updated with the D11/D12 phase-136 contract. +- Unit: rewritten/added argv matrix in `tests/unit/test_git_sync.py` — ff-success (no reset argv), ff-failure reset tail (both `branch=None` and `release`), mismatch guard still first, detached-HEAD raise, fetch-failure still raises, shallow self-heal before fetch, WARNING-line pin (caplog, URL-free). +- Integration: real-git `file://` matrix in `tests/integration/test_import_docs_git.py` — normal ff (no merge commit), force push (owner's repro), unrelated-history force push, branched force push (lands `origin/release` head, main untouched), `file_commit_dates` reflects the new head, and the sync pipeline end-to-end over the API seam (seeded row + 2 real syncs around a force push → `state == "success"`, checkout holds the new head — the API-seam regression pin, kept in this file per the task). + +**Results** +- `uv run pytest tests/unit/test_git_sync.py tests/integration/test_import_docs_git.py -v` → 53 passed +- `uv run pytest --cov=app --cov-report=term-missing` → 3231 passed, `app/` 99% (>90% bar) +- `uv run ruff check . && uv run pyright` → clean (0 errors) + +**Docs** — README "Git-based sources" updated (branch bullet + the clone/pull bullet now describe ff-or-reset; force push = logged self-healing event). AGENTS.md: no impact. + +**Decisions** — step-6 sync-seam test written in `test_import_docs_git.py` (not deferred to E2E): full real pipeline via TestClient + `FakeEmbedder`, mirroring `test_sync_api.py` state-reset/truncate patterns; deterministic commit dates via a new `date=` kwarg on the file's `_git` helper. + +**Next pending task** — `136_force_push_reset/02_e2e_force_push_resync.md` (dedicated Playwright E2E). diff --git a/.agents/reports/136_force_push_reset/136_force_push_reset__01_ff_or_reset.a1.validate b/.agents/reports/136_force_push_reset/136_force_push_reset__01_ff_or_reset.a1.validate new file mode 100644 index 0000000..85e1feb --- /dev/null +++ b/.agents/reports/136_force_push_reset/136_force_push_reset__01_ff_or_reset.a1.validate @@ -0,0 +1,116 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 11%] +........................................................................ [ 13%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 31%] +........................................................................ [ 33%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 69%] +........................................................................ [ 71%] +........................................................................ [ 73%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 98%] +............................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 268 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 179 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 256 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 133 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 412 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 56 0 100% +app/rag/importer.py 595 9 98% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 109 0 100% +app/rag/prompts.py 144 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 49 0 100% +app/schemas.py 422 0 100% +-------------------------------------------------- +TOTAL 5334 22 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/136_force_push_reset/136_force_push_reset__02_e2e_force_push_resync.a1.err b/.agents/reports/136_force_push_reset/136_force_push_reset__02_e2e_force_push_resync.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/136_force_push_reset/136_force_push_reset__02_e2e_force_push_resync.a1.md b/.agents/reports/136_force_push_reset/136_force_push_reset__02_e2e_force_push_resync.a1.md new file mode 100644 index 0000000..8cc7c02 --- /dev/null +++ b/.agents/reports/136_force_push_reset/136_force_push_reset__02_e2e_force_push_resync.a1.md @@ -0,0 +1,12 @@ +All green. Final report: + +**Phase 136 task 02 — E2E `tests/e2e/test_force_push_resync.py` (new, dedicated Playwright suite)** + +- **Implemented:** module app (port `E2E_APP_PORT_FPPUSH`=8135, `BOR_GIT_SOURCES=file://`, dedicated `BOR_SOURCES_DIR`); 1-branch/1-commit fixture repo (`notes/doc.md`, `RESE-FPPUSH-V1-5e8a`); `_force_push_new_head` (orphan-root rewrite via `update-ref -d` + recreate, asserts unrelated history via `merge-base` and leaves exactly one branch); `_wait_sync_state` (terminal poll `{"success","failed"}` + `#sync-label` settle + stale-terminal freshness gate); 120 s wall guard. +- **Test 1** (owner's exact UI flow): run 1 "1 added" + catalog row → force push V2 → run 2 `state=="success"` (not `failed`), banner hidden, no modal, "0 added · 1 updated", document modal serves `RESE-FPPUSH-V2-2b7f` → run 3 "0 added · 1 unchanged". +- **Test 2** (negative pin, task's PREFERRED direct-API leg as its own test): fresh V3 force push → `POST /api/sync` 202 → poll → `success`, `added==1`, V3 sentinel in the stored document. +- **Teeth verified:** with `scripts/git_sync.py` reverted to the pre-phase-136 (HEAD) version, run 2 lands `state: 'failed'` and the test fails (`assert 'failed' == 'success'`); restored (byte-identical to the verified phase-136 version) → green. +- **Notable decision:** the UI's 2 s poll loop starts only on a click or a boot-while-running, so an API-triggered run is never adopted visually — test 2 therefore asserts only the status endpoint + DB (`settle_ui=False`), documented in the docstrings (house rule: the status object is authoritative; the `test_sync_button` API-trigger pattern). +- **Results:** `uv run pytest tests/e2e/test_force_push_resync.py -v --no-cov` → **2 passed in 10.6 s** (isolation gate); `uv run pytest --cov=app --cov-report=term-missing` → **3231 passed**, `app/` coverage **99%** (>90%); `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors/0 warnings. +- **Docs:** no doc impact (test-only task; README already documents the force-push behavior from task 01; no README/AGENTS.md changes needed). +- **Next pending task:** none in phase 136 (task 02 is its last); next phase in `todo/` is `137_pdf_image_context`. diff --git a/.agents/reports/136_force_push_reset/136_force_push_reset__02_e2e_force_push_resync.a1.validate b/.agents/reports/136_force_push_reset/136_force_push_reset__02_e2e_force_push_resync.a1.validate new file mode 100644 index 0000000..85e1feb --- /dev/null +++ b/.agents/reports/136_force_push_reset/136_force_push_reset__02_e2e_force_push_resync.a1.validate @@ -0,0 +1,116 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 11%] +........................................................................ [ 13%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 20%] +........................................................................ [ 22%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 31%] +........................................................................ [ 33%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 69%] +........................................................................ [ 71%] +........................................................................ [ 73%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 98%] +............................................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 268 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 179 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 256 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 133 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 412 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 56 0 100% +app/rag/importer.py 595 9 98% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 109 0 100% +app/rag/prompts.py 144 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 49 0 100% +app/schemas.py 422 0 100% +-------------------------------------------------- +TOTAL 5334 22 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/README.md b/README.md index b1c1abb..b9f51d1 100644 --- a/README.md +++ b/README.md @@ -258,13 +258,20 @@ BOR_SOURCES_DIR=~/bor-sources # default; each repo lands in // ` + `git merge --ff-only FETCH_HEAD`), and a checkout sitting on a *different* branch fails the sync loudly with a remove-and- re-add message (never a silent switch). Rows without a branch — - every pre-existing row and the env-fallback rows — keep the default - branch with byte-identical clone/pull behavior. -- Every run **clones** (first time, full history) or **pulls** - (`git pull --ff-only`) each repo, then indexes the checkout. A legacy - shallow checkout is unshallowed once on its first post-phase-107 sync - (`git fetch --unshallow` — the one-time self-heal) so commit dates - stay real. + every pre-existing row and the env-fallback rows — track the + checkout's default branch (byte-identical clone behavior). +- Every run **clones** (first time, full history) or brings an existing + checkout to the **head of the tracked branch** (phase 136: `git fetch + origin ` + `git merge --ff-only FETCH_HEAD`) and then indexes + the checkout. If the remote was **force-pushed** — a fast-forward is + impossible — the checkout is reset hard to that head with one loud + log line **instead of failing the sync**: the checkout is a cache of + the remote head, and the KB is rebuilt from it on the same run, so a + force push is a logged, self-healing event. A genuinely broken fetch + (dead network, a branch deleted upstream) still fails the sync + loudly. A legacy shallow checkout is unshallowed once on its first + post-phase-107 sync (`git fetch --unshallow` — the one-time + self-heal) so commit dates stay real. - A failed sync aborts the run — no partial junk. ### Archive upload sources diff --git a/scripts/git_sync.py b/scripts/git_sync.py index 8284532..a62b2dc 100644 --- a/scripts/git_sync.py +++ b/scripts/git_sync.py @@ -4,14 +4,23 @@ clone_or_pull(url, dest, branch=None) clones ``url`` into ``dest`` with FULL history the first time — ``git clone --branch `` 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 `` + ``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 `` (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 `` + -``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 +`` + ``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 `` + ``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 - `` + ``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 `` — 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 + (force push) — reset → to + origin/`` — 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) diff --git a/tests/e2e/test_force_push_resync.py b/tests/e2e/test_force_push_resync.py new file mode 100644 index 0000000..d32de23 --- /dev/null +++ b/tests/e2e/test_force_push_resync.py @@ -0,0 +1,586 @@ +"""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; ``#sync-result`` shows + "0 added · 1 updated" 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, +) + +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. + expect(page.locator("#sync-result")).to_have_text("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). + expect(page.locator("#sync-result")).to_have_text("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). + expect(page.locator("#sync-result")).to_have_text("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 diff --git a/tests/integration/test_import_docs_git.py b/tests/integration/test_import_docs_git.py index 6869f05..ec61478 100644 --- a/tests/integration/test_import_docs_git.py +++ b/tests/integration/test_import_docs_git.py @@ -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/``'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/") 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 diff --git a/tests/unit/test_git_sync.py b/tests/unit/test_git_sync.py index 929c251..dbd8c9e 100644 --- a/tests/unit/test_git_sync.py +++ b/tests/unit/test_git_sync.py @@ -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 `` → ``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 `` (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 `` + ``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 `` → + ``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/"), 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)