phase: 105_hidden_folders_toggle
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:
@@ -77,7 +77,7 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api import sync as sync_api
|
||||
@@ -262,6 +262,11 @@ class FakeImportSources:
|
||||
# rows' ``ignore_paths`` (keyed by the root string the importer
|
||||
# sees; two rows sharing a root string get the union).
|
||||
self.ignore_maps: list[dict[str, list[str]]] = []
|
||||
# Phase 105: the per-root hidden-folders flag map the runner
|
||||
# builds from the rows' ``include_hidden`` (same root-string
|
||||
# keying; a shared-root collision ORs — if either row says
|
||||
# "index hidden", the root does).
|
||||
self.include_hidden_maps: list[dict[str, bool]] = []
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
@@ -273,12 +278,14 @@ class FakeImportSources:
|
||||
session: Session | None = None,
|
||||
progress: Callable[[str, str, int, int], None] | None = None,
|
||||
ignore_by_root: dict[str, list[str]] | None = None,
|
||||
include_hidden_by_root: dict[str, bool] | None = None,
|
||||
) -> ImportSummary:
|
||||
self.sources.append(list(sources))
|
||||
self.llms.append(llm)
|
||||
self.prune_flags.append(prune)
|
||||
self.progress_hooks.append(progress)
|
||||
self.ignore_maps.append(ignore_by_root or {})
|
||||
self.include_hidden_maps.append(include_hidden_by_root or {})
|
||||
if self.delay:
|
||||
await asyncio.sleep(self.delay)
|
||||
return self.summary
|
||||
@@ -823,6 +830,7 @@ def test_import_error_is_reported_with_credentials_masked(
|
||||
session: Session | None = None,
|
||||
progress: Callable[[str, str, int, int], None] | None = None,
|
||||
ignore_by_root: dict[str, list[str]] | None = None,
|
||||
include_hidden_by_root: dict[str, bool] | None = None,
|
||||
) -> ImportSummary:
|
||||
raise EmbeddingError(
|
||||
"embeddings request to https://user:secret@aipi.reeseapps.com/v1 "
|
||||
@@ -1100,3 +1108,114 @@ def test_sync_without_ignore_lists_passes_empty_map(
|
||||
|
||||
assert fake_import.sources == [[local_dir]]
|
||||
assert fake_import.ignore_maps == [{}] # no row carried a list
|
||||
|
||||
|
||||
# --- phase 105: per-row hidden-folders flag --------------------------------
|
||||
|
||||
|
||||
def test_local_row_hidden_flag_off_then_on_indexes_hidden_paths(
|
||||
sync_client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
db: Session,
|
||||
tmp_path: Path,
|
||||
clean_documents: None,
|
||||
) -> None:
|
||||
"""Phase 105 (A4 then A1): the SAME local row, synced first with the
|
||||
default flag (off) — the file inside the hidden folder never lands
|
||||
in the KB (no document row — hence no embedding, no summary), the
|
||||
visible file imports as usual; then the row is flipped on (direct
|
||||
model set — the PATCH round-trip is task 03's layer) and the next
|
||||
sync walks the hidden file too: it is indexed, embedded, and counted
|
||||
like any visible file."""
|
||||
local_dir = tmp_path / "LocalDocs"
|
||||
local_dir.mkdir()
|
||||
(local_dir / "visible.md").write_text("# Visible\nin scope\n", encoding="utf-8")
|
||||
(local_dir / ".hidden").mkdir()
|
||||
(local_dir / ".hidden" / "note.md").write_text("# Note\nhidden\n", encoding="utf-8")
|
||||
_seed_local(db, local_dir) # include_hidden defaults to False (A4)
|
||||
_stub_env(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
sync_api,
|
||||
"get_settings",
|
||||
lambda: _settings(sources_dir=str(tmp_path / "bor")),
|
||||
)
|
||||
_real_llm(monkeypatch) # real import_sources, deterministic embeddings
|
||||
|
||||
_login(sync_client)
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
body = _poll(sync_client, "success")
|
||||
|
||||
# Flag off (A4): only the visible file is walked and indexed — the
|
||||
# hidden file has NO documents row.
|
||||
assert body["detail"]["files"] == 1
|
||||
assert body["detail"]["added"] == 1
|
||||
assert body["detail"]["errors"] == 0
|
||||
docs = {
|
||||
(d["source"], d["path"]) for d in sync_client.get("/api/docs").json()["documents"]
|
||||
}
|
||||
assert docs == {("LocalDocs", "visible.md")}
|
||||
|
||||
# Flip the SAME row on — the next sync re-reads the flag per row.
|
||||
row = db.execute(select(GitSource).where(GitSource.url == str(local_dir))).scalar_one()
|
||||
row.include_hidden = True
|
||||
db.commit()
|
||||
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
body = _poll(sync_client, "success")
|
||||
|
||||
# Flag on (A1): the hidden file is walked (detail.files counts it),
|
||||
# embedded, and indexed alongside the visible file.
|
||||
assert body["detail"]["files"] == 2
|
||||
assert body["detail"]["added"] == 1
|
||||
assert body["detail"]["errors"] == 0
|
||||
docs = {
|
||||
(d["source"], d["path"]) for d in sync_client.get("/api/docs").json()["documents"]
|
||||
}
|
||||
assert docs == {("LocalDocs", "visible.md"), ("LocalDocs", ".hidden/note.md")}
|
||||
|
||||
|
||||
def test_sync_builds_include_hidden_map_by_root_string_with_or(
|
||||
sync_client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
db: Session,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Phase 105 wiring (fake import): the runner keys the flag map by
|
||||
the SAME root string the importer sees, and two rows sharing that
|
||||
root string (the sibling/repo-name edge — ``…/shared`` and
|
||||
``…/shared.git`` clone into the same checkout dir) get the OR of
|
||||
their flags — if EITHER row says "index hidden", the root does
|
||||
(the ignore-map union's boolean mirror)."""
|
||||
url_a = f"file://{tmp_path / 'shared'}"
|
||||
url_b = f"{url_a}.git" # same repo name → same checkout dir
|
||||
# Distinct added_at: the resolver orders by (added_at, id) — a
|
||||
# same-timestamp pair would tie-break on the random uuid.
|
||||
db.add(GitSource(url=url_a, kind="git", include_hidden=False,
|
||||
added_at=datetime(2026, 1, 1, tzinfo=UTC)))
|
||||
db.add(GitSource(url=url_b, kind="git", include_hidden=True,
|
||||
added_at=datetime(2026, 1, 2, tzinfo=UTC)))
|
||||
db.commit()
|
||||
_stub_env(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
sync_api,
|
||||
"get_settings",
|
||||
lambda: _settings(sources_dir=str(tmp_path / "bor")),
|
||||
)
|
||||
_, fake_clone = _fake_clone()
|
||||
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
||||
_stub_probe(monkeypatch)
|
||||
fake_import = FakeImportSources(ImportSummary(files=1, added=1))
|
||||
monkeypatch.setattr(sync_api, "import_sources", fake_import)
|
||||
monkeypatch.setattr(sync_api, "regenerate_overview", FakeOverview(ok=True))
|
||||
monkeypatch.setattr(sync_api, "generate_folder_summaries", FakeFolderSummaries())
|
||||
|
||||
_login(sync_client)
|
||||
assert sync_client.post("/api/sync").status_code == 202
|
||||
_poll(sync_client, "success")
|
||||
|
||||
shared = str(tmp_path / "bor" / "shared")
|
||||
# Both rows resolve to the SAME checkout (the collision itself) and
|
||||
# the map holds the OR of their flags, keyed by that one root
|
||||
# string — the flag-off row's False is overridden by the True.
|
||||
assert fake_import.sources == [[tmp_path / "bor" / "shared", tmp_path / "bor" / "shared"]]
|
||||
assert fake_import.include_hidden_maps == [{shared: True}]
|
||||
|
||||
Reference in New Issue
Block a user