phase: 136_force_push_reset
Everything is verified green and consistent. Final report: **Phase 136 — final verification pass (all tasks already complete in `complete/136_force_push_reset/`):** - **Verified implemented:** unified ff-or-reset update sequence in `scripts/git_sync.py::clone_or_pull` (phase-135 HEAD guard → shallow self-heal → `fetch origin <ref>` → `merge --ff-only` → on non-ff: SHA capture + `reset --hard FETCH_HEAD` + one WARNING line, returns normally); detached-HEAD fail-loud; `branch=None` legacy `git pull` deliberately gone - **Completion criteria — all met:** 1. ✅ Force push no longer fails sync (integration `test_clone_or_pull_force_push_resets_to_the_new_head` + API-seam `test_sync_pipeline_survives_a_force_push` + E2E run 2 asserts `success` and KB serves the V2 sentinel) 2. ✅ Unrelated-history force push handled by the same reset (`test_clone_or_pull_unrelated_history_force_push_resets`) 3. ✅ Broken fetch still fails loud (fetch-failure unit pins raise with no merge/reset argv; `test_main_git_failure_aborts_before_import`) 4. ✅ WARNING line pinned: one line, 7-char SHAs, checkout path, `origin/<ref>`, no URL (unit exact-string pin + integration caplog pin) 5. ✅ `uv run pytest --cov=app --cov-report=term-missing` → **3231 passed, app/ 99%**; `uv run pytest tests/e2e/test_force_push_resync.py -v --no-cov` → **2 passed in isolation** (10.6 s) 6. ✅ Phase-135 pins preserved (mismatch guard, `--branch` clone argv, `branch=None` fresh-clone argv all green in the 53-test run) - **Test/lint results:** `uv run pytest tests/unit/test_git_sync.py tests/integration/test_import_docs_git.py -v` → 53 passed; full suite 3231 passed (72 s); `uv run ruff check . && uv run pyright` → clean (0 errors) - **Documentation:** README.md git-sources section already updated this phase to the ff-or-reset contract; all `.env.example` vars documented; pages table matches router; AGENTS.md rules and quick-reference commands verified accurate (rule 5 correctly documents the phase-100 §7.1 supersession) — **no changes needed** - **Notable:** E2E "teeth" verification (fails on pre-phase code) is recorded in the task report; nothing was committed per harness protocol - **Next pending phase:** `todo/137_pdf_image_context`
This commit is contained in:
@@ -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 <ref>` → `merge --ff-only` → on non-ff: SHA capture + `reset --hard FETCH_HEAD` + one WARNING line, returns normally); detached-HEAD fail-loud; `branch=None` legacy `git pull` deliberately gone
|
||||
- **Completion criteria — all met:**
|
||||
1. ✅ Force push no longer fails sync (integration `test_clone_or_pull_force_push_resets_to_the_new_head` + API-seam `test_sync_pipeline_survives_a_force_push` + E2E run 2 asserts `success` and KB serves the V2 sentinel)
|
||||
2. ✅ Unrelated-history force push handled by the same reset (`test_clone_or_pull_unrelated_history_force_push_resets`)
|
||||
3. ✅ Broken fetch still fails loud (fetch-failure unit pins raise with no merge/reset argv; `test_main_git_failure_aborts_before_import`)
|
||||
4. ✅ WARNING line pinned: one line, 7-char SHAs, checkout path, `origin/<ref>`, no URL (unit exact-string pin + integration caplog pin)
|
||||
5. ✅ `uv run pytest --cov=app --cov-report=term-missing` → **3231 passed, app/ 99%**; `uv run pytest tests/e2e/test_force_push_resync.py -v --no-cov` → **2 passed in isolation** (10.6 s)
|
||||
6. ✅ Phase-135 pins preserved (mismatch guard, `--branch` clone argv, `branch=None` fresh-clone argv all green in the 53-test run)
|
||||
- **Test/lint results:** `uv run pytest tests/unit/test_git_sync.py tests/integration/test_import_docs_git.py -v` → 53 passed; full suite 3231 passed (72 s); `uv run ruff check . && uv run pyright` → clean (0 errors)
|
||||
- **Documentation:** README.md git-sources section already updated this phase to the ff-or-reset contract; all `.env.example` vars documented; pages table matches router; AGENTS.md rules and quick-reference commands verified accurate (rule 5 correctly documents the phase-100 §7.1 supersession) — **no changes needed**
|
||||
- **Notable:** E2E "teeth" verification (fails on pre-phase code) is recorded in the task report; nothing was committed per harness protocol
|
||||
- **Next pending phase:** `todo/137_pdf_image_context`
|
||||
@@ -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
|
||||
@@ -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 <ref>` (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/<ref>`, 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).
|
||||
+116
@@ -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
|
||||
+12
@@ -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://<fixture>`, 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`.
|
||||
+116
@@ -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
|
||||
@@ -258,13 +258,20 @@ BOR_SOURCES_DIR=~/bor-sources # default; each repo lands in <dir>/<repo-name>/
|
||||
<branch>` + `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 <branch>` + `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
|
||||
|
||||
+143
-68
@@ -4,14 +4,23 @@ clone_or_pull(url, dest, branch=None) clones ``url`` into ``dest`` with
|
||||
FULL history the first time — ``git clone --branch <b>`` when a branch
|
||||
is pinned (phase 135: the source's branch is checked out from the
|
||||
first commit), plain ``git clone`` when not — or, for an existing
|
||||
checkout, runs the branch guard first when a branch is pinned (a
|
||||
checkout on any other branch fails loudly — the source must be removed
|
||||
and re-added to switch, phase 135), unshallows a shallow one first
|
||||
(``git fetch --unshallow``, the one-time self-heal), and then
|
||||
fast-forwards it — ``git pull --ff-only`` with no branch, or the
|
||||
single-fetch ``git fetch origin <b>`` + ``git merge --ff-only
|
||||
FETCH_HEAD`` of the pinned ref with one (``branch=None`` keeps the
|
||||
pre-phase argv byte-identical — the unit argv pins are the proof).
|
||||
checkout, brings it to the head of the tracked ref with the UNIFIED
|
||||
ff-or-reset sequence (phase 136 — it replaces both the legacy
|
||||
``git pull --ff-only`` and phase 135's fetch + ff-merge pair): the
|
||||
phase-135 HEAD guard first when a branch is pinned (a checkout on any
|
||||
other branch fails loudly — the source must be removed and re-added
|
||||
to switch), the tracked ref — the pinned branch, or the checkout's
|
||||
own branch when ``branch=None`` (a detached HEAD fails loudly, D12) —
|
||||
unshallows a shallow one first (``git fetch --unshallow``, the
|
||||
one-time self-heal), ``git fetch origin <ref>`` (a fetch FAILURE
|
||||
still fails loudly), and ``git merge --ff-only FETCH_HEAD`` — when
|
||||
the fast-forward is impossible (a force push / any divergent remote
|
||||
head) the checkout is ``git reset --hard FETCH_HEAD`` to the ref's
|
||||
head with ONE loud WARNING line and the function returns normally
|
||||
(a force push is a logged, self-healing event, NEVER a failed sync —
|
||||
the checkout is a cache of the remote head; the legacy ``git pull
|
||||
--ff-only`` is deliberately gone from ``branch=None``: every row
|
||||
gets the ff-or-reset update).
|
||||
|
||||
Auth: nothing special — an ``https://…`` URL uses the OS credential
|
||||
helper / prompts; a ``git@host:repo.git`` URL uses the machine's SSH key.
|
||||
@@ -21,14 +30,17 @@ used.
|
||||
This module is the only place the ``git`` CLI is invoked (A11: stdlib
|
||||
``subprocess`` only, no new packages) — every git command goes through
|
||||
:func:`run_git`: the clone (with ``--branch`` when a branch is
|
||||
pinned, phase 135), the branch guard's ``git rev-parse --abbrev-ref
|
||||
HEAD`` probe, the shallow probe, the one-time ``git fetch
|
||||
--unshallow``, the pinned branch's ``git fetch origin <b>`` +
|
||||
``git merge --ff-only FETCH_HEAD`` pair, and the ``git pull
|
||||
--ff-only`` in :func:`clone_or_pull`; the per-file last-commit-date
|
||||
walk in :func:`file_commit_dates` (phase 106 — it walks the
|
||||
checkout's own log, so a branch checkout yields the BRANCH's history:
|
||||
the head-of-branch semantics phase 135 pins, untouched); and the
|
||||
pinned, phase 135), the branch guard's / ref-resolution's ``git
|
||||
rev-parse --abbrev-ref HEAD`` probe, the shallow probe, the one-time
|
||||
``git fetch --unshallow``, the unified update's ``git fetch origin
|
||||
<ref>`` + ``git merge --ff-only FETCH_HEAD`` pair, and the phase-136
|
||||
force-push fallback's ``git rev-parse HEAD`` / ``git rev-parse
|
||||
FETCH_HEAD`` capture + ``git reset --hard FETCH_HEAD`` in
|
||||
:func:`clone_or_pull`; the per-file last-commit-date walk in
|
||||
:func:`file_commit_dates` (phase 106 — it walks the checkout's own
|
||||
log, so a branch checkout yields the BRANCH's history: the
|
||||
head-of-branch semantics phase 135 pins, untouched — a post-reset
|
||||
checkout yields the NEW head's dates, the same semantics); and the
|
||||
docs-push sequence in :mod:`app.core.docs_push` (phase 59).
|
||||
|
||||
Per-file last-commit dates (phase 106 D2; phase 107 D11 supersedes
|
||||
@@ -42,9 +54,18 @@ gone; ``--branch`` clones, phase 135, included), and an existing
|
||||
shallow checkout (made pre-phase 107 — i.e. every deployed one,
|
||||
live + dev included) is probed with ``git
|
||||
rev-parse --is-shallow-repository`` and, while shallow, self-healed
|
||||
with the ONE-TIME ``git fetch --unshallow`` before the usual ``git
|
||||
pull --ff-only`` (no branch) / ``git merge --ff-only FETCH_HEAD``
|
||||
(pinned branch) — no re-clone;
|
||||
with the ONE-TIME ``git fetch --unshallow`` before the unified
|
||||
update's ``git fetch origin <ref>`` + ``git merge --ff-only
|
||||
FETCH_HEAD`` (+ the phase-136 ``git reset --hard FETCH_HEAD``
|
||||
fallback on a non-ff merge) — no re-clone;
|
||||
* D11/D12, extended by phase 136 (the force-push contract): a
|
||||
non-ff merge resets to the ref's head instead of failing — the
|
||||
checkout is a cache of the remote head, and "always the head of
|
||||
the branch" is the invariant ``clone_or_pull`` enforces. A force
|
||||
push (or any divergent remote head) is a LOGGED, SELF-HEALING
|
||||
event — one WARNING line, then the run continues — never a failed
|
||||
sync; everything else still fails loud per D12 (a fetch failure,
|
||||
a broken probe/unshallow, a detached HEAD, a branch mismatch);
|
||||
* on a full-history checkout :func:`file_commit_dates` yields the
|
||||
TRUE per-file last-commit date for ALL git sources. The old
|
||||
shallow-clone behavior was the bug the owner reported 2026-09-16:
|
||||
@@ -73,7 +94,8 @@ class GitSyncError(RuntimeError):
|
||||
|
||||
|
||||
def clone_or_pull(url: str, dest: Path | str, branch: str | None = None) -> Path:
|
||||
"""Clone ``url`` into ``dest`` (full history, first run) or fast-forward it.
|
||||
"""Clone ``url`` into ``dest`` (full history, first run) or bring the
|
||||
existing checkout to the tracked ref's head (ff-or-reset, phase 136).
|
||||
|
||||
- dest without a ``.git`` (or absent) → ``git clone url dest`` (full
|
||||
history for every transport — phase 107 D11: no ``--depth``, so a
|
||||
@@ -82,35 +104,56 @@ def clone_or_pull(url: str, dest: Path | str, branch: str | None = None) -> Path
|
||||
dest`` (phase 135: the source's branch is checked out from the
|
||||
first commit — no post-clone checkout; ``branch=None`` keeps the
|
||||
two-argument argv byte-identical).
|
||||
- dest with a ``.git`` and no ``branch`` → probe ``git rev-parse
|
||||
--is-shallow-repository``; while shallow, ``git fetch
|
||||
--unshallow`` (the ONE-TIME self-heal for checkouts made shallow
|
||||
pre-phase 107 — the next sync of a deployed checkout becomes
|
||||
full-history without a re-clone), then ``git pull --ff-only``
|
||||
(refuses to merge unrelated histories — a broken checkout fails
|
||||
loudly rather than producing a dirty index).
|
||||
- dest with a ``.git`` and a pinned ``branch`` (phase 135), in
|
||||
order: the HEAD GUARD first — ``git rev-parse --abbrev-ref
|
||||
HEAD``; a checkout sitting on any other branch raises the
|
||||
remove-and-re-add :class:`GitSyncError` (naming the checkout and
|
||||
BOTH branch names — never the URL, so no credential exposure)
|
||||
BEFORE any fetch or merge (no silent branch switch: the checkout
|
||||
may serve other rows' documents, and a switch would silently
|
||||
re-map the source's file set); a matching checkout then runs the
|
||||
SAME shallow probe + one-time unshallow self-heal, and pulls the
|
||||
pinned ref with the single-fetch sequence ``git fetch origin
|
||||
<b>`` + ``git merge --ff-only FETCH_HEAD`` (a non-ff merge still
|
||||
fails loudly HERE — the force-push reset fallback is phase 136's
|
||||
extension of this sequence, not this phase's).
|
||||
- dest with a ``.git`` (ANY ``branch``) → the UNIFIED update
|
||||
sequence (phase 136 — it replaces both the legacy
|
||||
``git pull --ff-only`` and phase 135's fetch + ff-merge pair), in
|
||||
order:
|
||||
1. the tracked REF: a pinned ``branch`` keeps the HEAD GUARD
|
||||
(phase 135, UNCHANGED and first — ``git rev-parse
|
||||
--abbrev-ref HEAD``; a checkout sitting on any other branch
|
||||
raises the remove-and-re-add :class:`GitSyncError`, naming
|
||||
the checkout and BOTH branch names — never the URL, so no
|
||||
credential exposure — BEFORE any update; no silent branch
|
||||
switch, since the checkout may serve other rows' documents)
|
||||
and ``ref = branch``; ``branch=None`` resolves the checkout's
|
||||
OWN branch (the pre-phase default-branch behavior) — and a
|
||||
DETACHED checkout (the result ``"HEAD"``) raises the
|
||||
remove-and-re-add :class:`GitSyncError` naming the checkout
|
||||
(D12 fail-loud: the app never CREATES a detached checkout —
|
||||
a fresh clone always lands on a branch — so this is a
|
||||
genuinely broken checkout, not a force-push case).
|
||||
2. the shallow probe + one-time ``git fetch --unshallow``
|
||||
self-heal (UNCHANGED — phase 107 D11).
|
||||
3. ``git fetch origin <ref>`` — a fetch FAILURE (network/auth,
|
||||
the branch gone from the remote, git missing) STILL raises
|
||||
:class:`GitSyncError` (a loud failure — a force push is NOT a
|
||||
fetch failure; the reset is scoped to the merge step only).
|
||||
4. try ``git merge --ff-only FETCH_HEAD``.
|
||||
5. ON MERGE FAILURE — the reset fallback (phase 136): capture
|
||||
``git rev-parse HEAD`` (old) and ``git rev-parse FETCH_HEAD``
|
||||
(new), run ``git reset --hard FETCH_HEAD``, log ONE WARNING
|
||||
line (PLAN §9: ``git sync: divergent remote head on <dest>
|
||||
(force push) — reset <old[:7]> → <new[:7]> to
|
||||
origin/<ref>`` — the checkout PATH, never the URL,
|
||||
credential-safety) carrying the merge's stderr for
|
||||
diagnosability, and RETURN the dest normally (the sync
|
||||
proceeds — a force push is a logged, self-healing event,
|
||||
NEVER a failed sync; the merge's stderr rides the log only —
|
||||
there is no user-facing error). The checkout is a CACHE of
|
||||
the remote head (the app owns it; the KB is rebuilt from it
|
||||
on the same run), so "always the head of the branch" is the
|
||||
invariant this sequence enforces.
|
||||
|
||||
Returns the destination path. Raises :class:`GitSyncError` when git is
|
||||
missing or a git invocation exits non-zero (with git's stderr in the
|
||||
message, so the caller can name the failing repo + reason). D12
|
||||
fail-loud: a failed probe/unshallow/pull/merge propagates exactly
|
||||
like a clone failure — never a silent fallback to tip dates or
|
||||
mtimes (a continued shallow checkout would silently re-serve the
|
||||
uniform tip date, i.e. the bug D11 fixes) and never a silent branch
|
||||
switch (phase 135 — the fail-loud guard, above).
|
||||
message, so the caller can name the failing repo + reason) — EXCEPT
|
||||
the non-ff merge, which takes the reset fallback of step 5 instead
|
||||
of failing (phase 136). D12 fail-loud still governs everything the
|
||||
reset does not cover: a failed probe/unshallow/fetch propagates
|
||||
exactly like a clone failure — never a silent fallback to tip
|
||||
dates or mtimes (a continued shallow checkout would silently
|
||||
re-serve the uniform tip date, i.e. the bug D11 fixes) and never a
|
||||
silent branch switch (phase 135 — the fail-loud guard, step 1).
|
||||
"""
|
||||
dest = Path(dest)
|
||||
if not dest.exists() or not (dest / ".git").exists():
|
||||
@@ -120,26 +163,58 @@ def clone_or_pull(url: str, dest: Path | str, branch: str | None = None) -> Path
|
||||
argv += ["--branch", branch]
|
||||
argv += [url, str(dest)]
|
||||
run_git(argv, cwd=dest.parent)
|
||||
elif branch is None:
|
||||
_unshallow_if_shallow(dest)
|
||||
run_git(["git", "pull", "--ff-only"], cwd=dest)
|
||||
else:
|
||||
# Phase 135 HEAD guard (first — before the probe, any fetch,
|
||||
# or any merge): a mismatched checkout fails loudly. The
|
||||
# message names the checkout and both branch names; it never
|
||||
# carries the URL (the only place a token could ride).
|
||||
current = run_git(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=dest).strip()
|
||||
if current != branch:
|
||||
raise GitSyncError(
|
||||
f"checkout {dest} is on branch '{current}' but the source "
|
||||
f"specifies branch '{branch}' — remove and re-add the "
|
||||
"source to switch branches"
|
||||
)
|
||||
# existing checkout (the fresh-clone path above is untouched):
|
||||
if branch is not None:
|
||||
# Phase 135 HEAD guard, UNCHANGED (runs first, before any
|
||||
# update): a mismatched checkout fails loudly. The message
|
||||
# names the checkout and both branch names; it never
|
||||
# carries the URL (the only place a token could ride).
|
||||
current = run_git(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=dest).strip()
|
||||
if current != branch:
|
||||
raise GitSyncError(
|
||||
f"checkout {dest} is on branch '{current}' but the source "
|
||||
f"specifies branch '{branch}' — remove and re-add the "
|
||||
"source to switch branches"
|
||||
)
|
||||
ref = branch
|
||||
else:
|
||||
# branch=None: the tracked ref is the checkout's OWN branch
|
||||
# (the pre-phase default-branch behavior). A detached HEAD
|
||||
# (the result "HEAD") is a genuinely broken checkout — the
|
||||
# app never creates one — and fails loud (D12), not a
|
||||
# force-push case.
|
||||
ref = run_git(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=dest).strip()
|
||||
if ref == "HEAD": # detached — the app never creates one (D12 fail-loud)
|
||||
raise GitSyncError(
|
||||
f"checkout {dest} is in a detached-HEAD state — remove and "
|
||||
"re-add the source to rebuild it"
|
||||
)
|
||||
_unshallow_if_shallow(dest)
|
||||
# The single-fetch ff pull of the pinned ref (phase 136
|
||||
# extends this sequence with its reset fallback).
|
||||
run_git(["git", "fetch", "origin", branch], cwd=dest)
|
||||
run_git(["git", "merge", "--ff-only", "FETCH_HEAD"], cwd=dest)
|
||||
# A fetch FAILURE (network/auth, the branch gone from the
|
||||
# remote, git missing) STILL raises — a force push is not a
|
||||
# fetch failure; the reset below is scoped to the merge step.
|
||||
run_git(["git", "fetch", "origin", ref], cwd=dest)
|
||||
try:
|
||||
run_git(["git", "merge", "--ff-only", "FETCH_HEAD"], cwd=dest)
|
||||
except GitSyncError as ff:
|
||||
# A divergent remote head (a force push, or any non-ff
|
||||
# state): the checkout is a cache of the remote head —
|
||||
# reset it to the tracked ref's head and continue. The ONE
|
||||
# WARNING line (PLAN §9) is the only observable side effect
|
||||
# besides the checkout state: no user-facing error, no
|
||||
# status change — the run continues. The merge's stderr
|
||||
# rides the log (diagnosability) but never a user-facing
|
||||
# error (there is no error). The log carries the checkout
|
||||
# PATH, never the URL (credential-safety).
|
||||
old = run_git(["git", "rev-parse", "HEAD"], cwd=dest).strip()
|
||||
new = run_git(["git", "rev-parse", "FETCH_HEAD"], cwd=dest).strip()
|
||||
run_git(["git", "reset", "--hard", "FETCH_HEAD"], cwd=dest)
|
||||
logger.warning(
|
||||
"git sync: divergent remote head on %s (force push) — reset %s → %s to "
|
||||
"origin/%s (%s)",
|
||||
dest, old[:7], new[:7], ref, ff,
|
||||
)
|
||||
return dest
|
||||
|
||||
|
||||
@@ -148,10 +223,10 @@ def _unshallow_if_shallow(dest: Path) -> None:
|
||||
|
||||
Probe ``git rev-parse --is-shallow-repository``; while shallow,
|
||||
``git fetch --unshallow`` (a pre-phase-107 deployed checkout
|
||||
becomes full-history on its next sync without a re-clone). BOTH
|
||||
pull paths of :func:`clone_or_pull` (no branch / pinned branch)
|
||||
run this before their pull — the argv sequence is the pre-phase
|
||||
one in both (the unit argv pins are the proof).
|
||||
becomes full-history on its next sync without a re-clone). The
|
||||
UNIFIED existing-checkout update of :func:`clone_or_pull` (phase
|
||||
136 — one sequence for both ``branch=None`` and a pinned branch)
|
||||
runs this before its fetch (the unit argv pins are the proof).
|
||||
"""
|
||||
shallow = (
|
||||
run_git(["git", "rev-parse", "--is-shallow-repository"], cwd=dest)
|
||||
|
||||
@@ -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/<branch>."
|
||||
|
||||
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://<fixture repo>`` (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
|
||||
@@ -33,25 +33,46 @@ and the ``_resolve_sources`` seam with a ``branch='release'`` row
|
||||
(importing file B, not file A) + an env-origin row without a branch
|
||||
(the default branch, byte-identical pre-phase behavior). Skipped, not
|
||||
failed, on a machine without the git CLI (the house pattern).
|
||||
|
||||
Phase 136 (the ff-or-reset) extends the REAL-git section: the owner's
|
||||
force-push repro (the origin's history REWRITTEN to a new root commit
|
||||
between two ``clone_or_pull`` runs — normal divergence AND fully
|
||||
unrelated history) now resets the checkout to the origin's new head
|
||||
instead of failing (the loud WARNING line pinned alongside), a
|
||||
branched row's force push lands ``origin/<branch>``'s head (main's
|
||||
content not involved), ``file_commit_dates`` over the post-reset
|
||||
checkout reflects the NEW head's dates, and the SYNC PIPELINE END-
|
||||
TO-END over the API seam (``POST /api/sync`` + the status poll — the
|
||||
seam ``test_sync_api.py`` exercises) lands ``success`` after a force
|
||||
push, with the checkout (and therefore the KB) holding the new head.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api import sync as sync_api
|
||||
from app.config import Settings
|
||||
from app.db import SessionLocal
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import GitSource
|
||||
from app.rag import git_sources as git_sources_resolver
|
||||
from app.rag.importer import ImportSummary
|
||||
from scripts import import_docs
|
||||
from scripts.git_sync import GitSyncError, clone_or_pull
|
||||
from scripts.git_sync import GitSyncError, clone_or_pull, file_commit_dates
|
||||
from tests.conftest import ADMIN_PASSWORD
|
||||
from tests.fakes import FakeEmbedder
|
||||
|
||||
|
||||
@@ -788,14 +809,22 @@ def two_branch_repo(tmp_path: Path) -> Path:
|
||||
return repo
|
||||
|
||||
|
||||
def _git(cwd: Path, *argv: str) -> None:
|
||||
def _git(cwd: Path, *argv: str, date: str | None = None) -> None:
|
||||
"""Run one git command for the test harness (fixture setup); a
|
||||
non-zero exit fails the fixture, not the test under test."""
|
||||
non-zero exit fails the fixture, not the test under test.
|
||||
|
||||
``date`` pins the commit date (``GIT_COMMITTER_DATE`` =
|
||||
``GIT_AUTHOR_DATE``) — deterministic per-file dates for the
|
||||
phase-136 ``file_commit_dates`` pin (scratch commits would
|
||||
otherwise share the wall clock's second)."""
|
||||
env = os.environ.copy()
|
||||
env["GIT_AUTHOR_NAME"] = "T"
|
||||
env["GIT_AUTHOR_EMAIL"] = "t@example.com"
|
||||
env["GIT_COMMITTER_NAME"] = "T"
|
||||
env["GIT_COMMITTER_EMAIL"] = "t@example.com"
|
||||
if date is not None:
|
||||
env["GIT_AUTHOR_DATE"] = date
|
||||
env["GIT_COMMITTER_DATE"] = date
|
||||
proc = subprocess.run(
|
||||
["git", *argv], cwd=cwd, env=env, capture_output=True, text=True, check=False
|
||||
)
|
||||
@@ -947,3 +976,358 @@ def test_resolve_sources_row_without_branch_clones_default_branch(
|
||||
assert sources == [root]
|
||||
assert (root / "a.md").is_file() # main's file — the default branch
|
||||
assert not (root / "b.md").exists()
|
||||
|
||||
|
||||
# --- phase 136: the ff-or-reset (real git, the force-push repro) -----------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def single_branch_repo(tmp_path: Path) -> Path:
|
||||
"""A real-git scratch origin on ``main`` with one file (``a.md``)
|
||||
(the phase-136 base shape — the owner's single-branch source;
|
||||
the root commit's date is pinned for the deterministic
|
||||
``file_commit_dates`` pin below)."""
|
||||
repo = tmp_path / "origin"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-q", "-b", "main")
|
||||
_git(repo, "config", "commit.gpgsign", "false")
|
||||
(repo / "a.md").write_text("# A\noriginal content\n", encoding="utf-8")
|
||||
_git(repo, "add", "a.md")
|
||||
_git(repo, "commit", "-q", "-m", "root: add a.md", date="2026-01-05T10:00:00+00:00")
|
||||
return repo
|
||||
|
||||
|
||||
def _force_push_new_root(
|
||||
origin: Path,
|
||||
files: dict[str, str | None],
|
||||
message: str,
|
||||
*,
|
||||
branch: str = "main",
|
||||
date: str | None = None,
|
||||
) -> None:
|
||||
"""Rewrite the origin's ``branch`` to a NEW ROOT commit (the
|
||||
phase-136 force push): ``git checkout --orphan`` + commit, so the
|
||||
branch now points at a root with NO shared ancestry with the old
|
||||
history — the strongest divergence, exactly what ``--ff-only``
|
||||
refuses. ``files`` maps name → new content (the orphan commit
|
||||
holds exactly that set — the old files are dropped)."""
|
||||
_git(origin, "checkout", "--orphan", "rewritten")
|
||||
for p in sorted(origin.iterdir()):
|
||||
if p.is_file() and p.name != ".git":
|
||||
p.unlink()
|
||||
for name, content in files.items():
|
||||
if content is not None:
|
||||
(origin / name).write_text(content, encoding="utf-8")
|
||||
_git(origin, "add", "-A")
|
||||
_git(origin, "commit", "-q", "-m", message, date=date)
|
||||
_git(origin, "branch", "-D", branch)
|
||||
_git(origin, "branch", "-m", "rewritten", branch)
|
||||
_git(origin, "checkout", "-q", "main")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not GIT, reason="git CLI not available")
|
||||
def test_clone_or_pull_normal_ff_updates_without_reset(
|
||||
tmp_path: Path, single_branch_repo: Path
|
||||
) -> None:
|
||||
"""Phase 136: a normal second commit (a true fast-forward) → the
|
||||
unified sequence ff-merges: the checkout's HEAD is the origin's
|
||||
head, the second commit's message is the HEAD subject, NO merge
|
||||
commit (a pure ff), and the working tree holds the new content —
|
||||
the reset tail never runs on a clean ff."""
|
||||
origin = single_branch_repo
|
||||
url = f"file://{origin}"
|
||||
dest = tmp_path / "bor" / "origin"
|
||||
|
||||
clone_or_pull(url, dest)
|
||||
(origin / "b.md").write_text("# B\nsecond commit\n", encoding="utf-8")
|
||||
_git(origin, "add", "b.md")
|
||||
_git(origin, "commit", "-q", "-m", "second: add b.md", date="2026-02-01T09:00:00+00:00")
|
||||
|
||||
clone_or_pull(url, dest)
|
||||
|
||||
dest_head = _git_out(dest, "rev-parse", "HEAD").strip()
|
||||
origin_head = _git_out(origin, "rev-parse", "main").strip()
|
||||
assert dest_head == origin_head
|
||||
subjects = _git_out(dest, "log", "--format=%s").splitlines()
|
||||
assert subjects[0] == "second: add b.md" # the second commit's message is the HEAD
|
||||
assert subjects == ["second: add b.md", "root: add a.md"] # pure ff: no merge commit
|
||||
assert (dest / "b.md").read_text(encoding="utf-8") == "# B\nsecond commit\n"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not GIT, reason="git CLI not available")
|
||||
def test_clone_or_pull_force_push_resets_to_the_new_head(
|
||||
tmp_path: Path, single_branch_repo: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Phase 136 (the owner's repro, TODO L5): after the first clone,
|
||||
the origin's history is REWRITTEN to a NEW ROOT commit (same
|
||||
file, new content — the branch now diverges; pre-phase, the next
|
||||
sync died on the non-ff ``git pull --ff-only`` → the whole sync
|
||||
landed ``failed``) → ``clone_or_pull`` NO LONGER raises: the
|
||||
checkout is reset to the origin's NEW head (the checkout's HEAD ==
|
||||
the origin's head), the working tree holds the NEW content, and
|
||||
ONE WARNING line (PLAN §9) is the only side effect — no
|
||||
user-facing error (the log carries the checkout path, never the
|
||||
URL)."""
|
||||
origin = single_branch_repo
|
||||
url = f"file://{origin}"
|
||||
dest = tmp_path / "bor" / "origin"
|
||||
new_content = "# A\nrewritten content\n"
|
||||
|
||||
clone_or_pull(url, dest)
|
||||
_force_push_new_root(
|
||||
origin, {"a.md": new_content}, "force: rewritten root",
|
||||
date="2026-03-10T12:00:00+00:00",
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="scripts.git_sync"):
|
||||
result = clone_or_pull(url, dest) # NO exception (the pre-phase break)
|
||||
|
||||
assert result == dest
|
||||
dest_head = _git_out(dest, "rev-parse", "HEAD").strip()
|
||||
origin_head = _git_out(origin, "rev-parse", "main").strip()
|
||||
assert dest_head == origin_head
|
||||
assert (dest / "a.md").read_text(encoding="utf-8") == new_content
|
||||
# ONE WARNING line: the checkout path + the tracked ref, never the URL.
|
||||
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
||||
assert len(warnings) == 1
|
||||
msg = warnings[0].getMessage()
|
||||
assert "git sync: divergent remote head on " in msg
|
||||
assert str(dest) in msg
|
||||
assert "to origin/main" in msg
|
||||
assert url not in msg # credential-safety: the log never carries the URL
|
||||
|
||||
|
||||
@pytest.mark.skipif(not GIT, reason="git CLI not available")
|
||||
def test_clone_or_pull_unrelated_history_force_push_resets(
|
||||
tmp_path: Path, single_branch_repo: Path
|
||||
) -> None:
|
||||
"""Phase 136: the rewritten origin has a DIFFERENT root commit
|
||||
with a fully UNRELATED tree (a different file, NO shared ancestry
|
||||
— the ff merge of an unrelated history is exactly what
|
||||
``--ff-only`` refuses) → the SAME reset path handles it: the
|
||||
checkout's HEAD is the origin's new head and the working tree IS
|
||||
the new tree (the old file GONE — the reset is a hard one, not a
|
||||
merge)."""
|
||||
origin = single_branch_repo
|
||||
url = f"file://{origin}"
|
||||
dest = tmp_path / "bor" / "origin"
|
||||
|
||||
clone_or_pull(url, dest)
|
||||
_force_push_new_root(
|
||||
origin, {"z.md": "# Z\nunrelated root\n"}, "force: unrelated root",
|
||||
date="2026-03-11T12:00:00+00:00",
|
||||
)
|
||||
|
||||
result = clone_or_pull(url, dest) # NO exception — the same reset path
|
||||
|
||||
assert result == dest
|
||||
dest_head = _git_out(dest, "rev-parse", "HEAD").strip()
|
||||
origin_head = _git_out(origin, "rev-parse", "main").strip()
|
||||
assert dest_head == origin_head
|
||||
assert (dest / "z.md").read_text(encoding="utf-8") == "# Z\nunrelated root\n"
|
||||
assert not (dest / "a.md").exists() # the hard reset dropped the old tree's file
|
||||
|
||||
|
||||
@pytest.mark.skipif(not GIT, reason="git CLI not available")
|
||||
def test_clone_or_pull_branched_force_push_resets_to_the_branch_head(
|
||||
tmp_path: Path, two_branch_repo: Path
|
||||
) -> None:
|
||||
"""Phase 136 + phase 135: a two-branch origin (the phase-135
|
||||
shape), a row-shaped call ``clone_or_pull(url, dest,
|
||||
"release")`` after a force push of ONLY ``release`` → the reset
|
||||
lands ``origin/release``'s head (the owner's verbatim "reset
|
||||
origin/<branch>") and ``main``'s content is not involved (file A
|
||||
is still absent — the checkout holds the branch's tree only)."""
|
||||
origin = two_branch_repo
|
||||
url = f"file://{origin}"
|
||||
dest = tmp_path / "bor" / "origin"
|
||||
forced = "# B\nrelease branch content FORCED\n"
|
||||
|
||||
clone_or_pull(url, dest, "release")
|
||||
_force_push_new_root(
|
||||
origin, {"b.md": forced}, "release: force-pushed root",
|
||||
branch="release", date="2026-03-15T08:00:00+00:00",
|
||||
)
|
||||
|
||||
clone_or_pull(url, dest, "release") # NO exception — the reset landed
|
||||
|
||||
assert _git_out(dest, "rev-parse", "--abbrev-ref", "HEAD").strip() == "release"
|
||||
dest_head = _git_out(dest, "rev-parse", "HEAD").strip()
|
||||
release_head = _git_out(origin, "rev-parse", "release").strip()
|
||||
assert dest_head == release_head
|
||||
assert (dest / "b.md").read_text(encoding="utf-8") == forced
|
||||
assert not (dest / "a.md").exists() # main's content is not involved
|
||||
|
||||
|
||||
@pytest.mark.skipif(not GIT, reason="git CLI not available")
|
||||
def test_file_commit_dates_after_reset_reflects_the_new_head(
|
||||
tmp_path: Path, single_branch_repo: Path
|
||||
) -> None:
|
||||
"""Phase 136: ``file_commit_dates`` over the post-reset checkout
|
||||
yields the NEW head's per-file dates — the new root commit's date
|
||||
is what the walk returns for its files (the head-of-branch dates
|
||||
the owner asked for: the KB is rebuilt from the reset checkout on
|
||||
the same run, so its document dates are the NEW history's, not
|
||||
the old one's)."""
|
||||
origin = single_branch_repo
|
||||
url = f"file://{origin}"
|
||||
dest = tmp_path / "bor" / "origin"
|
||||
|
||||
clone_or_pull(url, dest)
|
||||
old_dates = file_commit_dates(dest)
|
||||
assert old_dates["a.md"] == datetime.fromisoformat("2026-01-05T10:00:00+00:00")
|
||||
|
||||
_force_push_new_root(
|
||||
origin, {"a.md": "# A\nrewritten content\n"}, "force: rewritten root",
|
||||
date="2026-03-10T12:00:00+00:00",
|
||||
)
|
||||
clone_or_pull(url, dest)
|
||||
|
||||
new_dates = file_commit_dates(dest)
|
||||
assert set(new_dates) == {"a.md"} # the new tree's files only
|
||||
assert new_dates["a.md"] == datetime.fromisoformat("2026-03-10T12:00:00+00:00")
|
||||
assert new_dates["a.md"] != old_dates["a.md"] # the NEW head dates the file
|
||||
|
||||
|
||||
# --- phase 136: the sync pipeline end to end (the API seam) ---------------
|
||||
|
||||
|
||||
def _reset_sources_meta() -> None:
|
||||
"""Pin the single-row ``sources_meta`` counter to the
|
||||
migration-0010 seed (0) — the runner bumps it through its own
|
||||
short-lived ``SessionLocal`` (the ``test_sync_api`` pattern)."""
|
||||
session = SessionLocal()
|
||||
try:
|
||||
session.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1"))
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fresh_sync_state() -> Iterator[None]:
|
||||
"""Phase 136 (the API-seam pin): the sync runner's module-level
|
||||
status object + task + cancel event are process-global — reset
|
||||
them around the test (the ``test_sync_api`` autouse pattern, the
|
||||
full suite runs both files in one process)."""
|
||||
sync_api._status = sync_api.SyncStatus()
|
||||
sync_api._task = None
|
||||
sync_api._cancel_event = asyncio.Event()
|
||||
yield
|
||||
sync_api._status = sync_api.SyncStatus()
|
||||
sync_api._task = None
|
||||
sync_api._cancel_event = asyncio.Event()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def clean_sync_seam(db: Session) -> Iterator[None]:
|
||||
"""Phase 136 (the API-seam pin): the real sync runner writes
|
||||
global tables — ``git_sources`` (seeded), ``documents``/
|
||||
``chunks`` (the real import), and ``folder_summaries`` (the real
|
||||
generator) — truncated around the test; the ``sources_meta``
|
||||
counter is pinned to 0 before and after. Skips like the ``db``
|
||||
fixture when Postgres is down."""
|
||||
db.execute(text("TRUNCATE git_sources, chunks, documents, folder_summaries"))
|
||||
db.commit()
|
||||
_reset_sources_meta()
|
||||
yield
|
||||
db.execute(text("TRUNCATE git_sources, chunks, documents, folder_summaries"))
|
||||
db.commit()
|
||||
_reset_sources_meta()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sync_client() -> Iterator[TestClient]:
|
||||
"""Context-managed TestClient — one app event loop across
|
||||
requests (the background sync task must survive between the POST
|
||||
and the polls — the ``test_sync_api`` pattern; a TestClient
|
||||
without the context manager would cancel the task on request
|
||||
exit)."""
|
||||
with TestClient(fastapi_app) as client:
|
||||
yield client
|
||||
|
||||
|
||||
def _poll_sync(client: TestClient, want: str, timeout: float = 10.0) -> dict:
|
||||
"""Poll ``GET /api/sync/status`` until ``state == want``
|
||||
(terminal). Any state other than ``running`` before the deadline
|
||||
fails loudly — an unexpected ``failed`` must never be masked by
|
||||
the wait (the pre-phase force push landed exactly that)."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
body = client.get("/api/sync/status").json()
|
||||
if body["state"] == want:
|
||||
return body
|
||||
assert body["state"] == "running", (
|
||||
f"unexpected state {body['state']!r} while waiting for {want!r}: {body}"
|
||||
)
|
||||
time.sleep(0.05)
|
||||
raise AssertionError(f"sync did not reach {want!r} within {timeout}s")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not GIT, reason="git CLI not available")
|
||||
def test_sync_pipeline_survives_a_force_push(
|
||||
sync_client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
db: Session,
|
||||
tmp_path: Path,
|
||||
single_branch_repo: Path,
|
||||
fresh_sync_state: None,
|
||||
clean_sync_seam: None,
|
||||
) -> None:
|
||||
"""Phase 136, the SYNC PIPELINE END-TO-END (the seam
|
||||
``tests/integration/test_sync_api.py`` already exercises — the
|
||||
pre-phase force push landed the button sync in ``failed``; this
|
||||
test is the API-seam regression pin that it now lands
|
||||
``success``): a real-git ``file://`` origin + a seeded git row;
|
||||
sync #1 clones and imports the original content; the origin's
|
||||
history is REWRITTEN to a new root commit (the owner's exact
|
||||
repro); sync #2 → ``state == "success"`` (NOT ``failed``), the
|
||||
rewritten file is re-imported (``updated=1``), and the checkout
|
||||
(and therefore the KB) holds the NEW head. The REAL
|
||||
``clone_or_pull`` runs (no fake — the reset is what is under
|
||||
test) over the real import + real overview/folder-summary steps
|
||||
(deterministic in-process ``FakeEmbedder``, no network)."""
|
||||
origin = single_branch_repo
|
||||
url = f"file://{origin}"
|
||||
db.add(GitSource(url=url, kind="git"))
|
||||
db.commit()
|
||||
# The resolver's env fallback pinned empty (the DB row wins — the
|
||||
# dev ``.env`` never leaks in; the ``test_sync_api`` pattern).
|
||||
monkeypatch.setattr(
|
||||
git_sources_resolver,
|
||||
"get_settings",
|
||||
lambda: Settings(_env_file=None, git_sources=""), # pyright: ignore[reportCallIssue]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sync_api,
|
||||
"get_settings",
|
||||
lambda: Settings(_env_file=None, sources_dir=str(tmp_path / "bor")), # pyright: ignore[reportCallIssue]
|
||||
)
|
||||
monkeypatch.setattr(sync_api, "LLMClient", lambda: FakeEmbedder()) # probe passes against it
|
||||
|
||||
r = sync_client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
|
||||
|
||||
# Sync #1: the fresh clone imports the original content.
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
body1 = _poll_sync(sync_client, "success")
|
||||
assert body1["detail"]["added"] == 1
|
||||
|
||||
# The owner's repro: force push the origin (a NEW ROOT commit).
|
||||
new_content = "# A\nrewritten content\n"
|
||||
_force_push_new_root(origin, {"a.md": new_content}, "force: rewritten root")
|
||||
|
||||
# Sync #2: pre-phase this landed ``failed`` (the non-ff
|
||||
# ``git pull --ff-only`` died) — the reset is now self-healing
|
||||
# and the run SUCCEEDS.
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
body2 = _poll_sync(sync_client, "success") # NOT ``failed`` — the regression pin
|
||||
assert body2["error"] is None
|
||||
assert body2["detail"]["updated"] == 1 # the rewritten file is re-imported
|
||||
|
||||
# The checkout (and therefore the KB) holds the NEW head.
|
||||
root = tmp_path / "bor" / "origin"
|
||||
root_head = _git_out(root, "rev-parse", "HEAD").strip()
|
||||
origin_head = _git_out(origin, "rev-parse", "main").strip()
|
||||
assert root_head == origin_head
|
||||
assert (root / "a.md").read_text(encoding="utf-8") == new_content
|
||||
|
||||
+234
-65
@@ -1,19 +1,27 @@
|
||||
"""Unit tests: git clone/pull utility (phase 28; phase 107 D11/D12;
|
||||
phase 135 adds the branch pin — the argv matrix).
|
||||
phase 135 adds the branch pin; phase 136 replaces the existing-
|
||||
checkout update with the UNIFIED ff-or-reset sequence — the argv
|
||||
matrix).
|
||||
|
||||
``clone_or_pull`` dispatches to ``git clone`` (full history, fresh dest)
|
||||
or a shallow-probe + optional ``git fetch --unshallow`` +
|
||||
``git pull --ff-only`` (existing checkout) with ``subprocess`` fully
|
||||
mocked — the real git CLI is never invoked, so the tests run anywhere.
|
||||
Phase 135: with a pinned ``branch`` the fresh clone gains ``--branch``
|
||||
and the existing checkout runs the HEAD guard → probe → (unshallow) →
|
||||
``fetch origin <b>`` → ``merge --ff-only FETCH_HEAD`` sequence;
|
||||
``branch=None`` (and the two-argument call) keeps the pre-phase argv
|
||||
BYTE-IDENTICAL — the argv pins below are the regression gate for
|
||||
every existing deployment.
|
||||
or the UNIFIED existing-checkout update (phase 136 — one sequence for
|
||||
both branch shapes) with ``subprocess`` fully mocked — the real git
|
||||
CLI is never invoked, so the tests run anywhere: ref resolution
|
||||
(``rev-parse --abbrev-ref HEAD`` — the phase-135 HEAD guard for a
|
||||
pinned ``branch``, the checkout's own branch + the detached-HEAD
|
||||
fail-loud for ``branch=None``) → the shallow probe → optional
|
||||
``git fetch --unshallow`` (the one-time D11 self-heal) →
|
||||
``git fetch origin <ref>`` (a fetch failure STILL raises) →
|
||||
``git merge --ff-only FETCH_HEAD`` → on a non-ff merge: the
|
||||
``rev-parse HEAD`` / ``rev-parse FETCH_HEAD`` capture +
|
||||
``git reset --hard FETCH_HEAD`` + one WARNING line, then RETURN (a
|
||||
force push never fails a sync — phase 136's fix). The fresh-clone
|
||||
argv (with/without ``--branch``) is the pre-phase one — the argv pins
|
||||
below are the regression gate for every existing deployment.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
@@ -80,27 +88,45 @@ def test_clone_or_pull_clones_when_dest_has_no_git_dir(
|
||||
assert call["text"] is True
|
||||
|
||||
|
||||
def test_clone_or_pull_pulls_when_git_dir_exists(
|
||||
def test_clone_or_pull_existing_checkout_ff_updates_the_tracked_ref(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Steady state: dest/.git present and the probe says NOT shallow
|
||||
(``false``) → straight to ``git pull --ff-only`` in-place (one cheap
|
||||
local probe, no network fetch)."""
|
||||
"""Phase 136 steady state (``branch=None``): dest/.git present →
|
||||
the UNIFIED sequence — the checkout's OWN branch is resolved
|
||||
(``rev-parse --abbrev-ref HEAD`` → ``main``), the shallow probe
|
||||
says NOT shallow (``false``), and ``fetch origin main`` +
|
||||
``merge --ff-only FETCH_HEAD`` ff-update the ref (the legacy
|
||||
``git pull --ff-only`` is deliberately gone — every row gets the
|
||||
ff-or-reset update). A SUCCESSFUL merge leaves NO ``reset`` argv
|
||||
and NO ``rev-parse HEAD`` / ``rev-parse FETCH_HEAD`` capture pair
|
||||
(the capture is only on failure)."""
|
||||
dest = tmp_path / "homelab"
|
||||
(dest / ".git").mkdir(parents=True)
|
||||
calls = _fake_run(
|
||||
monkeypatch,
|
||||
responses={("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", "")},
|
||||
responses={
|
||||
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", ""),
|
||||
("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", ""),
|
||||
("git", "fetch", "origin", "main"): (0, "", ""),
|
||||
("git", "merge", "--ff-only", "FETCH_HEAD"): (0, "Already up to date.\n", ""),
|
||||
},
|
||||
)
|
||||
|
||||
result = clone_or_pull("https://github.com/user/homelab.git", dest)
|
||||
|
||||
assert result == dest
|
||||
assert len(calls) == 2
|
||||
assert calls[0]["argv"] == ["git", "rev-parse", "--is-shallow-repository"]
|
||||
assert calls[0]["cwd"] == dest
|
||||
assert calls[1]["argv"] == ["git", "pull", "--ff-only"]
|
||||
assert calls[1]["cwd"] == dest
|
||||
assert [call["argv"] for call in calls] == [
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
["git", "rev-parse", "--is-shallow-repository"],
|
||||
["git", "fetch", "origin", "main"],
|
||||
["git", "merge", "--ff-only", "FETCH_HEAD"],
|
||||
]
|
||||
for call in calls:
|
||||
assert call["cwd"] == dest
|
||||
# ff succeeded — the exact 4-step sequence above already excludes
|
||||
# the reset tail (and the rev-parse HEAD/FETCH_HEAD capture pair,
|
||||
# which is only on failure).
|
||||
assert ["git", "reset", "--hard", "FETCH_HEAD"] not in [c["argv"] for c in calls]
|
||||
|
||||
|
||||
def test_clone_or_pull_unshallows_existing_shallow_checkout(
|
||||
@@ -108,24 +134,33 @@ def test_clone_or_pull_unshallows_existing_shallow_checkout(
|
||||
) -> None:
|
||||
"""Existing shallow checkout (probe ``true`` — the one-time D11
|
||||
self-heal of deployed checkouts) → ``git fetch --unshallow``
|
||||
BEFORE the usual ``git pull --ff-only`` (order pinned), all in
|
||||
BETWEEN the ref-resolution probe and the unified update's
|
||||
``fetch origin <ref>`` + ``merge --ff-only FETCH_HEAD`` (order
|
||||
pinned — the self-heal still fires in front of the fetch), all in
|
||||
dest."""
|
||||
dest = tmp_path / "homelab"
|
||||
(dest / ".git").mkdir(parents=True)
|
||||
calls = _fake_run(
|
||||
monkeypatch,
|
||||
responses={
|
||||
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", ""),
|
||||
("git", "rev-parse", "--is-shallow-repository"): (0, "true\n", ""),
|
||||
("git", "fetch", "--unshallow"): (0, "Fetching full history\n", ""),
|
||||
("git", "fetch", "origin", "main"): (0, "", ""),
|
||||
("git", "merge", "--ff-only", "FETCH_HEAD"): (0, "", ""),
|
||||
},
|
||||
)
|
||||
|
||||
clone_or_pull("https://github.com/user/homelab.git", dest)
|
||||
|
||||
assert len(calls) == 3
|
||||
assert calls[0]["argv"] == ["git", "rev-parse", "--is-shallow-repository"]
|
||||
assert calls[1]["argv"] == ["git", "fetch", "--unshallow"]
|
||||
assert calls[2]["argv"] == ["git", "pull", "--ff-only"]
|
||||
assert len(calls) == 5
|
||||
assert [call["argv"] for call in calls] == [
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
["git", "rev-parse", "--is-shallow-repository"],
|
||||
["git", "fetch", "--unshallow"],
|
||||
["git", "fetch", "origin", "main"],
|
||||
["git", "merge", "--ff-only", "FETCH_HEAD"],
|
||||
]
|
||||
for call in calls:
|
||||
assert call["cwd"] == dest
|
||||
|
||||
@@ -134,14 +169,15 @@ def test_clone_or_pull_unshallow_failure_propagates(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""D12 fail-loud: a failed ``git fetch --unshallow`` raises
|
||||
:class:`GitSyncError` (with git's stderr) and the pull NEVER runs —
|
||||
continuing a failed self-heal would silently re-serve the uniform
|
||||
tip date (the bug D11 fixes)."""
|
||||
:class:`GitSyncError` (with git's stderr) and the update NEVER
|
||||
runs — continuing a failed self-heal would silently re-serve the
|
||||
uniform tip date (the bug D11 fixes)."""
|
||||
dest = tmp_path / "homelab"
|
||||
(dest / ".git").mkdir(parents=True)
|
||||
calls = _fake_run(
|
||||
monkeypatch,
|
||||
responses={
|
||||
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", ""),
|
||||
("git", "rev-parse", "--is-shallow-repository"): (0, "true\n", ""),
|
||||
("git", "fetch", "--unshallow"): (128, "", "fatal: could not fetch\n"),
|
||||
},
|
||||
@@ -153,9 +189,10 @@ def test_clone_or_pull_unshallow_failure_propagates(
|
||||
clone_or_pull("https://github.com/user/homelab.git", dest)
|
||||
|
||||
assert [call["argv"] for call in calls] == [
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
["git", "rev-parse", "--is-shallow-repository"],
|
||||
["git", "fetch", "--unshallow"],
|
||||
] # the pull never ran
|
||||
] # the fetch/merge/reset never ran
|
||||
|
||||
|
||||
def test_clone_or_pull_creates_missing_parent_before_clone(
|
||||
@@ -209,21 +246,36 @@ def test_failing_git_raises_error_carrying_stderr(
|
||||
):
|
||||
clone_or_pull("https://example.com/nope.git", dest)
|
||||
|
||||
# Same for the pull path (broken checkout, e.g. diverged history) —
|
||||
# the probe answers "false" (not shallow) so the pull runs.
|
||||
# Same for the unified update path — but the reset fallback is
|
||||
# scoped to the MERGE step: a genuinely broken FETCH (dead
|
||||
# network, the branch deleted upstream, git missing) STILL fails
|
||||
# loud, with no merge and no reset argv.
|
||||
(dest / ".git").mkdir(parents=True)
|
||||
_fake_run(
|
||||
calls = _fake_run(
|
||||
monkeypatch,
|
||||
returncode=1,
|
||||
stderr="error: cannot pull with rebase",
|
||||
responses={("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", "")},
|
||||
stderr="fatal: couldn't find remote ref main",
|
||||
responses={
|
||||
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", ""),
|
||||
("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", ""),
|
||||
("git", "fetch", "origin", "main"): (
|
||||
128, "", "fatal: couldn't find remote ref main\n"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
GitSyncError, match=r"git pull --ff-only failed \(exit 1\): error: cannot pull with rebase"
|
||||
GitSyncError,
|
||||
match=r"git fetch origin main failed \(exit 128\): fatal: couldn't find remote ref main",
|
||||
):
|
||||
clone_or_pull("https://example.com/homelab.git", dest)
|
||||
|
||||
assert [call["argv"] for call in calls] == [
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
["git", "rev-parse", "--is-shallow-repository"],
|
||||
["git", "fetch", "origin", "main"],
|
||||
] # the merge (and therefore any reset) never ran
|
||||
|
||||
|
||||
def test_missing_git_raises_named_error(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
@@ -248,48 +300,65 @@ def test_run_git_captures_and_returns_stdout(
|
||||
# --- phase 135: the branch pin (the argv matrix) ----------------------------
|
||||
|
||||
|
||||
def test_branch_none_argv_is_byte_identical_pre_phase_sequence(
|
||||
def test_branch_none_argv_pins_the_unified_phase_136_sequence(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The regression gate: ``branch=None`` (explicit, the call sites'
|
||||
form for every pre-phase-135 row) produces the EXACT pre-phase argv
|
||||
— fresh: one ``git clone`` without ``--branch``; existing (not
|
||||
shallow): probe → ``git pull --ff-only``; existing (shallow):
|
||||
probe → ``fetch --unshallow`` → ``git pull --ff-only``. A future
|
||||
refactor that rewrites the default path breaks this pin."""
|
||||
"""The ``branch=None`` argv pins (explicit ``None`` is the call
|
||||
sites' form for every row without a branch pin).
|
||||
|
||||
FRESH: one ``git clone`` without ``--branch`` — the pre-phase argv,
|
||||
UNCHANGED (the regression gate for every existing deployment).
|
||||
|
||||
EXISTING: the legacy ``git pull --ff-only`` is deliberately GONE —
|
||||
that is the fix (phase 136): every row, branched or not, gets the
|
||||
ff-or-reset update. The pinned sequence: ref resolution (``rev-
|
||||
parse --abbrev-ref HEAD``) → the shallow probe → (``fetch
|
||||
--unshallow`` while shallow) → ``fetch origin <branch>`` →
|
||||
``merge --ff-only FETCH_HEAD``. A future refactor that
|
||||
re-introduces ``git pull`` (or re-splits the two branch shapes)
|
||||
breaks this pin."""
|
||||
url = "https://github.com/user/homelab.git"
|
||||
dest = tmp_path / "homelab"
|
||||
|
||||
# fresh (dest absent)
|
||||
# fresh (dest absent) — the pre-phase argv, byte-identical
|
||||
calls = _fake_run(monkeypatch)
|
||||
clone_or_pull(url, dest, None)
|
||||
assert [call["argv"] for call in calls] == [["git", "clone", url, str(dest)]]
|
||||
|
||||
# existing, not shallow
|
||||
# existing, not shallow — the unified ff-or-reset sequence
|
||||
(dest / ".git").mkdir(parents=True)
|
||||
calls = _fake_run(
|
||||
monkeypatch,
|
||||
responses={("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", "")},
|
||||
responses={
|
||||
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", ""),
|
||||
("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", ""),
|
||||
},
|
||||
)
|
||||
clone_or_pull(url, dest, None)
|
||||
assert [call["argv"] for call in calls] == [
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
["git", "rev-parse", "--is-shallow-repository"],
|
||||
["git", "pull", "--ff-only"],
|
||||
["git", "fetch", "origin", "main"],
|
||||
["git", "merge", "--ff-only", "FETCH_HEAD"],
|
||||
]
|
||||
|
||||
# existing, shallow (the one-time D11 self-heal between probe and pull)
|
||||
# existing, shallow (the one-time D11 self-heal between probe
|
||||
# and fetch — still in front of the fetch)
|
||||
calls = _fake_run(
|
||||
monkeypatch,
|
||||
responses={
|
||||
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", ""),
|
||||
("git", "rev-parse", "--is-shallow-repository"): (0, "true\n", ""),
|
||||
("git", "fetch", "--unshallow"): (0, "", ""),
|
||||
},
|
||||
)
|
||||
clone_or_pull(url, dest, None)
|
||||
assert [call["argv"] for call in calls] == [
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
["git", "rev-parse", "--is-shallow-repository"],
|
||||
["git", "fetch", "--unshallow"],
|
||||
["git", "pull", "--ff-only"],
|
||||
["git", "fetch", "origin", "main"],
|
||||
["git", "merge", "--ff-only", "FETCH_HEAD"],
|
||||
]
|
||||
|
||||
|
||||
@@ -466,14 +535,23 @@ def test_existing_checkout_branch_fetch_failure_propagates_before_merge(
|
||||
] # the merge never ran
|
||||
|
||||
|
||||
def test_existing_checkout_branch_non_ff_merge_fails_loud(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
def test_existing_checkout_branch_non_ff_merge_resets_to_the_ref_head(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Phase 135: a non-ff ``merge --ff-only FETCH_HEAD`` (diverged /
|
||||
force-pushed origin) fails loudly HERE with git's stderr — the
|
||||
reset fallback is phase 136, not a silent fix in this phase."""
|
||||
"""Phase 136: a non-ff ``merge --ff-only FETCH_HEAD`` (diverged /
|
||||
force-pushed origin) NO LONGER fails the sync — the sequence
|
||||
captures the old/new SHAs, resets the checkout hard to
|
||||
``FETCH_HEAD`` (the branch's ref — the owner's verbatim "reset
|
||||
origin/<branch>"), logs ONE WARNING line (PLAN §9), and RETURNS
|
||||
the dest (no raise — the run continues). The WARNING is the only
|
||||
observable side effect besides the checkout state: no user-facing
|
||||
error, no status change."""
|
||||
url = "https://github.com/user/homelab.git"
|
||||
dest = tmp_path / "homelab"
|
||||
(dest / ".git").mkdir(parents=True)
|
||||
old, new = "a" * 40, "b" * 40
|
||||
calls = _fake_run(
|
||||
monkeypatch,
|
||||
responses={
|
||||
@@ -481,20 +559,111 @@ def test_existing_checkout_branch_non_ff_merge_fails_loud(
|
||||
("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", ""),
|
||||
("git", "fetch", "origin", "release"): (0, "", ""),
|
||||
("git", "merge", "--ff-only", "FETCH_HEAD"): (
|
||||
1,
|
||||
"",
|
||||
"fatal: Not possible to fast-forward, aborting.\n",
|
||||
1, "", "fatal: Not possible to fast-forward, aborting.\n"
|
||||
),
|
||||
("git", "rev-parse", "HEAD"): (0, old + "\n", ""),
|
||||
("git", "rev-parse", "FETCH_HEAD"): (0, new + "\n", ""),
|
||||
("git", "reset", "--hard", "FETCH_HEAD"): (0, "", ""),
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
GitSyncError,
|
||||
match=(
|
||||
r"git merge --ff-only FETCH_HEAD failed \(exit 1\): "
|
||||
r"fatal: Not possible to fast-forward"
|
||||
),
|
||||
):
|
||||
clone_or_pull("https://github.com/user/homelab.git", dest, "release")
|
||||
with caplog.at_level(logging.WARNING, logger="scripts.git_sync"):
|
||||
result = clone_or_pull(url, dest, "release") # NO raise — the run continues
|
||||
|
||||
assert len(calls) == 4 # all four argv ran; the merge failed loud
|
||||
assert result == dest
|
||||
assert [call["argv"] for call in calls] == [
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
["git", "rev-parse", "--is-shallow-repository"],
|
||||
["git", "fetch", "origin", "release"],
|
||||
["git", "merge", "--ff-only", "FETCH_HEAD"],
|
||||
["git", "rev-parse", "HEAD"],
|
||||
["git", "rev-parse", "FETCH_HEAD"],
|
||||
["git", "reset", "--hard", "FETCH_HEAD"],
|
||||
]
|
||||
for call in calls:
|
||||
assert call["cwd"] == dest
|
||||
# ONE WARNING line (PLAN §9): the checkout PATH (never the URL —
|
||||
# credential-safety), both short SHAs, the tracked ref, and the
|
||||
# merge's stderr (diagnosability, log-only — never user-facing).
|
||||
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
||||
assert len(warnings) == 1
|
||||
msg = warnings[0].getMessage()
|
||||
assert msg == (
|
||||
f"git sync: divergent remote head on {dest} (force push) — reset "
|
||||
f"{old[:7]} → {new[:7]} to origin/release "
|
||||
"(git merge --ff-only FETCH_HEAD failed (exit 1): "
|
||||
"fatal: Not possible to fast-forward, aborting.)"
|
||||
)
|
||||
assert url not in msg # credential-safety: never the URL
|
||||
assert "github" not in msg
|
||||
|
||||
|
||||
def test_null_branch_non_ff_merge_resets_and_returns(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Phase 136 (``branch=None`` — the legacy ``git pull --ff-only``
|
||||
path, the owner's reported break): a non-ff merge of the
|
||||
checkout's OWN branch takes the SAME reset tail —
|
||||
``rev-parse HEAD`` → ``rev-parse FETCH_HEAD`` →
|
||||
``reset --hard FETCH_HEAD`` — and RETURNS the dest (no raise;
|
||||
pre-phase, this exact shape was the
|
||||
``fatal: Not possible to fast-forward`` that failed the whole
|
||||
sync). The reset targets the checkout's branch ref (``fetch
|
||||
origin main``)."""
|
||||
dest = tmp_path / "homelab"
|
||||
(dest / ".git").mkdir(parents=True)
|
||||
old, new = "c" * 40, "d" * 40
|
||||
calls = _fake_run(
|
||||
monkeypatch,
|
||||
responses={
|
||||
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", ""),
|
||||
("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", ""),
|
||||
("git", "fetch", "origin", "main"): (0, "", ""),
|
||||
("git", "merge", "--ff-only", "FETCH_HEAD"): (
|
||||
1, "", "fatal: Not possible to fast-forward, aborting.\n"
|
||||
),
|
||||
("git", "rev-parse", "HEAD"): (0, old + "\n", ""),
|
||||
("git", "rev-parse", "FETCH_HEAD"): (0, new + "\n", ""),
|
||||
("git", "reset", "--hard", "FETCH_HEAD"): (0, "", ""),
|
||||
},
|
||||
)
|
||||
|
||||
result = clone_or_pull("https://github.com/user/homelab.git", dest, None)
|
||||
|
||||
assert result == dest # the function RETURNS the dest (no raise)
|
||||
assert [call["argv"] for call in calls] == [
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
["git", "rev-parse", "--is-shallow-repository"],
|
||||
["git", "fetch", "origin", "main"],
|
||||
["git", "merge", "--ff-only", "FETCH_HEAD"],
|
||||
["git", "rev-parse", "HEAD"],
|
||||
["git", "rev-parse", "FETCH_HEAD"],
|
||||
["git", "reset", "--hard", "FETCH_HEAD"],
|
||||
]
|
||||
|
||||
|
||||
def test_null_branch_detached_head_fails_loud(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Phase 136 D12 fail-loud: ``branch=None`` on a DETACHED checkout
|
||||
(the ref resolution returns ``"HEAD"``) raises the remove-and-
|
||||
re-add ``GitSyncError`` naming the checkout — the app never
|
||||
CREATES a detached checkout (a fresh clone always lands on a
|
||||
branch), so this is a genuinely broken checkout, not a force-push
|
||||
case: no shallow probe, no fetch, no merge, NO reset argv."""
|
||||
dest = tmp_path / "homelab"
|
||||
(dest / ".git").mkdir(parents=True)
|
||||
calls = _fake_run(
|
||||
monkeypatch,
|
||||
responses={("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "HEAD\n", "")},
|
||||
)
|
||||
|
||||
with pytest.raises(GitSyncError) as excinfo:
|
||||
clone_or_pull("https://github.com/user/homelab.git", dest, None)
|
||||
|
||||
msg = str(excinfo.value)
|
||||
assert f"checkout {dest} is in a detached-HEAD state" in msg
|
||||
assert "remove and re-add the source to rebuild it" in msg
|
||||
assert [call["argv"] for call in calls] == [
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"]
|
||||
] # the guard is first: exactly ONE argv ran (no fetch, no reset)
|
||||
|
||||
Reference in New Issue
Block a user