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
17 changes: 14 additions & 3 deletions packages/cli/src/repowise/cli/commands/update_cmd/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -819,12 +819,19 @@ def run_update(
if not dry_run:
_repair_module_attribution(repo_path)

# Stale structural pages (e.g. file_page rows marked stale or expired) in
# the DB must be reconciled even when HEAD has not moved.
from .deterministic import load_stale_structural_file_paths

stale_db_paths = load_stale_structural_file_paths(repo_path)

if (
head
and head == base_ref
and not config_changed
and not renderer_changed
and not working_tree_diffs
and not stale_db_paths
):
console.print("[green]Already up to date.[/green]")
# D7: on a template (index-only) wiki, "up to date" is true of the code
Expand Down Expand Up @@ -1198,9 +1205,13 @@ def run_update(
# and no model will come along later to fix it. Appended rather than merged
# so the cascade's own ordering is preserved.
stale_renderer_paths = _stale_renderer_paths(repo_path, parsed_files)
if stale_renderer_paths:
affected.regenerate = list(dict.fromkeys([*affected.regenerate, *stale_renderer_paths]))
console.print(f"Pages from an older renderer: [cyan]{len(stale_renderer_paths)}[/cyan]")
stale_extra = list(dict.fromkeys([*stale_renderer_paths, *stale_db_paths]))
if stale_extra:
affected.regenerate = list(dict.fromkeys([*affected.regenerate, *stale_extra]))
if stale_renderer_paths:
console.print(f"Pages from an older renderer: [cyan]{len(stale_renderer_paths)}[/cyan]")
if stale_db_paths and not stale_renderer_paths:
console.print(f"Reconciling stale structural pages: [cyan]{len(stale_db_paths)}[/cyan]")

console.print(f"Pages to regenerate: [cyan]{len(affected.regenerate)}[/cyan]")
if affected.decay_only:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -428,3 +428,54 @@
return {}
finally:
await engine.dispose()


def load_stale_structural_file_paths(repo_path: Path) -> list[str]:
"""Return file paths of structural pages currently marked stale or expired in DB.

Covers file_page rows and other structural pages whose freshness_status is 'stale'
or 'expired'. Extracting their file paths allows `repowise update` to reconcile
lingering stale structural pages even when the repo is already at HEAD.
"""
return run_async(_load_stale_structural_file_paths(repo_path))


async def _load_stale_structural_file_paths(repo_path: Path) -> list[str]:
from repowise.cli.helpers import get_db_url_for_repo
from repowise.core.persistence import create_engine, create_session_factory, get_session

db_path = repo_path / ".repowise" / "wiki.db"
if not db_path.exists():
return []

engine = create_engine(get_db_url_for_repo(repo_path))
try:
from sqlalchemy import select as sa_select

from repowise.core.cost_estimator import STRUCTURAL_PAGE_TYPES
from repowise.core.persistence.models import Page

async with get_session(create_session_factory(engine)) as session:
rows = await session.execute(
sa_select(Page.id, Page.page_type, Page.target_path).where(
Page.freshness_status.in_(["stale", "expired"])
)
)
stale_paths: list[str] = []
for pid, page_type, target_path in rows:
if page_type in STRUCTURAL_PAGE_TYPES or page_type == "file_page":
if page_type == "file_page" and target_path:
stale_paths.append(target_path)
elif pid.startswith("file_page:"):
stale_paths.append(pid[len("file_page:") :])
elif target_path:
file_path = target_path.split("::", 1)[0]
if file_path:
stale_paths.append(file_path)
return list(dict.fromkeys(stale_paths))
except Exception as exc:
console.print(f"[yellow]Could not read stale structural pages: {exc}[/yellow]")
return []
finally:
await engine.dispose()

Check warning on line 480 in packages/cli/src/repowise/cli/commands/update_cmd/deterministic.py

View check run for this annotation

Repowise Bot / Repowise / code health

Introduced: nested complexity

_load_stale_structural_file_paths nests 5 levels deep

12 changes: 12 additions & 0 deletions packages/core/src/repowise/core/workspace/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,18 @@ def check_repo_staleness(
return True, current_head, 0

if current_head == last_commit:
db_path = repo_path / ".repowise" / "wiki.db"
if db_path.exists():
try:
from repowise.cli.commands.update_cmd.deterministic import (
load_stale_structural_file_paths,
)

stale_db_paths = load_stale_structural_file_paths(repo_path)
if stale_db_paths:
return True, current_head, 0
except Exception:
pass
return False, current_head, 0

behind = count_commits_between(repo_path, last_commit, current_head)
Expand Down
147 changes: 147 additions & 0 deletions tests/unit/cli/test_update_stale_reconciliation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""Tests for stale structural page reconciliation during repowise update and workspace staleness checks."""

from __future__ import annotations

import asyncio
from pathlib import Path

import pytest

from repowise.cli.commands.doctor_cmd import repo_checks
from repowise.cli.commands.update_cmd.deterministic import load_stale_structural_file_paths
from repowise.core.workspace.update import check_repo_staleness


async def _setup_repo_with_pages(
tmp_path: Path, stale_paths: list[str]
) -> tuple[Path, str]:
"""Helper to initialize a git repo with a DB and pages (some of which are marked stale)."""
import git as gitpython

from repowise.core.persistence import (
create_engine,
create_session_factory,
get_session,
init_db,
upsert_page,
upsert_repository,
)

repo_path = (tmp_path / "test_repo").resolve()
repo_path.mkdir(parents=True, exist_ok=True)
git_repo = gitpython.Repo.init(repo_path)

# Commit a dummy file
dummy = repo_path / "foo.py"
dummy.write_text("def foo(): pass\n")
git_repo.index.add(["foo.py"])
commit = git_repo.index.commit("Initial commit")
head_sha = commit.hexsha

repowise_dir = repo_path / ".repowise"
repowise_dir.mkdir(exist_ok=True)

engine = create_engine(f"sqlite+aiosqlite:///{repowise_dir / 'wiki.db'}")
await init_db(engine)
sf = create_session_factory(engine)

async with get_session(sf) as session:
repo = await upsert_repository(
session, name="test_repo", local_path=str(repo_path), url="https://example.test/repo"
)
for path in ["foo.py", "bar.py"]:
page_id = f"file_page:{path}"
freshness = "stale" if path in stale_paths else "fresh"
await upsert_page(
session,
page_id=page_id,
repository_id=repo.id,
page_type="file_page",
title=f"File: {path}",
content="def code(): pass",
summary="Summary",
target_path=path,
source_hash="hash",
model_name="mock",
provider_name="mock",
freshness_status=freshness,
)
# Also test symbol spotlight structural page with target_path using '::'
if "spotlight.py" in stale_paths:
await upsert_page(
session,
page_id="symbol_spotlight:spotlight.py::my_func",
repository_id=repo.id,
page_type="symbol_spotlight",
title="Spotlight: my_func",
content="def my_func(): pass",
summary="Summary",
target_path="spotlight.py::my_func",
source_hash="hash",
model_name="mock",
provider_name="mock",
freshness_status="stale",
)
await session.commit()

await engine.dispose()
return repo_path, head_sha


def test_load_stale_structural_file_paths(tmp_path: Path) -> None:
"""load_stale_structural_file_paths returns file paths for file_pages marked stale or expired."""
repo_path, _ = asyncio.run(_setup_repo_with_pages(tmp_path, ["bar.py"]))

stale = load_stale_structural_file_paths(repo_path)
assert stale == ["bar.py"]


def test_load_stale_structural_file_paths_spotlight(tmp_path: Path) -> None:
"""load_stale_structural_file_paths extracts file path from symbol_spotlight target_path."""
repo_path, _ = asyncio.run(_setup_repo_with_pages(tmp_path, ["spotlight.py"]))

stale = load_stale_structural_file_paths(repo_path)
assert stale == ["spotlight.py"]


def test_load_stale_structural_file_paths_empty_when_all_fresh(tmp_path: Path) -> None:
"""load_stale_structural_file_paths returns empty list when all pages are fresh."""
repo_path, _ = asyncio.run(_setup_repo_with_pages(tmp_path, []))

stale = load_stale_structural_file_paths(repo_path)
assert stale == []


def test_check_repo_staleness_detects_stale_structural_pages(tmp_path: Path) -> None:
"""check_repo_staleness returns is_stale=True when current_head == last_commit but DB has stale pages."""
repo_path, head_sha = asyncio.run(_setup_repo_with_pages(tmp_path, ["bar.py"]))

is_stale, current_head, behind = check_repo_staleness(repo_path, head_sha)
assert is_stale is True
assert current_head == head_sha
assert behind == 0


def test_check_repo_staleness_returns_false_when_all_fresh(tmp_path: Path) -> None:
"""check_repo_staleness returns is_stale=False when current_head == last_commit and no pages are stale."""
repo_path, head_sha = asyncio.run(_setup_repo_with_pages(tmp_path, []))

is_stale, current_head, behind = check_repo_staleness(repo_path, head_sha)
assert is_stale is False
assert current_head == head_sha
assert behind == 0


def test_doctor_detects_stale_pages_and_clears_after_reconciliation(tmp_path: Path) -> None:
"""Doctor check flags stale pages, and load_stale_structural_file_paths identifies them."""
repo_path, _ = asyncio.run(_setup_repo_with_pages(tmp_path, ["foo.py", "bar.py"]))

# 1. Doctor initially reports stale pages
all_ok, checks = repo_checks._run_repo_checks(repo_path, repair=False, fmt="quiet")
stale_check = next(c for c in checks if c.name == "Stale pages")
assert stale_check.ok is False
assert stale_check.detail == "2 stale"

# 2. Verify load_stale_structural_file_paths returns the 2 stale paths
stale_paths = load_stale_structural_file_paths(repo_path)
assert sorted(stale_paths) == ["bar.py", "foo.py"]