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

Everything is verified green and consistent. Final report:

**Phase 136 — final verification pass (all tasks already complete in `complete/136_force_push_reset/`):**

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