feat(sources): removing a source deletes its files and index entries behind a confirmation modal
Build and Push Containers / build-and-push-app (push) Successful in 1m29s
Build and Push Containers / build-and-push-db (push) Successful in 11s

This commit is contained in:
2026-09-02 15:55:33 -04:00
parent 265e736b3d
commit 137d5fa1a5
24 changed files with 3489 additions and 118 deletions
+7 -9
View File
@@ -118,16 +118,16 @@ def test_agent_tools_names_and_parameters() -> None:
"Add the full content of one more indexed document to your context"
)
# Phase 63 (A2): the parameter descriptions point the LLM at the
# labeled `source:` / `path:` fields of the list_documents output.
# labeled `source:` / `path:` fields of the list_documents output
# (the example was dropped by the phase-68 description fix — the
# wording stays pinned, the model saw invented paths in calls).
assert read_params["properties"]["source"]["description"] == (
"The document's source, as shown after 'source: ' in the "
"list_documents output (e.g. 'Homelab' from "
"'source: Homelab | path: homelab/aws-route53.md')."
"list_documents output."
)
assert read_params["properties"]["path"]["description"] == (
"The document's path, as shown after 'path: ' in the "
"list_documents output (e.g. 'homelab/aws-route53.md' from "
"'source: Homelab | path: homelab/aws-route53.md')."
"list_documents output."
)
# Phase 68: search_documents — the third tool, a locator (locked A5).
search = by_name["search_documents"]["function"]
@@ -149,13 +149,11 @@ def test_agent_tools_names_and_parameters() -> None:
# Phase 63 labeled-field wording, same as read_document's parameters.
assert search_params["properties"]["source"]["description"] == (
"The document's source, as shown after 'source: ' in the "
"list_documents output (e.g. 'Homelab' from "
"'source: Homelab | path: homelab/aws-route53.md')."
"list_documents output."
)
assert search_params["properties"]["path"]["description"] == (
"The document's path, as shown after 'path: ' in the "
"list_documents output (e.g. 'homelab/aws-route53.md' from "
"'source: Homelab | path: homelab/aws-route53.md')."
"list_documents output."
)
+472
View File
@@ -0,0 +1,472 @@
"""Unit: the remove confirmation modal on /git-sources.html (phase 69,
task 02).
Removal is a TOTAL removal (owner request 2026-09-02): the row, the
source's indexed documents (chunks + embeddings), and — for git clones
and uploaded archives — the files on the server's disk, all immediately
(task 01's ``DELETE /api/git-sources/{id}`` rewire). The page must
confirm that through a real in-app ``role="alertdialog"`` modal —
``window.confirm`` is retired — that names the source and states the
policy BEFORE the request goes out.
The browser behavior itself is E2E-gated by the phase-69 story suite
(``tests/e2e/test_source_removal_cleanup.py`` — task 03: focus,
Escape, ≥44px targets, the a11y interaction layer); like the other
frontend-adjacent unit files (``test_stale_ui_copy.py`` /
``test_summary_edit_ui.py`` house pattern), this module pins the
source-level contract a silent regression would break:
* ``window.confirm`` is GONE from the whole frontend (the only
native confirm the app ever shipped);
* the static ``#remove-confirm-dialog`` markup — ``role="alertdialog"``,
``aria-modal``, ``aria-labelledby``/``aria-describedby``, hidden by
default, the six child ids, the ``role="alert"`` error line, real
``type="button"`` buttons, the locked modal copy (verbatim);
* the JS lifecycle — ``openRemoveConfirm`` (textContent-only source
population with makeRow's ``value`` expression, error cleared, focus
on Cancel, the trigger recorded), cancel = Escape / Cancel button /
backdrop (no request; focus returns to the trigger; a no-op while a
DELETE is in flight), ``confirmRemove`` (§7.4 in-flight state: both
buttons disable + "Removing…", success → close/reload/announce —
the removal confirmation is the LAST announcement, non-2xx → the
in-modal alert line + dialog stays open, network →
the fixed reachable? line, re-enable in the finally);
* the stale "prunes on the next sync" removal copy is GONE from
``git-sources.js`` + ``git-sources.html``; the new hint copy is
PRESENT (the README pins are task 03's);
* styles.css — the modal classes on the house dark-tech palette
(phase-08 tokens only, no CDN, no blur), ≥44px buttons, the
``[hidden]`` override, the err-token destructive pair.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
HTML = FRONTEND / "git-sources.html"
JS = FRONTEND / "assets" / "git-sources.js"
CSS = FRONTEND / "assets" / "styles.css"
#: The locked modal copy (phase 69, 00_phase.md) — one fixed paragraph
#: for both kinds (the UI cannot tell an upload from the owner's own
#: directory; naming the source above it makes the target
#: unambiguous). Pinned verbatim against the normalized text.
MODAL_COPY = (
"This permanently removes the source entry, all of its indexed "
"documents from the knowledge base, and — for git clones and "
"uploaded archives — the files on the server's disk. Files in "
"your own local directories are never touched. This cannot be undone."
)
#: The new hint-box contract (phase 69): removal is immediate and
#: total; the modal spells it out; the Sync button mirrors the
#: remaining sources (upstream churn is still pruned on that run).
HINT_TOTAL_REMOVAL = "Removing a source is a total removal, done immediately"
HINT_MODAL_SPELLS_OUT = (
"the confirmation modal spells out exactly what will be deleted"
)
HINT_FOREVER_SAFE = "files in your own local directories are never touched"
#: The success announce (the existing #git-sources-announcer live
#: region).
ANNOUNCE_OK = "Source removed — its files and index entries were cleaned up."
#: The retired confirm copy (task 02 stale-copy pins).
OLD_STAY_INDEXED = "stays indexed until the next sync"
# "prunes/pruned … on the next sync" in any shape (the old hint +
# confirm message).
OLD_NEXT_SYNC = re.compile(r"prun\w+[^.]{0,120}?next sync")
def _text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _norm(text: str) -> str:
"""Collapse whitespace runs — the markup wraps long lines, so the
locked copy is pinned against the normalized text."""
return re.sub(r"\s+", " ", text).strip()
def _js() -> str:
return _text(JS)
def _css() -> str:
return _text(CSS)
def _fn(js: str, name: str) -> str:
"""The source of a (possibly async) top-level function via
balanced-brace counting (the test_summary_edit_ui.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 _element_block(html: str, id_attr: str, tag: str = "div") -> str:
"""The <tag … id=…> element's full markup (a balanced-tag walk —
the dialog nests the backdrop / panel / actions divs; the hint is
a <p>)."""
marker = f'id="{id_attr}"'
i = html.find(marker)
assert i != -1, f"missing id={id_attr} in git-sources.html"
opens = [m.start() for m in re.finditer(rf"<{tag}\b", html[:i])]
assert opens, f"no <{tag}> owns id={id_attr}"
open_i = opens[-1]
depth = 0
for m in re.finditer(rf"<{tag}\b[^>]*>|</{tag}>", html[open_i :]):
if m.group(0).startswith(f"</{tag}>"):
depth -= 1
else:
depth += 1
if depth == 0:
return html[open_i : open_i + m.end()]
raise AssertionError(f"unbalanced <{tag}> for id={id_attr}")
# ---------- window.confirm is gone (the whole frontend) ----------
def test_window_confirm_is_gone_from_the_whole_frontend() -> None:
"""``window.confirm`` — the only native confirm the app ever
shipped — is absent from EVERY frontend file (markup, scripts,
styles — comments included: the retirement note must not keep the
literal)."""
for path in sorted(FRONTEND.rglob("*")):
if path.is_file():
assert "window.confirm" not in path.read_text(encoding="utf-8"), (
f"window.confirm survived in {path.relative_to(FRONTEND)}"
)
# ---------- the static dialog markup ----------
def test_dialog_markup_is_the_locked_alertdialog() -> None:
"""#remove-confirm-dialog: role="alertdialog" + aria-modal + the
labelled/describedby pair, hidden by default, inside the manager
(#git-sources-content — the static-markup convention, stable E2E
selectors); all six child ids present; the error line is
role="alert"; both buttons are real type="button"; the title is
the locked h2; the modal copy is the locked paragraph verbatim."""
html = _text(HTML)
frag = _element_block(html, "remove-confirm-dialog")
open_tag = frag[: frag.find(">") + 1]
assert 'role="alertdialog"' in open_tag
assert 'aria-modal="true"' in open_tag
assert 'aria-labelledby="remove-confirm-title"' in open_tag
assert 'aria-describedby="remove-confirm-copy"' in open_tag
assert "hidden" in open_tag, "the dialog ships hidden"
# Static markup INSIDE the manager (the #git-sources-hint / gate
# convention) — E2E can wait for the content to be revealed.
assert html.find('id="git-sources-content"') < html.find("remove-confirm-dialog")
for child in (
"remove-confirm-title",
"remove-confirm-source",
"remove-confirm-copy",
"remove-confirm-error",
"remove-confirm-cancel",
"remove-confirm-remove",
):
assert f'id="{child}"' in frag, f"missing #{child} in the dialog"
title = re.search(r"<h2[^>]*id=\"remove-confirm-title\"[^>]*>(.*?)</h2>", frag, re.S)
assert title and _norm(title.group(1)) == "Remove this source?"
err = re.search(r"<p[^>]*id=\"remove-confirm-error\"[^>]*>", frag)
assert err and 'role="alert"' in err.group(0), "the in-modal error is role=alert"
for btn in ("remove-confirm-cancel", "remove-confirm-remove"):
m = re.search(rf"<button[^>]*id=\"{btn}\"[^>]*>", frag)
assert m and 'type="button"' in m.group(0), f"#{btn} is a real type=button"
cancel = re.search(r'<button[^>]*id="remove-confirm-cancel"[^>]*>(.*?)</button>', frag, re.S)
remove = re.search(r'<button[^>]*id="remove-confirm-remove"[^>]*>(.*?)</button>', frag, re.S)
assert cancel and _norm(cancel.group(1)) == "Cancel"
assert remove and _norm(remove.group(1)) == "Remove source"
assert _norm(MODAL_COPY) in _norm(frag), "the locked modal copy, verbatim"
# The source value is a <code> (mono) — textContent-only in JS.
src = re.search(r"<code[^>]*id=\"remove-confirm-source\"[^>]*>", frag)
assert src, "#remove-confirm-source is a <code>"
# ---------- the JS lifecycle ----------
def test_open_populates_source_via_text_content_and_focuses_cancel() -> None:
"""openRemoveConfirm(s, triggerBtn): the source value is
textContent ONLY (never innerHTML — the credential-masking
discipline, phase 32) with makeRow's exact ``value`` expression
(``s.path ?? s.url`` for local rows, ``s.url`` for git); the error
line clears; the dialog unhides; the trigger is recorded; the
keydown handler attaches; and focus lands on Cancel — the safe
default for a destructive action (AFTER the unhide)."""
body = _fn(_js(), "openRemoveConfirm")
assert "s.kind === \"local\"" in body, "the kind-typed value branch"
assert (
"removeSourceEl.textContent = isLocal ? s.path ?? s.url : s.url" in body
), "the same `value` expression makeRow uses, via textContent"
assert "removeSourceEl.innerHTML" not in _js(), "XSS contract: textContent only"
assert "removeError.hidden = true" in body, "a new attempt starts clean"
assert "removeTriggerBtn = triggerBtn" in body, "the trigger is recorded"
trigger_i = body.find("removeTriggerBtn = triggerBtn")
unhide_i = body.find("removeDialog.hidden = false")
attach_i = body.find('document.addEventListener("keydown", onRemoveDialogKeydown)')
focus_i = body.find("removeCancelBtn.focus()")
assert -1 < trigger_i < unhide_i < attach_i < focus_i, (
"record the trigger → unhide → attach keydown → focus Cancel"
)
def test_row_button_opens_the_modal_and_the_row_error_span_is_retired() -> None:
"""makeRow's per-row Remove button opens the modal
(openRemoveConfirm(s, btn)) — no confirm call, no per-row error
span (the modal carries the in-flight error line); the retired
.git-source-row-error class is gone from the JS AND the CSS."""
js = _js()
make = _fn(js, "makeRow")
assert "openRemoveConfirm(s, btn)" in make, "the row's Remove opens the modal"
assert "removeSource(" not in js, "the window.confirm-era flow is gone"
assert "git-source-row-error" not in js
assert "git-source-row-error" not in _css(), "the dead per-row error CSS is retired"
def test_cancel_paths_close_without_a_request() -> None:
"""Cancel (Cancel button / Escape / backdrop) closes as cancel:
the dialog hides, the error line clears, the buttons reset
("Remove source"), the keydown handler detaches, and focus
RETURNS to the recorded trigger — and the cancel path never
fetches. A cancel while a DELETE is in flight is a no-op (no
half-cancel of an in-progress server-side removal)."""
js = _js()
cancel = _fn(js, "cancelRemoveConfirm")
guard_i = cancel.find("if (removeInFlight) return")
close_i = cancel.find("closeRemoveConfirm()")
assert -1 < guard_i < close_i, "the in-flight guard precedes the close"
assert "fetch" not in cancel, "cancel never sends a request"
close = _fn(js, "closeRemoveConfirm")
hide_i = close.find("removeDialog.hidden = true")
clear_i = close.find("removeError.hidden = true")
reset_i = close.find('removeRemoveBtn.textContent = "Remove source"')
detach_i = close.find('document.removeEventListener("keydown", onRemoveDialogKeydown)')
save_i = close.find("const trigger = removeTriggerBtn")
null_i = close.find("removeTriggerBtn = null")
focus_i = close.find("trigger.focus()")
assert -1 < hide_i < clear_i < reset_i < detach_i < save_i < null_i < focus_i, (
"hide → clear → reset → detach → save trigger → focus return"
)
assert "removeCancelBtn.disabled = false" in close
assert "removeRemoveBtn.disabled = false" in close
def test_escape_and_backdrop_and_cancel_button_all_cancel() -> None:
"""While open (handler attached on document in openRemoveConfirm,
detached in closeRemoveConfirm): Escape → preventDefault +
cancelRemoveConfirm; the dim backdrop AND the Cancel button wire
to cancelRemoveConfirm (only "Remove source" wires to
confirmRemove). Tab/Shift+Tab stay inside the two-button modal
(aria-modal honored for keyboard users)."""
js = _js()
keydown = _fn(js, "onRemoveDialogKeydown")
esc_i = keydown.find('e.key === "Escape"')
prevent_i = keydown.find("e.preventDefault()", esc_i)
cancel_i = keydown.find("cancelRemoveConfirm()", esc_i)
assert -1 < esc_i < prevent_i < cancel_i, "Escape: prevent + cancel"
assert 'e.key === "Tab"' in keydown, "the two-button focus cycle"
assert 'removeCancelBtn.addEventListener("click", cancelRemoveConfirm)' in js
assert 'removeBackdrop.addEventListener("click", cancelRemoveConfirm)' in js
assert 'removeRemoveBtn.addEventListener("click", confirmRemove)' in js
def test_confirm_runs_the_inflight_never_stale_lifecycle() -> None:
"""confirmRemove: the §7.4 in-flight state BEFORE the fetch —
both buttons disable + the confirm relabels "Removing…"; one
DELETE /api/git-sources/{id}. Success (204): close (focus return)
→ loadSources → announce (the removal confirmation is the LAST
announcement — the reload's "N sources listed." must not
overwrite it). Non-2xx: the in-modal role=alert line
(apiDetail, 422 shape-aware) and the dialog STAYS open (no
closeRemoveConfirm in the failure slice). Network failure: the
fixed reachable? line. The finally re-enables BOTH buttons +
relabels "Remove source" — never stale on any outcome."""
body = _fn(_js(), "confirmRemove")
guard_i = body.find("if (!removingId || removeInFlight) return")
inflight_i = body.find("removeInFlight = true")
clear_i = body.find("removeError.hidden = true")
dis_c = body.find("removeCancelBtn.disabled = true")
dis_r = body.find("removeRemoveBtn.disabled = true")
label_i = body.find('"Removing…"')
fetch_i = body.find("fetch(`/api/git-sources/${encodeURIComponent(removingId)}`")
method_i = body.find('method: "DELETE"', fetch_i)
assert -1 < guard_i < inflight_i < clear_i < dis_c < dis_r < label_i < fetch_i < method_i, (
"guard → in-flight → clear error → disable both + label → DELETE"
)
# Success: close → reload → announce (the exact order, the exact
# announce string) — the removal confirmation is the LAST
# announcement: the reload's "N sources listed." must not
# overwrite it (phase 69 task 03's E2E pins the success line on
# the announcer after a real removal).
ok_i = body.find("if (r.ok)")
close_i = body.find("closeRemoveConfirm()", ok_i)
reload_i = body.find("await loadSources()", close_i)
announce_i = body.find(f'announce("{ANNOUNCE_OK}")', reload_i)
assert -1 < ok_i < close_i < reload_i < announce_i
assert body.count("closeRemoveConfirm()") == 1, (
"only the success path closes — failures stay open for one retry"
)
# Non-2xx: the in-modal alert line, dialog stays open.
nonok_i = body.find("Could not remove the source — try again.")
catch_i = body.find("} catch {")
assert -1 < nonok_i < catch_i
assert "await apiDetail(r," in body[body.find("if (r.ok)"):catch_i], (
"the server detail is apiDetail-extracted (422 shape-aware)"
)
failure_slice = body[body.rfind("// non-2xx", 0, catch_i):catch_i]
assert "closeRemoveConfirm" not in failure_slice, "failure keeps the dialog open"
# Network: the fixed reachable? line.
net_i = body.find("Could not remove the source — is the app reachable?", catch_i)
assert -1 < net_i < body.find("finally"), "the network copy lands in the catch"
# Never stale: the finally re-enables BOTH buttons + relabels.
fin_i = body.find("finally")
fin = body[fin_i:]
assert "removeInFlight = false" in fin
assert "removeCancelBtn.disabled = false" in fin
assert "removeRemoveBtn.disabled = false" in fin
assert 'removeRemoveBtn.textContent = "Remove source"' in fin
# ---------- the stale copy is gone; the new hint is present ----------
def test_stale_next_sync_removal_copy_is_gone() -> None:
"""The phase-35 "prunes on the next sync" removal contract is
superseded: the retired confirm copy AND any 'prune(s/d) … next
sync' shape are absent from git-sources.js + git-sources.html
(code AND comments — the docstring copy moved with the flow).
The README pins are task 03's."""
for path in (JS, HTML):
raw = _text(path)
norm = _norm(raw)
assert OLD_STAY_INDEXED not in raw, (
f"retired confirm copy still in {path.name}"
)
m = OLD_NEXT_SYNC.search(norm)
assert m is None, f"'next sync' removal copy survived in {path.name}: {m.group(0)!r}"
def test_new_hint_copy_is_present_in_the_html() -> None:
"""The #git-sources-hint carries the new contract: removal is a
total removal, done immediately (the modal spells it out; foreign
local directories are never touched), and the Sync button still
mirrors the remaining sources (upstream churn is pruned on that
run — not 'on the next sync')."""
hint = _norm(_element_block(_text(HTML), "git-sources-hint", tag="p"))
for frag in (HINT_TOTAL_REMOVAL, HINT_MODAL_SPELLS_OUT, HINT_FOREVER_SAFE):
assert frag in hint, f"the new hint copy is missing: {frag!r}"
assert "pruned on that run" in hint, "the Sync-mirror clause (upstream churn)"
assert "next sync" not in hint, "no 'next sync' removal claim in the hint"
def test_module_docstring_carries_the_new_contract() -> None:
"""The git-sources.js module docstring's remove bullet + scope
boundary moved with the flow: the modal names the source + states
the policy, and removal performs the FULL cleanup server-side
(row + index + app-managed files) — with the navigating-away
note (the row + index commit first; an interrupted file step
leaves an inert orphan dir)."""
doc = _js().split("*/", 2)[0] # the module docstring (first block)
for frag in (
'role="alertdialog"',
"textContent ONLY",
'relabels "Removing…"',
"not recommended",
"inert orphan",
"FULL cleanup server-side",
"foreign local directories are never touched",
):
assert frag in doc, f"the module docstring lost: {frag!r}"
# ---------- styles.css ----------
def test_modal_css_classes_present_and_house_tokens_only() -> None:
"""styles.css carries the modal class family on the house
dark-tech palette (phase-08 tokens): the overlay + backdrop +
panel, the title / source / copy / error / actions / two-button
chrome; the [hidden] override (the documented, testable
contract); the global 3px :focus-visible outline is NOT
suppressed; no CDN; no blur (the phase-08 no-blur perf anchor)."""
css = _css()
for cls in (
".remove-confirm",
".remove-confirm-backdrop",
".remove-confirm-panel",
".remove-confirm-title",
".remove-confirm-source",
".remove-confirm-copy",
".remove-confirm-error",
".remove-confirm-actions",
".remove-confirm-btn",
".remove-confirm-cancel",
".remove-confirm-remove",
):
assert f"{cls} " in css or f"{cls}." in css or f"{cls}[" in css, (
f"styles.css must style {cls}"
)
hidden = css.find(".remove-confirm[hidden]")
assert hidden != -1 and "display: none" in css[hidden : hidden + 60], (
"the hidden attr must beat the display rule"
)
assert ":focus-visible {" in css and "outline: 3px solid var(--brand)" in css
# No blur (the phase-08 no-blur perf anchor) — the backdrop rule
# itself must not carry backdrop-filter.
backdrop = css[css.find(".remove-confirm-backdrop {") :]
backdrop = backdrop[: backdrop.find("\n}")]
# Comments stripped — the note "no backdrop-filter (no-blur)" must
# not trip the pin; only a real declaration may.
backdrop = re.sub(r"/\*.*?\*/", "", backdrop, flags=re.S)
assert "backdrop-filter" not in backdrop, "no blur (phase-08 perf anchor)"
assert "url(http" not in css and "@import url(" not in css, (
"no CDN (AGENTS.md rule 6)"
)
def test_modal_css_targets_and_contrast_pairs() -> None:
"""The WCAG 2.1 AA basics in CSS: both buttons >=44px; the
destructive button rides the err token family (err-ink on err-bg
9.3:1, the err-line border — the .tuning-delete / .steering-delete
convention; the hover inverts to --bg on --err-line, 5.2:1);
Cancel is the ghost ink-soft family (5.1:1 on --surface); the
error line is the err pair; the panel caps at the 46rem
chat-column width or the viewport."""
css = _css()
btn = css[css.find(".remove-confirm-btn {"):]
btn = btn[: btn.find("\n}")]
assert "min-height: 44px" in btn and "min-width: 44px" in btn
remove = css[css.find(".remove-confirm-remove {"):]
remove = remove[: remove.find("\n}")]
for prop in ("var(--err-bg)", "var(--err-ink)", "var(--err-line)"):
assert prop in remove, f"the destructive pair must keep {prop}"
hover = css[css.find(".remove-confirm-remove:hover:not(:disabled) {"):]
hover = hover[: hover.find("\n}")]
assert "var(--err-line)" in hover and "var(--bg)" in hover, (
"the hover inversion: dark --bg on --err-line (5.2:1)"
)
cancel = css[css.find(".remove-confirm-cancel {"):]
cancel = cancel[: cancel.find("\n}")]
assert "var(--ink-soft)" in cancel and "transparent" in cancel
err = css[css.find(".remove-confirm-error {"):]
err = err[: err.find("\n}")]
assert "var(--err-ink)" in err and "var(--err-bg)" in err
panel = css[css.find(".remove-confirm-panel {"):]
panel = panel[: panel.find("\n}")]
assert "min(46rem" in panel, "the 46rem chat-column cap (or the viewport)"
+310
View File
@@ -0,0 +1,310 @@
"""Unit: the total-removal helpers (phase 69, task 01).
Covers ``app.rag.source_removal`` with plain objects and ``tmp_path``
(no FastAPI, no database):
* ``resolve_source_name`` — exactly the sync/importer document labels:
git URL shapes (https ``.git`` / bare, scp-style ``git@host:repo.git``,
``ssh://``) and local paths (``~`` expansion, trailing slash, the
``path or url`` fallback);
* ``managed_dir_for`` — git → ``sources_dir/<repo>/``; local → the
stored dir only when it is ``upload_dir`` itself or nested under it
(the containment check, so a sibling named ``uploads-foo`` never
counts); any other local path → ``None`` (owner's own dir, never
touched);
* ``remove_managed_dir`` — ``None``/absent no-op (no filesystem write),
present tree removed → ``True``, ``OSError`` logged (``logger.
exception``) and returned as ``False`` — never raises;
* ``has_sibling`` — same-name sibling (git ``…/r`` vs ``…/r.git``, and
across kinds) → ``True``; different names → ``False``; self-excluded.
"""
from __future__ import annotations
import logging
import uuid
from pathlib import Path
from typing import cast
import pytest
from sqlalchemy.orm import Session
from app.models import GitSource
from app.rag import source_removal
from app.rag.source_removal import (
has_sibling,
managed_dir_for,
remove_managed_dir,
resolve_source_name,
)
def _git(url: str) -> GitSource:
return GitSource(id=uuid.uuid4(), url=url, kind="git")
def _local(path: str, path_column: str | None = None) -> GitSource:
# Phase 38 mirrors the expanded path in the NOT-NULL ``url`` column;
# ``path_column=None`` exercises the ``row.path or row.url`` fallback.
return GitSource(id=uuid.uuid4(), url=path, kind="local", path=path_column)
# ---------------------------------------------------------------------------
# resolve_source_name
# ---------------------------------------------------------------------------
def test_resolve_git_url_https_dot_git_suffix() -> None:
assert resolve_source_name(_git("https://example.com/reese/homelab.git")) == "homelab"
def test_resolve_git_url_https_bare() -> None:
assert resolve_source_name(_git("https://example.com/reese/homelab")) == "homelab"
def test_resolve_git_url_scp_style_git_at() -> None:
"""``git@host:repo.git`` — the ``:`` basename split (the phase-28
``repo_name`` behavior, reused not re-implemented)."""
assert resolve_source_name(_git("git@github.com:reese/deployments.git")) == "deployments"
assert resolve_source_name(_git("git@github.com:reese/deployments")) == "deployments"
def test_resolve_git_url_ssh_scheme() -> None:
assert resolve_source_name(_git("ssh://git@example.com/reese/ops.git")) == "ops"
def test_resolve_git_url_strips_whitespace() -> None:
assert resolve_source_name(_git(" https://example.com/reese/x.git ")) == "x"
def test_resolve_local_expands_tilde(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setenv("HOME", str(tmp_path / "home"))
(tmp_path / "home" / "notes").mkdir(parents=True)
assert resolve_source_name(_local("~/notes")) == "notes"
def test_resolve_local_trailing_slash_and_nested() -> None:
assert resolve_source_name(_local("/srv/docs/notes/")) == "notes"
assert resolve_source_name(_local("/srv/a/b/c")) == "c"
def test_resolve_local_falls_back_to_url_column_when_path_null() -> None:
"""Phase 38 mirrors the path in ``url`` — the ``or`` fallback keeps a
NULL ``path`` row resolvable the same way."""
assert resolve_source_name(_local("/srv/docs/notes", path_column=None)) == "notes"
# ---------------------------------------------------------------------------
# managed_dir_for
# ---------------------------------------------------------------------------
def test_managed_dir_git_maps_to_sources_dir_repo_name(tmp_path: Path) -> None:
sources = tmp_path / "sources"
row = _git("https://example.com/reese/homelab.git")
assert managed_dir_for(row, sources, tmp_path / "uploads") == sources / "homelab"
def test_managed_dir_git_expands_tilde_sources_dir(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setenv("HOME", str(tmp_path / "home"))
(tmp_path / "home" / "bor-sources").mkdir(parents=True)
row = _git("git@github.com:reese/ops.git")
got = managed_dir_for(row, Path("~/bor-sources"), tmp_path / "uploads")
assert got == tmp_path / "home" / "bor-sources" / "ops"
def test_managed_dir_local_upload_dir_itself(tmp_path: Path) -> None:
upload = tmp_path / "uploads"
upload.mkdir()
row = _local(str(upload))
assert managed_dir_for(row, tmp_path / "sources", upload) == upload
def test_managed_dir_local_nested_under_upload_dir(tmp_path: Path) -> None:
upload = tmp_path / "uploads"
folder = upload / "my-notes"
folder.mkdir(parents=True)
row = _local(str(folder))
assert managed_dir_for(row, tmp_path / "sources", upload) == folder
def test_managed_dir_local_deeply_nested_under_upload_dir(tmp_path: Path) -> None:
upload = tmp_path / "uploads"
folder = upload / "a" / "b"
folder.mkdir(parents=True)
row = _local(str(folder))
assert managed_dir_for(row, tmp_path / "sources", upload) == folder
def test_managed_dir_unresolvable_path_is_none(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Containment that cannot be established (an unresolvable path) is
``None`` — never delete when in doubt (the defensive branch)."""
row = _local("/srv/docs/notes")
def boom(self: Path, strict: bool = False) -> Path: # noqa: ARG001
raise OSError("simulated resolve failure")
monkeypatch.setattr(Path, "resolve", boom)
assert managed_dir_for(row, tmp_path / "sources", tmp_path / "uploads") is None
def test_managed_dir_local_foreign_path_is_none(tmp_path: Path) -> None:
"""The owner's own directory — never app-managed, never touched."""
foreign = tmp_path / "own" / "docs"
foreign.mkdir(parents=True)
row = _local(str(foreign))
assert managed_dir_for(row, tmp_path / "sources", tmp_path / "uploads") is None
def test_managed_dir_local_prefix_sibling_never_counts(tmp_path: Path) -> None:
"""The containment edge: ``…/u-evil`` is NOT under ``…/u`` — a
prefix-sharing sibling name must never map into the upload dir."""
evil = tmp_path / "u-evil"
evil.mkdir()
row = _local(str(evil))
assert managed_dir_for(row, tmp_path / "sources", tmp_path / "u") is None
def test_managed_dir_local_symlink_escaping_upload_dir_is_none(tmp_path: Path) -> None:
"""A symlink stored under the upload dir that points at the owner's
dir resolves OUTSIDE — containment fails → ``None`` (never delete
through a link)."""
upload = tmp_path / "uploads"
foreign = tmp_path / "foreign"
upload.mkdir()
foreign.mkdir()
link = upload / "sneaky"
link.symlink_to(foreign)
row = _local(str(link))
assert managed_dir_for(row, tmp_path / "sources", upload) is None
def test_managed_dir_local_under_upload_dir_symlink_still_maps(tmp_path: Path) -> None:
"""Mirror image: a link that stays under the upload dir resolves
inside it — the stored path is still the app-managed dir."""
upload = tmp_path / "uploads"
real = upload / "real"
upload.mkdir()
real.mkdir()
link = upload / "alias"
link.symlink_to(real)
row = _local(str(link))
assert managed_dir_for(row, tmp_path / "sources", upload) == link
# ---------------------------------------------------------------------------
# remove_managed_dir
# ---------------------------------------------------------------------------
def test_remove_managed_dir_none_is_noop_false() -> None:
assert remove_managed_dir(None) is False
def test_remove_managed_dir_absent_is_noop_false(tmp_path: Path) -> None:
missing = tmp_path / "never-created"
assert remove_managed_dir(missing) is False
assert not missing.exists() # no filesystem write
def test_remove_managed_dir_present_tree_removed_true(tmp_path: Path) -> None:
tree = tmp_path / "homelab"
(tree / "sub").mkdir(parents=True)
(tree / "alpha.md").write_text("one")
(tree / "sub" / "bravo.md").write_text("two")
assert remove_managed_dir(tree) is True
assert not tree.exists()
def test_remove_managed_dir_oserror_logged_not_fatal(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A permission/busy-dir failure is ``logger.exception`` + ``False``
— removal never raises (the DB removal is already committed)."""
stuck = tmp_path / "stuck"
stuck.mkdir()
(stuck / "alpha.md").write_text("one")
def boom(directory: Path) -> None:
raise OSError(13, "Permission denied", str(directory))
monkeypatch.setattr(source_removal.shutil, "rmtree", boom)
with caplog.at_level(logging.ERROR, logger="app.rag.source_removal"):
assert remove_managed_dir(stuck) is False
# The exception was logged (with the traceback) and the dir is as it
# was (the rmtree never ran) — inert, self-heals on re-add.
assert stuck.is_dir()
errors = [r for r in caplog.records if r.levelno >= logging.ERROR]
assert len(errors) == 1
assert "could not remove the on-disk directory" in errors[0].getMessage()
assert errors[0].exc_info is not None
# ---------------------------------------------------------------------------
# has_sibling
# ---------------------------------------------------------------------------
class _FakeScalars:
def __init__(self, rows: list[GitSource]) -> None:
self._rows = rows
def all(self) -> list[GitSource]:
return list(self._rows)
class _FakeSession:
"""The duck-typed seam: ``has_sibling`` only calls
``db.scalars(select(GitSource))`` (the statement builds fine without
a connection) — the fake returns the registry rows it was given."""
def __init__(self, rows: list[GitSource]) -> None:
self._rows = rows
def scalars(self, statement: object) -> _FakeScalars: # noqa: ARG002
return _FakeScalars(self._rows)
def _sibling(rows: list[GitSource], row: GitSource) -> bool:
"""``has_sibling`` against a fake registry session — the duck-typed
fake goes through the seam via ``cast`` (the ``test_agent.py``
pattern)."""
return has_sibling(cast("Session", _FakeSession(rows)), row)
def test_has_sibling_same_name_git_dot_git_pair() -> None:
a = _git("https://example.com/reese/r")
b = _git("https://example.com/reese/r.git")
assert _sibling([a, b], a) is True
assert _sibling([a, b], b) is True
def test_has_sibling_different_names_is_false() -> None:
a = _git("https://example.com/reese/one.git")
b = _git("https://example.com/reese/two.git")
assert _sibling([a, b], a) is False
assert _sibling([a, b], b) is False
def test_has_sibling_self_excluded() -> None:
a = _git("https://example.com/reese/only.git")
assert _sibling([a], a) is False
def test_has_sibling_empty_registry_is_false() -> None:
a = _git("https://example.com/reese/only.git")
assert _sibling([], a) is False
def test_has_sibling_across_kinds_same_resolved_name() -> None:
"""A git URL whose ``repo_name`` equals a local dir name is a
sibling too — both index under the same label."""
git_row = _git("https://example.com/reese/notes.git")
local_row = _local("/srv/docs/notes")
assert _sibling([git_row, local_row], git_row) is True
assert _sibling([git_row, local_row], local_row) is True