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() { ) : (
- {repo.name} -
-- {repo.local_path} -
-+ {repo.name} +
++ {repo.local_path} +
+