Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions packages/cli/src/repowise/cli/commands/delete_cmd.py
Original file line number Diff line number Diff line change
@@ -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())
2 changes: 2 additions & 0 deletions packages/cli/src/repowise/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/repowise/core/persistence/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
create_conversation,
delete_conversation,
delete_decision,
delete_repository,
get_all_git_metadata,
get_conversation,
get_dead_code_findings,
Expand All @@ -44,6 +45,7 @@
list_chat_messages,
list_conversations,
list_decisions,
list_page_ids,
list_pages,
mark_webhook_processed,
recompute_decision_staleness,
Expand Down Expand Up @@ -144,6 +146,7 @@
"create_session_factory",
"delete_conversation",
"delete_decision",
"delete_repository",
# git metadata crud
"get_all_git_metadata",
"get_configured_db_url",
Expand All @@ -169,6 +172,7 @@
"list_chat_messages",
"list_conversations",
"list_decisions",
"list_page_ids",
"list_pages",
"mark_webhook_processed",
"recompute_decision_staleness",
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/repowise/core/persistence/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/repowise/core/persistence/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
28 changes: 27 additions & 1 deletion packages/server/src/repowise/server/routers/repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
84 changes: 44 additions & 40 deletions packages/web/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" };

Expand Down Expand Up @@ -124,48 +125,51 @@ export default async function DashboardPage() {
) : (
<ul className="divide-y divide-[var(--color-border-default)]">
{repoList.map((repo) => (
<li key={repo.id}>
<Link
href={`/repos/${repo.id}`}
className="flex items-start gap-3 px-6 py-3.5 transition-colors hover:bg-[var(--color-bg-elevated)] group"
>
<div className="mt-0.5 h-2 w-2 rounded-full bg-[var(--color-accent-primary)] shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-[var(--color-text-primary)] truncate group-hover:text-[var(--color-accent-primary)] transition-colors">
{repo.name}
</p>
</div>
<div className="flex items-center gap-2 mt-0.5">
<p className="text-xs text-[var(--color-text-tertiary)] font-mono truncate" title={repo.local_path}>
{repo.local_path}
</p>
</div>
<div className="flex items-center gap-2 mt-1 flex-wrap">
{repo.head_commit && (
<span className="text-xs font-mono text-[var(--color-text-tertiary)]">
{repo.head_commit.slice(0, 7)}
<li key={repo.id} className="group">
<div className="flex items-start gap-3 px-6 py-3.5 transition-colors hover:bg-[var(--color-bg-elevated)]">
<Link
href={`/repos/${repo.id}`}
className="flex items-start gap-3 flex-1 min-w-0"
>
<div className="mt-0.5 h-2 w-2 rounded-full bg-[var(--color-accent-primary)] shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-[var(--color-text-primary)] truncate group-hover:text-[var(--color-accent-primary)] transition-colors">
{repo.name}
</p>
</div>
<div className="flex items-center gap-2 mt-0.5">
<p className="text-xs text-[var(--color-text-tertiary)] font-mono truncate" title={repo.local_path}>
{repo.local_path}
</p>
</div>
<div className="flex items-center gap-2 mt-1 flex-wrap">
{repo.head_commit && (
<span className="text-xs font-mono text-[var(--color-text-tertiary)]">
{repo.head_commit.slice(0, 7)}
</span>
)}
<span className="text-xs text-[var(--color-text-tertiary)]">
Updated {formatRelativeTime(repo.updated_at)}
</span>
)}
<span className="text-xs text-[var(--color-text-tertiary)]">
Updated {formatRelativeTime(repo.updated_at)}
</span>
{gitMap.has(repo.id) && (() => {
const g = gitMap.get(repo.id)!;
return (
<>
{g.hotspot_count > 0 && (
<Badge variant="outdated">{g.hotspot_count} hotspot{g.hotspot_count !== 1 ? "s" : ""}</Badge>
)}
{g.stable_count > 0 && (
<Badge variant="fresh">{g.stable_count} stable</Badge>
)}
</>
);
})()}
{gitMap.has(repo.id) && (() => {
const g = gitMap.get(repo.id)!;
return (
<>
{g.hotspot_count > 0 && (
<Badge variant="outdated">{g.hotspot_count} hotspot{g.hotspot_count !== 1 ? "s" : ""}</Badge>
)}
{g.stable_count > 0 && (
<Badge variant="fresh">{g.stable_count} stable</Badge>
)}
</>
);
})()}
</div>
</div>
</div>
</Link>
</Link>
<DeleteRepoButton repoId={repo.id} repoName={repo.name} />
</div>
</li>
))}
</ul>
Expand Down
Loading