phase: 135_git_source_branch
All verification complete. No edits were needed by this pass — the phase work and docs were already consistent and green. **Phase 135 final verification pass — report** - Verified all 5 tasks' work present & correct: migration 0024 (reversible), `GitSource.branch` + 4 schema shapes, `validate_branch_name` (full check order per design), `clone_or_pull(…, branch)` (HEAD guard first, `--branch` clone, fetch+ff merge), both call sites pass `row.branch`, UI (add-form field, editor field, row tag), `file://` URL transport. - Completion criteria: (1) branch-mirrored through Sync + CLI — **pass** (E2E + integration); (2) `branch=None` byte-identical — **pass** (`test_branch_none_argv_is_byte_identical_pre_phase_sequence` green); (3) fixed 422s / local-row 422 / tri-state — **pass**; (4) mismatch fails loudly with remove-and-re-add — **pass** (E2E); (5) coverage 99% >90%, E2E isolated green — **pass**; (6) no regressions — **pass**. - Tests: `uv run pytest --cov=app --cov-report=term-missing` → 3223 passed, app/ 99%; `uv run pytest tests/e2e/test_git_source_branch.py -v --no-cov` → 4 passed (16.4 s); `uv run ruff check .` clean; `uv run pyright` → 0 errors, 0 warnings. - Documentation: README.md reviewed in full — phase-135 branch section accurate; the phase's README diff also correctly fixed a pre-existing stale line (shallow `--depth 1` clone vs full-history, phase 107). Routes, env defaults, feature lists verified against code. AGENTS.md — all 10 rules + quick-reference verified (referenced tests, PLAN.md, phases dirs, commands all accurate). **Verified consistent — no changes needed.** - No live-infrastructure changes (dev DB already running and healthy; no live steps in this phase). - Next pending phase: `.agents/phases/todo/136_force_push_reset`.
This commit is contained in:
@@ -0,0 +1,753 @@
|
||||
"""Phase 135 story E2E (Playwright): a git source pins the branch it
|
||||
mirrors.
|
||||
|
||||
Source: ``TODO.md:4`` — "Need to be able to specify the branch for a git
|
||||
source."
|
||||
|
||||
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_git_source_branch.py -v --no-cov
|
||||
|
||||
The branch feature is pinned end to end against the REAL pipeline: a
|
||||
real ``git clone`` of the ``file://`` fixture repo, a real import
|
||||
against the mock LLM, a real KB — the owner's exact flow (add the
|
||||
source WITH a branch through the UI → sync → the branch's content is
|
||||
indexed, not the default branch's). The ``slow_llm`` proxy is NOT
|
||||
needed: the branch assertions are about WHICH files land, not about a
|
||||
running window.
|
||||
|
||||
Per-module app env (the E2E conftest pattern, module-scoped — the
|
||||
``test_sync_button.py`` + ``test_git_sources_admin.py`` combination):
|
||||
this module's app boots on its own port (a same-port second uvicorn
|
||||
dies on bind and would drive the wrong server) with a dedicated
|
||||
``BOR_SOURCES_DIR`` and NO ``BOR_GIT_SOURCES`` — the sources under
|
||||
test are DB rows the UI adds, so the env fallback stays EMPTY (pinned
|
||||
"" — the phase-61 leak guard: an operator's local gitignored ``.env``
|
||||
list must not leak into the app under test; process env ranks above
|
||||
the ``.env`` file). The session app (no git sources) is never started
|
||||
in this isolated run, so no port clash.
|
||||
|
||||
The fixture (module-scoped, real ``git``): a two-branch repo —
|
||||
``main`` (``notes/main-only.md`` with ``Marker: RESE-BRANCH-MAIN-7a31``
|
||||
+ a shared ``notes/common.md`` saying ``VERSION: 1``) and ``release``
|
||||
(``notes/release-only.md`` with ``Marker: RESE-BRANCH-REL-9c2d`` + the
|
||||
same ``notes/common.md`` saying ``VERSION: 2``) — the branches
|
||||
DIVERGE: release does not carry ``notes/main-only.md`` (a release
|
||||
checkout must not hold the default branch's doc). A SECOND module
|
||||
fixture is a main-only clone of it (identical history) — the
|
||||
``branch=None`` path's subject.
|
||||
|
||||
Test → contract mapping (four tests):
|
||||
|
||||
1. ``test_add_with_branch_syncs_that_branch`` — THE FEATURE: the
|
||||
owner adds the fixture WITH branch ``release`` through the UI (201
|
||||
→ the row's ``branch: release`` tag) → sync succeeds → the catalog
|
||||
holds ``release-only.md`` + ``common.md`` (the modal's stored
|
||||
content says ``VERSION: 2``) while ``main-only.md`` is ABSENT (the
|
||||
default branch's doc was never indexed); the idempotent re-sync
|
||||
reports "0 added · 2 unchanged".
|
||||
2. ``test_default_branch_source_unaffected`` — the ``branch=None``
|
||||
path against the main-only clone: no branch given → sync →
|
||||
``main-only.md`` present, ``release-only.md`` absent, ``common.md``
|
||||
holds ``VERSION: 1`` (the pre-phase byte-identical behavior,
|
||||
observable through the UI; the row carries NO branch tag).
|
||||
3. ``test_row_editor_branch_change_fails_loud`` — the ASSUMPTION B5
|
||||
guard end to end: the per-row editor (prefilled) changes the
|
||||
branch to ``main`` → the PATCH lands (row re-renders with the
|
||||
``branch: main`` tag) → the sync FAILS (the checkout sits on
|
||||
``release``) with the fixed mismatch copy naming
|
||||
``remove and re-add the source`` in the sanitized status error,
|
||||
the banner, and the modal; restoring ``release`` makes the sync
|
||||
succeed again (the guard is symmetric — the ff path works).
|
||||
4. ``test_api_branch_contracts`` — the API layer through the logged-in
|
||||
page context's request client (the admin cookie): invalid branch
|
||||
names (``a..b``, ``HEAD``) → 422 with the detail EXACTLY
|
||||
``not a valid branch name`` (no echo; the list unchanged); the
|
||||
PATCH tri-state (set ``hotfix/x`` → 200 reports it; a PATCH that
|
||||
does not touch the branch leaves it untouched; ``""`` → 200
|
||||
reports ``null``); a branch on a LOCAL row → 422 ``branch is only
|
||||
valid for git sources``.
|
||||
|
||||
Isolation: per-test TRUNCATE of the KB + registry tables (the
|
||||
``test_sync_button.py``/``test_git_source_dates.py`` shape, extended
|
||||
with ``sources_meta``) so each test's sync counts are its own; own
|
||||
``BOR_SOURCES_DIR``; the checkout dirs survive across tests WITHIN the
|
||||
module on purpose — test 3's fail-loud mismatch keys on test 1's
|
||||
checkout (on ``release``) still being on disk. The whole module is
|
||||
held to a 180 s wall budget (the ``_wall_time_guard`` fixture — the
|
||||
three-plus full syncs of a 3-file repo against the mock LLM are a
|
||||
regression canary for the sync pipeline's speed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Locator, 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
|
||||
# (env-overridable, as in the sibling per-module suites).
|
||||
APP_PORT = int(os.environ.get("E2E_APP_PORT_GITBR", "8134"))
|
||||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||
|
||||
GIT_SOURCES_URL = "/git-sources.html"
|
||||
SOURCES_URL = "/sources.html"
|
||||
|
||||
#: The fixture repo's directory names — the source names the import
|
||||
#: records for each (the repo_name basename rule, phase 28). The two
|
||||
#: names DIFFER, so the two checkouts (and the two catalog sources)
|
||||
#: never collide.
|
||||
FIXTURE_NAME = "branch-fixture"
|
||||
CLONE_NAME = "branch-fixture-main"
|
||||
|
||||
#: "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 user-visible fragment of the phase-135 mismatch error (the fixed
|
||||
#: copy — safe to pin): the checkout guard's remove-and-re-add fix.
|
||||
MISMATCH_FRAGMENT = "remove and re-add the source"
|
||||
|
||||
#: The module's wall budget (task 05 step 2): three-plus full syncs of
|
||||
#: a 3-file repo against the mock LLM must stay well under this.
|
||||
WALL_BUDGET_S = 180.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 _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 branch_fixture_repo(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""A real TWO-branch git repo the sync must clone over ``file://``
|
||||
(task 05 step 1): ``main`` (``notes/main-only.md`` +
|
||||
``notes/common.md`` = ``VERSION: 1``), then the DIVERGED
|
||||
``release`` branch (drops ``notes/main-only.md``, adds
|
||||
``notes/release-only.md``, rewrites ``notes/common.md`` =
|
||||
``VERSION: 2``).
|
||||
|
||||
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 — the
|
||||
``test_sync_button.py`` idiom. ``init -b main`` pins the default
|
||||
branch NAME (the task's fixture shape — the system's
|
||||
``init.defaultBranch`` may differ).
|
||||
"""
|
||||
root = tmp_path_factory.mktemp("bor_git_br")
|
||||
repo = root / FIXTURE_NAME
|
||||
(repo / "notes").mkdir(parents=True)
|
||||
(repo / "notes" / "main-only.md").write_text(
|
||||
"# Main only note\n"
|
||||
"\n"
|
||||
"A note that exists only on the main branch.\n"
|
||||
"\n"
|
||||
"Marker: RESE-BRANCH-MAIN-7a31\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(repo / "notes" / "common.md").write_text(
|
||||
"# Common note\n\nVERSION: 1\n", encoding="utf-8"
|
||||
)
|
||||
_git(repo, "init", "-q", "-b", "main")
|
||||
_git(repo, "add", "-A")
|
||||
_commit(repo, "main: base")
|
||||
# The release branch DIVERGES from main (the feature's observable
|
||||
# promise): it drops main-only.md (a release checkout must NOT hold
|
||||
# the default branch's doc), adds a branch-only file, and rewrites
|
||||
# the shared common.md (the two VERSIONs).
|
||||
_git(repo, "checkout", "-q", "-b", "release")
|
||||
(repo / "notes" / "main-only.md").unlink()
|
||||
(repo / "notes" / "release-only.md").write_text(
|
||||
"# Release only note\n"
|
||||
"\n"
|
||||
"A note that exists only on the release branch.\n"
|
||||
"\n"
|
||||
"Marker: RESE-BRANCH-REL-9c2d\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(repo / "notes" / "common.md").write_text(
|
||||
"# Common note\n\nVERSION: 2\n", encoding="utf-8"
|
||||
)
|
||||
_git(repo, "add", "-A")
|
||||
_commit(repo, "release: branch content")
|
||||
assert (repo / ".git").is_dir()
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def branch_main_clone(
|
||||
branch_fixture_repo: Path, tmp_path_factory: pytest.TempPathFactory
|
||||
) -> Path:
|
||||
"""A main-only clone of the fixture (identical history) — the
|
||||
``branch=None`` path's subject (task 05 test 2): a SECOND
|
||||
``file://`` repo whose default branch is ``main``. The clone's
|
||||
``origin/release`` ref is remote-tracking only — the app's
|
||||
no-branch clone/pull never touches it (its one sync is a FRESH
|
||||
clone of the default branch, the pre-phase byte-identical path)."""
|
||||
root = tmp_path_factory.mktemp("bor_git_br_main")
|
||||
clone = root / CLONE_NAME
|
||||
proc = subprocess.run(
|
||||
["git", "clone", "-q", "-b", "main", f"file://{branch_fixture_repo}", str(clone)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise AssertionError(f"git clone of the fixture failed: {proc.stderr.strip()}")
|
||||
assert (clone / ".git").is_dir()
|
||||
return clone
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(mock_llm: int, branch_fixture_repo: Path) -> Iterator[str]:
|
||||
"""The real app under test — per-module env: NO
|
||||
``BOR_GIT_SOURCES`` (pinned EMPTY — the phase-61 leak guard; the
|
||||
sources under test are DB rows the UI adds, so the env fallback
|
||||
stays empty) and a dedicated ``BOR_SOURCES_DIR`` under the fixture
|
||||
root (the checkouts never aim at anything outside the temp area).
|
||||
The session app 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).
|
||||
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
|
||||
# NO env git sources (task 05 step 1): the rows under test are DB
|
||||
# rows — the fallback list stays EMPTY (pinned, not absent: an
|
||||
# operator's local .env list must not leak into the app under test).
|
||||
env["BOR_GIT_SOURCES"] = ""
|
||||
env["BOR_SOURCES_DIR"] = str(branch_fixture_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 — this suite
|
||||
adds its rows through the UI, and a leftover row from another suite
|
||||
would flip ``effective_sources`` to a list this test does not
|
||||
control. ``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 budget (task 05 step 2): the whole module
|
||||
— app boot + the full syncs of a 3-file repo against the mock LLM
|
||||
— stays under 180 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 _git_source_row(page: Page, url: str) -> Locator:
|
||||
"""The table row whose source cell shows ``url`` (the
|
||||
``test_git_sources_admin.py`` _row idiom)."""
|
||||
return page.locator("#git-sources-tbody tr", has_text=url)
|
||||
|
||||
|
||||
def add_git_source(
|
||||
page: Page, url: str, branch: str | None = None, token: str | None = None
|
||||
) -> Locator:
|
||||
"""Fill + submit the add form — the branch field when given (blank
|
||||
→ the key is omitted from the POST, NULL = the default branch),
|
||||
the masked token field when given — and wait for the new row
|
||||
(the 201 → reload → row lifecycle, §7.4 never-stale)."""
|
||||
page.fill("#git-source-url", url)
|
||||
if branch is not None:
|
||||
page.fill("#git-source-branch", branch)
|
||||
if token is not None:
|
||||
page.fill("#git-source-token", token)
|
||||
page.locator("#git-source-add").click()
|
||||
row = _git_source_row(page, url)
|
||||
expect(row).to_have_count(1, timeout=30_000)
|
||||
return row
|
||||
|
||||
|
||||
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 phase-64 recorder's freshness convention)."""
|
||||
started = body.get("started_at")
|
||||
if not started:
|
||||
return 0.0
|
||||
try:
|
||||
return datetime.fromisoformat(str(started)).timestamp()
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _close_sync_failure_modal(page: Page) -> None:
|
||||
"""Close the sync failure modal when it is open.
|
||||
|
||||
A Sources-view remount over a stale ``failed`` terminal re-renders
|
||||
the failure modal (``initSyncButton`` — the never-stale contract:
|
||||
the page boots to the last run's state), and the fixed-inset
|
||||
backdrop would intercept the next ``#sync-btn`` click — closing it
|
||||
is exactly what the owner does after reading the error."""
|
||||
if page.locator(".sync-modal-backdrop.is-open").count():
|
||||
page.click(".sync-modal-close")
|
||||
expect(page.locator(".sync-modal-backdrop.is-open")).to_have_count(
|
||||
0, timeout=5_000
|
||||
)
|
||||
|
||||
|
||||
def sync_and_wait(
|
||||
page: Page, app_url: str, timeout_s: float = 90.0
|
||||
) -> dict[str, Any]:
|
||||
"""Click ``#sync-btn`` and poll the (cookie-authenticated) status
|
||||
endpoint until THIS run reaches a terminal state — the
|
||||
``_wait_sync_done`` pattern with the stale-terminal freshness gate
|
||||
(the ``test_incremental_folder_summaries.py`` idiom — the module
|
||||
app is shared, 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). A re-rendered failure modal (the remount
|
||||
case above) is closed before the click. Waiting for the
|
||||
synchronous "Syncing…" label first also pins every LATER UI
|
||||
terminal assertion (the label / result line / banner) to this run
|
||||
— a stale "Synced HH:MM" from a prior test cannot satisfy them."""
|
||||
_close_sync_failure_modal(page)
|
||||
r = page.request.get(f"{app_url}/api/sync/status")
|
||||
assert r.status == 200, r.text
|
||||
t0 = time.time()
|
||||
page.click("#sync-btn")
|
||||
# The click handler adopts the run on 202 (the label flips
|
||||
# "Syncing…" before the first poll).
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…", timeout=15_000)
|
||||
saw_running = False
|
||||
deadline = time.monotonic() + timeout_s
|
||||
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", "cancelled") and (
|
||||
saw_running or _started_at(body) >= t0 - 1.0
|
||||
):
|
||||
return body
|
||||
time.sleep(0.25)
|
||||
raise AssertionError(f"sync did not reach a terminal state: {body}")
|
||||
|
||||
|
||||
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 catalog_has_doc(page: Page, source_name: str, doc_name: str) -> Locator:
|
||||
"""Drill the catalog tree (top → *source_name* → ``notes``) and
|
||||
return the locator for *doc_name*'s file row (the
|
||||
``_expect_fixture_row`` pattern, parameterized — the count
|
||||
assertion is the caller's: 1 = present, 0 = absent)."""
|
||||
_go_top(page)
|
||||
source_link = page.locator(
|
||||
f'#folders-tbody a.folder-link:text-is("{source_name}")'
|
||||
)
|
||||
expect(source_link).to_have_count(1)
|
||||
source_link.click()
|
||||
folder_link = page.locator('#folders-tbody a.folder-link:text-is("notes")')
|
||||
expect(folder_link).to_have_count(1)
|
||||
folder_link.click()
|
||||
return page.locator("#docs-tbody tr", has_text=doc_name)
|
||||
|
||||
|
||||
def _assert_admin_settled(page: Page) -> None:
|
||||
"""The git-sources view settled as admin (the
|
||||
``test_git_sources_admin.py`` _admin_git_sources_page tail)."""
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#git-sources-gate")).to_be_hidden()
|
||||
expect(page.locator("#git-sources-content")).to_be_visible()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. THE FEATURE: add WITH a branch → the branch's content is indexed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_with_branch_syncs_that_branch(
|
||||
page: Page, app_url: str, db_ready: None, branch_fixture_repo: Path
|
||||
) -> None:
|
||||
"""The owner's exact flow: the add form WITH the branch field →
|
||||
201 → the row's ``branch: release`` tag → the real sync indexes
|
||||
the BRANCH's tree (``release-only.md`` + ``common.md`` at
|
||||
``VERSION: 2``) — never the default branch's (``main-only.md``
|
||||
absent) — and the idempotent re-sync reports "0 added · 2
|
||||
unchanged"."""
|
||||
page.set_default_timeout(30_000)
|
||||
url = f"file://{branch_fixture_repo}"
|
||||
login(page, app_url, next=GIT_SOURCES_URL)
|
||||
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
|
||||
_assert_admin_settled(page)
|
||||
|
||||
# --- add through the UI form, WITH the branch (→ 201) --------------
|
||||
post_statuses: list[int] = []
|
||||
|
||||
def track_post(r: Any) -> None:
|
||||
if r.request.method == "POST" and r.url.endswith("/api/git-sources"):
|
||||
post_statuses.append(r.status)
|
||||
|
||||
page.on("response", track_post)
|
||||
try:
|
||||
add_git_source(page, url, branch="release")
|
||||
finally:
|
||||
page.remove_listener("response", track_post)
|
||||
assert post_statuses == [201], (
|
||||
f"the add must be a single 201 POST, saw: {post_statuses}"
|
||||
)
|
||||
|
||||
# The row renders with the ``branch: release`` tag in the source
|
||||
# cell (the "hidden on" tag idiom — text + background)…
|
||||
row = _git_source_row(page, url)
|
||||
expect(row).to_have_count(1)
|
||||
expect(row.locator("td.git-source-url-cell code")).to_have_text(url)
|
||||
tag = row.locator(".git-source-branch-count")
|
||||
expect(tag).to_have_count(1)
|
||||
expect(tag).to_have_text("branch: release")
|
||||
# …and the 201 cleared the credential-free inputs (never stale).
|
||||
expect(page.locator("#git-source-url")).to_have_value("")
|
||||
expect(page.locator("#git-source-branch")).to_have_value("")
|
||||
|
||||
# --- the real sync (switch to the Sources/RAG view) ----------------
|
||||
page.goto(app_url + SOURCES_URL)
|
||||
expect(page.locator("#sync-btn")).to_be_visible(timeout=30_000)
|
||||
body = sync_and_wait(page, app_url)
|
||||
assert body["state"] == "success", body
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=15_000)
|
||||
# Both branch docs are fresh after the per-test truncate.
|
||||
expect(page.locator("#sync-result")).to_have_text("2 added")
|
||||
|
||||
# --- the branch's content is what got indexed ----------------------
|
||||
expect(catalog_has_doc(page, FIXTURE_NAME, "release-only.md")).to_have_count(1)
|
||||
expect(catalog_has_doc(page, FIXTURE_NAME, "common.md")).to_have_count(1)
|
||||
# The default branch's doc was NEVER imported…
|
||||
expect(page.locator("#docs-tbody tr", has_text="main-only.md")).to_have_count(0)
|
||||
# …and the shared file holds the RELEASE-side content: drill into
|
||||
# it and assert the stored content (the document modal — the
|
||||
# dates suite's content-check idiom, at the UI level).
|
||||
common_row = page.locator("#docs-tbody tr", has_text="common.md")
|
||||
expect(common_row).to_have_count(1)
|
||||
common_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("VERSION: 2")
|
||||
page.keyboard.press("Escape")
|
||||
expect(page.locator(".doc-modal")).to_be_hidden()
|
||||
|
||||
# --- the idempotent re-sync: the ff pull of the pinned ref --------
|
||||
body = sync_and_wait(page, app_url)
|
||||
assert body["state"] == "success", body
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=15_000)
|
||||
# Nothing re-embeds (sha256 delta) — the no-op run announces the
|
||||
# unchanged count (release's two files).
|
||||
expect(page.locator("#sync-result")).to_have_text("0 added · 2 unchanged")
|
||||
# The branch's doc survived the prune re-import.
|
||||
expect(catalog_has_doc(page, FIXTURE_NAME, "release-only.md")).to_have_count(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. The branch=None path: the default branch, exactly as pre-phase
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_default_branch_source_unaffected(
|
||||
page: Page, app_url: str, db_ready: None, branch_main_clone: Path
|
||||
) -> None:
|
||||
"""A SECOND source (a main-only clone of the fixture — a second
|
||||
``file://`` repo, identical history, ``main`` only) added with NO
|
||||
branch: the ``branch=None`` path mirrors the pre-phase behavior —
|
||||
the DEFAULT branch's tree is indexed (``main-only.md`` present,
|
||||
``release-only.md`` absent, ``common.md`` at ``VERSION: 1``), and
|
||||
the row carries NO branch tag."""
|
||||
page.set_default_timeout(30_000)
|
||||
url = f"file://{branch_main_clone}"
|
||||
login(page, app_url, next=GIT_SOURCES_URL)
|
||||
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
|
||||
_assert_admin_settled(page)
|
||||
|
||||
# Add with NO branch: the blank field omits the key (stored NULL).
|
||||
add_git_source(page, url)
|
||||
row = _git_source_row(page, url)
|
||||
expect(row).to_have_count(1)
|
||||
expect(row.locator(".git-source-branch-count")).to_have_count(0)
|
||||
|
||||
page.goto(app_url + SOURCES_URL)
|
||||
expect(page.locator("#sync-btn")).to_be_visible(timeout=30_000)
|
||||
body = sync_and_wait(page, app_url)
|
||||
assert body["state"] == "success", body
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=15_000)
|
||||
expect(page.locator("#sync-result")).to_have_text("2 added")
|
||||
|
||||
# The default branch (main) is what landed — the pre-phase
|
||||
# byte-identical guarantee, observable through the UI…
|
||||
expect(catalog_has_doc(page, CLONE_NAME, "main-only.md")).to_have_count(1)
|
||||
expect(page.locator("#docs-tbody tr", has_text="release-only.md")).to_have_count(0)
|
||||
# …and the shared file holds MAIN's content (VERSION: 1 — the
|
||||
# branch the owner did NOT name).
|
||||
common_row = page.locator("#docs-tbody tr", has_text="common.md")
|
||||
expect(common_row).to_have_count(1)
|
||||
common_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("VERSION: 1")
|
||||
page.keyboard.press("Escape")
|
||||
expect(page.locator(".doc-modal")).to_be_hidden()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. The fail-loud mismatch (ASSUMPTION B5, end to end)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_row_editor_branch_change_fails_loud(
|
||||
page: Page, app_url: str, db_ready: None, branch_fixture_repo: Path
|
||||
) -> None:
|
||||
"""The per-row editor changes the branch to one the checkout is
|
||||
NOT on: the PATCH lands (the row re-renders with the new tag),
|
||||
the sync FAILS — the checkout guard names the remove-and-re-add
|
||||
fix in the sanitized status error, the banner, and the modal —
|
||||
and restoring the matching branch makes the sync succeed again
|
||||
(the guard is symmetric: back to ``release``, the ff path works).
|
||||
The fixture state is explicit: the autouse truncate re-creates the
|
||||
row fresh, while test 1's checkout (on ``release``) is what the
|
||||
guard runs against (the checkout dirs outlive the per-test
|
||||
truncate — on purpose)."""
|
||||
page.set_default_timeout(30_000)
|
||||
url = f"file://{branch_fixture_repo}"
|
||||
login(page, app_url, next=GIT_SOURCES_URL)
|
||||
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
|
||||
_assert_admin_settled(page)
|
||||
|
||||
# Re-add the release source fresh (the autouse truncate emptied the
|
||||
# registry; the KB is empty too — nothing is indexed yet).
|
||||
add_git_source(page, url, branch="release")
|
||||
expect(
|
||||
_git_source_row(page, url).locator(".git-source-branch-count")
|
||||
).to_have_text("branch: release")
|
||||
|
||||
# --- the per-row editor: prefilled, change to main, save ----------
|
||||
_git_source_row(page, url).locator(".git-source-ignore").click()
|
||||
dialog = page.locator("#ignore-editor-dialog")
|
||||
expect(dialog).to_be_visible()
|
||||
# Git rows open the branch field VISIBLE, prefilled from the stored
|
||||
# value (unlike the token — there IS a field to read it from).
|
||||
expect(page.locator("#ignore-editor-branch")).to_have_value("release")
|
||||
page.fill("#ignore-editor-branch", "main")
|
||||
page.locator("#ignore-editor-save").click()
|
||||
expect(dialog).to_be_hidden(timeout=30_000)
|
||||
# The PATCH landed — the row re-renders from the server with the
|
||||
# changed tag (the registry is updated; the sync is what guards).
|
||||
expect(
|
||||
_git_source_row(page, url).locator(".git-source-branch-count")
|
||||
).to_have_text("branch: main", timeout=15_000)
|
||||
|
||||
# --- sync → FAILED: the checkout is on release, the row says main -
|
||||
page.goto(app_url + SOURCES_URL)
|
||||
expect(page.locator("#sync-btn")).to_be_visible(timeout=30_000)
|
||||
body = sync_and_wait(page, app_url)
|
||||
assert body["state"] == "failed", body
|
||||
# The status error carries the fixed mismatch copy (sanitized —
|
||||
# never the URL, never a credential)…
|
||||
assert body["error"] is not None and MISMATCH_FRAGMENT in body["error"], body
|
||||
# …and the UI surfaces it in the sanitized error banner AND the
|
||||
# failure modal (the owner is never left with a silently-wrong
|
||||
# checkout — the fail-loud promise, ASSUMPTION B5).
|
||||
expect(page.locator("#sync-error-banner")).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#sync-error-text")).to_contain_text(MISMATCH_FRAGMENT)
|
||||
expect(page.locator(".sync-modal-backdrop")).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#sync-modal-error")).to_contain_text(MISMATCH_FRAGMENT)
|
||||
# The owner's next step: close the modal (its backdrop intercepts
|
||||
# clicks until it does).
|
||||
page.locator(".sync-modal-close").click()
|
||||
expect(page.locator(".sync-modal-backdrop.is-open")).to_have_count(0)
|
||||
|
||||
# --- restore: back to the matching branch → the ff path works -----
|
||||
page.goto(app_url + GIT_SOURCES_URL)
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
row = _git_source_row(page, url)
|
||||
expect(row).to_have_count(1)
|
||||
row.locator(".git-source-ignore").click()
|
||||
expect(page.locator("#ignore-editor-dialog")).to_be_visible(timeout=30_000)
|
||||
# The editor now prefills the (wrong-branch) stored value…
|
||||
expect(page.locator("#ignore-editor-branch")).to_have_value("main")
|
||||
page.fill("#ignore-editor-branch", "release")
|
||||
page.locator("#ignore-editor-save").click()
|
||||
expect(page.locator("#ignore-editor-dialog")).to_be_hidden(timeout=30_000)
|
||||
expect(
|
||||
_git_source_row(page, url).locator(".git-source-branch-count")
|
||||
).to_have_text("branch: release", timeout=15_000)
|
||||
|
||||
page.goto(app_url + SOURCES_URL)
|
||||
body = sync_and_wait(page, app_url)
|
||||
assert body["state"] == "success", body
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=15_000)
|
||||
# The KB is still empty (the failed sync aborted before the
|
||||
# import) — the restored branch's two docs are fresh additions.
|
||||
expect(page.locator("#sync-result")).to_have_text("2 added")
|
||||
# And the branch's doc is what is indexed again.
|
||||
expect(catalog_has_doc(page, FIXTURE_NAME, "release-only.md")).to_have_count(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. The API contracts (the tri-state, the fixed 422s, the local-row
|
||||
# rejection) through the logged-in page context's request client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_api_branch_contracts(
|
||||
page: Page, app_url: str, db_ready: None, tmp_path: Path
|
||||
) -> None:
|
||||
"""The branch's API layer, admin-cookie only (the real API login —
|
||||
no form, no page): invalid branch names 422 with the detail
|
||||
EXACTLY ``not a valid branch name`` (fixed — no echo; the list
|
||||
unchanged), the PATCH tri-state round-trips (set → untouched →
|
||||
clear), and a branch on a LOCAL row is the fixed 422. The token
|
||||
field is untouched by all of the above (the phase-121 tests stay
|
||||
green — no regression pin needed here)."""
|
||||
base = f"{app_url}/api/git-sources"
|
||||
r = page.request.post(f"{app_url}/api/login", data={"password": ADMIN_PASSWORD})
|
||||
assert r.status == 204, r.text
|
||||
|
||||
# --- POST with an invalid branch name → the EXACT fixed 422 ------
|
||||
for bad in ("a..b", "HEAD"):
|
||||
r = page.request.post(
|
||||
base,
|
||||
data={"url": f"https://example.com/reese/branch-{bad}.git", "branch": bad},
|
||||
)
|
||||
assert r.status == 422, r.text
|
||||
# The detail is the FIXED string — never a substring of the
|
||||
# input (the credential-safety discipline, applied to names).
|
||||
assert r.json()["detail"] == "not a valid branch name"
|
||||
# …and no row was created (the list is unchanged).
|
||||
r = page.request.get(base)
|
||||
assert r.status == 200, r.text
|
||||
assert r.json()["sources"] == []
|
||||
|
||||
# --- the PATCH tri-state on a stored row ---------------------------
|
||||
r = page.request.post(
|
||||
base, data={"url": "https://example.com/reese/branch-patch.git"}
|
||||
)
|
||||
assert r.status == 201, r.text
|
||||
body = r.json()
|
||||
assert body["branch"] is None # absent at create → NULL (default branch)
|
||||
row_id = body["id"]
|
||||
|
||||
# Set: non-empty → 200 reports it (re-validated).
|
||||
r = page.request.patch(f"{base}/{row_id}", data={"branch": "hotfix/x"})
|
||||
assert r.status == 200, r.text
|
||||
assert r.json()["branch"] == "hotfix/x"
|
||||
# A PATCH that does not touch the branch leaves it untouched.
|
||||
r = page.request.patch(f"{base}/{row_id}", data={"include_hidden": True})
|
||||
assert r.status == 200, r.text
|
||||
assert r.json()["branch"] == "hotfix/x"
|
||||
assert r.json()["include_hidden"] is True
|
||||
# Clear: "" → 200 reports null (the tri-state's clear leg).
|
||||
r = page.request.patch(f"{base}/{row_id}", data={"branch": ""})
|
||||
assert r.status == 200, r.text
|
||||
assert r.json()["branch"] is None
|
||||
|
||||
# --- a branch on a LOCAL row → the fixed 422 ----------------------
|
||||
local_dir = tmp_path / "local-src"
|
||||
local_dir.mkdir()
|
||||
r = page.request.post(
|
||||
base, data={"kind": "local", "path": str(local_dir), "branch": "x"}
|
||||
)
|
||||
assert r.status == 422, r.text
|
||||
assert r.json()["detail"] == "branch is only valid for git sources"
|
||||
@@ -371,10 +371,14 @@ def test_admin_editor_token_opens_blank_and_save_keeps_token(
|
||||
|
||||
# One PATCH, 200, and the body OMITS the token key (blank = the
|
||||
# tri-state no-change — the key is not sent, let alone empty).
|
||||
# Phase 135 (task 04): a GIT row's PATCH body ALWAYS includes the
|
||||
# branch key (the tri-state asymmetry — a blank save is the
|
||||
# deliberate CLEAR leg: "" here; the seeded row stores no branch,
|
||||
# so the clear is a no-op and the stored token is untouched).
|
||||
assert patch_statuses == [200], f"the blank-token save did not 200: {patch_statuses}"
|
||||
assert len(patch_bodies) == 1 and patch_bodies[0] is not None
|
||||
assert "token" not in patch_bodies[0], "a blank token must OMIT the PATCH key"
|
||||
assert patch_bodies[0] == {"ignore_paths": []}
|
||||
assert patch_bodies[0] == {"ignore_paths": [], "branch": ""}
|
||||
|
||||
# The row is intact and the stored token is KEPT (the no-change
|
||||
# tri-state, verified in the trusted store).
|
||||
|
||||
@@ -58,6 +58,22 @@ injected ``https://x-access-token:<token>@…`` URL with a
|
||||
credential-free checkout path (mock ``clone_or_pull``) and a legacy
|
||||
row's clone still receives its ORIGINAL stored URL (the credential
|
||||
keeps working).
|
||||
|
||||
Phase 135 (task 01 — branch storage + reporting): the output shapes
|
||||
gain ``branch`` (NULL = the repo's default branch) — list DB rows
|
||||
report the stored value, env-fallback rows report ``null`` (no DB row
|
||||
to store one on), and POST 201 / PATCH 200 report the stored value.
|
||||
|
||||
Phase 135 (task 02 — branch validation + editing): POST stores a
|
||||
branch on GIT rows only (absent/empty → NULL = the default branch;
|
||||
non-empty → :func:`app.rag.git_sources.validate_branch_name`, a
|
||||
``ValueError`` → the FIXED-detail 422 ``not a valid branch name`` —
|
||||
the input is never echoed, and the row is not created); a ``branch``
|
||||
on a LOCAL row is 422 ``branch is only valid for git sources`` (POST
|
||||
and PATCH alike — local rows are walked, not cloned); PATCH edits
|
||||
the branch with the token's EXACT tri-state — absent/None = no
|
||||
change, non-empty = set (re-validated), empty string = clear (stored
|
||||
NULL).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -161,10 +177,11 @@ def test_get_empty_table_with_env_returns_env_rows(
|
||||
"url": "https://a.example.com/one.git",
|
||||
"path": None,
|
||||
"added_at": None,
|
||||
# Env rows have no DB row to store a list on (phase 89) or
|
||||
# a flag on (phase 105).
|
||||
# Env rows have no DB row to store a list on (phase 89), a
|
||||
# flag on (phase 105) or a branch on (phase 135).
|
||||
"ignore_paths": [],
|
||||
"include_hidden": False,
|
||||
"branch": None,
|
||||
},
|
||||
{
|
||||
"id": None,
|
||||
@@ -174,6 +191,7 @@ def test_get_empty_table_with_env_returns_env_rows(
|
||||
"added_at": None,
|
||||
"ignore_paths": [],
|
||||
"include_hidden": False,
|
||||
"branch": None,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -241,10 +259,11 @@ def test_post_creates_trimmed_and_list_stops_using_env(
|
||||
assert body["added_at"] is not None
|
||||
# Phase 89: the response gains ``ignore_paths`` — absent at create
|
||||
# time → ``[]``; phase 105 adds ``include_hidden`` — absent →
|
||||
# ``False`` (A4).
|
||||
assert set(body) == {"id", "url", "added_at", "ignore_paths", "include_hidden"}
|
||||
# ``False`` (A4); phase 135 adds ``branch`` — absent → ``null``.
|
||||
assert set(body) == {"id", "url", "added_at", "ignore_paths", "include_hidden", "branch"}
|
||||
assert body["ignore_paths"] == []
|
||||
assert body["include_hidden"] is False
|
||||
assert body["branch"] is None
|
||||
|
||||
# The DB row now wins: from_env False, the env URL is gone from the list.
|
||||
body = admin_client.get("/api/git-sources").json()
|
||||
@@ -258,6 +277,10 @@ def test_post_accepts_accepted_url_shapes(admin_client: TestClient) -> None:
|
||||
"http://git.local/repo.git",
|
||||
"ssh://git@example.com/repo.git",
|
||||
"git@github.com:owner/repo.git",
|
||||
# Phase 135 (task 05): the file:// local transport — the E2E
|
||||
# fixture repos the UI adds end to end (the phase-28/32/107
|
||||
# transport-true idiom, promoted from the env fallback).
|
||||
"file:///srv/repos/homelab-notes",
|
||||
):
|
||||
r = admin_client.post("/api/git-sources", json={"url": url})
|
||||
assert r.status_code == 201, f"{url} must be accepted: {r.text}"
|
||||
@@ -356,10 +379,12 @@ def test_post_local_creates_stored_row_with_expanded_path(
|
||||
# The phase-35 response shape is unchanged — the local row reports
|
||||
# its (expanded) path in ``url``; ``kind`` + ``path`` via GET;
|
||||
# phase 89 adds ``ignore_paths`` (absent → ``[]``); phase 105 adds
|
||||
# ``include_hidden`` (absent → ``False``, A4).
|
||||
assert set(body) == {"id", "url", "added_at", "ignore_paths", "include_hidden"}
|
||||
# ``include_hidden`` (absent → ``False``, A4); phase 135 adds
|
||||
# ``branch`` (absent → ``null``).
|
||||
assert set(body) == {"id", "url", "added_at", "ignore_paths", "include_hidden", "branch"}
|
||||
assert body["ignore_paths"] == []
|
||||
assert body["include_hidden"] is False
|
||||
assert body["branch"] is None
|
||||
uuid.UUID(body["id"])
|
||||
assert body["url"] == str(real_dir)
|
||||
assert body["added_at"] is not None
|
||||
@@ -592,7 +617,8 @@ def test_delete_removes_row_and_falls_back_to_env(
|
||||
"path": None,
|
||||
"added_at": None,
|
||||
"ignore_paths": [], # env rows: no DB row to store a list on
|
||||
"include_hidden": False, # … or a flag on (phase 105)
|
||||
"include_hidden": False, # … a flag on (phase 105)
|
||||
"branch": None, # … or a branch on (phase 135)
|
||||
}
|
||||
]
|
||||
db.execute(text("TRUNCATE chunks, documents"))
|
||||
@@ -709,6 +735,7 @@ def test_get_reports_stored_ignore_paths_and_env_rows_empty(
|
||||
"added_at": None,
|
||||
"ignore_paths": [],
|
||||
"include_hidden": False,
|
||||
"branch": None, # env rows: no DB row to store a branch on (135)
|
||||
}
|
||||
]
|
||||
|
||||
@@ -728,9 +755,10 @@ def test_patch_replaces_ignore_paths_including_clear(admin_client: TestClient) -
|
||||
r = admin_client.patch(f"/api/git-sources/{before['id']}", json={"ignore_paths": ["a/", "b"]})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert set(body) == {"id", "url", "added_at", "ignore_paths", "include_hidden"}
|
||||
assert set(body) == {"id", "url", "added_at", "ignore_paths", "include_hidden", "branch"}
|
||||
assert body["ignore_paths"] == ["a", "b"] # normalized
|
||||
assert body["include_hidden"] is False # untouched by a list-only PATCH
|
||||
assert body["branch"] is None # untouched by a list-only PATCH
|
||||
for key in ("id", "url", "added_at"):
|
||||
assert body[key] == before[key]
|
||||
# Round-trip through GET.
|
||||
@@ -988,7 +1016,8 @@ def test_post_with_token_stores_column_and_never_echoes(
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["url"] == "https://github.com/owner/private.git"
|
||||
assert set(body) == {"id", "url", "added_at", "ignore_paths", "include_hidden"}
|
||||
assert set(body) == {"id", "url", "added_at", "ignore_paths", "include_hidden", "branch"}
|
||||
assert body["branch"] is None # no branch sent → the default-branch NULL
|
||||
assert TOKEN not in r.text
|
||||
|
||||
row = db.scalars(select(GitSource)).one()
|
||||
@@ -1329,7 +1358,8 @@ def _stub_sync_stack(
|
||||
|
||||
clone_calls: list[tuple[str, Path]] = []
|
||||
|
||||
def fake_clone_or_pull(url: str, dest: Path | str) -> Path:
|
||||
def fake_clone_or_pull(url: str, dest: Path | str, branch: str | None = None) -> Path:
|
||||
# phase 135: the row's branch pin (None for this file's rows)
|
||||
dest = Path(dest)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
clone_calls.append((url, dest))
|
||||
@@ -1421,3 +1451,229 @@ def test_sync_public_row_clones_with_verbatim_url(
|
||||
assert _poll_sync(sync_admin_client, "success")["error"] is None
|
||||
|
||||
assert clone_calls == [(url, tmp_path / "sources" / "public")]
|
||||
|
||||
|
||||
# --- branch: create + validation (phase 135, task 02) ----------------------
|
||||
|
||||
|
||||
def test_post_git_source_with_branch_stores_and_reports(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""POST a git source with ``branch: "release"`` → 201 reports the
|
||||
branch; the stored model row carries it; the GET round-trip
|
||||
reports it too (201, list DB row)."""
|
||||
r = admin_client.post(
|
||||
"/api/git-sources",
|
||||
json={"url": "https://example.com/branched.git", "branch": "release"},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["branch"] == "release"
|
||||
assert set(body) == {"id", "url", "added_at", "ignore_paths", "include_hidden", "branch"}
|
||||
|
||||
row = db.scalars(select(GitSource)).one()
|
||||
assert row.branch == "release" # the column, not response-only
|
||||
|
||||
listed = admin_client.get("/api/git-sources").json()["sources"][0]
|
||||
assert listed["branch"] == "release"
|
||||
|
||||
|
||||
def test_post_git_source_branch_is_trimmed(admin_client: TestClient) -> None:
|
||||
"""Surrounding whitespace on the branch is trimmed (the schema's
|
||||
``_trim_branch`` + the API's defensive strip) before storage."""
|
||||
r = admin_client.post(
|
||||
"/api/git-sources",
|
||||
json={"url": "https://example.com/trim.git", "branch": " release/1.2\t"},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["branch"] == "release/1.2"
|
||||
|
||||
|
||||
def test_post_git_source_empty_branch_stores_null(admin_client: TestClient) -> None:
|
||||
"""Absent/None AND trimmed-EMPTY branch at create → stored NULL
|
||||
(the repo's default branch — the pre-phase-135 behavior): the
|
||||
create-time split from PATCH's clear leg (GitSourceIn docstring)."""
|
||||
for i, branch_value in enumerate((None, "", " ")):
|
||||
payload: dict[str, object] = {
|
||||
"url": f"https://example.com/def-{i}.git"
|
||||
} # distinct URLs: the loop must not 409 on itself
|
||||
if branch_value is not None:
|
||||
payload["branch"] = branch_value
|
||||
r = admin_client.post("/api/git-sources", json=payload)
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["branch"] is None
|
||||
|
||||
|
||||
def test_post_git_source_invalid_branch_422_fixed_detail_no_row(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""Invalid branch names are 422 with the EXACT fixed detail
|
||||
``not a valid branch name`` — the input is never echoed (the
|
||||
credential-safety discipline, applied for consistency) — and no
|
||||
row is created."""
|
||||
for bad in ("a..b", "a b", "a@b", "-x", ".x", "/x", "x/", "x.", "HEAD", "head"):
|
||||
r = admin_client.post(
|
||||
"/api/git-sources",
|
||||
json={"url": "https://example.com/bad-branch.git", "branch": bad},
|
||||
)
|
||||
assert r.status_code == 422, f"{bad!r} must be rejected: {r.text}"
|
||||
assert r.json()["detail"] == "not a valid branch name" # EXACT
|
||||
assert bad not in r.text, f"422 must not echo the input ({bad!r})"
|
||||
# Nothing stored.
|
||||
assert db.execute(text("SELECT count(*) FROM git_sources")).scalar_one() == 0
|
||||
|
||||
|
||||
def test_post_git_source_oversized_branch_422(admin_client: TestClient, db: Session) -> None:
|
||||
""">200 chars: the schema's ``max_length=200`` rejects it before
|
||||
the handler (FastAPI's standard schema 422 — the same house
|
||||
precedent as the oversized-URL 422, which this router also
|
||||
answers with the framework's standard shape); no row is created.
|
||||
The API-layer branch 422s (charset/sequence/leading/trailing/
|
||||
HEAD) are the FIXED-detail ones — see the adjacent tests."""
|
||||
r = admin_client.post(
|
||||
"/api/git-sources",
|
||||
json={"url": "https://example.com/long-branch.git", "branch": "a" * 201},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert db.execute(text("SELECT count(*) FROM git_sources")).scalar_one() == 0
|
||||
|
||||
|
||||
def test_post_local_source_with_branch_422(
|
||||
admin_client: TestClient, tmp_path: Path, db: Session
|
||||
) -> None:
|
||||
"""A ``branch`` on a LOCAL row is 422 with the fixed detail (local
|
||||
rows are walked, not cloned) — for BOTH valid and invalid branch
|
||||
names (the kind check precedes validation); nothing is stored."""
|
||||
real_dir = tmp_path / "local-branch"
|
||||
real_dir.mkdir()
|
||||
for bad in ("main", "a..b"):
|
||||
r = admin_client.post(
|
||||
"/api/git-sources",
|
||||
json={"kind": "local", "path": str(real_dir), "branch": bad},
|
||||
)
|
||||
assert r.status_code == 422, f"{bad!r} must be rejected: {r.text}"
|
||||
assert r.json()["detail"] == "branch is only valid for git sources"
|
||||
assert db.execute(text("SELECT count(*) FROM git_sources")).scalar_one() == 0
|
||||
|
||||
|
||||
# --- branch: PATCH tri-state (phase 135, task 02) ---------------------------
|
||||
|
||||
|
||||
def test_patch_branch_set_change_clear_and_noop(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""The branch tri-state on a git row — the token's exact
|
||||
semantics field-for-field:
|
||||
|
||||
* set: ``"hotfix/x"`` → 200 reports it (stored + GET round-trip);
|
||||
* change: ``"hotfix/y"`` → 200 reports the new value;
|
||||
* clear: ``""`` → 200 reports ``null`` (stored NULL = the
|
||||
default branch); whitespace-only trims to empty → the same
|
||||
clear leg;
|
||||
* no-op: branch ABSENT (or explicit None) → 200, the stored
|
||||
branch survives (a flag-only PATCH must not touch it).
|
||||
"""
|
||||
created = admin_client.post(
|
||||
"/api/git-sources", json={"url": "https://example.com/patch-branch.git"}
|
||||
)
|
||||
assert created.status_code == 201
|
||||
sid = created.json()["id"]
|
||||
assert created.json()["branch"] is None # started on the default branch
|
||||
|
||||
def stored_branch() -> str | None:
|
||||
# The endpoint commits in its own session — expire this one's
|
||||
# identity map before reading back (the house pattern).
|
||||
db.expire_all()
|
||||
row = db.get(GitSource, uuid.UUID(sid))
|
||||
assert row is not None
|
||||
return row.branch
|
||||
|
||||
# SET — "hotfix/x".
|
||||
r = admin_client.patch(f"/api/git-sources/{sid}", json={"branch": "hotfix/x"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["branch"] == "hotfix/x"
|
||||
assert stored_branch() == "hotfix/x"
|
||||
assert (
|
||||
admin_client.get("/api/git-sources").json()["sources"][0]["branch"] == "hotfix/x"
|
||||
)
|
||||
|
||||
# CHANGE — "hotfix/y".
|
||||
r = admin_client.patch(f"/api/git-sources/{sid}", json={"branch": "hotfix/y"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["branch"] == "hotfix/y"
|
||||
assert stored_branch() == "hotfix/y"
|
||||
|
||||
# NO-OP — an unrelated field only: the branch is UNTOUCHED.
|
||||
r = admin_client.patch(f"/api/git-sources/{sid}", json={"include_hidden": True})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["branch"] == "hotfix/y"
|
||||
assert r.json()["include_hidden"] is True
|
||||
assert stored_branch() == "hotfix/y"
|
||||
|
||||
# NO-OP — explicit None is absent too.
|
||||
r = admin_client.patch(f"/api/git-sources/{sid}", json={"branch": None})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["branch"] == "hotfix/y"
|
||||
assert stored_branch() == "hotfix/y"
|
||||
|
||||
# CLEAR — "" (the tri-state's clear leg; a deliberate empty save).
|
||||
r = admin_client.patch(f"/api/git-sources/{sid}", json={"branch": ""})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["branch"] is None
|
||||
assert stored_branch() is None
|
||||
|
||||
# Whitespace-only trims to empty → the same clear leg.
|
||||
admin_client.patch(f"/api/git-sources/{sid}", json={"branch": "hotfix/x"})
|
||||
r = admin_client.patch(f"/api/git-sources/{sid}", json={"branch": " "})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["branch"] is None
|
||||
assert stored_branch() is None
|
||||
|
||||
|
||||
def test_patch_branch_invalid_422_leaves_row_unchanged(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""A PRESENT invalid branch 422s with the EXACT fixed detail —
|
||||
and the stored branch is untouched (validation raises before the
|
||||
assignment; the row's other fields in the same body never
|
||||
half-apply)."""
|
||||
created = admin_client.post(
|
||||
"/api/git-sources",
|
||||
json={"url": "https://example.com/patch-bad.git", "branch": "stable"},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
sid = created.json()["id"]
|
||||
|
||||
for bad in ("a..b", "HEAD", "x/"):
|
||||
r = admin_client.patch(
|
||||
f"/api/git-sources/{sid}", json={"branch": bad, "include_hidden": True}
|
||||
)
|
||||
assert r.status_code == 422, f"{bad!r}: {r.text}"
|
||||
assert r.json()["detail"] == "not a valid branch name"
|
||||
assert bad not in r.text
|
||||
# Nothing half-applied: the branch AND the flag are still the
|
||||
# originals.
|
||||
row = admin_client.get("/api/git-sources").json()["sources"][0]
|
||||
assert row["branch"] == "stable"
|
||||
assert row["include_hidden"] is False
|
||||
|
||||
|
||||
def test_patch_branch_on_local_row_422(admin_client: TestClient, tmp_path: Path) -> None:
|
||||
"""A PRESENT branch on a LOCAL row is the same fixed 422 as POST
|
||||
(local rows are walked, not cloned) — valid name or not; the row
|
||||
is untouched."""
|
||||
real_dir = tmp_path / "local-patch"
|
||||
real_dir.mkdir()
|
||||
created = admin_client.post(
|
||||
"/api/git-sources", json={"kind": "local", "path": str(real_dir)}
|
||||
)
|
||||
assert created.status_code == 201
|
||||
sid = created.json()["id"]
|
||||
|
||||
for branch_value in ("main", "a..b"):
|
||||
r = admin_client.patch(f"/api/git-sources/{sid}", json={"branch": branch_value})
|
||||
assert r.status_code == 422, f"{branch_value!r}: {r.text}"
|
||||
assert r.json()["detail"] == "branch is only valid for git sources"
|
||||
row = admin_client.get("/api/git-sources").json()["sources"][0]
|
||||
assert row["branch"] is None
|
||||
assert row["kind"] == "local"
|
||||
|
||||
@@ -23,10 +23,22 @@ is asserted against the canned value).
|
||||
stderr, and **zero** import attempts.
|
||||
- A missing local directory → the same pre-import fail-loud: exit code
|
||||
1, ``local source missing: <path>`` on stderr, zero import attempts.
|
||||
|
||||
Phase 135 (the branch pin) adds the REAL-git section at the bottom:
|
||||
a two-branch ``file://`` fixture repo (``main`` with file A, ``release``
|
||||
with file B) drives the real :func:`scripts.git_sync.clone_or_pull` —
|
||||
the fresh ``--branch`` checkout, the ff-merge pull of a new commit on
|
||||
the pinned branch, the fail-loud branch MISMATCH (checkout untouched),
|
||||
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).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -39,7 +51,7 @@ from app.config import Settings
|
||||
from app.models import GitSource
|
||||
from app.rag.importer import ImportSummary
|
||||
from scripts import import_docs
|
||||
from scripts.git_sync import GitSyncError
|
||||
from scripts.git_sync import GitSyncError, clone_or_pull
|
||||
from tests.fakes import FakeEmbedder
|
||||
|
||||
|
||||
@@ -106,10 +118,15 @@ class FakeImportSources:
|
||||
|
||||
|
||||
def _fake_clone_factory() -> tuple[list[tuple[str, Path]], object]:
|
||||
"""A ``clone_or_pull`` that materialises a checkout with one .md file."""
|
||||
"""A ``clone_or_pull`` that materialises a checkout with one .md
|
||||
file. Phase 135: accepts the row's branch pin (``None`` for every
|
||||
row in this file's fixtures — the real-git branch behavior is
|
||||
pinned by the phase-135 section below, which uses the REAL
|
||||
``clone_or_pull``)."""
|
||||
calls: list[tuple[str, Path]] = []
|
||||
|
||||
def fake_clone_or_pull(url: str, dest: Path | str) -> Path:
|
||||
def fake_clone_or_pull(url: str, dest: Path | str, branch: str | None = None) -> Path:
|
||||
assert branch is None # every row in this file's fixtures pins no branch
|
||||
dest = Path(dest)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
(dest / "notes.md").write_text(f"# {dest.name}\ncontent for the KB\n", encoding="utf-8")
|
||||
@@ -687,7 +704,7 @@ def test_main_git_failure_aborts_before_import(
|
||||
lambda db: ([_git_row("https://host/a/bad.git")], "env"),
|
||||
)
|
||||
|
||||
def failing_clone(url: str, dest: Path | str) -> Path:
|
||||
def failing_clone(url: str, dest: Path | str, branch: str | None = None) -> Path:
|
||||
raise GitSyncError(
|
||||
f"git clone {url} failed (exit 128): "
|
||||
"fatal: repository not found"
|
||||
@@ -732,3 +749,201 @@ def test_main_missing_local_dir_aborts_before_import(
|
||||
assert "import_docs: source sync failed" in err
|
||||
assert f"local source missing: {missing}" in err # the path is named
|
||||
assert fake_import.calls == [] # no partial import
|
||||
|
||||
|
||||
# --- phase 135: the branch pin (real git, two-branch file:// origin) -------
|
||||
|
||||
|
||||
def _git_available() -> bool:
|
||||
try:
|
||||
proc = subprocess.run(["git", "--version"], capture_output=True, check=False)
|
||||
return proc.returncode == 0
|
||||
except (FileNotFoundError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
#: Real ``git`` in the test environment — skipped cleanly without it
|
||||
#: (the ``test_git_file_dates.py`` house pattern).
|
||||
GIT = _git_available()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def two_branch_repo(tmp_path: Path) -> Path:
|
||||
"""A real-git scratch origin (the phase-135 recipe): ``main`` holds
|
||||
``a.md``, ``release`` holds ``b.md`` — different content per branch
|
||||
(a branch checkout is the branch's tree, not main's)."""
|
||||
repo = tmp_path / "origin"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-q", "-b", "main")
|
||||
_git(repo, "config", "commit.gpgsign", "false")
|
||||
(repo / "a.md").write_text("# A\nmain branch content\n", encoding="utf-8")
|
||||
_git(repo, "add", "a.md")
|
||||
_git(repo, "commit", "-q", "-m", "main: add a.md")
|
||||
_git(repo, "checkout", "-q", "-b", "release")
|
||||
(repo / "a.md").unlink()
|
||||
(repo / "b.md").write_text("# B\nrelease branch content v1\n", encoding="utf-8")
|
||||
_git(repo, "add", "-A")
|
||||
_git(repo, "commit", "-q", "-m", "release: add b.md")
|
||||
_git(repo, "checkout", "-q", "main")
|
||||
return repo
|
||||
|
||||
|
||||
def _git(cwd: Path, *argv: str) -> None:
|
||||
"""Run one git command for the test harness (fixture setup); a
|
||||
non-zero exit fails the fixture, not the test under test."""
|
||||
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"
|
||||
proc = subprocess.run(
|
||||
["git", *argv], cwd=cwd, env=env, capture_output=True, text=True, check=False
|
||||
)
|
||||
assert proc.returncode == 0, f"git {' '.join(argv)} failed: {proc.stderr}"
|
||||
|
||||
|
||||
def _git_out(cwd: Path, *argv: str) -> str:
|
||||
"""One harness git read (a non-zero exit fails the test)."""
|
||||
proc = subprocess.run(
|
||||
["git", *argv], cwd=cwd, capture_output=True, text=True, check=False
|
||||
)
|
||||
assert proc.returncode == 0, f"git {' '.join(argv)} failed: {proc.stderr}"
|
||||
return proc.stdout
|
||||
|
||||
|
||||
@pytest.mark.skipif(not GIT, reason="git CLI not available")
|
||||
def test_clone_or_pull_fresh_branch_checkout_is_on_that_branch(
|
||||
tmp_path: Path, two_branch_repo: Path
|
||||
) -> None:
|
||||
"""Phase 135: a FRESH ``clone_or_pull(url, dest, 'release')``
|
||||
(real git, ``file://``) checks out the pinned branch — HEAD is
|
||||
``release``, file B present, file A (main's tree) absent."""
|
||||
url = f"file://{two_branch_repo}"
|
||||
dest = tmp_path / "bor" / "origin"
|
||||
|
||||
result = clone_or_pull(url, dest, "release")
|
||||
|
||||
assert result == dest
|
||||
assert _git_out(dest, "rev-parse", "--abbrev-ref", "HEAD").strip() == "release"
|
||||
assert (dest / "b.md").is_file()
|
||||
assert (dest / "b.md").read_text(encoding="utf-8") == "# B\nrelease branch content v1\n"
|
||||
assert not (dest / "a.md").exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not GIT, reason="git CLI not available")
|
||||
def test_clone_or_pull_existing_branch_checkout_ff_merges_new_commits(
|
||||
tmp_path: Path, two_branch_repo: Path
|
||||
) -> None:
|
||||
"""Phase 135: a SECOND ``clone_or_pull`` of the same pinned branch
|
||||
after the origin gains a commit on it → the guarded checkout is
|
||||
ff-merged onto the new tip (file B updated, no error, still on
|
||||
``release``)."""
|
||||
url = f"file://{two_branch_repo}"
|
||||
dest = tmp_path / "bor" / "origin"
|
||||
|
||||
clone_or_pull(url, dest, "release")
|
||||
# the origin gains a commit on release (v2 of file B)
|
||||
_git(two_branch_repo, "checkout", "-q", "release")
|
||||
(two_branch_repo / "b.md").write_text("# B\nrelease branch content v2\n", encoding="utf-8")
|
||||
_git(two_branch_repo, "add", "b.md")
|
||||
_git(two_branch_repo, "commit", "-q", "-m", "release: bump b.md")
|
||||
_git(two_branch_repo, "checkout", "-q", "main")
|
||||
|
||||
clone_or_pull(url, dest, "release")
|
||||
|
||||
assert _git_out(dest, "rev-parse", "--abbrev-ref", "HEAD").strip() == "release"
|
||||
assert (dest / "b.md").read_text(encoding="utf-8") == "# B\nrelease branch content v2\n"
|
||||
assert not (dest / "a.md").exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not GIT, reason="git CLI not available")
|
||||
def test_clone_or_pull_branch_mismatch_fails_loud_checkout_untouched(
|
||||
tmp_path: Path, two_branch_repo: Path
|
||||
) -> None:
|
||||
"""Phase 135 (ASSUMPTION B5): a checkout on the DEFAULT branch
|
||||
(fresh clone with no branch → ``main``) + a source row pinning
|
||||
``release`` → the remove-and-re-add ``GitSyncError`` — and the
|
||||
checkout is UNTOUCHED (still on main, still main's tree: no silent
|
||||
branch switch)."""
|
||||
url = f"file://{two_branch_repo}"
|
||||
dest = tmp_path / "bor" / "origin"
|
||||
|
||||
clone_or_pull(url, dest) # no branch → the origin's default (main)
|
||||
assert _git_out(dest, "rev-parse", "--abbrev-ref", "HEAD").strip() == "main"
|
||||
assert (dest / "a.md").is_file()
|
||||
|
||||
with pytest.raises(GitSyncError) as excinfo:
|
||||
clone_or_pull(url, dest, "release")
|
||||
|
||||
msg = str(excinfo.value)
|
||||
assert "remove and re-add the source to switch branches" in msg
|
||||
assert "'main'" in msg and "'release'" in msg
|
||||
assert url not in msg # the guard message never carries the URL
|
||||
# the checkout is untouched: still on main, still main's tree
|
||||
assert _git_out(dest, "rev-parse", "--abbrev-ref", "HEAD").strip() == "main"
|
||||
assert (dest / "a.md").is_file()
|
||||
assert not (dest / "b.md").exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not GIT, reason="git CLI not available")
|
||||
def test_resolve_sources_git_row_with_branch_checks_out_that_branch(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, two_branch_repo: Path
|
||||
) -> None:
|
||||
"""Phase 135, the CLI seam: a ``git_sources`` row carrying
|
||||
``branch='release'`` → ``_resolve_sources`` passes ``row.branch`` to
|
||||
the REAL ``clone_or_pull`` (no fake — the branch is honored
|
||||
end to end): the resolved root is the RELEASE tree (file B imported,
|
||||
file A absent), and the date walk over the real checkout lists ONLY
|
||||
the branch's tree (head-of-branch semantics, ``file_commit_dates``
|
||||
untouched)."""
|
||||
url = f"file://{two_branch_repo}"
|
||||
monkeypatch.setattr(
|
||||
import_docs,
|
||||
"effective_sources",
|
||||
lambda db: ([GitSource(url=url, kind="git", branch="release")], "db"),
|
||||
)
|
||||
settings = _settings(sources_dir=str(tmp_path / "bor"))
|
||||
|
||||
sources, ignore_map, hidden_map, date_map = import_docs._resolve_sources(None, settings)
|
||||
|
||||
root = tmp_path / "bor" / "origin"
|
||||
assert sources == [root]
|
||||
assert ignore_map == {}
|
||||
assert hidden_map == {str(root): False}
|
||||
assert (root / "b.md").is_file() # the branch's file is what gets imported
|
||||
assert not (root / "a.md").exists()
|
||||
# the (real, untouched) date walk over the branch checkout yields
|
||||
# TRUE dates for the branch's files (not the fail-soft ``{}`` — the
|
||||
# checkout is a real full-history repo). The walk reads the
|
||||
# reachable LOG (``git log --name-only``), so it also lists files
|
||||
# from shared ancestor commits (``a.md`` — release was cut after
|
||||
# main's first commit); the importer only consults the map for the
|
||||
# files it actually finds in the working tree, so that is harmless.
|
||||
dates = date_map[str(root)]
|
||||
assert "b.md" in dates
|
||||
assert isinstance(dates["b.md"], datetime)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not GIT, reason="git CLI not available")
|
||||
def test_resolve_sources_row_without_branch_clones_default_branch(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, two_branch_repo: Path
|
||||
) -> None:
|
||||
"""Phase 135, the byte-identical leg: a row WITHOUT a branch (the
|
||||
env-fallback synthetic rows' shape — ``GitSource(url=…, kind="git")``
|
||||
carries the model default ``branch=None``) → the real
|
||||
``clone_or_pull`` takes the pre-phase path: the origin's DEFAULT
|
||||
branch (``main``) is checked out — file A present, file B absent."""
|
||||
url = f"file://{two_branch_repo}"
|
||||
monkeypatch.setattr(
|
||||
import_docs,
|
||||
"effective_sources",
|
||||
lambda db: ([_git_row(url)], "env"), # branch defaults to None
|
||||
)
|
||||
settings = _settings(git_sources=url, sources_dir=str(tmp_path / "bor"))
|
||||
|
||||
sources, *_ = import_docs._resolve_sources(None, settings)
|
||||
|
||||
root = tmp_path / "bor" / "origin"
|
||||
assert sources == [root]
|
||||
assert (root / "a.md").is_file() # main's file — the default branch
|
||||
assert not (root / "b.md").exists()
|
||||
|
||||
@@ -319,10 +319,8 @@ def test_upgrade_round_trip_restores_the_column(db: Session, alembic: Config) ->
|
||||
with the full contract (TEXT, NULLABLE, no server default) — a
|
||||
clean upgrade → downgrade → upgrade round-trip (A13)."""
|
||||
command.downgrade(alembic, "0022")
|
||||
command.upgrade(alembic, "head")
|
||||
assert _version(db) == "0023", (
|
||||
"round-trip upgrade must land at head (0023)"
|
||||
)
|
||||
command.upgrade(alembic, "0023")
|
||||
assert _version(db) == "0023", "round-trip upgrade must land at 0023"
|
||||
|
||||
context = _column(db, "image_context")
|
||||
assert context is not None, "documents.image_context must be back"
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Integration: migration 0024 (git_sources.branch) schema contract
|
||||
(phase 135, task 01).
|
||||
|
||||
Drives the **real Alembic engine** against the live dev database
|
||||
(``podman compose up -d db``), mirroring the house pattern of
|
||||
``test_migration_0021.py`` (information_schema assertions on the state
|
||||
the migration must leave). The tests target the 0023 → 0024 step
|
||||
explicitly so later migrations cannot break the pins:
|
||||
|
||||
* upgrade 0023 → 0024 → ``branch`` exists with the full contract —
|
||||
TEXT, NULLABLE, no server default — while the 0023 ``git_sources``
|
||||
schema (``url`` NOT NULL + the unique index, ``kind``, ``path``,
|
||||
``ignore_paths``, ``include_hidden``, ``token``, ``added_at``)
|
||||
survives;
|
||||
* rows inserted while the DB is at 0023 backfill ``branch`` to NULL —
|
||||
ALL of them (the byte-identical guarantee: a pre-phase-135 row is a
|
||||
default-branch source by definition; no backfill, no default);
|
||||
* the ORM contract agrees: a freshly inserted ``GitSource`` with an
|
||||
explicit ``branch`` round-trips it through a fresh session, and a
|
||||
row without one reads ``branch is None``;
|
||||
* downgrade to 0023 → the column is GONE (A13) while the row + its
|
||||
``url`` survive; upgrade back to 0024 → the column is back
|
||||
(round-trip).
|
||||
|
||||
The ``alembic`` fixture guarantees the DB ends at head even if a test
|
||||
fails or the process is interrupted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from alembic import command
|
||||
from app.db import SessionLocal, db_available
|
||||
from app.models import GitSource
|
||||
|
||||
URL_A = "https://github.com/mig0024/repo-a.git"
|
||||
URL_B = "https://github.com/mig0024/repo-b.git"
|
||||
BRANCH = "release/1.0"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def alembic(db: Session) -> Iterator[Config]:
|
||||
"""Real Alembic config bound to the dev DB (URL from app settings).
|
||||
|
||||
Starts at head (repairs an interrupted earlier run); teardown
|
||||
upgrades to head no matter what happened, so the dev DB is never
|
||||
left below head.
|
||||
"""
|
||||
if not db_available():
|
||||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||||
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
|
||||
cfg.set_main_option("script_location", "alembic")
|
||||
command.upgrade(cfg, "head")
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
# Release the test session's open transaction BEFORE the repair
|
||||
# DDL: an idle-in-transaction SELECT holds an ACCESS SHARE lock
|
||||
# on ``git_sources``, which would deadlock the repair's
|
||||
# ``ALTER TABLE`` (0024) forever.
|
||||
db.rollback()
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
def _version(db: Session) -> str | None:
|
||||
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
||||
|
||||
|
||||
def _column(db: Session, column: str) -> tuple[Any, ...] | None:
|
||||
"""(data_type, is_nullable, column_default) for one git_sources
|
||||
column."""
|
||||
row = db.execute(
|
||||
text(
|
||||
"SELECT data_type, is_nullable, column_default"
|
||||
" FROM information_schema.columns"
|
||||
" WHERE table_name = 'git_sources' AND column_name = :c"
|
||||
),
|
||||
{"c": column},
|
||||
).fetchone()
|
||||
return tuple(row) if row is not None else None
|
||||
|
||||
|
||||
def _insert_sql(db: Session, url: str, token: str | None = None) -> uuid.UUID:
|
||||
"""Insert one git_sources row with the PRE-0024 column set (the
|
||||
0023 shape — the branch column, when present, is omitted so a NULL
|
||||
backfill is what the row reads)."""
|
||||
row_id = uuid.uuid4()
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO git_sources (id, url, kind, path, ignore_paths,"
|
||||
" include_hidden, token)"
|
||||
" VALUES (:id, :u, 'git', NULL, '[]', false, :t)"
|
||||
),
|
||||
{"id": row_id, "u": url, "t": token},
|
||||
)
|
||||
db.commit()
|
||||
return row_id
|
||||
|
||||
|
||||
def _delete(db: Session, row_id: uuid.UUID) -> None:
|
||||
db.execute(text("DELETE FROM git_sources WHERE id = :id"), {"id": row_id})
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_upgrade_to_0024_adds_branch(db: Session, alembic: Config) -> None:
|
||||
"""Upgrade 0023 → 0024: ``branch`` exists with the full contract
|
||||
(TEXT, NULLABLE, no server default — NULL = the repo's default
|
||||
branch), is ABSENT at 0023, and every pre-0024 row (WITH and
|
||||
WITHOUT a stored token) backfills ``branch`` to NULL — the
|
||||
byte-identical guarantee, no backfill and no default — while the
|
||||
0023 table contract (``url`` + unique index, ``kind``,
|
||||
``ignore_paths``, ``include_hidden``, ``token``, ``added_at``)
|
||||
survives."""
|
||||
command.downgrade(alembic, "0023") # start from the pre-0024 state
|
||||
assert _version(db) == "0023"
|
||||
assert _column(db, "branch") is None, "branch must be absent at 0023"
|
||||
|
||||
pre_a = _insert_sql(db, URL_A) # the 0023 column set
|
||||
pre_b = _insert_sql(db, URL_B, token="ghp_mig0024secret")
|
||||
try:
|
||||
command.upgrade(alembic, "0024")
|
||||
assert _version(db) == "0024", "alembic_version must be at 0024"
|
||||
|
||||
branch = _column(db, "branch")
|
||||
assert branch is not None, "git_sources.branch is missing"
|
||||
assert branch[0] == "text", "branch must be TEXT"
|
||||
assert branch[1] == "YES", "branch must be NULLABLE"
|
||||
assert branch[2] is None, (
|
||||
"branch must carry NO server default — NULL is the"
|
||||
" default-branch value"
|
||||
)
|
||||
|
||||
# Every pre-0024 row backfilled to NULL (the repo's default
|
||||
# branch — the byte-identical guarantee for ALL existing rows).
|
||||
for pre_id, url in ((pre_a, URL_A), (pre_b, URL_B)):
|
||||
row = db.execute(
|
||||
text("SELECT url, token, branch FROM git_sources WHERE id = :id"),
|
||||
{"id": pre_id},
|
||||
).fetchone()
|
||||
assert row is not None and row[0] == url, (
|
||||
"the pre-0024 row must survive the upgrade"
|
||||
)
|
||||
assert row[2] is None, (
|
||||
"every pre-0024 row must backfill branch to NULL — no"
|
||||
" backfill, no default"
|
||||
)
|
||||
# The tokened row kept its credential (the upgrade is
|
||||
# branch-only).
|
||||
assert db.execute(
|
||||
text("SELECT token FROM git_sources WHERE id = :id"),
|
||||
{"id": pre_b},
|
||||
).scalar() == "ghp_mig0024secret", (
|
||||
"a pre-0024 stored token must survive the branch upgrade"
|
||||
)
|
||||
|
||||
# A row written without the column stays NULL (no default to
|
||||
# fill it in — the Python-side default is None, same value).
|
||||
new_id = _insert_sql(db, URL_A.replace("repo-a", "repo-c"))
|
||||
try:
|
||||
omitted = db.execute(
|
||||
text("SELECT branch FROM git_sources WHERE id = :id"),
|
||||
{"id": new_id},
|
||||
).scalar()
|
||||
assert omitted is None, "an omitted branch must stay NULL"
|
||||
finally:
|
||||
_delete(db, new_id)
|
||||
|
||||
# The 0023 schema survives the additive upgrade.
|
||||
url = _column(db, "url")
|
||||
assert url is not None and url[0] == "text" and url[1] == "NO", (
|
||||
"git_sources.url (0006) must keep its 0023 contract"
|
||||
)
|
||||
kind = _column(db, "kind")
|
||||
assert kind is not None and kind[0] == "text" and kind[1] == "NO", (
|
||||
"git_sources.kind (0007) must survive the upgrade"
|
||||
)
|
||||
ignore = _column(db, "ignore_paths")
|
||||
assert ignore is not None and ignore[0] == "jsonb" and ignore[1] == "NO", (
|
||||
"git_sources.ignore_paths (0013) must survive the upgrade"
|
||||
)
|
||||
hidden = _column(db, "include_hidden")
|
||||
assert hidden is not None and hidden[0] == "boolean" and hidden[1] == "NO", (
|
||||
"git_sources.include_hidden (0019) must survive the upgrade"
|
||||
)
|
||||
token = _column(db, "token")
|
||||
assert token is not None and token[0] == "text" and token[1] == "YES", (
|
||||
"git_sources.token (0021) must survive the upgrade"
|
||||
)
|
||||
added = _column(db, "added_at")
|
||||
assert added is not None and added[0] == "timestamp with time zone", (
|
||||
"git_sources.added_at (0006) must survive the upgrade"
|
||||
)
|
||||
assert added[1] == "NO" and "now()" in str(added[2]), (
|
||||
"git_sources.added_at must keep its `now()` server default"
|
||||
)
|
||||
index = db.execute(
|
||||
text(
|
||||
"SELECT indexname FROM pg_indexes"
|
||||
" WHERE tablename = 'git_sources'"
|
||||
" AND indexname = 'uq_git_sources_url'"
|
||||
)
|
||||
).scalar()
|
||||
assert index is not None, (
|
||||
"the uq_git_sources_url unique index must survive the upgrade"
|
||||
)
|
||||
finally:
|
||||
_delete(db, pre_a)
|
||||
_delete(db, pre_b)
|
||||
|
||||
|
||||
def test_orm_branch_round_trips(db: Session, alembic: Config) -> None:
|
||||
"""The ORM contract agrees with the column contract: a freshly
|
||||
inserted ``GitSource`` with an explicit ``branch`` round-trips the
|
||||
value through a FRESH session, and a row without one reads
|
||||
``branch is None`` (the NULL default-branch state)."""
|
||||
command.upgrade(alembic, "head")
|
||||
row_branched = GitSource(url=URL_A, kind="git", branch=BRANCH)
|
||||
row_default = GitSource(url=URL_B, kind="git")
|
||||
db.add(row_branched)
|
||||
db.add(row_default)
|
||||
db.commit()
|
||||
try:
|
||||
with SessionLocal() as fresh:
|
||||
reloaded_branched = fresh.get(GitSource, row_branched.id)
|
||||
assert reloaded_branched is not None, "the branched row must be readable"
|
||||
assert reloaded_branched.branch == BRANCH, (
|
||||
"the explicit branch must round-trip through the DB"
|
||||
)
|
||||
reloaded_default = fresh.get(GitSource, row_default.id)
|
||||
assert reloaded_default is not None, "the default row must be readable"
|
||||
assert reloaded_default.branch is None, (
|
||||
"a row without a branch must read branch is None"
|
||||
)
|
||||
finally:
|
||||
_delete(db, row_branched.id)
|
||||
_delete(db, row_default.id)
|
||||
|
||||
|
||||
def test_downgrade_to_0023_drops_the_column(db: Session, alembic: Config) -> None:
|
||||
"""Downgrade 0024 → 0023: the branch column is gone (A13 — fully
|
||||
reversible) while the row + its ``url`` (and ``token``) survive,
|
||||
and the rest of the 0023 table contract (``url`` unique index,
|
||||
``kind``, ``added_at``) is intact."""
|
||||
command.upgrade(alembic, "head")
|
||||
row = GitSource(url=URL_A, kind="git", branch=BRANCH, token="ghp_mig0024secret")
|
||||
db.add(row)
|
||||
db.commit()
|
||||
try:
|
||||
command.downgrade(alembic, "0023")
|
||||
assert _version(db) == "0023"
|
||||
assert _column(db, "branch") is None, "branch must be dropped"
|
||||
|
||||
surviving = db.execute(
|
||||
text(
|
||||
"SELECT url, kind, ignore_paths, include_hidden, token, added_at"
|
||||
" FROM git_sources WHERE id = :id"
|
||||
),
|
||||
{"id": row.id},
|
||||
).fetchone()
|
||||
assert surviving is not None and surviving[0] == URL_A, (
|
||||
"the row must survive the column drop"
|
||||
)
|
||||
assert surviving[1] == "git" and surviving[2] == [] and surviving[3] is False, (
|
||||
"kind + ignore_paths + include_hidden must survive the drop"
|
||||
)
|
||||
assert surviving[4] == "ghp_mig0024secret", "token must survive the drop"
|
||||
assert surviving[5] is not None, "added_at must survive the drop"
|
||||
|
||||
index = db.execute(
|
||||
text(
|
||||
"SELECT indexname FROM pg_indexes"
|
||||
" WHERE tablename = 'git_sources'"
|
||||
" AND indexname = 'uq_git_sources_url'"
|
||||
)
|
||||
).scalar()
|
||||
assert index is not None, "the unique index must survive the downgrade"
|
||||
finally:
|
||||
_delete(db, row.id)
|
||||
# Repair: the fixture teardown re-upgrades to head.
|
||||
|
||||
|
||||
def test_upgrade_round_trip_restores_the_column(db: Session, alembic: Config) -> None:
|
||||
"""Downgrade to 0023, then upgrade back to 0024: ``branch`` is back
|
||||
with the full contract (TEXT, NULLABLE, no server default), and a
|
||||
row seeded at 0023 reads ``branch is None`` (the byte-identical
|
||||
guarantee on the round-trip)."""
|
||||
command.downgrade(alembic, "0023")
|
||||
pre_id = _insert_sql(db, URL_B)
|
||||
try:
|
||||
command.upgrade(alembic, "0024")
|
||||
assert _version(db) == "0024", "round-trip upgrade must land at 0024"
|
||||
|
||||
branch = _column(db, "branch")
|
||||
assert branch is not None, "git_sources.branch must be back"
|
||||
assert branch[0] == "text", "branch must be TEXT after the round-trip"
|
||||
assert branch[1] == "YES", "branch must be NULLABLE after the round-trip"
|
||||
assert branch[2] is None, (
|
||||
"branch must still carry NO server default after the round-trip"
|
||||
)
|
||||
|
||||
assert db.execute(
|
||||
text("SELECT branch FROM git_sources WHERE id = :id"),
|
||||
{"id": pre_id},
|
||||
).scalar() is None, (
|
||||
"a row seeded at 0023 must read branch NULL after the round-trip"
|
||||
)
|
||||
finally:
|
||||
_delete(db, pre_id)
|
||||
@@ -91,6 +91,8 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
@@ -113,7 +115,7 @@ from app.rag.folder_summaries import affected_folder_keys
|
||||
from app.rag.importer import ImportSummary
|
||||
from app.rag.llm import EmbeddingError, LLMClient, ModelUnavailableError
|
||||
from app.rag.sources_meta import current_sources_version
|
||||
from scripts.git_sync import GitSyncError
|
||||
from scripts.git_sync import GitSyncError, clone_or_pull
|
||||
from tests.conftest import ADMIN_PASSWORD
|
||||
from tests.fakes import FakeEmbedder
|
||||
|
||||
@@ -414,10 +416,12 @@ class FakeFolderSummaries:
|
||||
|
||||
|
||||
def _fake_clone() -> tuple[list[tuple[str, Path]], object]:
|
||||
"""A ``clone_or_pull`` that materialises a checkout with one .md file."""
|
||||
"""A ``clone_or_pull`` that materialises a checkout with one .md
|
||||
file. Phase 135: accepts the row's branch pin (the third argument
|
||||
— ``None`` for every row this file seeds)."""
|
||||
calls: list[tuple[str, Path]] = []
|
||||
|
||||
def fake_clone_or_pull(url: str, dest: Path | str) -> Path:
|
||||
def fake_clone_or_pull(url: str, dest: Path | str, branch: str | None = None) -> Path:
|
||||
dest = Path(dest)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
(dest / "notes.md").write_text(f"# {dest.name}\ncontent for the KB\n", encoding="utf-8")
|
||||
@@ -1193,7 +1197,7 @@ def test_git_failure_marks_failed_and_skips_import(
|
||||
lambda: _settings(sources_dir=str(tmp_path / "bor")),
|
||||
)
|
||||
|
||||
def failing_clone(url: str, dest: Path | str) -> Path:
|
||||
def failing_clone(url: str, dest: Path | str, branch: str | None = None) -> Path:
|
||||
raise GitSyncError(
|
||||
f"git clone {url} failed (exit 128): "
|
||||
"fatal: repository not found"
|
||||
@@ -1993,3 +1997,142 @@ def test_cancel_mid_run_lands_in_cancelled_then_resyncs_clean(
|
||||
assert current_sources_version(db) == 1
|
||||
assert len(second_import.sources) == 1
|
||||
assert second_import.prune_flags == [True]
|
||||
|
||||
|
||||
# --- phase 135: the branch pin (real git, two-branch file:// origin) -------
|
||||
|
||||
|
||||
def _git_available() -> bool:
|
||||
try:
|
||||
proc = subprocess.run(["git", "--version"], capture_output=True, check=False)
|
||||
return proc.returncode == 0
|
||||
except (FileNotFoundError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
#: Real ``git`` in the test environment — skipped cleanly without it
|
||||
#: (the ``test_git_file_dates.py`` house pattern).
|
||||
GIT = _git_available()
|
||||
|
||||
|
||||
def _git(cwd: Path, *argv: str) -> None:
|
||||
"""Run one git command for the test harness (fixture setup); a
|
||||
non-zero exit fails the fixture, not the test under test."""
|
||||
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"
|
||||
proc = subprocess.run(
|
||||
["git", *argv], cwd=cwd, env=env, capture_output=True, text=True, check=False
|
||||
)
|
||||
assert proc.returncode == 0, f"git {' '.join(argv)} failed: {proc.stderr}"
|
||||
|
||||
|
||||
def _two_branch_origin(tmp_path: Path) -> Path:
|
||||
"""A real-git scratch origin (the phase-135 recipe): ``main`` holds
|
||||
``a.md``, ``release`` holds ``b.md`` — different content per
|
||||
branch (a branch checkout is the branch's tree, not main's)."""
|
||||
origin = tmp_path / "origin"
|
||||
origin.mkdir()
|
||||
_git(origin, "init", "-q", "-b", "main")
|
||||
_git(origin, "config", "commit.gpgsign", "false")
|
||||
(origin / "a.md").write_text("# A\nmain branch content\n", encoding="utf-8")
|
||||
_git(origin, "add", "a.md")
|
||||
_git(origin, "commit", "-q", "-m", "main: add a.md")
|
||||
_git(origin, "checkout", "-q", "-b", "release")
|
||||
(origin / "a.md").unlink()
|
||||
(origin / "b.md").write_text("# B\nrelease branch content\n", encoding="utf-8")
|
||||
_git(origin, "add", "-A")
|
||||
_git(origin, "commit", "-q", "-m", "release: add b.md")
|
||||
_git(origin, "checkout", "-q", "main")
|
||||
return origin
|
||||
|
||||
|
||||
@pytest.mark.skipif(not GIT, reason="git CLI not available")
|
||||
def test_git_row_with_branch_button_path_clones_that_branch(
|
||||
sync_client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
db: Session,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Phase 135: the BUTTON path honors the row's branch pin — a
|
||||
``git_sources`` row with ``branch='release'`` is cloned with the
|
||||
REAL ``clone_or_pull`` (no fake in this test): the imported root
|
||||
is the RELEASE tree (file B present, file A absent)."""
|
||||
origin = _two_branch_origin(tmp_path)
|
||||
db.add(GitSource(url=f"file://{origin}", kind="git", branch="release"))
|
||||
db.commit()
|
||||
_stub_env(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
sync_api,
|
||||
"get_settings",
|
||||
lambda: _settings(sources_dir=str(tmp_path / "bor")),
|
||||
)
|
||||
_stub_probe(monkeypatch)
|
||||
fake_import = FakeImportSources(ImportSummary(files=1, added=1))
|
||||
monkeypatch.setattr(sync_api, "import_sources", fake_import)
|
||||
monkeypatch.setattr(sync_api, "regenerate_overview", FakeOverview(ok=True))
|
||||
monkeypatch.setattr(sync_api, "generate_folder_summaries", FakeFolderSummaries())
|
||||
|
||||
_login(sync_client)
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
body = _poll(sync_client, "success")
|
||||
assert body["error"] is None
|
||||
|
||||
root = tmp_path / "bor" / "origin"
|
||||
assert fake_import.sources == [[root]]
|
||||
assert (root / "b.md").is_file() # the branch's file is what got imported
|
||||
assert not (root / "a.md").exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not GIT, reason="git CLI not available")
|
||||
def test_git_row_branch_mismatch_button_path_fails_loud(
|
||||
sync_client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
db: Session,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Phase 135 (ASSUMPTION B5): a row pinning a branch the EXISTING
|
||||
checkout does not sit on fails the button sync LOUDLY — the
|
||||
remove-and-re-add message, zero import attempts, and the checkout
|
||||
untouched (still on its branch: no silent switch)."""
|
||||
origin = _two_branch_origin(tmp_path)
|
||||
url = f"file://{origin}"
|
||||
dest = tmp_path / "bor" / "origin"
|
||||
# A pre-existing checkout on the default branch (main) — e.g. a
|
||||
# row that existed before the branch was pinned (the sync's
|
||||
# sources_dir is seeded with it before the run starts).
|
||||
clone_or_pull(url, dest)
|
||||
db.add(GitSource(url=url, kind="git", branch="release"))
|
||||
db.commit()
|
||||
_stub_env(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
sync_api,
|
||||
"get_settings",
|
||||
lambda: _settings(sources_dir=str(tmp_path / "bor")),
|
||||
)
|
||||
_stub_probe(monkeypatch)
|
||||
fake_import = FakeImportSources(ImportSummary())
|
||||
monkeypatch.setattr(sync_api, "import_sources", fake_import)
|
||||
monkeypatch.setattr(sync_api, "regenerate_overview", FakeOverview(ok=True))
|
||||
monkeypatch.setattr(sync_api, "generate_folder_summaries", FakeFolderSummaries())
|
||||
|
||||
_login(sync_client)
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
|
||||
body = _poll(sync_client, "failed")
|
||||
assert "remove and re-add the source to switch branches" in body["error"]
|
||||
assert "'main'" in body["error"] and "'release'" in body["error"]
|
||||
assert body["detail"] == {}
|
||||
assert fake_import.sources == [] # no partial import
|
||||
# Phase 53: a FAILED sync never bumps.
|
||||
assert current_sources_version(db) == 0
|
||||
# the checkout is untouched: still on main, still main's tree
|
||||
proc = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=dest, capture_output=True, text=True, check=False,
|
||||
)
|
||||
assert proc.returncode == 0 and proc.stdout.strip() == "main"
|
||||
assert (dest / "a.md").is_file()
|
||||
assert not (dest / "b.md").exists()
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
"""Unit: the branch-pin UI on the Sources page (phase 135, task 04).
|
||||
|
||||
``TODO.md`` L4: "Need to be able to specify the branch for a git
|
||||
source." The Sources manager lets the owner see, set, change, and
|
||||
clear a source's branch (ASSUMPTION B6):
|
||||
|
||||
* the ADD form gains the optional ``#git-source-branch`` (maxlength
|
||||
200, autocomplete=off, the visible "optional — default: the repo's
|
||||
default branch" hint) BETWEEN the URL and Token fields; the submit
|
||||
body is ``(url, token, branch) => ({ url, ...(branch ? { branch } :
|
||||
{}), ...(token ? { token } : {}) })`` — a BLANK branch OMITS the key
|
||||
(NULL = the repo's default branch — the pre-phase-135 clone/pull
|
||||
behavior), and 201 clears all three credential-free inputs;
|
||||
* the per-row EDITOR dialog gains ``#ignore-editor-branch`` (after the
|
||||
source line, before the ignore textarea, with the "Leave empty for
|
||||
the repo's default branch." hint line) — VISIBLE FOR GIT ROWS ONLY
|
||||
(local rows hide label + input + hint and the save body OMITS the
|
||||
key — local rows are walked, not cloned; the API's 422 is the
|
||||
backstop), opens PREFILLED from the stored value (``s.branch ?? ""``
|
||||
— unlike the token, the API reports it, so there IS a field to read
|
||||
it from);
|
||||
* THE TRI-STATE ASYMMETRY: the git row's PATCH body ALWAYS includes
|
||||
``branch`` (the trimmed value — an EMPTY save is the deliberate
|
||||
CLEAR leg: NULL = the repo's default branch), unlike the token's
|
||||
blank = keep (the token stays the conditional key — untouched);
|
||||
* every STORED git row with a branch shows a ``branch: <name>`` text
|
||||
tag in the source cell (the "hidden on" tag idiom — text +
|
||||
background, never color alone: the text IS the signal);
|
||||
env-fallback rows (no ``s.id``) and NULL-branch rows show no tag;
|
||||
the source cell's title names the branch (hover precision).
|
||||
|
||||
The browser behavior itself is E2E-gated by the phase-135 story suite
|
||||
(``tests/e2e/test_git_source_branch.py``, task 05); like the phase-121
|
||||
``test_git_source_token.py`` house pattern, this module pins the
|
||||
source-level contract a silent regression would break.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.schemas import GitSourceIn, GitSourceOut, GitSourcePatchIn, GitSourceRow
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
SHELL_HTML = FRONTEND / "index.html"
|
||||
GIT_SOURCES_JS = FRONTEND / "assets" / "git-sources.js"
|
||||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||
|
||||
ADD_BRANCH_PLACEHOLDER = "main, release/1.2, …"
|
||||
EDIT_BRANCH_PLACEHOLDER = "(default branch)"
|
||||
ADD_HINT_TEXT = "optional — default: the repo's default branch"
|
||||
EDITOR_HINT_TEXT = "Leave empty for the repo's default branch."
|
||||
ADD_BODY_EXPR = (
|
||||
"(url, token, branch) => ({ url, ...(branch ? { branch } : {}), "
|
||||
"...(token ? { token } : {}) })"
|
||||
)
|
||||
BRANCH_TAG_CLASS = "git-source-branch-count"
|
||||
BRANCH_TAG_TEXT_EXPR = "`branch: ${s.branch}`"
|
||||
TITLE_EXPR = 's.id && s.branch ? `${value} — branch: ${s.branch}` : value'
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _js_text() -> str:
|
||||
return _read(GIT_SOURCES_JS)
|
||||
|
||||
|
||||
def _fn(js: str, name: str) -> str:
|
||||
"""The source of a (possibly async) top-level function via
|
||||
balanced-brace counting (the test_git_source_token.py helper)."""
|
||||
for prefix in ("async function ", "function "):
|
||||
start = js.find(f"{prefix}{name}(")
|
||||
if start != -1:
|
||||
depth = 0
|
||||
for i in range(js.find("{", start), len(js)):
|
||||
if js[i] == "{":
|
||||
depth += 1
|
||||
elif js[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return js[start : i + 1]
|
||||
raise AssertionError(f"unbalanced braces in {name}()")
|
||||
raise AssertionError(f"{name}() must exist in git-sources.js")
|
||||
|
||||
|
||||
def _input_tag(html: str, id_attr: str) -> str:
|
||||
m = re.search(rf"<input[^>]*id=\"{id_attr}\"[^>]*>", html, re.S)
|
||||
assert m, f"missing <input id={id_attr}> in the shell"
|
||||
return m.group(0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The add form — #git-source-branch (between URL and Token)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_form_branch_field_is_optional_text() -> None:
|
||||
"""``#git-source-branch`` (task 04 step 1): ``type="text"`` (a
|
||||
branch is a name, not a credential — no masking), the 200-char cap
|
||||
mirrored (the schema's max_length), ``autocomplete="off"``, the
|
||||
placeholder, INSIDE ``#git-source-form`` BETWEEN the URL input and
|
||||
the Token input (before the submit button), and a visible
|
||||
``<label for=…>`` (WCAG — never aria-label-only) carrying the
|
||||
"optional" hint span."""
|
||||
html = _read(SHELL_HTML)
|
||||
tag = _input_tag(html, "git-source-branch")
|
||||
assert 'type="text"' in tag, "the branch is a plain name (no masking)"
|
||||
assert 'maxlength="200"' in tag, "the schema cap, mirrored in the form"
|
||||
assert 'autocomplete="off"' in tag, "no browser save offer"
|
||||
assert ADD_BRANCH_PLACEHOLDER in tag
|
||||
form_i = html.find('id="git-source-form"')
|
||||
url_i = html.find('id="git-source-url"')
|
||||
br_i = html.find('id="git-source-branch"')
|
||||
tok_i = html.find('id="git-source-token"')
|
||||
btn_i = html.find('id="git-source-add"')
|
||||
assert -1 < form_i < url_i < br_i < tok_i < btn_i, (
|
||||
"the field sits in the add form, between URL and Token"
|
||||
)
|
||||
label = re.search(r"<label[^>]*for=\"git-source-branch\"[^>]*>(.*?)</label>", html, re.S)
|
||||
assert label, "a visible <label for=…> (never aria-label-only)"
|
||||
assert "Branch" in label.group(1)
|
||||
hint = re.search(r"<span[^>]*class=\"field-hint\"[^>]*>(.*?)</span>", label.group(1))
|
||||
assert hint and hint.group(1) == ADD_HINT_TEXT, "the visible 'optional' hint span"
|
||||
|
||||
|
||||
def test_add_form_submit_omits_a_blank_branch() -> None:
|
||||
"""The submit body is ``(url, token, branch) => ({ url, ...(branch
|
||||
? { branch } : {}), ...(token ? { token } : {}) })`` — a BLANK
|
||||
branch omits the key (NULL = the repo's default branch);
|
||||
wireAddForm reads the field (trimmed) and passes it to the body
|
||||
callback AFTER the token, and 201 clears all three inputs
|
||||
(the credential-free inputs clear together)."""
|
||||
js = _js_text()
|
||||
assert f"body: {ADD_BODY_EXPR}" in js, "the submit body omits the key when blank"
|
||||
wire = _fn(js, "wireAddForm")
|
||||
read_i = wire.find('const branch = branchInput ? branchInput.value.trim() : "";')
|
||||
tok_i = wire.find('const token = tokenInput ? tokenInput.value.trim() : "";')
|
||||
call_i = wire.find("opts.body(value, token, branch)", read_i)
|
||||
assert -1 < tok_i < read_i < call_i, (
|
||||
"the branch is read (trimmed) after the token and passed to the body"
|
||||
)
|
||||
clear_url_i = wire.find('input.value = ""')
|
||||
clear_tok_i = wire.find('tokenInput.value = ""', clear_url_i)
|
||||
clear_br_i = wire.find('branchInput.value = ""', clear_tok_i)
|
||||
assert -1 < clear_url_i < clear_tok_i < clear_br_i, "201: all three inputs clear"
|
||||
|
||||
|
||||
def test_add_form_branch_opt_in_the_wire_opts() -> None:
|
||||
"""The wireAddForm call site threads ``branchInput`` into the opts
|
||||
(next to the token lookup — the add form's single wiring point)."""
|
||||
js = _js_text()
|
||||
lookup_i = js.find('root.querySelector("#git-source-branch")')
|
||||
assert lookup_i != -1, "the element lookup next to the token lookups"
|
||||
opts = js[js.find("wireAddForm({") :]
|
||||
opts = opts[: opts.find("});")]
|
||||
assert "branchInput," in opts, "the opts object gains branchInput"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The per-row editor — #ignore-editor-branch (git rows only, prefilled,
|
||||
# empty-save = clear)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_editor_branch_field_sits_after_source_before_textarea() -> None:
|
||||
"""``#ignore-editor-branch`` (task 04 step 1): ``type="text"`` +
|
||||
the 200-char cap + ``autocomplete="off"`` + the "(default branch)"
|
||||
placeholder, INSIDE ``#ignore-editor-dialog`` AFTER the source line
|
||||
and BEFORE the ignore textarea (before the token field + error
|
||||
line), with a visible label and the muted hint line."""
|
||||
html = _read(SHELL_HTML)
|
||||
tag = _input_tag(html, "ignore-editor-branch")
|
||||
assert 'type="text"' in tag
|
||||
assert 'maxlength="200"' in tag
|
||||
assert 'autocomplete="off"' in tag
|
||||
assert f'placeholder="{EDIT_BRANCH_PLACEHOLDER}"' in tag
|
||||
dialog_i = html.find('id="ignore-editor-dialog"')
|
||||
src_i = html.find('id="ignore-editor-source"')
|
||||
br_i = html.find('id="ignore-editor-branch"')
|
||||
copy_i = html.find('id="ignore-editor-copy"')
|
||||
ta_i = html.find('id="ignore-editor-textarea"')
|
||||
tok_i = html.find('id="ignore-editor-token"')
|
||||
err_i = html.find('id="ignore-editor-error"')
|
||||
assert -1 < dialog_i < src_i < br_i < copy_i < ta_i < tok_i < err_i, (
|
||||
"the field sits after the source line, before the ignore textarea"
|
||||
)
|
||||
label = re.search(r"<label[^>]*for=\"ignore-editor-branch\"[^>]*>(.*?)</label>", html, re.S)
|
||||
assert label and "Branch" in label.group(1), "a visible label (WCAG)"
|
||||
assert 'class="ignore-editor-label"' in (label.group(0) if label else ""), (
|
||||
"the house dialog-label styling"
|
||||
)
|
||||
hint = re.search(
|
||||
r"<p class=\"ignore-editor-branch-hint\">(.*?)</p>", html, re.S
|
||||
)
|
||||
assert hint and hint.group(1).strip() == EDITOR_HINT_TEXT, "the muted hint line"
|
||||
|
||||
|
||||
def test_editor_opens_prefilled_for_git_hidden_for_local() -> None:
|
||||
"""openIgnoreEditor: git rows open the branch field prefilled from
|
||||
the stored value (``s.branch ?? ""`` — there IS a field to read it
|
||||
from, unlike the token) and VISIBLE; local rows HIDE the whole
|
||||
field group (label + input + hint — local rows are walked, not
|
||||
cloned) and clear the value."""
|
||||
js = _js_text()
|
||||
open_body = _fn(js, "openIgnoreEditor")
|
||||
assert "const branchIsGit = s.kind !== \"local\";" in open_body, (
|
||||
"the git-row gate"
|
||||
)
|
||||
prefill_i = open_body.find('ignoreBranchInput.value = branchIsGit ? (s.branch ?? "") : "";')
|
||||
assert prefill_i != -1, "prefilled from the stored value (s.branch ?? \"\")"
|
||||
for frag in (
|
||||
"ignoreBranchLabel.hidden = !branchIsGit",
|
||||
"ignoreBranchHint.hidden = !branchIsGit",
|
||||
"ignoreBranchInput.hidden = !branchIsGit",
|
||||
):
|
||||
assert frag in open_body, f"the field group hides for local rows: {frag!r}"
|
||||
label_i = open_body.find("ignoreBranchLabel.hidden = !branchIsGit")
|
||||
hint_i = open_body.find("ignoreBranchHint.hidden = !branchIsGit")
|
||||
assert -1 < label_i < hint_i < prefill_i, "the group hides before the prefill"
|
||||
|
||||
|
||||
def test_editor_save_always_sends_branch_for_git_rows() -> None:
|
||||
"""saveIgnorePaths: the branch is read (trimmed) before the PATCH;
|
||||
the git row's body ALWAYS includes ``branch`` —
|
||||
``...(isGitRow ? { branch } : {})`` — an EMPTY value is the
|
||||
deliberate CLEAR leg (the tri-state asymmetry, documented at the
|
||||
read site), while a LOCAL row's body OMITS the key; the token
|
||||
stays the conditional key (blank = keep — untouched) and lands
|
||||
AFTER the branch key."""
|
||||
js = _js_text()
|
||||
save = _fn(js, "saveIgnorePaths")
|
||||
read_i = save.find('const branch = ignoreBranchInput ? ignoreBranchInput.value.trim() : "";')
|
||||
assert read_i != -1, "the branch is read (trimmed) before the PATCH"
|
||||
assert "const isGitRow = t.kind !== \"local\";" in save, "the git-row gate"
|
||||
# The asymmetry is documented at the read site (the docstring
|
||||
# comment above the read names the clear leg).
|
||||
comment_i = save.find("the tri-state ASYMMETRY", 0, read_i)
|
||||
assert -1 < comment_i < read_i, "the tri-state asymmetry is documented at the read"
|
||||
body_i = save.find("...(isGitRow ? { branch } : {})")
|
||||
tok_i = save.find("...(token ? { token } : {})", body_i)
|
||||
assert -1 < read_i < body_i < tok_i, (
|
||||
"git rows always send branch (\"\" = clear); the token stays conditional"
|
||||
)
|
||||
assert "ignore_paths: lines" in save[body_i - 60 : body_i + 60], (
|
||||
"the ignore_paths replace is untouched"
|
||||
)
|
||||
|
||||
|
||||
def test_editor_close_resets_the_branch_field() -> None:
|
||||
"""closeIgnoreEditor: the branch input resets to "" (it re-opens
|
||||
prefilled from the server row — the phase-121 token reset
|
||||
precedent, with a prefilled twist)."""
|
||||
js = _js_text()
|
||||
close_body = _fn(js, "closeIgnoreEditor")
|
||||
assert 'ignoreBranchInput.value = ""' in close_body, "resets on close"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# makeRow — the "branch: <name>" tag + the hover title
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_make_row_branch_tag_is_stored_git_rows_only() -> None:
|
||||
"""makeRow: the ``branch: <name>`` text tag is appended to the
|
||||
source cell IFF ``s.id && s.branch`` (env-fallback rows carry no
|
||||
id, NULL-branch rows no truthy branch — no tag either way); the
|
||||
tag uses the "hidden on" idiom (text + background — the
|
||||
.git-source-branch-count class, grouped with
|
||||
.git-source-hidden-count in styles.css) and the TEXT is the signal
|
||||
(``branch: <name>`` — never color alone)."""
|
||||
js = _js_text()
|
||||
make = _fn(js, "makeRow")
|
||||
guard_i = make.find("if (s.id && s.branch) {")
|
||||
assert guard_i != -1, "stored git rows with a branch only"
|
||||
block = make[guard_i :]
|
||||
block = block[: block.find("\n }")]
|
||||
assert f"branchTag.className = \"{BRANCH_TAG_CLASS}\";" in block
|
||||
assert f"branchTag.textContent = {BRANCH_TAG_TEXT_EXPR};" in block, (
|
||||
"the text IS the signal (branch: <name>)"
|
||||
)
|
||||
assert "urlTd.append(branchTag)" in block, "the tag lands in the source cell"
|
||||
# The tag comes after the hidden tag (the source-cell tag order:
|
||||
# ignored → hidden → branch) and before the cell is appended.
|
||||
hidden_i = make.find('hiddenTag.className = "git-source-hidden-count"')
|
||||
append_i = make.find("tr.appendChild(urlTd)")
|
||||
assert -1 < hidden_i < guard_i < append_i
|
||||
|
||||
|
||||
def test_make_row_title_names_the_branch() -> None:
|
||||
"""The source cell's title (the hover-precision idiom) includes the
|
||||
pinned branch for stored git rows with one; every other row keeps
|
||||
the bare value (the pre-phase-135 title — the token E2E's
|
||||
to_have_attribute("title", URL) pins stay green)."""
|
||||
js = _js_text()
|
||||
make = _fn(js, "makeRow")
|
||||
assert f"urlTd.title = {TITLE_EXPR};" in make, (
|
||||
"the title names the branch (stored git rows only)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-file wire contract — the JS key names match the API shapes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_js_branch_keys_match_the_api_shapes() -> None:
|
||||
"""The wire contract: the POST body key and the PATCH body key are
|
||||
both ``branch`` (task 04 step 3 — the branch rides the existing
|
||||
payloads), and every API shape carries the field (task 01/02):
|
||||
``GitSourceIn`` (create), ``GitSourcePatchIn`` (tri-state),
|
||||
``GitSourceOut`` + ``GitSourceRow`` (the row the UI renders —
|
||||
``s.branch`` is read off it). A rename on either side breaks."""
|
||||
assert "branch" in GitSourceIn.model_fields
|
||||
assert "branch" in GitSourcePatchIn.model_fields
|
||||
assert "branch" in GitSourceOut.model_fields
|
||||
assert "branch" in GitSourceRow.model_fields
|
||||
js = _js_text()
|
||||
assert "...(branch ? { branch } : {})" in js, "the POST body key (blank-omits)"
|
||||
assert "...(isGitRow ? { branch } : {})" in js, "the PATCH body key (always for git)"
|
||||
assert "(s.branch ?? \"\")" in js, "the prefill reads s.branch off the row"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The module docstring — the phase-135 paragraph (the house pattern)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_module_docstring_carries_the_phase_135_contract() -> None:
|
||||
"""The git-sources.js module docstring gained the phase-135
|
||||
paragraph (the field, the tri-state asymmetry, the tag rule)."""
|
||||
doc = _js_text().split("*/", 2)[0] # the module docstring (first block)
|
||||
for frag in (
|
||||
"Phase 135 (task 04)",
|
||||
"#git-source-branch",
|
||||
"#ignore-editor-branch",
|
||||
"s.branch ?? \"\"",
|
||||
"THE TRI-STATE ASYMMETRY",
|
||||
"branch: <name>",
|
||||
"the tag appears/disappears",
|
||||
):
|
||||
assert frag in doc, f"the module docstring lost: {frag!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# styles.css — house-styled rules (no second input language)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_branch_field_css_rules_are_house_styled() -> None:
|
||||
"""styles.css: the add form's branch input joins the URL/token box
|
||||
group (mono, >=44px floor, placeholder + focus rules); the editor's
|
||||
branch field is full panel width at the 44px floor (the token
|
||||
field's box language); the hint line carries the .field-hint values
|
||||
(ink-soft, 0.8rem); the row tag joins the .git-source-hidden-count
|
||||
rule (text + a distinct background, never color alone); the mobile
|
||||
squeeze groups the add input with URL/token."""
|
||||
css = _read(STYLES_CSS)
|
||||
grouped = css.find("#git-source-url,\n#git-source-branch,\n#git-source-token {")
|
||||
assert grouped != -1, "#git-source-branch shares the URL/token box"
|
||||
rule = css[grouped : css.find("}", grouped)]
|
||||
assert "min-height: 44px" in rule and "var(--mono)" in rule
|
||||
assert "#git-source-branch::placeholder" in css, "the muted placeholder"
|
||||
assert "#git-source-branch:focus-visible" in css, "the brand focus border"
|
||||
br = css.find("#ignore-editor-branch {")
|
||||
assert br != -1, "the editor's branch field rule"
|
||||
br_rule = css[br : css.find("}", br)]
|
||||
assert "width: 100%" in br_rule and "min-height: 44px" in br_rule
|
||||
hint = css.find(".ignore-editor-branch-hint {")
|
||||
assert hint != -1, "the muted hint line rule"
|
||||
hint_rule = css[hint : css.find("}", hint)]
|
||||
assert "var(--ink-soft)" in hint_rule and "font-size: 0.8rem" in hint_rule, (
|
||||
"the .field-hint values (the token field's hint styling)"
|
||||
)
|
||||
tag = css.find(".git-source-hidden-count,\n.git-source-branch-count {")
|
||||
assert tag != -1, "the row tag joins the 'hidden on' tag rule"
|
||||
tag_rule = css[tag : css.find("}", tag)]
|
||||
assert "background: var(--bg)" in tag_rule and "border" in tag_rule, (
|
||||
"text + background (never color alone — WCAG 1.4.1)"
|
||||
)
|
||||
mobile = css.find("#git-source-url,\n #git-source-branch,\n #git-source-token,")
|
||||
assert mobile != -1, "the mobile squeeze groups the branch input"
|
||||
@@ -174,6 +174,7 @@ def _out_kwargs() -> dict:
|
||||
"added_at": None,
|
||||
"ignore_paths": [],
|
||||
"include_hidden": False,
|
||||
"branch": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -186,6 +187,7 @@ def _row_kwargs() -> dict:
|
||||
"added_at": None,
|
||||
"ignore_paths": [],
|
||||
"include_hidden": False,
|
||||
"branch": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -464,8 +466,19 @@ STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||
|
||||
ADD_TOKEN_PLACEHOLDER = "ghp_… or another PAT"
|
||||
EDIT_TOKEN_PLACEHOLDER = "leave blank to keep the current token"
|
||||
ADD_BODY_EXPR = "(url, token) => ({ url, ...(token ? { token } : {}) })"
|
||||
PATCH_BODY_EXPR = "JSON.stringify({ ignore_paths: lines, ...(token ? { token } : {}) })"
|
||||
#: Phase 135 (task 04) widened the body to the branch pin — the token
|
||||
#: key keeps its conditional shape (the contract under test) and the
|
||||
#: branch key (git rows always — "" = the tri-state's clear leg) lands
|
||||
#: first (test_git_source_branch_ui.py pins the branch contract).
|
||||
ADD_BODY_EXPR = (
|
||||
"(url, token, branch) => ({ url, ...(branch ? { branch } : {}), "
|
||||
"...(token ? { token } : {}) })"
|
||||
)
|
||||
#: The phase-121 paragraph of the module docstring records the body AS
|
||||
#: IT WAS in phase 121 (a historical record — the phase-135 paragraph
|
||||
#: records the widened expression, pinned by test_git_source_branch_ui.py).
|
||||
ADD_BODY_EXPR_PHASE_121 = "(url, token) => ({ url, ...(token ? { token } : {}) })"
|
||||
PATCH_BODY_EXPR = "...(token ? { token } : {})"
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
@@ -526,20 +539,22 @@ def test_add_form_token_field_is_masked_and_optional() -> None:
|
||||
|
||||
|
||||
def test_add_form_submit_omits_a_blank_token() -> None:
|
||||
"""The submit body is `(url, token) => ({ url, ...(token ?
|
||||
{ token } : {}) })` — a BLANK token omits the key (None = no
|
||||
credential); wireAddForm reads the masked field and passes it to
|
||||
the body callback, and 201 clears BOTH inputs (the credential is
|
||||
stored — write-only)."""
|
||||
"""The submit body is `(url, token, branch) => ({ url, ...(branch ?
|
||||
{ branch } : {}), ...(token ? { token } : {}) })` — a BLANK token
|
||||
omits the key (None = no credential; phase 135 added the branch
|
||||
key, which also omits when blank); wireAddForm reads the masked
|
||||
field and passes it to the body callback, and 201 clears ALL THREE
|
||||
credential-free inputs (the credential is stored — write-only)."""
|
||||
js = _js_text()
|
||||
assert f"body: {ADD_BODY_EXPR}" in js, "the submit body omits the key when blank"
|
||||
wire = _fn(js, "wireAddForm")
|
||||
read_i = wire.find('tokenInput ? tokenInput.value.trim() : ""')
|
||||
call_i = wire.find("opts.body(value, token)", read_i)
|
||||
call_i = wire.find("opts.body(value, token, branch)", read_i)
|
||||
assert -1 < read_i < call_i, "the token is read from the field and passed to the body"
|
||||
clear_url_i = wire.find('input.value = ""')
|
||||
clear_tok_i = wire.find("tokenInput.value = \"\"", clear_url_i)
|
||||
assert -1 < clear_url_i < clear_tok_i, "201: both inputs clear"
|
||||
clear_br_i = wire.find("branchInput.value = \"\"", clear_tok_i)
|
||||
assert -1 < clear_url_i < clear_tok_i < clear_br_i, "201: all three inputs clear"
|
||||
|
||||
|
||||
def test_editor_token_field_is_masked_and_blanks_to_keep() -> None:
|
||||
@@ -570,15 +585,23 @@ def test_editor_token_field_is_masked_and_blanks_to_keep() -> None:
|
||||
|
||||
def test_editor_patch_omits_a_blank_token() -> None:
|
||||
"""saveIgnorePaths: the token is read AFTER the line parse and the
|
||||
PATCH body is `{ ignore_paths: lines, ...(token ? { token } : {}) }`
|
||||
— a BLANK token omits the key (the task-01 tri-state: absent = no
|
||||
change, the row's stored credential is kept)."""
|
||||
PATCH body keeps the token as the CONDITIONAL key
|
||||
(`...(token ? { token } : {})` — a BLANK token omits the key, the
|
||||
task-01 tri-state: absent = no change, the row's stored credential
|
||||
is kept). Phase 135 (task 04) widens the body with the branch key
|
||||
(`...(isGitRow ? { branch } : {})` — ALWAYS for git rows: an empty
|
||||
save is the deliberate clear leg, unlike the token's blank = keep)
|
||||
landing before the token key — the token's semantics are untouched
|
||||
(the branch contract is pinned by test_git_source_branch_ui.py)."""
|
||||
js = _js_text()
|
||||
save = _fn(js, "saveIgnorePaths")
|
||||
drop_i = save.find(".filter(Boolean)")
|
||||
read_i = save.find('ignoreTokenInput ? ignoreTokenInput.value.trim() : ""', drop_i)
|
||||
body_i = save.find(PATCH_BODY_EXPR, read_i)
|
||||
assert -1 < drop_i < read_i < body_i, ("blank token → key omitted (tri-state no-change)")
|
||||
branch_i = save.find("...(isGitRow ? { branch } : {})", read_i)
|
||||
body_i = save.find(PATCH_BODY_EXPR, branch_i)
|
||||
assert -1 < drop_i < read_i < branch_i < body_i, (
|
||||
"blank token → key omitted (tri-state no-change; the branch key lands first)"
|
||||
)
|
||||
|
||||
|
||||
def test_display_sites_render_the_server_bare_url_only() -> None:
|
||||
@@ -609,7 +632,7 @@ def test_module_docstring_carries_the_phase_121_token_contract() -> None:
|
||||
"#git-source-token",
|
||||
"type=password",
|
||||
"optional — private repos",
|
||||
ADD_BODY_EXPR,
|
||||
ADD_BODY_EXPR_PHASE_121,
|
||||
"leave blank to keep the current token",
|
||||
"sanitize_url",
|
||||
):
|
||||
@@ -625,7 +648,9 @@ def test_token_field_css_rules_are_house_styled() -> None:
|
||||
width at the 44px floor; the mobile media query groups it in the
|
||||
same min-width:0 rule as the URL input."""
|
||||
css = _read(STYLES_CSS)
|
||||
grouped = css.find("#git-source-url,\n#git-source-token {")
|
||||
# Phase 135 (task 04): the branch input joined the group — the
|
||||
# token input still shares the URL input's box.
|
||||
grouped = css.find("#git-source-url,\n#git-source-branch,\n#git-source-token {")
|
||||
assert grouped != -1, "#git-source-token shares #git-source-url's box"
|
||||
rule = css[grouped : css.find("}", grouped)]
|
||||
assert "min-height: 44px" in rule and "var(--mono)" in rule
|
||||
@@ -637,5 +662,5 @@ def test_token_field_css_rules_are_house_styled() -> None:
|
||||
assert tok != -1, "the editor's token field rule"
|
||||
tok_rule = css[tok : css.find("}", tok)]
|
||||
assert "width: 100%" in tok_rule and "min-height: 44px" in tok_rule
|
||||
mobile = css.find("#git-source-url,\n #git-source-token,")
|
||||
mobile = css.find("#git-source-url,\n #git-source-branch,\n #git-source-token,")
|
||||
assert mobile != -1, "the mobile squeeze groups the token input"
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Unit: the branch-name validator (phase 135, task 02).
|
||||
|
||||
``validate_branch_name`` is the single choke point for every branch
|
||||
value that enters the API (POST create + PATCH set/change legs —
|
||||
ASSUMPTION B2). The contract, checked in the documented order (trim →
|
||||
empty → length → charset → ``..``/``//`` → leading → trailing →
|
||||
HEAD):
|
||||
|
||||
* TRIM first — the returned name is the trimmed input, so a
|
||||
schema-trimmed value is a fixed point;
|
||||
* empty (or whitespace-only) → ``ValueError``;
|
||||
* > 200 chars → ``ValueError`` (exactly 200 passes — the schema's
|
||||
``max_length=200`` is the first line, the validator the choke point
|
||||
a future caller must not bypass);
|
||||
* outside the safe charset ``[A-Za-z0-9._/-]+`` → ``ValueError``
|
||||
(whitespace, ``@``, ``~``, ``^``, ``:``, ``?``, ``*``, ``[``,
|
||||
backslash — all out);
|
||||
* contains ``..`` or ``//`` → ``ValueError`` (path-traversal-shaped
|
||||
names, even though the charset would allow the pieces);
|
||||
* leading ``-`` / ``.`` / ``/`` → ``ValueError``;
|
||||
* trailing ``/`` or ``.`` → ``ValueError``;
|
||||
* ``HEAD`` (any case) → ``ValueError`` (git's refname — a branch
|
||||
literally named ``head`` is a documented cost of the simple rule).
|
||||
|
||||
The raised ``ValueError`` messages are FIXED reason strings — the
|
||||
API maps them to the fixed-detail 422 and the raw input is never
|
||||
echoed (the house credential-safety discipline, applied for
|
||||
consistency): the tests pin that no message repeats the rejected
|
||||
input.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.rag.git_sources import validate_branch_name
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Accept
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"main",
|
||||
"release/1.2",
|
||||
"feature_x",
|
||||
"a.b/c",
|
||||
"v2-rc.1",
|
||||
],
|
||||
)
|
||||
def test_valid_names_pass_through_trimmed(raw: str) -> None:
|
||||
"""The accepted shapes — plain, slashed, dotted, dashed,
|
||||
underscored — return exactly the trimmed input."""
|
||||
assert validate_branch_name(raw) == raw
|
||||
|
||||
|
||||
def test_trim_applied_before_validation_and_returned() -> None:
|
||||
"""Surrounding whitespace is stripped: the returned value is the
|
||||
trimmed name (``" main "`` → ``main``)."""
|
||||
assert validate_branch_name(" main ") == "main"
|
||||
assert validate_branch_name("\trelease/1.2\n") == "release/1.2"
|
||||
|
||||
|
||||
def test_exactly_200_chars_passes() -> None:
|
||||
"""The length boundary is > 200: a 200-char name is valid
|
||||
(the charset allows it — a long but well-formed branch path)."""
|
||||
name = "a" * 196 + "/x.1"
|
||||
assert len(name) == 200
|
||||
assert validate_branch_name(name) == name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reject
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"",
|
||||
" ",
|
||||
"a b", # whitespace inside
|
||||
"a@b",
|
||||
"a~b",
|
||||
"a:b",
|
||||
"a?b",
|
||||
"a*b",
|
||||
"a[b",
|
||||
"a\\b",
|
||||
"a^b",
|
||||
],
|
||||
)
|
||||
def test_empty_and_charset_violations_rejected(raw: str) -> None:
|
||||
"""Empty/whitespace-only and non-charset characters all raise."""
|
||||
with pytest.raises(ValueError):
|
||||
validate_branch_name(raw)
|
||||
|
||||
|
||||
def test_201_chars_rejected() -> None:
|
||||
"""One past the length limit raises (even though the charset
|
||||
allows every character)."""
|
||||
with pytest.raises(ValueError):
|
||||
validate_branch_name("a" * 201)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["a..b", "a//b", "..", "//"])
|
||||
def test_path_sequences_rejected(raw: str) -> None:
|
||||
"""``..`` and ``//`` are disallowed — traversal-shaped names,
|
||||
including the two-character bare forms."""
|
||||
with pytest.raises(ValueError):
|
||||
validate_branch_name(raw)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["-x", ".x", "/x"])
|
||||
def test_leading_characters_rejected(raw: str) -> None:
|
||||
"""A leading ``-`` (git would read it as an option), ``.``, or
|
||||
``/`` raises."""
|
||||
with pytest.raises(ValueError):
|
||||
validate_branch_name(raw)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["x/", "x."])
|
||||
def test_trailing_characters_rejected(raw: str) -> None:
|
||||
"""A trailing ``/`` or ``.`` raises."""
|
||||
with pytest.raises(ValueError):
|
||||
validate_branch_name(raw)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["HEAD", "head", "HeAd", " hEaD "])
|
||||
def test_head_rejected_case_insensitively(raw: str) -> None:
|
||||
"""``HEAD`` in any case is git's refname — rejected (the trim runs
|
||||
first, so even `` hEaD `` is the reserved name)."""
|
||||
with pytest.raises(ValueError):
|
||||
validate_branch_name(raw)
|
||||
|
||||
|
||||
def test_reject_messages_are_fixed_and_input_free() -> None:
|
||||
"""The ``ValueError`` reasons are fixed strings — none of them
|
||||
echoes the rejected input (the API maps to the fixed-detail 422
|
||||
on the same discipline; a secret-ish name typed into the field
|
||||
must not surface in a log line either)."""
|
||||
for raw in ("a b", "a..b", "x/", "HEAD", "a" * 201, ""):
|
||||
try:
|
||||
validate_branch_name(raw)
|
||||
except ValueError as e:
|
||||
if raw:
|
||||
assert raw not in str(e)
|
||||
else:
|
||||
pytest.fail(f"{raw!r} must be rejected")
|
||||
+263
-1
@@ -1,9 +1,16 @@
|
||||
"""Unit tests: git clone/pull utility (phase 28; phase 107 D11/D12).
|
||||
"""Unit tests: git clone/pull utility (phase 28; phase 107 D11/D12;
|
||||
phase 135 adds the branch pin — 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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -236,3 +243,258 @@ def test_run_git_captures_and_returns_stdout(
|
||||
|
||||
assert git_sync.run_git(["git", "status"], cwd=tmp_path) == "From example.com\n + abc..def main"
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
# --- phase 135: the branch pin (the argv matrix) ----------------------------
|
||||
|
||||
|
||||
def test_branch_none_argv_is_byte_identical_pre_phase_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."""
|
||||
url = "https://github.com/user/homelab.git"
|
||||
dest = tmp_path / "homelab"
|
||||
|
||||
# fresh (dest absent)
|
||||
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
|
||||
(dest / ".git").mkdir(parents=True)
|
||||
calls = _fake_run(
|
||||
monkeypatch,
|
||||
responses={("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", "--is-shallow-repository"],
|
||||
["git", "pull", "--ff-only"],
|
||||
]
|
||||
|
||||
# existing, shallow (the one-time D11 self-heal between probe and pull)
|
||||
calls = _fake_run(
|
||||
monkeypatch,
|
||||
responses={
|
||||
("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", "--is-shallow-repository"],
|
||||
["git", "fetch", "--unshallow"],
|
||||
["git", "pull", "--ff-only"],
|
||||
]
|
||||
|
||||
|
||||
def test_fresh_clone_with_branch_uses_clone_branch(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Phase 135: fresh dest + pinned branch → ``git clone --branch <b>
|
||||
url dest`` (the branch is checked out from the first commit — no
|
||||
post-clone checkout; still full history, no ``--depth``)."""
|
||||
url = "https://github.com/user/homelab.git"
|
||||
dest = tmp_path / "homelab"
|
||||
calls = _fake_run(monkeypatch)
|
||||
|
||||
result = clone_or_pull(url, dest, "release")
|
||||
|
||||
assert result == dest
|
||||
assert [call["argv"] for call in calls] == [
|
||||
["git", "clone", "--branch", "release", url, str(dest)]
|
||||
]
|
||||
assert calls[0]["cwd"] == dest.parent
|
||||
|
||||
|
||||
def test_existing_checkout_matching_branch_fetches_and_ff_merges_the_ref(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Phase 135: existing checkout ALREADY on the pinned branch → the
|
||||
full sequence in order: the HEAD guard (``rev-parse --abbrev-ref
|
||||
HEAD``) → the shallow probe → the single-fetch ``fetch origin <b>``
|
||||
→ ``merge --ff-only FETCH_HEAD`` (not ``git pull`` — the default
|
||||
path's argv is reserved for ``branch=None``)."""
|
||||
dest = tmp_path / "homelab"
|
||||
(dest / ".git").mkdir(parents=True)
|
||||
calls = _fake_run(
|
||||
monkeypatch,
|
||||
responses={
|
||||
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "release\n", ""),
|
||||
("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", ""),
|
||||
("git", "fetch", "origin", "release"): (0, " * branch release -> FETCH_HEAD", ""),
|
||||
("git", "merge", "--ff-only", "FETCH_HEAD"): (0, "Already up to date.\n", ""),
|
||||
},
|
||||
)
|
||||
|
||||
result = clone_or_pull("https://github.com/user/homelab.git", dest, "release")
|
||||
|
||||
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"],
|
||||
]
|
||||
for call in calls:
|
||||
assert call["cwd"] == dest
|
||||
|
||||
|
||||
def test_existing_shallow_checkout_matching_branch_unshallows_before_fetch(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Phase 135 + phase 107 D11: the branch path runs the SAME one-time
|
||||
self-heal — a shallow matching checkout is unshallowed BETWEEN the
|
||||
guard/probe and the branch fetch (order pinned)."""
|
||||
dest = tmp_path / "homelab"
|
||||
(dest / ".git").mkdir(parents=True)
|
||||
calls = _fake_run(
|
||||
monkeypatch,
|
||||
responses={
|
||||
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "release\n", ""),
|
||||
("git", "rev-parse", "--is-shallow-repository"): (0, "true\n", ""),
|
||||
("git", "fetch", "--unshallow"): (0, "", ""),
|
||||
("git", "fetch", "origin", "release"): (0, "", ""),
|
||||
("git", "merge", "--ff-only", "FETCH_HEAD"): (0, "", ""),
|
||||
},
|
||||
)
|
||||
|
||||
clone_or_pull("https://github.com/user/homelab.git", dest, "release")
|
||||
|
||||
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", "release"],
|
||||
["git", "merge", "--ff-only", "FETCH_HEAD"],
|
||||
]
|
||||
|
||||
|
||||
def test_existing_checkout_branch_mismatch_fails_loud_before_any_fetch(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Phase 135 (ASSUMPTION B5, D12): a checkout on a DIFFERENT branch
|
||||
raises the remove-and-re-add ``GitSyncError`` — naming the checkout
|
||||
and BOTH branch names — and the guard is FIRST: no shallow probe,
|
||||
no fetch, no merge argv was invoked (no silent branch switch)."""
|
||||
url = "https://github.com/user/homelab.git"
|
||||
dest = tmp_path / "homelab"
|
||||
(dest / ".git").mkdir(parents=True)
|
||||
calls = _fake_run(
|
||||
monkeypatch,
|
||||
responses={("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", "")},
|
||||
)
|
||||
|
||||
with pytest.raises(GitSyncError) as excinfo:
|
||||
clone_or_pull(url, dest, "release")
|
||||
|
||||
msg = str(excinfo.value)
|
||||
assert f"checkout {dest}" in msg # the checkout is named
|
||||
assert "'main'" in msg # the checkout's branch
|
||||
assert "'release'" in msg # the source's pinned branch
|
||||
assert "remove and re-add the source to switch branches" in msg
|
||||
# credential safety: the message carries NO URL (the only place a
|
||||
# token could ride) — even for a plain URL.
|
||||
assert url not in msg
|
||||
assert "github" not in msg
|
||||
# the guard is first: exactly ONE argv (the guard probe) ran.
|
||||
assert [call["argv"] for call in calls] == [
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"]
|
||||
]
|
||||
|
||||
|
||||
def test_branch_mismatch_message_never_contains_url_even_with_token(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Phase 135 credential-safety: even when the CLONE URL carries the
|
||||
injected token (the phase-121 ``clone_url_for`` form), the guard
|
||||
message is URL-free — the checkout path and the two branch names
|
||||
only."""
|
||||
url = "https://x-access-token:ghp_supersecret@git.example.com/user/repo.git"
|
||||
dest = tmp_path / "repo"
|
||||
(dest / ".git").mkdir(parents=True)
|
||||
calls = _fake_run(
|
||||
monkeypatch,
|
||||
responses={("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", "")},
|
||||
)
|
||||
|
||||
with pytest.raises(GitSyncError) as excinfo:
|
||||
clone_or_pull(url, dest, "release")
|
||||
|
||||
msg = str(excinfo.value)
|
||||
assert url not in msg
|
||||
assert "ghp_supersecret" not in msg
|
||||
assert "git.example.com" not in msg
|
||||
assert f"checkout {dest} is on branch 'main' but the source " \
|
||||
"specifies branch 'release'" in msg
|
||||
assert [call["argv"] for call in calls] == [
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"]
|
||||
]
|
||||
|
||||
|
||||
def test_existing_checkout_branch_fetch_failure_propagates_before_merge(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Phase 135 D12 fail-loud: a failed ``fetch origin <b>`` raises
|
||||
(with git's stderr) and the merge NEVER runs."""
|
||||
dest = tmp_path / "homelab"
|
||||
(dest / ".git").mkdir(parents=True)
|
||||
calls = _fake_run(
|
||||
monkeypatch,
|
||||
responses={
|
||||
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "release\n", ""),
|
||||
("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", ""),
|
||||
("git", "fetch", "origin", "release"): (128, "", "fatal: couldn't find remote ref\n"),
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
GitSyncError,
|
||||
match=r"git fetch origin release failed \(exit 128\): fatal: couldn't find remote ref",
|
||||
):
|
||||
clone_or_pull("https://github.com/user/homelab.git", dest, "release")
|
||||
|
||||
assert [call["argv"] for call in calls] == [
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
["git", "rev-parse", "--is-shallow-repository"],
|
||||
["git", "fetch", "origin", "release"],
|
||||
] # the merge never ran
|
||||
|
||||
|
||||
def test_existing_checkout_branch_non_ff_merge_fails_loud(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> 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."""
|
||||
dest = tmp_path / "homelab"
|
||||
(dest / ".git").mkdir(parents=True)
|
||||
calls = _fake_run(
|
||||
monkeypatch,
|
||||
responses={
|
||||
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "release\n", ""),
|
||||
("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",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
assert len(calls) == 4 # all four argv ran; the merge failed loud
|
||||
|
||||
@@ -389,7 +389,10 @@ def test_hidden_checkbox_css_rules_exist_with_accent_color() -> None:
|
||||
assert "opacity" in disabled and "cursor: wait" in disabled, (
|
||||
"the .git-source-remove:disabled idiom"
|
||||
)
|
||||
tag = _css_rule(css, ".git-source-hidden-count")
|
||||
# Phase 135 (task 04): the "branch: <name>" state tag joined this
|
||||
# rule — the "hidden on" tag keeps the same idiom (text + a
|
||||
# distinct background, never color alone).
|
||||
tag = _css_rule(css, ".git-source-hidden-count,\n.git-source-branch-count")
|
||||
assert "var(--ink)" in tag and "var(--bg)" in tag, (
|
||||
"text + a distinct background (never color alone)"
|
||||
)
|
||||
|
||||
@@ -384,14 +384,20 @@ def test_save_runs_the_inflight_never_stale_lifecycle() -> None:
|
||||
label_i = body.find(f'"{SAVING_LABEL}"', dis_s)
|
||||
fetch_i = body.find("`/api/git-sources/${", label_i)
|
||||
method_i = body.find('method: "PATCH"', fetch_i)
|
||||
body_i = body.find(
|
||||
"JSON.stringify({ ignore_paths: lines, ...(token ? { token } : {}) })", method_i
|
||||
)
|
||||
# Phase 135 (task 04) widened the body: the branch key lands
|
||||
# BEFORE the (still conditional) token key — git rows always send
|
||||
# it ("" = the tri-state's clear leg), local rows omit it.
|
||||
body_i = body.find("JSON.stringify({\n ignore_paths: lines,", method_i)
|
||||
branch_key_i = body.find("...(isGitRow ? { branch } : {})", body_i)
|
||||
token_key_i = body.find("...(token ? { token } : {})", branch_key_i)
|
||||
assert -1 < guard_i < parse_i < trim_i < drop_i < token_i < inflight_i, (
|
||||
"guard → split + trim + drop blank lines → read the token → in-flight"
|
||||
)
|
||||
assert -1 < dis_c < dis_s < label_i < fetch_i < method_i < body_i, (
|
||||
"disable both + 'Saving…' → the PATCH (lines + token only when non-blank)"
|
||||
"disable both + 'Saving…' → the PATCH"
|
||||
)
|
||||
assert -1 < body_i < branch_key_i < token_key_i, (
|
||||
"the body: lines, branch for git rows, token only when non-blank"
|
||||
)
|
||||
# Success: close → reload → announce (the exact order) — the
|
||||
# update confirmation is the LAST announcement: the reload's
|
||||
|
||||
@@ -812,14 +812,15 @@ def fresh_sync_status() -> Iterator[None]:
|
||||
class _GatedClone:
|
||||
"""A ``clone_or_pull`` that parks between start and finish on a
|
||||
threading gate, so the test can read the status during the
|
||||
clone/pull phase (A4: no file yet)."""
|
||||
clone/pull phase (A4: no file yet). Phase 135: accepts the row's
|
||||
branch pin (the third argument — ``None`` for this file's rows)."""
|
||||
|
||||
def __init__(self, started: threading.Event, release: threading.Event) -> None:
|
||||
self.started = started
|
||||
self.release = release
|
||||
self.calls: list[tuple[str, Path]] = []
|
||||
|
||||
def __call__(self, url: str, dest: Path | str) -> Path:
|
||||
def __call__(self, url: str, dest: Path | str, branch: str | None = None) -> Path:
|
||||
dest = Path(dest)
|
||||
self.calls.append((url, dest))
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
@@ -1402,10 +1403,12 @@ def test_chunk_position_clears_at_phase_transitions_and_terminal(
|
||||
# the event set / releases.
|
||||
|
||||
|
||||
def _fast_clone(url: str, dest: Path | str) -> Path:
|
||||
def _fast_clone(url: str, dest: Path | str, branch: str | None = None) -> Path:
|
||||
"""A non-gated ``clone_or_pull`` — the loop thread must STAY FREE
|
||||
for the loop-thread action, so nothing in the prelude may block it
|
||||
(the import's gate is an ``asyncio.to_thread`` park — loop-safe)."""
|
||||
(the import's gate is an ``asyncio.to_thread`` park — loop-safe).
|
||||
Phase 135: accepts the row's branch pin (the third argument —
|
||||
``None`` for this file's rows)."""
|
||||
dest = Path(dest)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
(dest / "notes.md").write_text("# repo\ncontent for the KB\n", encoding="utf-8")
|
||||
@@ -1721,7 +1724,8 @@ def test_event_alone_cancels_at_the_import_boundary(
|
||||
clone_release = threading.Event()
|
||||
clone_calls: list[tuple[str, Path]] = []
|
||||
|
||||
def parking_clone(url: str, dest: Path | str) -> Path:
|
||||
def parking_clone(url: str, dest: Path | str, branch: str | None = None) -> Path:
|
||||
# phase 135: the row's branch pin (None for this file's rows)
|
||||
dest = Path(dest)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
(dest / "notes.md").write_text("# repo\ncontent for the KB\n", encoding="utf-8")
|
||||
@@ -1864,7 +1868,8 @@ def test_event_cancels_at_the_top_of_the_source_loop(
|
||||
release.wait() # blocking is fine: the date walk is a sync call
|
||||
return {}
|
||||
|
||||
def fast_clone(url: str, dest: Path | str) -> Path:
|
||||
def fast_clone(url: str, dest: Path | str, branch: str | None = None) -> Path:
|
||||
# phase 135: the row's branch pin (None for this file's rows)
|
||||
dest = Path(dest)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
(dest / "notes.md").write_text("# repo\ncontent for the KB\n", encoding="utf-8")
|
||||
|
||||
Reference in New Issue
Block a user