diff --git a/packages/cli/src/repowise/cli/commands/delete_cmd.py b/packages/cli/src/repowise/cli/commands/delete_cmd.py new file mode 100644 index 0000000..048d6f0 --- /dev/null +++ b/packages/cli/src/repowise/cli/commands/delete_cmd.py @@ -0,0 +1,139 @@ +"""``repowise delete`` — remove a repository and all its generated data.""" + +from __future__ import annotations + +import click +from rich.table import Table + +from repowise.cli.helpers import ( + console, + get_db_url_for_repo, + get_repowise_dir, + resolve_repo_path, + run_async, +) + + +@click.command("delete") +@click.argument("repo_id", required=False, default=None) +@click.option("--force", "-f", is_flag=True, default=False, help="Skip confirmation prompt.") +@click.option("--path", "-p", default=None, help="Path to the repository directory.") +def delete_command(repo_id: str | None, force: bool, path: str | None) -> None: + """Delete a repository and all its generated data.""" + repo_path = resolve_repo_path(path) + repowise_dir = get_repowise_dir(repo_path) + + if not repowise_dir.exists(): + console.print("[yellow]No .repowise/ directory found. Run 'repowise init' first.[/yellow]") + return + + db_path = repowise_dir / "wiki.db" + if not db_path.exists(): + console.print("[yellow]Database not found.[/yellow]") + return + + async def _run() -> None: + from sqlalchemy import func, select + + from repowise.core.persistence import ( + Repository, + create_engine, + create_session_factory, + delete_repository, + get_session, + list_page_ids, + ) + from repowise.core.persistence.models import Page + from repowise.core.persistence.search import FullTextSearch + + url = get_db_url_for_repo(repo_path) + engine = create_engine(url) + sf = create_session_factory(engine) + + # List all repos with page counts + async with get_session(sf) as session: + result = await session.execute( + select( + Repository.id, + Repository.name, + Repository.local_path, + func.count(Page.id).label("page_count"), + ) + .outerjoin(Page, Page.repository_id == Repository.id) + .group_by(Repository.id) + .order_by(Repository.updated_at.desc()) + ) + repos = list(result.all()) + + if not repos: + console.print("[yellow]No repositories found in the database.[/yellow]") + await engine.dispose() + return + + # If no repo_id given, let the user pick + target_id = repo_id + if target_id is None: + table = Table(title="Repositories") + table.add_column("#", style="cyan", justify="right") + table.add_column("Name", style="bold") + table.add_column("Path") + table.add_column("Pages", justify="right") + table.add_column("ID", style="dim") + + for i, (rid, name, lpath, pcount) in enumerate(repos, 1): + table.add_row(str(i), name, lpath, str(pcount), rid[:12]) + + console.print(table) + choice = click.prompt( + "Enter number to delete (or 'q' to quit)", + default="q", + ) + if choice.lower() == "q": + await engine.dispose() + return + try: + idx = int(choice) - 1 + if idx < 0 or idx >= len(repos): + raise ValueError + target_id = repos[idx][0] + except (ValueError, IndexError): + console.print("[red]Invalid selection.[/red]") + await engine.dispose() + return + + # Find the target repo info + target = next((r for r in repos if r[0] == target_id), None) + if target is None: + console.print(f"[red]Repository {target_id} not found.[/red]") + await engine.dispose() + return + + rid, name, lpath, pcount = target + console.print( + f"\nAbout to delete [bold]{name}[/bold] ({lpath}) — " + f"[yellow]{pcount} pages[/yellow] will be removed." + ) + + if not force: + if not click.confirm("Are you sure?", default=False): + console.print("Cancelled.") + await engine.dispose() + return + + # Collect page IDs, clean FTS, delete repo + async with get_session(sf) as session: + page_ids = await list_page_ids(session, rid) + + fts = FullTextSearch(engine) + await fts.delete_many(page_ids) + + await delete_repository(session, rid) + + console.print( + f"[bold green]Deleted[/bold green] {name} — " + f"{len(page_ids)} pages removed." + ) + + await engine.dispose() + + run_async(_run()) diff --git a/packages/cli/src/repowise/cli/main.py b/packages/cli/src/repowise/cli/main.py index 02dd069..3c4e1e6 100644 --- a/packages/cli/src/repowise/cli/main.py +++ b/packages/cli/src/repowise/cli/main.py @@ -7,6 +7,7 @@ from repowise.cli import __version__ from repowise.cli.commands.claude_md_cmd import claude_md_command from repowise.cli.commands.dead_code_cmd import dead_code_command +from repowise.cli.commands.delete_cmd import delete_command from repowise.cli.commands.decision_cmd import decision_group from repowise.cli.commands.doctor_cmd import doctor_command from repowise.cli.commands.export_cmd import export_command @@ -27,6 +28,7 @@ def cli() -> None: cli.add_command(init_command) +cli.add_command(delete_command) cli.add_command(claude_md_command) cli.add_command(update_command) cli.add_command(dead_code_command) diff --git a/packages/core/src/repowise/core/persistence/__init__.py b/packages/core/src/repowise/core/persistence/__init__.py index 790afc8..c16f051 100644 --- a/packages/core/src/repowise/core/persistence/__init__.py +++ b/packages/core/src/repowise/core/persistence/__init__.py @@ -26,6 +26,7 @@ create_conversation, delete_conversation, delete_decision, + delete_repository, get_all_git_metadata, get_conversation, get_dead_code_findings, @@ -44,6 +45,7 @@ list_chat_messages, list_conversations, list_decisions, + list_page_ids, list_pages, mark_webhook_processed, recompute_decision_staleness, @@ -144,6 +146,7 @@ "create_session_factory", "delete_conversation", "delete_decision", + "delete_repository", # git metadata crud "get_all_git_metadata", "get_configured_db_url", @@ -169,6 +172,7 @@ "list_chat_messages", "list_conversations", "list_decisions", + "list_page_ids", "list_pages", "mark_webhook_processed", "recompute_decision_staleness", diff --git a/packages/core/src/repowise/core/persistence/crud.py b/packages/core/src/repowise/core/persistence/crud.py index 00dcf64..f353bf0 100644 --- a/packages/core/src/repowise/core/persistence/crud.py +++ b/packages/core/src/repowise/core/persistence/crud.py @@ -111,6 +111,30 @@ async def get_repository_by_path(session: AsyncSession, local_path: str) -> Repo return result.scalar_one_or_none() +async def delete_repository(session: AsyncSession, repo_id: str) -> bool: + """Delete a repository and all cascaded children. + + Returns True if deleted, False if not found. + + NOTE: The caller should clean up the FTS index *before* calling this, + since the CASCADE will delete Page rows and we lose the page IDs. + """ + repo = await session.get(Repository, repo_id) + if repo is None: + return False + await session.delete(repo) + await session.flush() + return True + + +async def list_page_ids(session: AsyncSession, repository_id: str) -> list[str]: + """Return all page IDs for a repository (lightweight, ID-only query).""" + result = await session.execute( + select(Page.id).where(Page.repository_id == repository_id) + ) + return list(result.scalars().all()) + + # --------------------------------------------------------------------------- # GenerationJob CRUD # --------------------------------------------------------------------------- diff --git a/packages/core/src/repowise/core/persistence/search.py b/packages/core/src/repowise/core/persistence/search.py index cc2023c..2399f83 100644 --- a/packages/core/src/repowise/core/persistence/search.py +++ b/packages/core/src/repowise/core/persistence/search.py @@ -217,6 +217,19 @@ async def delete(self, page_id: str) -> None: {"pid": page_id}, ) + async def delete_many(self, page_ids: list[str]) -> None: + """Remove multiple pages from the FTS index in a single transaction.""" + if not page_ids: + return + if self._dialect == "sqlite": + async with self._engine.begin() as conn: + for pid in page_ids: + await conn.execute( + text("DELETE FROM page_fts WHERE page_id = :pid"), + {"pid": pid}, + ) + # PostgreSQL: rows deleted via CASCADE automatically remove from GIN index. + async def list_indexed_ids(self) -> set[str]: """Return the set of page IDs currently in the FTS index. diff --git a/packages/server/src/repowise/server/routers/repos.py b/packages/server/src/repowise/server/routers/repos.py index d2a1ecd..04b7fe9 100644 --- a/packages/server/src/repowise/server/routers/repos.py +++ b/packages/server/src/repowise/server/routers/repos.py @@ -17,7 +17,7 @@ Page, Repository, ) -from repowise.server.deps import get_db_session, verify_api_key +from repowise.server.deps import get_db_session, get_fts, verify_api_key from repowise.server.job_executor import execute_job from repowise.server.schemas import RepoCreate, RepoResponse, RepoStatsResponse, RepoUpdate @@ -94,6 +94,32 @@ async def update_repo( return RepoResponse.from_orm(repo) +@router.delete("/{repo_id}") +async def delete_repo( + repo_id: str, + session: AsyncSession = Depends(get_db_session), # noqa: B008 + fts=Depends(get_fts), # noqa: B008 +) -> dict: + """Delete a repository and all its data.""" + repo = await crud.get_repository(session, repo_id) + if repo is None: + raise HTTPException(status_code=404, detail="Repository not found") + + # Collect page IDs before CASCADE deletes the Page rows + page_ids = await crud.list_page_ids(session, repo_id) + + # Clean up FTS index (FTS5 virtual table has no FK cascade). + # fts is always initialized in the lifespan before the server accepts + # requests, so this guard is purely defensive. + if fts is not None: + await fts.delete_many(page_ids) + + # Delete repository — CASCADE handles all child ORM tables + await crud.delete_repository(session, repo_id) + + return {"ok": True, "deleted_pages": len(page_ids)} + + @router.get("/{repo_id}/stats", response_model=RepoStatsResponse) async def get_repo_stats( repo_id: str, diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index e1b30b1..ac76038 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -21,6 +21,7 @@ import { ConfidenceBadge } from "@/components/wiki/confidence-badge"; import { EmptyState } from "@/components/shared/empty-state"; import { formatRelativeTime, formatNumber } from "@/lib/utils/format"; import { scoreToStatus } from "@/lib/utils/confidence"; +import { DeleteRepoButton } from "@/components/repos/delete-repo-button"; export const metadata: Metadata = { title: "Dashboard" }; @@ -124,48 +125,51 @@ export default async function DashboardPage() { ) : ( diff --git a/packages/web/src/app/repos/[id]/settings/page.tsx b/packages/web/src/app/repos/[id]/settings/page.tsx index 35d9647..cd1d167 100644 --- a/packages/web/src/app/repos/[id]/settings/page.tsx +++ b/packages/web/src/app/repos/[id]/settings/page.tsx @@ -3,6 +3,7 @@ import { notFound } from "next/navigation"; import { Settings } from "lucide-react"; import { getRepo } from "@/lib/api/repos"; import { RepoSettingsForm } from "@/components/repos/repo-settings-form"; +import { DeleteRepoButton } from "@/components/repos/delete-repo-button"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { OperationsPanel } from "@/components/repos/operations-panel"; @@ -83,6 +84,20 @@ export default async function RepoSettingsPage({ params }: Props) { ))} + + + + + + Danger Zone + + Permanently delete this repository and all its generated pages, symbols, and history. + + + + + + ); } diff --git a/packages/web/src/components/repos/delete-repo-button.tsx b/packages/web/src/components/repos/delete-repo-button.tsx new file mode 100644 index 0000000..ab34f4d --- /dev/null +++ b/packages/web/src/components/repos/delete-repo-button.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { Trash2, AlertTriangle } from "lucide-react"; +import { deleteRepo } from "@/lib/api/repos"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; + +interface DeleteRepoButtonProps { + repoId: string; + repoName: string; + variant?: "icon" | "button"; +} + +export function DeleteRepoButton({ repoId, repoName, variant = "icon" }: DeleteRepoButtonProps) { + const [open, setOpen] = useState(false); + const [deleting, setDeleting] = useState(false); + const router = useRouter(); + + async function handleDelete() { + setDeleting(true); + try { + const result = await deleteRepo(repoId); + toast.success(`Deleted ${repoName} — ${result.deleted_pages} pages removed`); + setOpen(false); + router.refresh(); + } catch (err) { + toast.error(`Failed to delete: ${err instanceof Error ? err.message : "Unknown error"}`); + } finally { + setDeleting(false); + } + } + + return ( + <> + {variant === "button" ? ( + + ) : ( + + )} + + + + + + + Delete Repository + + +

