phase: 105_hidden_folders_toggle
Build and Push Containers / build-and-push-app (push) Successful in 1m44s
Build and Push Containers / build-and-push-db (push) Successful in 13s

All completion criteria verified. Everything is green.

**Phase 105 final verification pass — all criteria verified**

- Verified the full implementation in the working tree: `git_sources.include_hidden` column + alembic `0019` (dev DB at head, column present), `iter_importable_files`/`import_sources` flag support with `str(root)`-keyed map used by both walk and progress pre-walk, `GitSourcePatchIn` rename with optional fields, sync/CLI pipeline wiring (OR-collision), and the per-row "Hidden" checkbox + tag + error line on the Sources page
- Unit + integration: `uv run pytest` → exit 0 (2148 tests collected, all pass; this sandbox occasionally swallows pytest's final status line — exit codes verified)
- Coverage: `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (3879 stmts, 15 miss) — >90% gate ✓
- Dedicated E2E: `uv run pytest tests/e2e/test_hidden_folders_toggle.py -v --no-cov` → **6 passed in 23.20s** (DB up, in isolation)
- Regression E2E in isolation: `test_source_ignore_paths` 6 passed, `test_git_sources_admin` 6 passed, `test_local_directory_sources` 3 passed, `test_sync_button` 3 passed, `test_smoke` 3 passed
- Lint/types: `uv run ruff check .` + `uv run pyright` → clean (0 errors/warnings)

**Completion criteria:** (1) checkbox persists via PATCH 200 → "hidden on" tag + GET round-trips `include_hidden: true`; failure path reverts box + `role="alert"` canned message ✓; (2) flag OFF byte-identical (only `visible.md` indexed), ON indexes `.hidden/note.md` into the KB catalog, `EXCLUDED_DIRS` excluded both states ✓; (3) A2: flag OFF → `detail.pruned==1`, doc gone from catalog ✓; (4) PATCH bool-only/list-only/both/neither no-op, phase-89 fixed 422s unchanged, 404, anonymous 403 (incl. bool-only body) ✓; (5) env-fallback rows render no checkbox, WCAG-clean (aria-label, keyboard focus, visible label, text tag) ✓; (6) full gate green ✓; (7) commit left to the harness per instructions (no `git add`/`commit` run; phase files untouched).

**Deviations:** none — no defects found; no code changes were needed on this pass.

**Next pending phase:** `.agents/phases/todo/98_sync_summary_visibility`.
This commit is contained in:
2026-09-12 21:26:30 -04:00
parent ecc921098a
commit d731169b8b
49 changed files with 3553 additions and 93 deletions
+197 -20
View File
@@ -32,9 +32,16 @@ Contract under test:
* ignore paths (phase 89) — POST accepts the RAW box lines for both
kinds (optional, absent → ``[]``), stored normalized (A1) with the A4
fixed-detail 422s (shared gate with PATCH); GET reports each row's
stored list (env rows ``[]``); ``PATCH /{source_id}`` (admin-only)
replaces the list wholesale (A5 — an empty list clears all), 404
unknown id, 422 fixed details for the A4 limits, the row otherwise
stored list (env rows ``[]``);
* hidden-folders flag (phase 105) — POST accepts ``include_hidden`` for
both kinds (optional, absent → stored ``False``, A4); GET reports the
stored flag (env rows ``False`` — no DB row to store a flag on);
* ``PATCH /{source_id}`` (admin-only) — phase 89 A5 + phase 105: each
field is optional, present-wins — ``ignore_paths`` when present
REPLACES the list wholesale (A5 — an empty list clears all),
``include_hidden`` when present sets the flag, both absent → 200
no-op, 404 unknown id, 422 fixed details for the A4 limits, a
rejected list never half-applies the flag, the row otherwise
unchanged.
``git_sources`` is global state: truncated around every test.
@@ -99,10 +106,14 @@ def test_anonymous_gets_403_on_all_routes(client: TestClient, db: Session) -> No
r = client.delete(f"/api/git-sources/{uuid.uuid4()}")
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
# The phase-89 ignore-list PATCH is gated the same way.
# The phase-89 ignore-list PATCH is gated the same way — and so is
# the phase-105 bool-only payload (the toggle's exact request).
r = client.patch(f"/api/git-sources/{uuid.uuid4()}", json={"ignore_paths": ["a"]})
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
r = client.patch(f"/api/git-sources/{uuid.uuid4()}", json={"include_hidden": True})
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
# Nothing landed in the table.
assert db.execute(text("SELECT count(*) FROM git_sources")).scalar_one() == 0
@@ -132,8 +143,10 @@ def test_get_empty_table_with_env_returns_env_rows(
"url": "https://a.example.com/one.git",
"path": None,
"added_at": None,
# Env rows have no DB row to store a list on (phase 89).
# Env rows have no DB row to store a list on (phase 89) or
# a flag on (phase 105).
"ignore_paths": [],
"include_hidden": False,
},
{
"id": None,
@@ -142,6 +155,7 @@ def test_get_empty_table_with_env_returns_env_rows(
"path": None,
"added_at": None,
"ignore_paths": [],
"include_hidden": False,
},
]
@@ -208,9 +222,11 @@ def test_post_creates_trimmed_and_list_stops_using_env(
uuid.UUID(body["id"])
assert body["added_at"] is not None
# Phase 89: the response gains ``ignore_paths`` — absent at create
# time → ``[]``.
assert set(body) == {"id", "url", "added_at", "ignore_paths"}
# time → ``[]``; phase 105 adds ``include_hidden`` — absent →
# ``False`` (A4).
assert set(body) == {"id", "url", "added_at", "ignore_paths", "include_hidden"}
assert body["ignore_paths"] == []
assert body["include_hidden"] is False
# The DB row now wins: from_env False, the env URL is gone from the list.
body = admin_client.get("/api/git-sources").json()
@@ -321,9 +337,11 @@ def test_post_local_creates_stored_row_with_expanded_path(
body = r.json()
# The phase-35 response shape is unchanged — the local row reports
# its (expanded) path in ``url``; ``kind`` + ``path`` via GET;
# phase 89 adds ``ignore_paths`` (absent → ``[]``).
assert set(body) == {"id", "url", "added_at", "ignore_paths"}
# phase 89 adds ``ignore_paths`` (absent → ``[]``); phase 105 adds
# ``include_hidden`` (absent → ``False``, A4).
assert set(body) == {"id", "url", "added_at", "ignore_paths", "include_hidden"}
assert body["ignore_paths"] == []
assert body["include_hidden"] is False
uuid.UUID(body["id"])
assert body["url"] == str(real_dir)
assert body["added_at"] is not None
@@ -556,6 +574,7 @@ def test_delete_removes_row_and_falls_back_to_env(
"path": None,
"added_at": None,
"ignore_paths": [], # env rows: no DB row to store a list on
"include_hidden": False, # … or a flag on (phase 105)
}
]
db.execute(text("TRUNCATE chunks, documents"))
@@ -671,6 +690,7 @@ def test_get_reports_stored_ignore_paths_and_env_rows_empty(
"path": None,
"added_at": None,
"ignore_paths": [],
"include_hidden": False,
}
]
@@ -690,8 +710,9 @@ def test_patch_replaces_ignore_paths_including_clear(admin_client: TestClient) -
r = admin_client.patch(f"/api/git-sources/{before['id']}", json={"ignore_paths": ["a/", "b"]})
assert r.status_code == 200, r.text
body = r.json()
assert set(body) == {"id", "url", "added_at", "ignore_paths"}
assert set(body) == {"id", "url", "added_at", "ignore_paths", "include_hidden"}
assert body["ignore_paths"] == ["a", "b"] # normalized
assert body["include_hidden"] is False # untouched by a list-only PATCH
for key in ("id", "url", "added_at"):
assert body[key] == before[key]
# Round-trip through GET.
@@ -706,17 +727,34 @@ def test_patch_replaces_ignore_paths_including_clear(admin_client: TestClient) -
assert admin_client.get("/api/git-sources").json()["sources"][0]["ignore_paths"] == []
def test_patch_missing_field_is_422(admin_client: TestClient) -> None:
"""``ignore_paths`` is REQUIRED (replace semantics, A5) — an absent
field is a 422 with the model's own detail."""
created = admin_client.post("/api/git-sources", json={"url": "https://example.com/req.git"})
assert created.status_code == 201
r = admin_client.patch(f"/api/git-sources/{created.json()['id']}", json={})
assert r.status_code == 422
# The model's own (Pydantic) detail — the missing required field.
assert any(
item.get("loc") == ["body", "ignore_paths"] for item in r.json()["detail"]
def test_patch_empty_body_is_a_noop_200(admin_client: TestClient) -> None:
"""Phase 105: BOTH fields absent (or explicit None) → 200 no-op —
the row is untouched. (The pre-phase-105 pin of an absent
``ignore_paths`` as a 422 is retired — the list is optional now; a
PRESENT list keeps the phase-89 A5 replace semantics.)"""
created = admin_client.post(
"/api/git-sources",
json={"url": "https://example.com/noop.git", "ignore_paths": ["a/b"]},
)
assert created.status_code == 201
before = created.json()
r = admin_client.patch(f"/api/git-sources/{before['id']}", json={})
assert r.status_code == 200, r.text
body = r.json()
assert body["ignore_paths"] == ["a/b"]
assert body["include_hidden"] is False
for key in ("id", "url", "added_at"):
assert body[key] == before[key]
# Explicit None values are "absent" too — still a no-op.
r = admin_client.patch(
f"/api/git-sources/{before['id']}", json={"ignore_paths": None, "include_hidden": None}
)
assert r.status_code == 200, r.text
assert r.json()["ignore_paths"] == ["a/b"]
assert r.json()["include_hidden"] is False
# The row never changed — round-trip through GET.
assert admin_client.get("/api/git-sources").json()["sources"][0]["ignore_paths"] == ["a/b"]
def test_patch_unknown_id_returns_404(admin_client: TestClient) -> None:
@@ -772,3 +810,142 @@ def test_patch_accepts_a4_boundaries(admin_client: TestClient) -> None:
r = admin_client.patch(f"/api/git-sources/{sid}", json={"ignore_paths": [long_entry]})
assert r.status_code == 200, r.text
assert r.json()["ignore_paths"] == [long_entry]
# --- hidden-folders flag (phase 105) ----------------------------------------
def test_get_reports_include_hidden_default_false(admin_client: TestClient, db: Session) -> None:
"""A fresh stored row (no flag passed) reports ``include_hidden:
false`` — in the GET round-trip AND on the model (the column's
server default, A4)."""
db.add(GitSource(url="https://example.com/fresh.git"))
db.commit()
body = admin_client.get("/api/git-sources").json()
assert body["sources"][0]["include_hidden"] is False
row = db.scalars(select(GitSource)).one()
assert row.include_hidden is False
def test_post_stores_include_hidden_both_kinds(
admin_client: TestClient, tmp_path: Path
) -> None:
"""POST accepts ``include_hidden`` for both kinds (present → stored
as sent); the 201 body and the GET round-trip report it."""
r = admin_client.post(
"/api/git-sources",
json={"url": "https://example.com/hidden-git.git", "include_hidden": True},
)
assert r.status_code == 201, r.text
assert r.json()["include_hidden"] is True
real_dir = tmp_path / "hidden-local"
real_dir.mkdir()
r = admin_client.post(
"/api/git-sources",
json={"kind": "local", "path": str(real_dir), "include_hidden": True},
)
assert r.status_code == 201, r.text
assert r.json()["include_hidden"] is True
by_url = {
s["url"]: s["include_hidden"]
for s in admin_client.get("/api/git-sources").json()["sources"]
}
assert by_url["https://example.com/hidden-git.git"] is True
assert by_url[str(real_dir)] is True
def test_patch_bool_only_sets_flag_leaves_list(admin_client: TestClient) -> None:
"""The toggle's exact payload — ``{"include_hidden": …}`` alone:
the flag is set, the ignore list is UNCHANGED (present-wins, phase
105)."""
created = admin_client.post(
"/api/git-sources",
json={"url": "https://example.com/toggle.git", "ignore_paths": ["a/b"]},
)
assert created.status_code == 201
sid = created.json()["id"]
r = admin_client.patch(f"/api/git-sources/{sid}", json={"include_hidden": True})
assert r.status_code == 200, r.text
body = r.json()
assert body["include_hidden"] is True
assert body["ignore_paths"] == ["a/b"] # untouched
assert admin_client.get("/api/git-sources").json()["sources"][0]["include_hidden"] is True
# And back off again.
r = admin_client.patch(f"/api/git-sources/{sid}", json={"include_hidden": False})
assert r.status_code == 200, r.text
assert r.json()["include_hidden"] is False
assert r.json()["ignore_paths"] == ["a/b"]
def test_patch_list_only_replaces_list_leaves_flag(admin_client: TestClient) -> None:
"""The dialog's exact payload — ``{"ignore_paths": …}`` alone:
the list is REPLACED (normalized, phase-89 A5), the flag is
UNCHANGED — byte-identical to the pre-phase-105 dialog PATCH."""
created = admin_client.post(
"/api/git-sources",
json={
"url": "https://example.com/dialog.git",
"ignore_paths": ["a/b"],
"include_hidden": True,
},
)
assert created.status_code == 201
sid = created.json()["id"]
r = admin_client.patch(f"/api/git-sources/{sid}", json={"ignore_paths": ["c/d", " e "]})
assert r.status_code == 200, r.text
body = r.json()
assert body["ignore_paths"] == ["c/d", "e"] # normalized
assert body["include_hidden"] is True # untouched
assert admin_client.get("/api/git-sources").json()["sources"][0]["include_hidden"] is True
def test_patch_both_fields_apply_independently(admin_client: TestClient) -> None:
"""``{"ignore_paths": [], "include_hidden": true}`` — the list is
cleared AND the flag is set in one request."""
created = admin_client.post(
"/api/git-sources",
json={"url": "https://example.com/both.git", "ignore_paths": ["old/one"]},
)
assert created.status_code == 201
sid = created.json()["id"]
r = admin_client.patch(
f"/api/git-sources/{sid}", json={"ignore_paths": [], "include_hidden": True}
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ignore_paths"] == [] # cleared
assert body["include_hidden"] is True # set
def test_patch_422_does_not_half_apply_flag(admin_client: TestClient) -> None:
"""A PRESENT bad list 422s with the fixed A4 details — and the
OTHER field in the same body never half-applies: after each 422
the flag is still the stored value (validation raises before any
assignment, request transaction untouched)."""
created = admin_client.post(
"/api/git-sources",
json={"url": "https://example.com/half.git", "ignore_paths": ["keep"]},
)
assert created.status_code == 201
sid = created.json()["id"]
for payload, detail in (
([" "], "ignore paths must be non-empty"),
([f"e{i}" for i in range(201)], "a source has at most 200 ignore paths"),
(["a" * 501], "an ignore path exceeds 500 characters"),
):
r = admin_client.patch(
f"/api/git-sources/{sid}", json={"ignore_paths": payload, "include_hidden": True}
)
assert r.status_code == 422, f"{detail!r}: {r.text}"
assert r.json()["detail"] == detail
# Nothing half-applied: list AND flag are both still the originals.
row = admin_client.get("/api/git-sources").json()["sources"][0]
assert row["ignore_paths"] == ["keep"]
assert row["include_hidden"] is False