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
+383
View File
@@ -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"
+42 -17
View File
@@ -174,6 +174,7 @@ def _out_kwargs() -> dict:
"added_at": None,
"ignore_paths": [],
"include_hidden": False,
"branch": None,
}
@@ -186,6 +187,7 @@ def _row_kwargs() -> dict:
"added_at": None,
"ignore_paths": [],
"include_hidden": False,
"branch": None,
}
@@ -464,8 +466,19 @@ STYLES_CSS = FRONTEND / "assets" / "styles.css"
ADD_TOKEN_PLACEHOLDER = "ghp_… or another PAT"
EDIT_TOKEN_PLACEHOLDER = "leave blank to keep the current token"
ADD_BODY_EXPR = "(url, token) => ({ url, ...(token ? { token } : {}) })"
PATCH_BODY_EXPR = "JSON.stringify({ ignore_paths: lines, ...(token ? { token } : {}) })"
#: Phase 135 (task 04) widened the body to the branch pin — the token
#: key keeps its conditional shape (the contract under test) and the
#: branch key (git rows always — "" = the tri-state's clear leg) lands
#: first (test_git_source_branch_ui.py pins the branch contract).
ADD_BODY_EXPR = (
"(url, token, branch) => ({ url, ...(branch ? { branch } : {}), "
"...(token ? { token } : {}) })"
)
#: The phase-121 paragraph of the module docstring records the body AS
#: IT WAS in phase 121 (a historical record — the phase-135 paragraph
#: records the widened expression, pinned by test_git_source_branch_ui.py).
ADD_BODY_EXPR_PHASE_121 = "(url, token) => ({ url, ...(token ? { token } : {}) })"
PATCH_BODY_EXPR = "...(token ? { token } : {})"
def _read(path: Path) -> str:
@@ -526,20 +539,22 @@ def test_add_form_token_field_is_masked_and_optional() -> None:
def test_add_form_submit_omits_a_blank_token() -> None:
"""The submit body is `(url, token) => ({ url, ...(token ?
{ token } : {}) })` — a BLANK token omits the key (None = no
credential); wireAddForm reads the masked field and passes it to
the body callback, and 201 clears BOTH inputs (the credential is
stored — write-only)."""
"""The submit body is `(url, token, branch) => ({ url, ...(branch ?
{ branch } : {}), ...(token ? { token } : {}) })` — a BLANK token
omits the key (None = no credential; phase 135 added the branch
key, which also omits when blank); wireAddForm reads the masked
field and passes it to the body callback, and 201 clears ALL THREE
credential-free inputs (the credential is stored — write-only)."""
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('tokenInput ? tokenInput.value.trim() : ""')
call_i = wire.find("opts.body(value, token)", read_i)
call_i = wire.find("opts.body(value, token, branch)", read_i)
assert -1 < read_i < call_i, "the token is read from the field and passed to the body"
clear_url_i = wire.find('input.value = ""')
clear_tok_i = wire.find("tokenInput.value = \"\"", clear_url_i)
assert -1 < clear_url_i < clear_tok_i, "201: both inputs clear"
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_editor_token_field_is_masked_and_blanks_to_keep() -> None:
@@ -570,15 +585,23 @@ def test_editor_token_field_is_masked_and_blanks_to_keep() -> None:
def test_editor_patch_omits_a_blank_token() -> None:
"""saveIgnorePaths: the token is read AFTER the line parse and the
PATCH body is `{ ignore_paths: lines, ...(token ? { token } : {}) }`
— a BLANK token omits the key (the task-01 tri-state: absent = no
change, the row's stored credential is kept)."""
PATCH body keeps the token as the CONDITIONAL key
(`...(token ? { token } : {})` — a BLANK token omits the key, the
task-01 tri-state: absent = no change, the row's stored credential
is kept). Phase 135 (task 04) widens the body with the branch key
(`...(isGitRow ? { branch } : {})` — ALWAYS for git rows: an empty
save is the deliberate clear leg, unlike the token's blank = keep)
landing before the token key — the token's semantics are untouched
(the branch contract is pinned by test_git_source_branch_ui.py)."""
js = _js_text()
save = _fn(js, "saveIgnorePaths")
drop_i = save.find(".filter(Boolean)")
read_i = save.find('ignoreTokenInput ? ignoreTokenInput.value.trim() : ""', drop_i)
body_i = save.find(PATCH_BODY_EXPR, read_i)
assert -1 < drop_i < read_i < body_i, ("blank token → key omitted (tri-state no-change)")
branch_i = save.find("...(isGitRow ? { branch } : {})", read_i)
body_i = save.find(PATCH_BODY_EXPR, branch_i)
assert -1 < drop_i < read_i < branch_i < body_i, (
"blank token → key omitted (tri-state no-change; the branch key lands first)"
)
def test_display_sites_render_the_server_bare_url_only() -> None:
@@ -609,7 +632,7 @@ def test_module_docstring_carries_the_phase_121_token_contract() -> None:
"#git-source-token",
"type=password",
"optional — private repos",
ADD_BODY_EXPR,
ADD_BODY_EXPR_PHASE_121,
"leave blank to keep the current token",
"sanitize_url",
):
@@ -625,7 +648,9 @@ def test_token_field_css_rules_are_house_styled() -> None:
width at the 44px floor; the mobile media query groups it in the
same min-width:0 rule as the URL input."""
css = _read(STYLES_CSS)
grouped = css.find("#git-source-url,\n#git-source-token {")
# Phase 135 (task 04): the branch input joined the group — the
# token input still shares the URL input's box.
grouped = css.find("#git-source-url,\n#git-source-branch,\n#git-source-token {")
assert grouped != -1, "#git-source-token shares #git-source-url's box"
rule = css[grouped : css.find("}", grouped)]
assert "min-height: 44px" in rule and "var(--mono)" in rule
@@ -637,5 +662,5 @@ def test_token_field_css_rules_are_house_styled() -> None:
assert tok != -1, "the editor's token field rule"
tok_rule = css[tok : css.find("}", tok)]
assert "width: 100%" in tok_rule and "min-height: 44px" in tok_rule
mobile = css.find("#git-source-url,\n #git-source-token,")
mobile = css.find("#git-source-url,\n #git-source-branch,\n #git-source-token,")
assert mobile != -1, "the mobile squeeze groups the token input"
+150
View File
@@ -0,0 +1,150 @@
"""Unit: the branch-name validator (phase 135, task 02).
``validate_branch_name`` is the single choke point for every branch
value that enters the API (POST create + PATCH set/change legs —
ASSUMPTION B2). The contract, checked in the documented order (trim →
empty → length → charset → ``..``/``//`` → leading → trailing →
HEAD):
* TRIM first — the returned name is the trimmed input, so a
schema-trimmed value is a fixed point;
* empty (or whitespace-only) → ``ValueError``;
* > 200 chars → ``ValueError`` (exactly 200 passes — the schema's
``max_length=200`` is the first line, the validator the choke point
a future caller must not bypass);
* outside the safe charset ``[A-Za-z0-9._/-]+`` → ``ValueError``
(whitespace, ``@``, ``~``, ``^``, ``:``, ``?``, ``*``, ``[``,
backslash — all out);
* contains ``..`` or ``//`` → ``ValueError`` (path-traversal-shaped
names, even though the charset would allow the pieces);
* leading ``-`` / ``.`` / ``/`` → ``ValueError``;
* trailing ``/`` or ``.`` → ``ValueError``;
* ``HEAD`` (any case) → ``ValueError`` (git's refname — a branch
literally named ``head`` is a documented cost of the simple rule).
The raised ``ValueError`` messages are FIXED reason strings — the
API maps them to the fixed-detail 422 and the raw input is never
echoed (the house credential-safety discipline, applied for
consistency): the tests pin that no message repeats the rejected
input.
"""
from __future__ import annotations
import pytest
from app.rag.git_sources import validate_branch_name
# ---------------------------------------------------------------------------
# Accept
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"raw",
[
"main",
"release/1.2",
"feature_x",
"a.b/c",
"v2-rc.1",
],
)
def test_valid_names_pass_through_trimmed(raw: str) -> None:
"""The accepted shapes — plain, slashed, dotted, dashed,
underscored — return exactly the trimmed input."""
assert validate_branch_name(raw) == raw
def test_trim_applied_before_validation_and_returned() -> None:
"""Surrounding whitespace is stripped: the returned value is the
trimmed name (``" main "`` → ``main``)."""
assert validate_branch_name(" main ") == "main"
assert validate_branch_name("\trelease/1.2\n") == "release/1.2"
def test_exactly_200_chars_passes() -> None:
"""The length boundary is > 200: a 200-char name is valid
(the charset allows it — a long but well-formed branch path)."""
name = "a" * 196 + "/x.1"
assert len(name) == 200
assert validate_branch_name(name) == name
# ---------------------------------------------------------------------------
# Reject
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"raw",
[
"",
" ",
"a b", # whitespace inside
"a@b",
"a~b",
"a:b",
"a?b",
"a*b",
"a[b",
"a\\b",
"a^b",
],
)
def test_empty_and_charset_violations_rejected(raw: str) -> None:
"""Empty/whitespace-only and non-charset characters all raise."""
with pytest.raises(ValueError):
validate_branch_name(raw)
def test_201_chars_rejected() -> None:
"""One past the length limit raises (even though the charset
allows every character)."""
with pytest.raises(ValueError):
validate_branch_name("a" * 201)
@pytest.mark.parametrize("raw", ["a..b", "a//b", "..", "//"])
def test_path_sequences_rejected(raw: str) -> None:
"""``..`` and ``//`` are disallowed — traversal-shaped names,
including the two-character bare forms."""
with pytest.raises(ValueError):
validate_branch_name(raw)
@pytest.mark.parametrize("raw", ["-x", ".x", "/x"])
def test_leading_characters_rejected(raw: str) -> None:
"""A leading ``-`` (git would read it as an option), ``.``, or
``/`` raises."""
with pytest.raises(ValueError):
validate_branch_name(raw)
@pytest.mark.parametrize("raw", ["x/", "x."])
def test_trailing_characters_rejected(raw: str) -> None:
"""A trailing ``/`` or ``.`` raises."""
with pytest.raises(ValueError):
validate_branch_name(raw)
@pytest.mark.parametrize("raw", ["HEAD", "head", "HeAd", " hEaD "])
def test_head_rejected_case_insensitively(raw: str) -> None:
"""``HEAD`` in any case is git's refname — rejected (the trim runs
first, so even `` hEaD `` is the reserved name)."""
with pytest.raises(ValueError):
validate_branch_name(raw)
def test_reject_messages_are_fixed_and_input_free() -> None:
"""The ``ValueError`` reasons are fixed strings — none of them
echoes the rejected input (the API maps to the fixed-detail 422
on the same discipline; a secret-ish name typed into the field
must not surface in a log line either)."""
for raw in ("a b", "a..b", "x/", "HEAD", "a" * 201, ""):
try:
validate_branch_name(raw)
except ValueError as e:
if raw:
assert raw not in str(e)
else:
pytest.fail(f"{raw!r} must be rejected")
+263 -1
View File
@@ -1,9 +1,16 @@
"""Unit tests: git clone/pull utility (phase 28; phase 107 D11/D12).
"""Unit tests: git clone/pull utility (phase 28; phase 107 D11/D12;
phase 135 adds the branch pin — the argv matrix).
``clone_or_pull`` dispatches to ``git clone`` (full history, fresh dest)
or a shallow-probe + optional ``git fetch --unshallow`` +
``git pull --ff-only`` (existing checkout) with ``subprocess`` fully
mocked — the real git CLI is never invoked, so the tests run anywhere.
Phase 135: with a pinned ``branch`` the fresh clone gains ``--branch``
and the existing checkout runs the HEAD guard → probe → (unshallow) →
``fetch origin <b>`` → ``merge --ff-only FETCH_HEAD`` sequence;
``branch=None`` (and the two-argument call) keeps the pre-phase argv
BYTE-IDENTICAL — the argv pins below are the regression gate for
every existing deployment.
"""
from __future__ import annotations
@@ -236,3 +243,258 @@ def test_run_git_captures_and_returns_stdout(
assert git_sync.run_git(["git", "status"], cwd=tmp_path) == "From example.com\n + abc..def main"
assert len(calls) == 1
# --- phase 135: the branch pin (the argv matrix) ----------------------------
def test_branch_none_argv_is_byte_identical_pre_phase_sequence(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The regression gate: ``branch=None`` (explicit, the call sites'
form for every pre-phase-135 row) produces the EXACT pre-phase argv
— fresh: one ``git clone`` without ``--branch``; existing (not
shallow): probe → ``git pull --ff-only``; existing (shallow):
probe → ``fetch --unshallow`` → ``git pull --ff-only``. A future
refactor that rewrites the default path breaks this pin."""
url = "https://github.com/user/homelab.git"
dest = tmp_path / "homelab"
# fresh (dest absent)
calls = _fake_run(monkeypatch)
clone_or_pull(url, dest, None)
assert [call["argv"] for call in calls] == [["git", "clone", url, str(dest)]]
# existing, not shallow
(dest / ".git").mkdir(parents=True)
calls = _fake_run(
monkeypatch,
responses={("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", "")},
)
clone_or_pull(url, dest, None)
assert [call["argv"] for call in calls] == [
["git", "rev-parse", "--is-shallow-repository"],
["git", "pull", "--ff-only"],
]
# existing, shallow (the one-time D11 self-heal between probe and pull)
calls = _fake_run(
monkeypatch,
responses={
("git", "rev-parse", "--is-shallow-repository"): (0, "true\n", ""),
("git", "fetch", "--unshallow"): (0, "", ""),
},
)
clone_or_pull(url, dest, None)
assert [call["argv"] for call in calls] == [
["git", "rev-parse", "--is-shallow-repository"],
["git", "fetch", "--unshallow"],
["git", "pull", "--ff-only"],
]
def test_fresh_clone_with_branch_uses_clone_branch(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Phase 135: fresh dest + pinned branch → ``git clone --branch <b>
url dest`` (the branch is checked out from the first commit — no
post-clone checkout; still full history, no ``--depth``)."""
url = "https://github.com/user/homelab.git"
dest = tmp_path / "homelab"
calls = _fake_run(monkeypatch)
result = clone_or_pull(url, dest, "release")
assert result == dest
assert [call["argv"] for call in calls] == [
["git", "clone", "--branch", "release", url, str(dest)]
]
assert calls[0]["cwd"] == dest.parent
def test_existing_checkout_matching_branch_fetches_and_ff_merges_the_ref(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Phase 135: existing checkout ALREADY on the pinned branch → the
full sequence in order: the HEAD guard (``rev-parse --abbrev-ref
HEAD``) → the shallow probe → the single-fetch ``fetch origin <b>``
→ ``merge --ff-only FETCH_HEAD`` (not ``git pull`` — the default
path's argv is reserved for ``branch=None``)."""
dest = tmp_path / "homelab"
(dest / ".git").mkdir(parents=True)
calls = _fake_run(
monkeypatch,
responses={
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "release\n", ""),
("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", ""),
("git", "fetch", "origin", "release"): (0, " * branch release -> FETCH_HEAD", ""),
("git", "merge", "--ff-only", "FETCH_HEAD"): (0, "Already up to date.\n", ""),
},
)
result = clone_or_pull("https://github.com/user/homelab.git", dest, "release")
assert result == dest
assert [call["argv"] for call in calls] == [
["git", "rev-parse", "--abbrev-ref", "HEAD"],
["git", "rev-parse", "--is-shallow-repository"],
["git", "fetch", "origin", "release"],
["git", "merge", "--ff-only", "FETCH_HEAD"],
]
for call in calls:
assert call["cwd"] == dest
def test_existing_shallow_checkout_matching_branch_unshallows_before_fetch(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Phase 135 + phase 107 D11: the branch path runs the SAME one-time
self-heal — a shallow matching checkout is unshallowed BETWEEN the
guard/probe and the branch fetch (order pinned)."""
dest = tmp_path / "homelab"
(dest / ".git").mkdir(parents=True)
calls = _fake_run(
monkeypatch,
responses={
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "release\n", ""),
("git", "rev-parse", "--is-shallow-repository"): (0, "true\n", ""),
("git", "fetch", "--unshallow"): (0, "", ""),
("git", "fetch", "origin", "release"): (0, "", ""),
("git", "merge", "--ff-only", "FETCH_HEAD"): (0, "", ""),
},
)
clone_or_pull("https://github.com/user/homelab.git", dest, "release")
assert [call["argv"] for call in calls] == [
["git", "rev-parse", "--abbrev-ref", "HEAD"],
["git", "rev-parse", "--is-shallow-repository"],
["git", "fetch", "--unshallow"],
["git", "fetch", "origin", "release"],
["git", "merge", "--ff-only", "FETCH_HEAD"],
]
def test_existing_checkout_branch_mismatch_fails_loud_before_any_fetch(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Phase 135 (ASSUMPTION B5, D12): a checkout on a DIFFERENT branch
raises the remove-and-re-add ``GitSyncError`` — naming the checkout
and BOTH branch names — and the guard is FIRST: no shallow probe,
no fetch, no merge argv was invoked (no silent branch switch)."""
url = "https://github.com/user/homelab.git"
dest = tmp_path / "homelab"
(dest / ".git").mkdir(parents=True)
calls = _fake_run(
monkeypatch,
responses={("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", "")},
)
with pytest.raises(GitSyncError) as excinfo:
clone_or_pull(url, dest, "release")
msg = str(excinfo.value)
assert f"checkout {dest}" in msg # the checkout is named
assert "'main'" in msg # the checkout's branch
assert "'release'" in msg # the source's pinned branch
assert "remove and re-add the source to switch branches" in msg
# credential safety: the message carries NO URL (the only place a
# token could ride) — even for a plain URL.
assert url not in msg
assert "github" not in msg
# the guard is first: exactly ONE argv (the guard probe) ran.
assert [call["argv"] for call in calls] == [
["git", "rev-parse", "--abbrev-ref", "HEAD"]
]
def test_branch_mismatch_message_never_contains_url_even_with_token(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Phase 135 credential-safety: even when the CLONE URL carries the
injected token (the phase-121 ``clone_url_for`` form), the guard
message is URL-free — the checkout path and the two branch names
only."""
url = "https://x-access-token:ghp_supersecret@git.example.com/user/repo.git"
dest = tmp_path / "repo"
(dest / ".git").mkdir(parents=True)
calls = _fake_run(
monkeypatch,
responses={("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "main\n", "")},
)
with pytest.raises(GitSyncError) as excinfo:
clone_or_pull(url, dest, "release")
msg = str(excinfo.value)
assert url not in msg
assert "ghp_supersecret" not in msg
assert "git.example.com" not in msg
assert f"checkout {dest} is on branch 'main' but the source " \
"specifies branch 'release'" in msg
assert [call["argv"] for call in calls] == [
["git", "rev-parse", "--abbrev-ref", "HEAD"]
]
def test_existing_checkout_branch_fetch_failure_propagates_before_merge(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Phase 135 D12 fail-loud: a failed ``fetch origin <b>`` raises
(with git's stderr) and the merge NEVER runs."""
dest = tmp_path / "homelab"
(dest / ".git").mkdir(parents=True)
calls = _fake_run(
monkeypatch,
responses={
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "release\n", ""),
("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", ""),
("git", "fetch", "origin", "release"): (128, "", "fatal: couldn't find remote ref\n"),
},
)
with pytest.raises(
GitSyncError,
match=r"git fetch origin release failed \(exit 128\): fatal: couldn't find remote ref",
):
clone_or_pull("https://github.com/user/homelab.git", dest, "release")
assert [call["argv"] for call in calls] == [
["git", "rev-parse", "--abbrev-ref", "HEAD"],
["git", "rev-parse", "--is-shallow-repository"],
["git", "fetch", "origin", "release"],
] # the merge never ran
def test_existing_checkout_branch_non_ff_merge_fails_loud(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Phase 135: a non-ff ``merge --ff-only FETCH_HEAD`` (diverged /
force-pushed origin) fails loudly HERE with git's stderr — the
reset fallback is phase 136, not a silent fix in this phase."""
dest = tmp_path / "homelab"
(dest / ".git").mkdir(parents=True)
calls = _fake_run(
monkeypatch,
responses={
("git", "rev-parse", "--abbrev-ref", "HEAD"): (0, "release\n", ""),
("git", "rev-parse", "--is-shallow-repository"): (0, "false\n", ""),
("git", "fetch", "origin", "release"): (0, "", ""),
("git", "merge", "--ff-only", "FETCH_HEAD"): (
1,
"",
"fatal: Not possible to fast-forward, aborting.\n",
),
},
)
with pytest.raises(
GitSyncError,
match=(
r"git merge --ff-only FETCH_HEAD failed \(exit 1\): "
r"fatal: Not possible to fast-forward"
),
):
clone_or_pull("https://github.com/user/homelab.git", dest, "release")
assert len(calls) == 4 # all four argv ran; the merge failed loud
+4 -1
View File
@@ -389,7 +389,10 @@ def test_hidden_checkbox_css_rules_exist_with_accent_color() -> None:
assert "opacity" in disabled and "cursor: wait" in disabled, (
"the .git-source-remove:disabled idiom"
)
tag = _css_rule(css, ".git-source-hidden-count")
# Phase 135 (task 04): the "branch: <name>" state tag joined this
# rule — the "hidden on" tag keeps the same idiom (text + a
# distinct background, never color alone).
tag = _css_rule(css, ".git-source-hidden-count,\n.git-source-branch-count")
assert "var(--ink)" in tag and "var(--bg)" in tag, (
"text + a distinct background (never color alone)"
)
+10 -4
View File
@@ -384,14 +384,20 @@ def test_save_runs_the_inflight_never_stale_lifecycle() -> None:
label_i = body.find(f'"{SAVING_LABEL}"', dis_s)
fetch_i = body.find("`/api/git-sources/${", label_i)
method_i = body.find('method: "PATCH"', fetch_i)
body_i = body.find(
"JSON.stringify({ ignore_paths: lines, ...(token ? { token } : {}) })", method_i
)
# Phase 135 (task 04) widened the body: the branch key lands
# BEFORE the (still conditional) token key — git rows always send
# it ("" = the tri-state's clear leg), local rows omit it.
body_i = body.find("JSON.stringify({\n ignore_paths: lines,", method_i)
branch_key_i = body.find("...(isGitRow ? { branch } : {})", body_i)
token_key_i = body.find("...(token ? { token } : {})", branch_key_i)
assert -1 < guard_i < parse_i < trim_i < drop_i < token_i < inflight_i, (
"guard → split + trim + drop blank lines → read the token → in-flight"
)
assert -1 < dis_c < dis_s < label_i < fetch_i < method_i < body_i, (
"disable both + 'Saving…' → the PATCH (lines + token only when non-blank)"
"disable both + 'Saving…' → the PATCH"
)
assert -1 < body_i < branch_key_i < token_key_i, (
"the body: lines, branch for git rows, token only when non-blank"
)
# Success: close → reload → announce (the exact order) — the
# update confirmation is the LAST announcement: the reload's
+11 -6
View File
@@ -812,14 +812,15 @@ def fresh_sync_status() -> Iterator[None]:
class _GatedClone:
"""A ``clone_or_pull`` that parks between start and finish on a
threading gate, so the test can read the status during the
clone/pull phase (A4: no file yet)."""
clone/pull phase (A4: no file yet). Phase 135: accepts the row's
branch pin (the third argument — ``None`` for this file's rows)."""
def __init__(self, started: threading.Event, release: threading.Event) -> None:
self.started = started
self.release = release
self.calls: list[tuple[str, Path]] = []
def __call__(self, url: str, dest: Path | str) -> Path:
def __call__(self, url: str, dest: Path | str, branch: str | None = None) -> Path:
dest = Path(dest)
self.calls.append((url, dest))
dest.mkdir(parents=True, exist_ok=True)
@@ -1402,10 +1403,12 @@ def test_chunk_position_clears_at_phase_transitions_and_terminal(
# the event set / releases.
def _fast_clone(url: str, dest: Path | str) -> Path:
def _fast_clone(url: str, dest: Path | str, branch: str | None = None) -> Path:
"""A non-gated ``clone_or_pull`` — the loop thread must STAY FREE
for the loop-thread action, so nothing in the prelude may block it
(the import's gate is an ``asyncio.to_thread`` park — loop-safe)."""
(the import's gate is an ``asyncio.to_thread`` park — loop-safe).
Phase 135: accepts the row's branch pin (the third argument —
``None`` for this file's rows)."""
dest = Path(dest)
dest.mkdir(parents=True, exist_ok=True)
(dest / "notes.md").write_text("# repo\ncontent for the KB\n", encoding="utf-8")
@@ -1721,7 +1724,8 @@ def test_event_alone_cancels_at_the_import_boundary(
clone_release = threading.Event()
clone_calls: list[tuple[str, Path]] = []
def parking_clone(url: str, dest: Path | str) -> Path:
def parking_clone(url: str, dest: Path | str, branch: str | None = None) -> Path:
# phase 135: the row's branch pin (None for this file's rows)
dest = Path(dest)
dest.mkdir(parents=True, exist_ok=True)
(dest / "notes.md").write_text("# repo\ncontent for the KB\n", encoding="utf-8")
@@ -1864,7 +1868,8 @@ def test_event_cancels_at_the_top_of_the_source_loop(
release.wait() # blocking is fine: the date walk is a sync call
return {}
def fast_clone(url: str, dest: Path | str) -> Path:
def fast_clone(url: str, dest: Path | str, branch: str | None = None) -> Path:
# phase 135: the row's branch pin (None for this file's rows)
dest = Path(dest)
dest.mkdir(parents=True, exist_ok=True)
(dest / "notes.md").write_text("# repo\ncontent for the KB\n", encoding="utf-8")