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:
@@ -0,0 +1,383 @@
|
||||
"""Unit: the branch-pin UI on the Sources page (phase 135, task 04).
|
||||
|
||||
``TODO.md`` L4: "Need to be able to specify the branch for a git
|
||||
source." The Sources manager lets the owner see, set, change, and
|
||||
clear a source's branch (ASSUMPTION B6):
|
||||
|
||||
* 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 — the pre-phase-135 clone/pull
|
||||
behavior), and 201 clears all three credential-free inputs;
|
||||
* the per-row EDITOR dialog gains ``#ignore-editor-branch`` (after the
|
||||
source line, before the ignore textarea, with the "Leave empty for
|
||||
the repo's default branch." hint line) — VISIBLE FOR GIT ROWS ONLY
|
||||
(local rows hide label + input + hint and the save body OMITS the
|
||||
key — local rows are walked, not cloned; the API's 422 is the
|
||||
backstop), opens PREFILLED from the stored value (``s.branch ?? ""``
|
||||
— unlike the token, the API reports it, so there IS a field to read
|
||||
it from);
|
||||
* THE TRI-STATE ASYMMETRY: the 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 (the token stays the conditional key — 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;
|
||||
the source cell's title names the branch (hover precision).
|
||||
|
||||
The browser behavior itself is E2E-gated by the phase-135 story suite
|
||||
(``tests/e2e/test_git_source_branch.py``, task 05); like the phase-121
|
||||
``test_git_source_token.py`` house pattern, this module pins the
|
||||
source-level contract a silent regression would break.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.schemas import GitSourceIn, GitSourceOut, GitSourcePatchIn, GitSourceRow
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
SHELL_HTML = FRONTEND / "index.html"
|
||||
GIT_SOURCES_JS = FRONTEND / "assets" / "git-sources.js"
|
||||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||
|
||||
ADD_BRANCH_PLACEHOLDER = "main, release/1.2, …"
|
||||
EDIT_BRANCH_PLACEHOLDER = "(default branch)"
|
||||
ADD_HINT_TEXT = "optional — default: the repo's default branch"
|
||||
EDITOR_HINT_TEXT = "Leave empty for the repo's default branch."
|
||||
ADD_BODY_EXPR = (
|
||||
"(url, token, branch) => ({ url, ...(branch ? { branch } : {}), "
|
||||
"...(token ? { token } : {}) })"
|
||||
)
|
||||
BRANCH_TAG_CLASS = "git-source-branch-count"
|
||||
BRANCH_TAG_TEXT_EXPR = "`branch: ${s.branch}`"
|
||||
TITLE_EXPR = 's.id && s.branch ? `${value} — branch: ${s.branch}` : value'
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _js_text() -> str:
|
||||
return _read(GIT_SOURCES_JS)
|
||||
|
||||
|
||||
def _fn(js: str, name: str) -> str:
|
||||
"""The source of a (possibly async) top-level function via
|
||||
balanced-brace counting (the test_git_source_token.py helper)."""
|
||||
for prefix in ("async function ", "function "):
|
||||
start = js.find(f"{prefix}{name}(")
|
||||
if start != -1:
|
||||
depth = 0
|
||||
for i in range(js.find("{", start), len(js)):
|
||||
if js[i] == "{":
|
||||
depth += 1
|
||||
elif js[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return js[start : i + 1]
|
||||
raise AssertionError(f"unbalanced braces in {name}()")
|
||||
raise AssertionError(f"{name}() must exist in git-sources.js")
|
||||
|
||||
|
||||
def _input_tag(html: str, id_attr: str) -> str:
|
||||
m = re.search(rf"<input[^>]*id=\"{id_attr}\"[^>]*>", html, re.S)
|
||||
assert m, f"missing <input id={id_attr}> in the shell"
|
||||
return m.group(0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The add form — #git-source-branch (between URL and Token)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_form_branch_field_is_optional_text() -> None:
|
||||
"""``#git-source-branch`` (task 04 step 1): ``type="text"`` (a
|
||||
branch is a name, not a credential — no masking), the 200-char cap
|
||||
mirrored (the schema's max_length), ``autocomplete="off"``, the
|
||||
placeholder, INSIDE ``#git-source-form`` BETWEEN the URL input and
|
||||
the Token input (before the submit button), and a visible
|
||||
``<label for=…>`` (WCAG — never aria-label-only) carrying the
|
||||
"optional" hint span."""
|
||||
html = _read(SHELL_HTML)
|
||||
tag = _input_tag(html, "git-source-branch")
|
||||
assert 'type="text"' in tag, "the branch is a plain name (no masking)"
|
||||
assert 'maxlength="200"' in tag, "the schema cap, mirrored in the form"
|
||||
assert 'autocomplete="off"' in tag, "no browser save offer"
|
||||
assert ADD_BRANCH_PLACEHOLDER in tag
|
||||
form_i = html.find('id="git-source-form"')
|
||||
url_i = html.find('id="git-source-url"')
|
||||
br_i = html.find('id="git-source-branch"')
|
||||
tok_i = html.find('id="git-source-token"')
|
||||
btn_i = html.find('id="git-source-add"')
|
||||
assert -1 < form_i < url_i < br_i < tok_i < btn_i, (
|
||||
"the field sits in the add form, between URL and Token"
|
||||
)
|
||||
label = re.search(r"<label[^>]*for=\"git-source-branch\"[^>]*>(.*?)</label>", html, re.S)
|
||||
assert label, "a visible <label for=…> (never aria-label-only)"
|
||||
assert "Branch" in label.group(1)
|
||||
hint = re.search(r"<span[^>]*class=\"field-hint\"[^>]*>(.*?)</span>", label.group(1))
|
||||
assert hint and hint.group(1) == ADD_HINT_TEXT, "the visible 'optional' hint span"
|
||||
|
||||
|
||||
def test_add_form_submit_omits_a_blank_branch() -> None:
|
||||
"""The submit body is ``(url, token, branch) => ({ url, ...(branch
|
||||
? { branch } : {}), ...(token ? { token } : {}) })`` — a BLANK
|
||||
branch omits the key (NULL = the repo's default branch);
|
||||
wireAddForm reads the field (trimmed) and passes it to the body
|
||||
callback AFTER the token, and 201 clears all three inputs
|
||||
(the credential-free inputs clear together)."""
|
||||
js = _js_text()
|
||||
assert f"body: {ADD_BODY_EXPR}" in js, "the submit body omits the key when blank"
|
||||
wire = _fn(js, "wireAddForm")
|
||||
read_i = wire.find('const branch = branchInput ? branchInput.value.trim() : "";')
|
||||
tok_i = wire.find('const token = tokenInput ? tokenInput.value.trim() : "";')
|
||||
call_i = wire.find("opts.body(value, token, branch)", read_i)
|
||||
assert -1 < tok_i < read_i < call_i, (
|
||||
"the branch is read (trimmed) after the token and passed to the body"
|
||||
)
|
||||
clear_url_i = wire.find('input.value = ""')
|
||||
clear_tok_i = wire.find('tokenInput.value = ""', clear_url_i)
|
||||
clear_br_i = wire.find('branchInput.value = ""', clear_tok_i)
|
||||
assert -1 < clear_url_i < clear_tok_i < clear_br_i, "201: all three inputs clear"
|
||||
|
||||
|
||||
def test_add_form_branch_opt_in_the_wire_opts() -> None:
|
||||
"""The wireAddForm call site threads ``branchInput`` into the opts
|
||||
(next to the token lookup — the add form's single wiring point)."""
|
||||
js = _js_text()
|
||||
lookup_i = js.find('root.querySelector("#git-source-branch")')
|
||||
assert lookup_i != -1, "the element lookup next to the token lookups"
|
||||
opts = js[js.find("wireAddForm({") :]
|
||||
opts = opts[: opts.find("});")]
|
||||
assert "branchInput," in opts, "the opts object gains branchInput"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The per-row editor — #ignore-editor-branch (git rows only, prefilled,
|
||||
# empty-save = clear)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_editor_branch_field_sits_after_source_before_textarea() -> None:
|
||||
"""``#ignore-editor-branch`` (task 04 step 1): ``type="text"`` +
|
||||
the 200-char cap + ``autocomplete="off"`` + the "(default branch)"
|
||||
placeholder, INSIDE ``#ignore-editor-dialog`` AFTER the source line
|
||||
and BEFORE the ignore textarea (before the token field + error
|
||||
line), with a visible label and the muted hint line."""
|
||||
html = _read(SHELL_HTML)
|
||||
tag = _input_tag(html, "ignore-editor-branch")
|
||||
assert 'type="text"' in tag
|
||||
assert 'maxlength="200"' in tag
|
||||
assert 'autocomplete="off"' in tag
|
||||
assert f'placeholder="{EDIT_BRANCH_PLACEHOLDER}"' in tag
|
||||
dialog_i = html.find('id="ignore-editor-dialog"')
|
||||
src_i = html.find('id="ignore-editor-source"')
|
||||
br_i = html.find('id="ignore-editor-branch"')
|
||||
copy_i = html.find('id="ignore-editor-copy"')
|
||||
ta_i = html.find('id="ignore-editor-textarea"')
|
||||
tok_i = html.find('id="ignore-editor-token"')
|
||||
err_i = html.find('id="ignore-editor-error"')
|
||||
assert -1 < dialog_i < src_i < br_i < copy_i < ta_i < tok_i < err_i, (
|
||||
"the field sits after the source line, before the ignore textarea"
|
||||
)
|
||||
label = re.search(r"<label[^>]*for=\"ignore-editor-branch\"[^>]*>(.*?)</label>", html, re.S)
|
||||
assert label and "Branch" in label.group(1), "a visible label (WCAG)"
|
||||
assert 'class="ignore-editor-label"' in (label.group(0) if label else ""), (
|
||||
"the house dialog-label styling"
|
||||
)
|
||||
hint = re.search(
|
||||
r"<p class=\"ignore-editor-branch-hint\">(.*?)</p>", html, re.S
|
||||
)
|
||||
assert hint and hint.group(1).strip() == EDITOR_HINT_TEXT, "the muted hint line"
|
||||
|
||||
|
||||
def test_editor_opens_prefilled_for_git_hidden_for_local() -> None:
|
||||
"""openIgnoreEditor: git rows open the branch field prefilled from
|
||||
the stored value (``s.branch ?? ""`` — there IS a field to read it
|
||||
from, unlike the token) and VISIBLE; local rows HIDE the whole
|
||||
field group (label + input + hint — local rows are walked, not
|
||||
cloned) and clear the value."""
|
||||
js = _js_text()
|
||||
open_body = _fn(js, "openIgnoreEditor")
|
||||
assert "const branchIsGit = s.kind !== \"local\";" in open_body, (
|
||||
"the git-row gate"
|
||||
)
|
||||
prefill_i = open_body.find('ignoreBranchInput.value = branchIsGit ? (s.branch ?? "") : "";')
|
||||
assert prefill_i != -1, "prefilled from the stored value (s.branch ?? \"\")"
|
||||
for frag in (
|
||||
"ignoreBranchLabel.hidden = !branchIsGit",
|
||||
"ignoreBranchHint.hidden = !branchIsGit",
|
||||
"ignoreBranchInput.hidden = !branchIsGit",
|
||||
):
|
||||
assert frag in open_body, f"the field group hides for local rows: {frag!r}"
|
||||
label_i = open_body.find("ignoreBranchLabel.hidden = !branchIsGit")
|
||||
hint_i = open_body.find("ignoreBranchHint.hidden = !branchIsGit")
|
||||
assert -1 < label_i < hint_i < prefill_i, "the group hides before the prefill"
|
||||
|
||||
|
||||
def test_editor_save_always_sends_branch_for_git_rows() -> None:
|
||||
"""saveIgnorePaths: the branch is read (trimmed) before the PATCH;
|
||||
the git row's body ALWAYS includes ``branch`` —
|
||||
``...(isGitRow ? { branch } : {})`` — an EMPTY value is the
|
||||
deliberate CLEAR leg (the tri-state asymmetry, documented at the
|
||||
read site), while a LOCAL row's body OMITS the key; the token
|
||||
stays the conditional key (blank = keep — untouched) and lands
|
||||
AFTER the branch key."""
|
||||
js = _js_text()
|
||||
save = _fn(js, "saveIgnorePaths")
|
||||
read_i = save.find('const branch = ignoreBranchInput ? ignoreBranchInput.value.trim() : "";')
|
||||
assert read_i != -1, "the branch is read (trimmed) before the PATCH"
|
||||
assert "const isGitRow = t.kind !== \"local\";" in save, "the git-row gate"
|
||||
# The asymmetry is documented at the read site (the docstring
|
||||
# comment above the read names the clear leg).
|
||||
comment_i = save.find("the tri-state ASYMMETRY", 0, read_i)
|
||||
assert -1 < comment_i < read_i, "the tri-state asymmetry is documented at the read"
|
||||
body_i = save.find("...(isGitRow ? { branch } : {})")
|
||||
tok_i = save.find("...(token ? { token } : {})", body_i)
|
||||
assert -1 < read_i < body_i < tok_i, (
|
||||
"git rows always send branch (\"\" = clear); the token stays conditional"
|
||||
)
|
||||
assert "ignore_paths: lines" in save[body_i - 60 : body_i + 60], (
|
||||
"the ignore_paths replace is untouched"
|
||||
)
|
||||
|
||||
|
||||
def test_editor_close_resets_the_branch_field() -> None:
|
||||
"""closeIgnoreEditor: the branch input resets to "" (it re-opens
|
||||
prefilled from the server row — the phase-121 token reset
|
||||
precedent, with a prefilled twist)."""
|
||||
js = _js_text()
|
||||
close_body = _fn(js, "closeIgnoreEditor")
|
||||
assert 'ignoreBranchInput.value = ""' in close_body, "resets on close"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# makeRow — the "branch: <name>" tag + the hover title
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_make_row_branch_tag_is_stored_git_rows_only() -> None:
|
||||
"""makeRow: the ``branch: <name>`` text tag is appended to the
|
||||
source cell IFF ``s.id && s.branch`` (env-fallback rows carry no
|
||||
id, NULL-branch rows no truthy branch — no tag either way); the
|
||||
tag uses the "hidden on" idiom (text + background — the
|
||||
.git-source-branch-count class, grouped with
|
||||
.git-source-hidden-count in styles.css) and the TEXT is the signal
|
||||
(``branch: <name>`` — never color alone)."""
|
||||
js = _js_text()
|
||||
make = _fn(js, "makeRow")
|
||||
guard_i = make.find("if (s.id && s.branch) {")
|
||||
assert guard_i != -1, "stored git rows with a branch only"
|
||||
block = make[guard_i :]
|
||||
block = block[: block.find("\n }")]
|
||||
assert f"branchTag.className = \"{BRANCH_TAG_CLASS}\";" in block
|
||||
assert f"branchTag.textContent = {BRANCH_TAG_TEXT_EXPR};" in block, (
|
||||
"the text IS the signal (branch: <name>)"
|
||||
)
|
||||
assert "urlTd.append(branchTag)" in block, "the tag lands in the source cell"
|
||||
# The tag comes after the hidden tag (the source-cell tag order:
|
||||
# ignored → hidden → branch) and before the cell is appended.
|
||||
hidden_i = make.find('hiddenTag.className = "git-source-hidden-count"')
|
||||
append_i = make.find("tr.appendChild(urlTd)")
|
||||
assert -1 < hidden_i < guard_i < append_i
|
||||
|
||||
|
||||
def test_make_row_title_names_the_branch() -> None:
|
||||
"""The source cell's title (the hover-precision idiom) includes the
|
||||
pinned branch for stored git rows with one; every other row keeps
|
||||
the bare value (the pre-phase-135 title — the token E2E's
|
||||
to_have_attribute("title", URL) pins stay green)."""
|
||||
js = _js_text()
|
||||
make = _fn(js, "makeRow")
|
||||
assert f"urlTd.title = {TITLE_EXPR};" in make, (
|
||||
"the title names the branch (stored git rows only)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-file wire contract — the JS key names match the API shapes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_js_branch_keys_match_the_api_shapes() -> None:
|
||||
"""The wire contract: the POST body key and the PATCH body key are
|
||||
both ``branch`` (task 04 step 3 — the branch rides the existing
|
||||
payloads), and every API shape carries the field (task 01/02):
|
||||
``GitSourceIn`` (create), ``GitSourcePatchIn`` (tri-state),
|
||||
``GitSourceOut`` + ``GitSourceRow`` (the row the UI renders —
|
||||
``s.branch`` is read off it). A rename on either side breaks."""
|
||||
assert "branch" in GitSourceIn.model_fields
|
||||
assert "branch" in GitSourcePatchIn.model_fields
|
||||
assert "branch" in GitSourceOut.model_fields
|
||||
assert "branch" in GitSourceRow.model_fields
|
||||
js = _js_text()
|
||||
assert "...(branch ? { branch } : {})" in js, "the POST body key (blank-omits)"
|
||||
assert "...(isGitRow ? { branch } : {})" in js, "the PATCH body key (always for git)"
|
||||
assert "(s.branch ?? \"\")" in js, "the prefill reads s.branch off the row"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The module docstring — the phase-135 paragraph (the house pattern)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_module_docstring_carries_the_phase_135_contract() -> None:
|
||||
"""The git-sources.js module docstring gained the phase-135
|
||||
paragraph (the field, the tri-state asymmetry, the tag rule)."""
|
||||
doc = _js_text().split("*/", 2)[0] # the module docstring (first block)
|
||||
for frag in (
|
||||
"Phase 135 (task 04)",
|
||||
"#git-source-branch",
|
||||
"#ignore-editor-branch",
|
||||
"s.branch ?? \"\"",
|
||||
"THE TRI-STATE ASYMMETRY",
|
||||
"branch: <name>",
|
||||
"the tag appears/disappears",
|
||||
):
|
||||
assert frag in doc, f"the module docstring lost: {frag!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# styles.css — house-styled rules (no second input language)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_branch_field_css_rules_are_house_styled() -> None:
|
||||
"""styles.css: the add form's branch input joins the URL/token box
|
||||
group (mono, >=44px floor, placeholder + focus rules); the editor's
|
||||
branch field is full panel width at the 44px floor (the token
|
||||
field's box language); the hint line carries the .field-hint values
|
||||
(ink-soft, 0.8rem); the row tag joins the .git-source-hidden-count
|
||||
rule (text + a distinct background, never color alone); the mobile
|
||||
squeeze groups the add input with URL/token."""
|
||||
css = _read(STYLES_CSS)
|
||||
grouped = css.find("#git-source-url,\n#git-source-branch,\n#git-source-token {")
|
||||
assert grouped != -1, "#git-source-branch shares the URL/token box"
|
||||
rule = css[grouped : css.find("}", grouped)]
|
||||
assert "min-height: 44px" in rule and "var(--mono)" in rule
|
||||
assert "#git-source-branch::placeholder" in css, "the muted placeholder"
|
||||
assert "#git-source-branch:focus-visible" in css, "the brand focus border"
|
||||
br = css.find("#ignore-editor-branch {")
|
||||
assert br != -1, "the editor's branch field rule"
|
||||
br_rule = css[br : css.find("}", br)]
|
||||
assert "width: 100%" in br_rule and "min-height: 44px" in br_rule
|
||||
hint = css.find(".ignore-editor-branch-hint {")
|
||||
assert hint != -1, "the muted hint line rule"
|
||||
hint_rule = css[hint : css.find("}", hint)]
|
||||
assert "var(--ink-soft)" in hint_rule and "font-size: 0.8rem" in hint_rule, (
|
||||
"the .field-hint values (the token field's hint styling)"
|
||||
)
|
||||
tag = css.find(".git-source-hidden-count,\n.git-source-branch-count {")
|
||||
assert tag != -1, "the row tag joins the 'hidden on' tag rule"
|
||||
tag_rule = css[tag : css.find("}", tag)]
|
||||
assert "background: var(--bg)" in tag_rule and "border" in tag_rule, (
|
||||
"text + background (never color alone — WCAG 1.4.1)"
|
||||
)
|
||||
mobile = css.find("#git-source-url,\n #git-source-branch,\n #git-source-token,")
|
||||
assert mobile != -1, "the mobile squeeze groups the branch input"
|
||||
Reference in New Issue
Block a user