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