phase: 135_git_source_branch
Build and Push Containers / build-and-push-app (push) Successful in 2m34s
Build and Push Containers / build-and-push-db (push) Successful in 14s

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:
2026-09-28 21:15:03 -04:00
parent fcac876d40
commit d3f8c7f2c8
50 changed files with 3955 additions and 140 deletions
+219 -4
View File
@@ -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()