Files
brain-of-reese/.agents/phases/complete/135_git_source_branch/00_phase.md
T
ducoterra d3f8c7f2c8
Build and Push Containers / build-and-push-app (push) Successful in 2m34s
Build and Push Containers / build-and-push-db (push) Successful in 14s
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`.
2026-09-28 21:15:03 -04:00

9.1 KiB

Phase 135 — A git source can pin the branch it mirrors

Source: TODO.md L4 — "Need to be able to specify the branch for a git source" Story: n/a (TODO-derived phase, Protocol B append). Context: app/models.py::GitSource (id, url, kind, path, ignore_paths, include_hidden, token, added_at — no branch column; the phase-121 token column is the nullable-write-only precedent); alembic/versions/ (0023 is the newest; every migration is reversible — the house rule); app/schemas.py — GitSourceIn / GitSourceOut / GitSourceRow / GitSourcePatchIn (the token field is the write-trimmed precedent; the output shapes use extra="forbid"); app/api/git_sources.py — POST (the _create_git_row / _create_local_row split, the fixed-detail 422s, the token normalization), PATCH /{source_id} (the per-field present-wins tri-state — the token's absent/None = no change, "" = clear, non-empty = replace), GET (DB rows vs the env-fallback synthetic rows); scripts/git_sync.py::clone_or_pull(url, dest) (fresh clone with full history; the shallow probe + one-time git fetch --unshallow self-heal (phase 107 D11); git pull --ff-only — the fail-loud D12 contract); the TWO call sites that resolve effective sources and clone git rows: app/api/sync.py::_run_sync and scripts/import_docs.py::_resolve_sources (both already pass clone_url_for(row) + repo_name(row.url)); file_commit_dates (walks the checkout — untouched); the UI: frontend/index.html — the add form #git-source-form (the url + token inputs) and the per-row editor dialog #ignore-editor-dialog (the source line, the ignore textarea, the token field); frontend/assets/git-sources.js — loadSources / makeRow (the "hidden on" text-tag idiom in the source cell), wireAddForm (opts.body(value, token) builds the POST body), the dialog open/save handlers (the PATCH body builder), the toggleHidden never-stale lifecycle as the per-row control precedent.

Objective

A git source can specify the branch it mirrors: the owner gives a branch when adding a git source (or edits it per-row later), the clone/pull honors it (a fresh clone checks the branch out; an existing checkout tracks it), BOTH entry points — the Sync button and the import_docs CLI — honor it, and every row without a branch (all existing rows + the env-fallback rows) behaves byte-identically to today (default branch, git pull --ff-only).

Dependencies

  • 134_sync_cancel (todo) — pipeline order only; no code dependency (independent surfaces).

Design (shared by all tasks — the executor reads this, not the chat)

  • The column (ASSUMPTION B1): git_sources.branch — TEXT NULL, server default NULL; alembic 0024 (reversible — add_column / drop_column, no data migration: NULL = "the repo's default branch", so every existing row is unchanged by definition). GitSource.branch: Mapped[str | None] = mapped_column(Text, nullable=True).
  • Validation (ASSUMPTION B2): a pure helper validate_branch_name(raw: str) -> str in app/rag/git_sources.py (next to normalize_credential — the write-path helper home; the API layer maps its ValueError to the fixed-detail 422). Contract: trim → reject empty; reject length > 200; reject anything not matching ^[A-Za-z0-9._/-]+$ (alphanumerics + dot, underscore, hyphen, slash — whitespace/@/~/^/:/?/*/[/backslash are all out); reject a name containing .. or //; reject a leading -, ., or /; reject a trailing / or .; reject HEAD (case-insensitive — git's refname; a branch literally named head is a documented cost of the simple rule). Returns the trimmed value. The API's 422 detail is the FIXED string not a valid branch name (never echoes the input — the house credential-safety discipline, applied for consistency).
  • The write paths (ASSUMPTION B3): GitSourceIn.branch: str | None (optional at create — absent/empty → NULL; trimmed in the schema before the length constraint, the _trim_url precedent). GitSourcePatchIn.branch: str | None = None — TRI-STATE exactly like the token: absent/None = no change; "" = clear (stored NULL); non-empty = set (re-validated). A branch on a LOCAL row (POST or PATCH) → 422 fixed detail branch is only valid for git sources (local rows are walked, not cloned). GitSourceOut.branch: str | None + GitSourceRow.branch: str | None (env-fallback synthetic rows report None — no DB row to store one on, the ignore_paths: [] precedent).
  • clone_or_pull(url, dest, branch: str | None = None) (ASSUMPTION B4): branch=None → the EXISTING code path byte-identical (same argv: clone without --branch; probe; unshallow-if-shallow; git pull --ff-only). A fresh clone with a branch → git clone --branch <b> url dest. An existing checkout with a branch → (1) the HEAD GUARD first: git rev-parse --abbrev-ref HEAD — if the result is not <b> → GitSyncError("checkout <dest> is on branch '<current>' but the source specifies branch '<b>' — remove and re-add the source to switch branches") (ASSUMPTION B5 — fail loud, the D12 convention; NO silent checkout switch in this phase: the checkout may serve other rows' documents, and a switch would silently re-map the source's file set; phase 136's explicit-ref reset makes the ref unambiguous later without changing this guard); (2) the shallow probe + one-time unshallow (unchanged); (3) git fetch origin <b>; (4) git merge --ff-only FETCH_HEAD (the single-fetch ff pull — the sequence phase 136 extends with its reset fallback).
  • Wiring: both call sites pass row.branch — app/api/sync.py::_run_sync and scripts/import_docs.py::_resolve_sources: clone_or_pull(clone_url_for(row), sources_root / repo_name(row.url), row.branch). Env-fallback synthetic rows carry branch=None (the GitSource(url=…, kind="git") constructor default). file_commit_dates, the checkout naming (repo_name stays on the BARE url), and the ignore/hidden/date maps are untouched.
  • The UI (ASSUMPTION B6): the add form gains an optional #git-source-branch text input (maxlength 200, autocomplete="off", hint "optional — default: the repo's default branch") between the URL and Token fields; the submit body becomes (url, token, branch) => ({ url, ...(branch ? { branch } : {}), ...(token ? { token } : {}) }) — a blank branch OMITS the key (NULL). The per-row editor dialog gains a #ignore-editor-branch field (visible for GIT rows only — local rows' dialogs hide it; it opens prefilled from s.branch ?? ""; the PATCH body for a git row ALWAYS includes branch: <trimmed value> — empty = the tri-state's clear leg, so clearing a branch is a deliberate empty save, unlike the token's blank = keep). Every STORED git row with a branch shows a branch: <name> text tag in the source cell (the "hidden on" tag idiom — text + background, WCAG: the text IS the signal, never color alone).

Tasks

  1. 01_branch_column.md — the alembic 0024 migration, the GitSource.branch column, the schema fields, the migration round-trip test.
  2. 02_branch_api.md — validate_branch_name + POST/PATCH/GET wiring (the tri-state, the local-row 422, the env rows).
  3. 03_clone_pull_branch.md — clone_or_pull(…, branch) (the --branch clone, the HEAD guard, the fetch+ff sequence) + both call sites.
  4. 04_branch_ui.md — the add-form field, the per-row editor field, the row tag.
  5. 05_e2e_git_source_branch.md — the dedicated Playwright E2E (the two-branch file:// fixture, the UI add-with-branch → sync → branch content, the fail-loud mismatch, the API contracts).

Testing & Quality

  • Unit: validate_branch_name (the full accept/reject matrix); clone_or_pull's argv sequences with mocked subprocess (the tests/unit/test_git_sync.py pattern — fresh-clone --branch, the existing-checkout guard → probe → fetch → merge, the mismatch raise, branch=None byte-identical).
  • Integration: the migration round-trip (0024); the API round-trips (create/patch/list, the tri-state, the 422s); a real file:// two-branch clone through clone_or_pull (the tests/integration/test_import_docs_git.py pattern) + the sync pipeline importing the branch's content.
  • E2E: one dedicated Playwright file, run in isolation.
  • Coverage: >90% on new/modified code (app/).

Completion Criteria

  • a git source added with a branch mirrors THAT branch (fresh clone + subsequent pulls) through both the Sync button and the CLI — pinned by the E2E + the integration test
  • a row with no branch (all existing rows + env rows) behaves byte-identically to pre-phase (the branch=None argv pin is green)
  • invalid branch names are rejected with the fixed 422 (never echoing input); a branch on a local row is a 422; the PATCH tri-state (set/change/clear) round-trips
  • setting a branch on an existing checkout on a different branch fails the sync LOUDLY with the remove-and-re-add message (no silent switch)
  • uv run pytest --cov=app --cov-report=term-missing green, app/ coverage >90%; uv run pytest tests/e2e/test_git_source_branch.py -v --no-cov green in isolation
  • no behavior change in completed phases (the token/ignore/hidden round-trips, the sync pipeline, the CLI — all existing tests green untouched)