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`.
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) -> strinapp/rag/git_sources.py(next tonormalize_credential— the write-path helper home; the API layer maps itsValueErrorto 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.; rejectHEAD(case-insensitive — git's refname; a branch literally namedheadis a documented cost of the simple rule). Returns the trimmed value. The API's 422 detail is the FIXED stringnot 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_urlprecedent).GitSourcePatchIn.branch: str | None = None— TRI-STATE exactly like the token: absent/None = no change;""= clear (stored NULL); non-empty = set (re-validated). Abranchon a LOCAL row (POST or PATCH) → 422 fixed detailbranch is only valid for git sources(local rows are walked, not cloned).GitSourceOut.branch: str | None+GitSourceRow.branch: str | None(env-fallback synthetic rows reportNone— no DB row to store one on, theignore_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_syncandscripts/import_docs.py::_resolve_sources:clone_or_pull(clone_url_for(row), sources_root / repo_name(row.url), row.branch). Env-fallback synthetic rows carrybranch=None(theGitSource(url=…, kind="git")constructor default).file_commit_dates, the checkout naming (repo_namestays on the BARE url), and the ignore/hidden/date maps are untouched. - The UI (ASSUMPTION B6): the add form gains an optional
#git-source-branchtext 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-branchfield (visible for GIT rows only — local rows' dialogs hide it; it opens prefilled froms.branch ?? ""; the PATCH body for a git row ALWAYS includesbranch: <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 abranch: <name>text tag in the source cell (the "hidden on" tag idiom — text + background, WCAG: the text IS the signal, never color alone).
Tasks
01_branch_column.md— the alembic 0024 migration, theGitSource.branchcolumn, the schema fields, the migration round-trip test.02_branch_api.md—validate_branch_name+ POST/PATCH/GET wiring (the tri-state, the local-row 422, the env rows).03_clone_pull_branch.md—clone_or_pull(…, branch)(the--branchclone, the HEAD guard, the fetch+ff sequence) + both call sites.04_branch_ui.md— the add-form field, the per-row editor field, the row tag.05_e2e_git_source_branch.md— the dedicated Playwright E2E (the two-branchfile://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 mockedsubprocess(thetests/unit/test_git_sync.pypattern — fresh-clone--branch, the existing-checkout guard → probe → fetch → merge, the mismatch raise,branch=Nonebyte-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 throughclone_or_pull(thetests/integration/test_import_docs_git.pypattern) + 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=Noneargv 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-missinggreen,app/coverage >90%;uv run pytest tests/e2e/test_git_source_branch.py -v --no-covgreen in isolation- no behavior change in completed phases (the token/ignore/hidden round-trips, the sync pipeline, the CLI — all existing tests green untouched)