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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user