+ This will permanently delete{" "} + {repoName}{" "} + and all its generated pages, symbols, and history. +

+ + + + +
+
+ + ); +} diff --git a/packages/web/src/lib/api/repos.ts b/packages/web/src/lib/api/repos.ts index d9b8b04..a0d3c03 100644 --- a/packages/web/src/lib/api/repos.ts +++ b/packages/web/src/lib/api/repos.ts @@ -1,4 +1,4 @@ -import { apiGet, apiPost, apiPatch } from "./client"; +import { apiGet, apiPost, apiPatch, apiDelete } from "./client"; import type { RepoCreate, RepoUpdate, RepoResponse, JobResponse, RepoStatsResponse } from "./types"; export async function listRepos(): Promise { @@ -25,6 +25,10 @@ export async function fullResyncRepo(repoId: string): Promise { return apiPost(`/api/repos/${repoId}/full-resync`); } +export async function deleteRepo(repoId: string): Promise<{ ok: boolean; deleted_pages: number }> { + return apiDelete<{ ok: boolean; deleted_pages: number }>(`/api/repos/${repoId}`); +} + export async function getRepoStats(repoId: string): Promise { return apiGet(`/api/repos/${repoId}/stats`); } diff --git a/tests/unit/persistence/test_crud.py b/tests/unit/persistence/test_crud.py index 981c34d..07bd23b 100644 --- a/tests/unit/persistence/test_crud.py +++ b/tests/unit/persistence/test_crud.py @@ -15,12 +15,14 @@ batch_upsert_graph_edges, batch_upsert_graph_nodes, batch_upsert_symbols, + delete_repository, get_generation_job, get_page, get_page_versions, get_repository, get_repository_by_path, get_stale_pages, + list_page_ids, list_pages, mark_webhook_processed, store_webhook_event, @@ -72,6 +74,32 @@ async def test_get_repository_returns_inserted(async_session): assert found.id == repo.id +async def test_delete_repository_success(async_session): + repo = await insert_repo(async_session, name="doomed", local_path="/tmp/doomed") + repo_id = repo.id + await async_session.commit() + + deleted = await delete_repository(async_session, repo_id) + await async_session.commit() + assert deleted is True + assert await get_repository(async_session, repo_id) is None + + +async def test_delete_repository_not_found(async_session): + deleted = await delete_repository(async_session, "nonexistent") + assert deleted is False + + +async def test_list_page_ids(async_session): + repo = await insert_repo(async_session) + kwargs = make_page_kwargs(repo.id) + page = await upsert_page(async_session, **kwargs) + await async_session.commit() + + ids = await list_page_ids(async_session, repo.id) + assert page.id in ids + + # --------------------------------------------------------------------------- # GenerationJob CRUD # --------------------------------------------------------------------------- diff --git a/tests/unit/server/test_repos.py b/tests/unit/server/test_repos.py index d0bb20a..3f7712a 100644 --- a/tests/unit/server/test_repos.py +++ b/tests/unit/server/test_repos.py @@ -125,3 +125,22 @@ async def test_full_resync_duplicate_returns_409(client: AsyncClient) -> None: resp2 = await client.post(f"/api/repos/{repo['id']}/full-resync") assert resp2.status_code == 409 + + +@pytest.mark.asyncio +async def test_delete_repo_success(client: AsyncClient) -> None: + repo = await create_test_repo(client) + resp = await client.delete(f"/api/repos/{repo['id']}") + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + + # Verify repo is gone + resp = await client.get(f"/api/repos/{repo['id']}") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_repo_not_found(client: AsyncClient) -> None: + resp = await client.delete("/api/repos/nonexistent") + assert resp.status_code == 404