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
+138
View File
@@ -0,0 +1,138 @@
"""Total source removal helpers (phase 69, task 01).
Removing a source is a **total removal** (owner request 2026-09-02):
the stored row, every indexed document of that source (chunks +
embeddings), and — for app-managed sources — the files on disk (the git
checkout or the unpacked upload folder), all in one action. These
helpers carry the row-agnostic pieces (the source-name resolution, the
managed-dir mapping, the disk removal, the sibling guard) so the
``DELETE /api/git-sources/{id}`` endpoint stays thin.
Locked order (phase 69, ``00_phase.md``):
* **DB first, disk second.** The row + document prune commit atomically
first (the RAG is always consistent with the registry — the owner's
core ask); the disk removal runs **after** the commit, and an
``OSError`` is logged but never fails the 204 (a leftover dir is
inert — no row → never imported — and self-heals on re-add). The
reverse order is forbidden: a disk failure must never leave a row
pointing at deleted files.
* **App-managed files only.** Git checkouts live under
``sources_dir/<repo>/`` and unpacked uploads under
``upload_dir/<name>/`` — both app-managed. A ``kind='local'`` row
pointing at any other directory (the owner's own) is never touched
on disk — only its row + index entries are removed.
* **Sibling guard.** Two rows that resolve to the same source name
(e.g. ``https://e.com/r`` and ``https://e.com/r.git`` → both ``r``)
share documents and files — removing one of them deletes only the
row; the shared documents and files stay.
The module is stdlib + SQLAlchemy + ``app``/``scripts`` imports only
(**no FastAPI**), so every helper unit-tests with plain objects and
``tmp_path``.
"""
from __future__ import annotations
import logging
import shutil
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import GitSource
from scripts.import_docs import repo_name
logger = logging.getLogger("app.rag.source_removal")
def resolve_source_name(row: GitSource) -> str:
"""The label under which the sync/importer index this row's documents.
Exactly the expressions the import pipeline walks — reuse them here
or removal would prune the wrong documents: ``kind='git'`` →
:func:`scripts.import_docs.repo_name` (the phase-28 helper — strips
the trailing ``.git`` and handles scp-style ``git@host:repo``);
``kind='local'`` → ``Path(row.path or row.url).expanduser().name``
(the same expression ``app.api.sync._run_sync`` walks; phase 38
mirrors the expanded path in the NOT-NULL ``url`` column, the
``or`` keeps the type checker honest).
"""
if row.kind == "git":
return repo_name(row.url)
return Path(row.path or row.url).expanduser().name
def managed_dir_for(row: GitSource, sources_dir: Path, upload_dir: Path) -> Path | None:
"""The app-managed on-disk directory whose files belong to ``row``.
* ``kind='git'`` → ``sources_dir/<repo-name>/`` — the checkout
location (the ``clone_or_pull`` target of the sync/import).
* ``kind='local'`` → the stored directory **only when** it equals
``upload_dir`` or is nested under it (the unpacked uploads,
phase 49). Containment is checked on **resolved** paths with
``parents``, so a sibling directory whose name merely shares a
prefix (``…/uploads-foo`` next to ``…/uploads``) never counts —
and a symlink that escapes the upload dir never counts either
(``resolve()`` follows it to its target).
* any other ``kind='local'`` path → ``None``: the owner's own
directory, **never** touched on disk (row + index entries only).
``None`` as well when containment cannot be established (an
unresolvable path) — never delete when in doubt.
"""
if row.kind == "git":
return Path(sources_dir).expanduser() / repo_name(row.url)
stored = Path(row.path or row.url).expanduser()
upload = Path(upload_dir).expanduser()
try:
stored_resolved = stored.resolve()
upload_resolved = upload.resolve()
except OSError:
return None
if stored_resolved == upload_resolved or upload_resolved in stored_resolved.parents:
return stored
return None
def remove_managed_dir(directory: Path | None) -> bool:
"""Delete the app-managed directory (recursively), best-effort.
``None`` or an absent directory → ``False`` (a no-op, no
filesystem write — the row may simply have no checkout yet).
Present → ``shutil.rmtree`` → ``True`` (only a dir that was
actually removed). An ``OSError`` (permissions, a busy file, a
symlink-to-dir refusal, …) is logged with ``logger.exception`` and
returns ``False`` — this function **never raises**: the row + index
are already committed at call time, and a leftover dir is inert
(no row → never imported) and self-heals on re-add (git re-clones,
a re-upload recreates the folder).
"""
if directory is None or not directory.exists():
return False
try:
shutil.rmtree(directory)
except OSError:
logger.exception(
"source removal: could not remove the on-disk directory %s", directory
)
return False
return True
def has_sibling(db: Session, row: GitSource) -> bool:
"""Whether another stored row resolves to the same source name.
The registry is tiny — every row is resolved in Python (no SQL
trickery): ``True`` when a different-id row's
:func:`resolve_source_name` equals this row's (e.g.
``https://e.com/r`` and ``https://e.com/r.git`` → both ``r``). Such
a sibling still owns the shared documents and files, so the caller
must delete **only the row** and skip the index/disk work (the
phase-69 sibling guard).
"""
name = resolve_source_name(row)
for other in db.scalars(select(GitSource)).all():
if other.id != row.id and resolve_source_name(other) == name:
return True
return False