139 lines
5.9 KiB
Python
139 lines
5.9 KiB
Python
"""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
|