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?",
+37 -2
View File
@@ -2788,6 +2788,7 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
credential — the URL input's treatment VERBATIM (mono, >=44px
target, brand focus); type=password masks the value in display. */
#git-source-url,
#git-source-branch,
#git-source-token {
flex: 1;
min-width: 14rem;
@@ -2801,8 +2802,10 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
padding: 0.45rem 0.7rem;
}
#git-source-url::placeholder,
#git-source-branch::placeholder,
#git-source-token::placeholder { color: var(--ink-soft); }
#git-source-url:focus-visible,
#git-source-branch:focus-visible,
#git-source-token:focus-visible { outline-offset: 0; border-color: var(--brand); }
#git-source-add,
#archive-upload-btn {
@@ -3226,6 +3229,34 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
border-radius: var(--radius-sm);
}
/* Phase 135 (task 04): the editor's branch field — the token field's
box language (full panel width, --bg fill, the line border, mono)
at the 44px touch floor; type=text (a branch is a name, not a
credential — no masking) + autocomplete=off live in the markup, and
the label reuses .ignore-editor-label (the source line's 0.75rem
bottom margin is the field pair's top gap — no extra margin class).
The muted hint line carries the .field-hint values (ink-soft, 400,
0.8rem — the token field's hint styling) as a standalone <p>; the
bottom margin is the gap to the copy line (which has no top margin).
Git rows only — git-sources.js hides the group for local rows. */
#ignore-editor-branch {
display: block;
width: 100%;
min-height: 44px;
padding: 0.55rem 0.65rem;
font-family: var(--mono);
font-size: 0.85rem;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
}
.ignore-editor-branch-hint {
margin: 0.35rem 0 1rem;
color: var(--ink-soft);
font-size: 0.8rem;
}
/* The in-dialog failure line (role=alert): the err pair (err-ink on
err-bg 9.3:1, the err-line border) — the .remove-confirm-error
language. */
@@ -3450,8 +3481,11 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
.git-source-hidden:disabled { opacity: 0.5; cursor: wait; }
/* Phase 105 (A5): the "hidden on" state tag — a copy of the
.git-source-ignore-count idiom (TEXT + background, never
color alone — WCAG 1.4.1: --ink on --bg 16.7:1). */
.git-source-hidden-count {
color alone — WCAG 1.4.1: --ink on --bg 16.7:1). Phase 135 (A6):
the "branch: <name>" state tag joins the same rule — stored git
rows with a branch only, the text IS the signal. */
.git-source-hidden-count,
.git-source-branch-count {
display: inline-block;
margin-left: 0.55rem;
padding: 0.08rem 0.5rem;
@@ -5405,6 +5439,7 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
#git-source-form > label,
#archive-upload-form > label { white-space: normal; }
#git-source-url,
#git-source-branch,
#git-source-token,
#archive-upload-file { min-width: 0; }
#git-source-add,
+32
View File
@@ -707,6 +707,22 @@
placeholder="https://github.com/you/your-repo.git"
required
>
<!-- Phase 135 (task 04): the optional branch pin
(ASSUMPTION B6) — which branch of the repo the source
mirrors. BLANK = the POST omits the key (NULL = the
repo's DEFAULT branch — the pre-phase-135 clone/pull
behavior, byte-identical); a non-blank value is
validated server-side (validate_branch_name — the
fixed-detail 422 never echoes the input). -->
<label for="git-source-branch">Branch <span class="field-hint">optional — default: the repo's default branch</span></label>
<input
id="git-source-branch"
name="branch"
type="text"
maxlength="200"
autocomplete="off"
placeholder="main, release/1.2, …"
>
<!-- Phase 121 (task 03, LOCKED A2): the optional masked
private-repo credential — type=password +
autocomplete=off (a PAT is not a site credential: no
@@ -877,6 +893,22 @@
<div class="ignore-editor-panel">
<h2 class="ignore-editor-title" id="ignore-editor-title">Source settings</h2>
<code class="ignore-editor-source" id="ignore-editor-source"></code>
<!-- Phase 135 (task 04): the per-row branch editor —
GIT rows only (git-sources.js hides the label +
input + hint for local rows — local rows are walked,
not cloned — and the save body omits the key; the
API's 422 is the backstop). Opens prefilled from the
stored value (the API reports branch on every git
row — unlike the token, there IS a field to read it
from). THE TRI-STATE ASYMMETRY: a git row's PATCH
body ALWAYS includes branch — an EMPTY save is the
deliberate CLEAR leg (NULL = the repo's default
branch), unlike the token's blank = keep. -->
<label class="ignore-editor-label" for="ignore-editor-branch">Branch</label>
<input id="ignore-editor-branch"
type="text" maxlength="200" autocomplete="off"
placeholder="(default branch)">
<p class="ignore-editor-branch-hint">Leave empty for the repo's default branch.</p>
<p class="ignore-editor-copy" id="ignore-editor-copy">
One path per line. A file is ignored when its path in the
source starts with any of these — <code>/my/files/</code>,