"""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//`` and unpacked uploads under ``upload_dir//`` — 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//`` — 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