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
+94 -5
View File
@@ -222,6 +222,36 @@
* token-free with no per-site change; the UI must never re-embed a
* credential.
*
* Phase 135 (task 04) — the branch pin (the field, the tri-state
* asymmetry, the tag rule): 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), and 201 clears the input
* alongside the URL/token. The per-row editor mirrors it:
* `#ignore-editor-branch` (after the source line, before the ignore
* textarea, with the "Leave empty for the repo's default branch." hint
* line) opens PREFILLED from the stored value (`s.branch ?? ""` —
* unlike the token, the API reports it, so there IS a field to read it
* from) and is VISIBLE FOR GIT ROWS ONLY — local rows hide the label +
* input + hint and the save body OMITS the key (local rows are walked,
* not cloned — the API's 422 is the backstop, the UI simply doesn't
* offer it). THE TRI-STATE ASYMMETRY: the git row's PATCH body ALWAYS
* includes `branch: <trimmed>` — an EMPTY save is the deliberate CLEAR
* leg (NULL = the repo's default branch), unlike the token's blank =
* keep (the token stays the blank = keep tri-state — 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, and the source cell's title names
* the branch (the hover-precision idiom). The §7.4 lifecycle is the
* existing save handler's, unchanged: Save disables in flight, 200 →
* close the dialog + loadSources() (the row re-renders from the server
* — the tag appears/disappears), failure → the dialog's error line +
* the inputs keep the user's text.
*
* Scope boundary (phase locked decisions): adding a git repo does
* NOT clone — the sync service (server-side) does that. Removing a
* source, however, performs the FULL cleanup server-side (phase 69):
@@ -261,6 +291,9 @@ export async function mount(root) {
/* Phase 121: the add form's optional masked token field — blank =
no credential (the key is omitted from the POST body). */
const tokenInput = root.querySelector("#git-source-token");
/* Phase 135: the add form's optional branch pin — blank = the key
is omitted (NULL = the repo's default branch). */
const branchInput = root.querySelector("#git-source-branch");
const addBtn = root.querySelector("#git-source-add");
const addError = root.querySelector("#git-source-error");
/* Phase 49: the archive upload form (replaces the phase-38 local
@@ -300,6 +333,14 @@ export async function mount(root) {
/* Phase 121: the editor's masked token field — blank = keep the
current token (the PATCH omits the key, the tri-state no-change). */
const ignoreTokenInput = root.querySelector("#ignore-editor-token");
/* Phase 135: the editor's branch field group (label + input + hint)
— git rows only (local rows hide all three); prefilled from the
stored value, and a git row's save ALWAYS sends the key (an empty
save is the tri-state's CLEAR leg — unlike the token's blank =
keep). */
const ignoreBranchLabel = root.querySelector('label[for="ignore-editor-branch"]');
const ignoreBranchInput = root.querySelector("#ignore-editor-branch");
const ignoreBranchHint = root.querySelector(".ignore-editor-branch-hint");
const ignoreErrorEl = root.querySelector("#ignore-editor-error");
const ignoreCancelBtn = root.querySelector("#ignore-editor-cancel");
const ignoreSaveBtn = root.querySelector("#ignore-editor-save");
@@ -423,7 +464,10 @@ export async function mount(root) {
const urlTd = document.createElement("td");
urlTd.className = "git-source-url-cell";
urlTd.title = value; // full URL/path on hover (long values scroll the wrapper)
// Full URL/path on hover (long values scroll the wrapper) — phase
// 135: a pinned branch is named too (the tag below is the
// at-a-glance signal, the title the full context).
urlTd.title = s.id && s.branch ? `${value} — branch: ${s.branch}` : value;
const badge = document.createElement("span");
badge.className = `git-source-kind is-${isLocal ? "local" : "git"}`;
badge.textContent = isLocal ? "Local" : "Git"; // text + color, never color alone
@@ -450,6 +494,17 @@ export async function mount(root) {
hiddenTag.textContent = "hidden on";
urlTd.append(hiddenTag);
}
/* Phase 135 (A6): the "branch: <name>" state tag — STORED git rows
with a branch only (env-fallback rows carry no id, NULL-branch
rows no truthy branch — no tag); the .git-source-hidden-count
idiom (TEXT + background, never color alone — WCAG 1.4.1: the
text IS the signal). */
if (s.id && s.branch) {
const branchTag = document.createElement("span");
branchTag.className = "git-source-branch-count";
branchTag.textContent = `branch: ${s.branch}`;
urlTd.append(branchTag);
}
tr.appendChild(urlTd);
const addedTd = document.createElement("td");
@@ -726,6 +781,19 @@ export async function mount(root) {
// token field to prefill from (LOCKED A2); blank = the PATCH
// omits the key (no change — the stored token is kept).
if (ignoreTokenInput) ignoreTokenInput.value = "";
// Phase 135: the branch field — git rows open PREFILLED from the
// stored value (the API reports it on every git row — unlike the
// token, there IS a field to read it from); local rows HIDE the
// field group (label + input + hint — local rows are walked, not
// cloned; the API's 422 is the backstop, the UI simply doesn't
// offer it).
const branchIsGit = s.kind !== "local";
if (ignoreBranchLabel) ignoreBranchLabel.hidden = !branchIsGit;
if (ignoreBranchHint) ignoreBranchHint.hidden = !branchIsGit;
if (ignoreBranchInput) {
ignoreBranchInput.hidden = !branchIsGit;
ignoreBranchInput.value = branchIsGit ? (s.branch ?? "") : "";
}
if (ignoreErrorEl) {
ignoreErrorEl.textContent = "";
ignoreErrorEl.hidden = true; // a new attempt starts clean
@@ -750,6 +818,7 @@ export async function mount(root) {
ignoreInFlight = false;
if (ignoreTextarea) ignoreTextarea.value = "";
if (ignoreTokenInput) ignoreTokenInput.value = ""; // phase 121: re-opens blank
if (ignoreBranchInput) ignoreBranchInput.value = ""; // phase 135: re-opens prefilled
if (ignoreErrorEl) {
ignoreErrorEl.textContent = "";
ignoreErrorEl.hidden = true;
@@ -795,6 +864,15 @@ export async function mount(root) {
// credential is kept); non-blank replaces it.
const token = ignoreTokenInput ? ignoreTokenInput.value.trim() : "";
const t = ignoreTarget;
// Phase 135: the branch pin — the tri-state ASYMMETRY (module
// docstring): a 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. A LOCAL row's body OMITS the key (the field group is
// hidden in the dialog — local rows are walked, not cloned; the
// API's 422 is the backstop).
const isGitRow = t.kind !== "local";
const branch = ignoreBranchInput ? ignoreBranchInput.value.trim() : "";
const value = t.kind === "local" ? (t.path ?? t.url) : t.url;
ignoreInFlight = true;
if (ignoreErrorEl) {
@@ -810,7 +888,11 @@ export async function mount(root) {
const r = await fetch(`/api/git-sources/${encodeURIComponent(t.id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ignore_paths: lines, ...(token ? { token } : {}) }),
body: JSON.stringify({
ignore_paths: lines,
...(isGitRow ? { branch } : {}), // phase 135: git rows always ("" = clear)
...(token ? { token } : {}),
}),
});
if (r.ok) {
// 200: the server replaced the row's list (A5).
@@ -917,7 +999,7 @@ export async function mount(root) {
* 409/422 details are fixed generic strings (credential safety — the
* URL is never echoed). */
function wireAddForm(opts) {
const { form, input, btn, error, tokenInput } = opts;
const { form, input, btn, error, tokenInput, branchInput } = opts;
if (!form || !input || !btn) return;
form.addEventListener("submit", async (e) => {
e.preventDefault();
@@ -938,10 +1020,13 @@ export async function mount(root) {
// Phase 121: the masked token rides the same POST — blank =
// the key is omitted (None = no credential; LOCKED A2).
const token = tokenInput ? tokenInput.value.trim() : "";
// Phase 135: the branch pin rides the same POST — blank = the
// key is omitted (NULL = the repo's default branch).
const branch = branchInput ? branchInput.value.trim() : "";
const r = await fetch("/api/git-sources", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(opts.body(value, token)),
body: JSON.stringify(opts.body(value, token, branch)),
});
if (r.ok) {
let createdId = null;
@@ -952,6 +1037,7 @@ export async function mount(root) {
}
input.value = ""; // 201: the source is stored
if (tokenInput) tokenInput.value = ""; // the credential is stored (write-only)
if (branchInput) branchInput.value = ""; // the branch is stored (NULL when blank)
announce(opts.addedMessage);
await loadSources(); // the new row lands in the table
focusNewRow(createdId); // a11y: land the caret on the new row
@@ -980,11 +1066,14 @@ export async function mount(root) {
form: formEl,
input: urlInput,
tokenInput,
branchInput,
btn: addBtn,
error: addError,
// Phase 135: the branch pin is included ONLY when non-blank
// (blank = key omitted = the repo's default branch, stored NULL).
// Phase 121: the masked token is included ONLY when non-blank
// (blank = key omitted = no credential — the API's tri-state).
body: (url, token) => ({ url, ...(token ? { token } : {}) }),
body: (url, token, branch) => ({ url, ...(branch ? { branch } : {}), ...(token ? { token } : {}) }),
emptyMessage: "Enter a git URL to add.",
failMessage: "Could not add the git source — try again.",
networkMessage: "Could not add the git source — is the app reachable?",