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:
@@ -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?",
|
||||
|
||||
Reference in New Issue
Block a user