diff --git a/packages/cli/src/repowise/cli/commands/update_cmd/command.py b/packages/cli/src/repowise/cli/commands/update_cmd/command.py index 6f331f8597..96a8369859 100644 --- a/packages/cli/src/repowise/cli/commands/update_cmd/command.py +++ b/packages/cli/src/repowise/cli/commands/update_cmd/command.py @@ -1606,6 +1606,8 @@ def _run_partial_analyses(run: UpdateRun) -> None: repo_function_mod_p80=repo_function_mod_p80, timings=run.timings, dead_code=not _dead_code_unchanged(run), + # The forced walk re-blamed every file, not only the changed ones. + full_git_history=bool(run.full_git_summaries), ) run.doc_drift_report = _run_doc_drift_partial( run.graph_builder, diff --git a/packages/cli/src/repowise/cli/commands/update_cmd/incremental.py b/packages/cli/src/repowise/cli/commands/update_cmd/incremental.py index f5fe56802a..279afef4ee 100644 --- a/packages/cli/src/repowise/cli/commands/update_cmd/incremental.py +++ b/packages/cli/src/repowise/cli/commands/update_cmd/incremental.py @@ -223,6 +223,7 @@ def _run_partial_analysis( repo_function_mod_p80: int | None = None, timings: PhaseTimings | None = None, dead_code: bool = True, + full_git_history: bool = False, ) -> tuple[Any, Any]: """Run partial code-health + repo-wide dead-code analysis. @@ -254,6 +255,7 @@ def _run_partial_analysis( log=console.print, timings=timings, dead_code=dead_code, + full_git_history=full_git_history, ) diff --git a/packages/core/src/repowise/core/analysis/health/engine.py b/packages/core/src/repowise/core/analysis/health/engine.py index 47e71525e0..9111e726d5 100644 --- a/packages/core/src/repowise/core/analysis/health/engine.py +++ b/packages/core/src/repowise/core/analysis/health/engine.py @@ -891,6 +891,19 @@ def _function_fact_rows( ) return rows + def function_blame_rows(self, config: dict | None = None) -> list[dict]: + """The blame rollup over every file a full pass walks, as init builds it. + + For an update whose git walk re-read the whole history: ``git_meta_map`` + then holds every file's blame index, but an incremental :meth:`analyze` + walks only the changed files, so its rows cover only those. Unchanged + sources come out of the walk cache. + """ + walked = self._walk_files( + self._target_files(None), _RunConfig.read(config).vocab, None, partial=True + ) + return self._function_blame_rows(walked) + def _function_blame_rows(self, walked: list[tuple[Any, FileComplexity]]) -> list[dict]: """Build the per-function blame rollup from the walked files + the FULL-tier blame indexes attached to ``git_meta_map``. diff --git a/packages/core/src/repowise/core/pipeline/incremental/analysis.py b/packages/core/src/repowise/core/pipeline/incremental/analysis.py index 8602f478fb..09f5a50445 100644 --- a/packages/core/src/repowise/core/pipeline/incremental/analysis.py +++ b/packages/core/src/repowise/core/pipeline/incremental/analysis.py @@ -52,6 +52,7 @@ def run_partial_analysis( log: LogFn | None = None, timings: PhaseTimings | None = None, dead_code: bool = True, + full_git_history: bool = False, ) -> tuple[Any, Any]: """Run partial code-health + repo-wide dead-code analysis. @@ -91,6 +92,12 @@ def run_partial_analysis( ``dead_code=False`` skips the dead-code pass and returns ``None`` for it, which keeps the stored findings. + + ``full_git_history=True`` says ``git_meta_map`` came from a walk of the + whole history, so it carries a blame index for every file. The report's + blame rows then cover every file, as init writes them, instead of the + changed ones; with no changed file the report carries those rows and + nothing else. """ log = log or _noop_log @@ -133,18 +140,16 @@ def run_partial_analysis( repo_root=repo_path, coverage_map=coverage_map, ) - if _health_scope: + _analyzer_config = None + if _health_scope or full_git_history: _hcfg = HealthConfig.load(repo_path) - _analyzer_config = ( - _hcfg.to_analyzer_config([pf.file_info.path for pf in parsed_files]) - if _hcfg.has_overrides() - else None - ) - _analyzer_config = _performance_only_config( - _analyzer_config, _performance_changed - _health_changed - ) + if _hcfg.has_overrides(): + _analyzer_config = _hcfg.to_analyzer_config( + [pf.file_info.path for pf in parsed_files] + ) + if _health_scope: partial_health_report = _health_analyzer.analyze( - _analyzer_config, + _performance_only_config(_analyzer_config, _performance_changed - _health_changed), changed_files=_health_scope, repo_function_mod_p80=repo_function_mod_p80, timings=timings, @@ -187,6 +192,18 @@ def run_partial_analysis( f"[cyan]{len(_performance_changed)} performance-affected[/cyan], " f"[yellow]{len(partial_health_report.findings)} findings[/yellow]" ) + if full_git_history: + # Every file was re-blamed, and the stored rollup can predate that + # walk, so it is rebuilt whole, as init builds it. + blame_rows = _health_analyzer.function_blame_rows(_analyzer_config) + if partial_health_report is None and blame_rows: + from datetime import UTC, datetime + + from repowise.core.analysis.health import HealthReport + + partial_health_report = HealthReport(repo_id="", analyzed_at=datetime.now(UTC)) + if partial_health_report is not None: + partial_health_report.function_blame_rows = blame_rows except Exception as exc: log(f"[yellow]Health analysis skipped: {exc}[/yellow]") finally: diff --git a/packages/core/src/repowise/core/pipeline/incremental/health.py b/packages/core/src/repowise/core/pipeline/incremental/health.py index 8d92b1fe83..980a48e0af 100644 --- a/packages/core/src/repowise/core/pipeline/incremental/health.py +++ b/packages/core/src/repowise/core/pipeline/incremental/health.py @@ -186,6 +186,14 @@ def _refreshed_metric(refreshed: Any, stored: Any, commit: str | None) -> dict: } +async def _upsert_function_blame(session: Any, repo_id: str, report: Any) -> None: + fn_blame_rows = getattr(report, "function_blame_rows", None) + if fn_blame_rows: + from repowise.core.persistence.crud import upsert_git_function_blame_bulk + + await upsert_git_function_blame_bulk(session, repo_id, fn_blame_rows) + + async def persist_partial_health( session: Any, repo_id: str, report: Any, repo_path: Any = None ) -> None: @@ -216,8 +224,10 @@ async def persist_partial_health( ) # Nothing analysed: a deletion-only update runs no health analysis, so it # has no role map to restamp stored roles with. Roles, like performance - # findings, refresh on the next run that analyses any file. + # findings, refresh on the next run that analyses any file. A full history + # walk with no changed file still brings the blame rollup it rebuilt. if not changed_paths and not performance_paths: + await _upsert_function_blame(session, repo_id, report) return analyzed_commit = await _analyzed_commit(session, repo_id) # One savepoint over the findings and everything derived from them. This @@ -296,13 +306,10 @@ async def persist_partial_health( # opportunity folds a file's plans, and a file this run did not touch # can still lose one when a cross-file plan elsewhere resolves. await finalize_refactoring_opportunities(session, repo_id, analyzed_commit=analyzed_commit) - # Per-function blame rollup for the changed files (keeps git_function_blame - # current between full indexes; FULL git tier only — empty otherwise). - fn_blame_rows = getattr(report, "function_blame_rows", None) - if fn_blame_rows: - from repowise.core.persistence.crud import upsert_git_function_blame_bulk - - await upsert_git_function_blame_bulk(session, repo_id, fn_blame_rows) + # Per-function blame rollup for the changed files, or for every file after a + # full history walk (keeps git_function_blame current between full indexes; + # FULL git tier only — empty otherwise). + await _upsert_function_blame(session, repo_id, report) # Only a full index rewrites the whole population, so rows an older version # scored for prose or configuration go here rather than lingering in the # average, and rows written before ``is_test`` existed get it from their diff --git a/packages/core/src/repowise/core/workspace/update/repo.py b/packages/core/src/repowise/core/workspace/update/repo.py index a1046b0bf9..52e6ae8c1e 100644 --- a/packages/core/src/repowise/core/workspace/update/repo.py +++ b/packages/core/src/repowise/core/workspace/update/repo.py @@ -233,7 +233,14 @@ async def _incremental_repo_update( ) partial_health_report, dead_code_report, doc_drift_report = await _partial_analysis( - repo_path, base_ref, file_diffs, parsed_files, source_map, graph_builder, git_meta_map + repo_path, + base_ref, + file_diffs, + parsed_files, + source_map, + graph_builder, + git_meta_map, + full_git_history=bool(full_git_summaries), ) # Drop the transient, non-serializable ``BlameIndex`` before persistence. @@ -377,6 +384,8 @@ async def _partial_analysis( source_map: dict, graph_builder: Any, git_meta_map: dict, + *, + full_git_history: bool = False, ) -> tuple[Any, Any, Any]: """Partial health, dead-code and doc-drift reports over the changed files. @@ -419,6 +428,7 @@ async def _partial_analysis( repo_function_mod_p80=stored_function_mod_p80, coverage_map=stored_coverage_map, log=_log.info, + full_git_history=full_git_history, ) doc_drift_report = run_doc_drift_partial( graph_builder, diff --git a/tests/unit/cli/test_update_git_history_stamp.py b/tests/unit/cli/test_update_git_history_stamp.py index 6f0d0e1d38..0aff4081c4 100644 --- a/tests/unit/cli/test_update_git_history_stamp.py +++ b/tests/unit/cli/test_update_git_history_stamp.py @@ -8,6 +8,7 @@ from __future__ import annotations import asyncio +from pathlib import Path from types import SimpleNamespace import pytest @@ -23,7 +24,7 @@ git_history_stale, ) -from .test_update_up_to_date_lock import _indexed_repo, _invoke_update +from .test_update_up_to_date_lock import _git, _indexed_repo, _invoke_update UP_TO_DATE = "Already up to date" @@ -220,3 +221,126 @@ async def _seed() -> None: assert "Git history rules changed" in _invoke_update(repo) assert _health_shas(repo).get(old_sha) == 7 + + +def _repo_with_functions(tmp_path: Path) -> Path: + """Two Python files with the five commits each that blame asks for, indexed + at HEAD with every stamp current.""" + from repowise.core.pipeline.full_index import index_repo_full + + repo = tmp_path / "repo" + repo.mkdir() + _git(repo, "init") + _git(repo, "config", "user.email", "t@t.com") + _git(repo, "config", "user.name", "T") + for version in range(6): + (repo / "a.py").write_text( + f"def alpha():\n return {version}\n\n\ndef beta():\n return 2\n" + ) + (repo / "b.py").write_text(f"def gamma():\n return {version}\n") + _git(repo, "add", ".") + _git(repo, "commit", "-m", f"c{version}") + head = _git(repo, "rev-parse", "HEAD") + (repo / ".repowise").mkdir() + asyncio.run(index_repo_full(repo)) + save_state( + repo, + { + "last_sync_commit": head, + "last_docs_commit": head, + "docs_enabled": False, + GIT_HISTORY_VERSION_KEY: GIT_HISTORY_VERSION, + "health_analyzer_version": HEALTH_ANALYZER_VERSION, + }, + ) + return repo + + +async def _with_session(repo: Path, work): + from repowise.core.persistence import create_engine, create_session_factory, get_session + from repowise.core.persistence.database import resolve_db_url + + engine = create_engine(resolve_db_url(repo)) + try: + async with get_session(create_session_factory(engine)) as session: + return await work(session) + finally: + await engine.dispose() + + +def _function_blame(repo: Path) -> dict[str, int]: + """The stored blame rollup, ``symbol_id -> mod_count``.""" + from sqlalchemy import select + + from repowise.core.persistence.models import GitFunctionBlame + + async def _read(session) -> dict[str, int]: + rows = (await session.execute(select(GitFunctionBlame))).scalars() + return {r.symbol_id: r.mod_count for r in rows} + + return asyncio.run(_with_session(repo, _read)) + + +def _empty_function_blame(repo: Path) -> None: + from sqlalchemy import delete + + from repowise.core.persistence.models import GitFunctionBlame + + async def _delete(session) -> None: + await session.execute(delete(GitFunctionBlame)) + + asyncio.run(_with_session(repo, _delete)) + + +@pytest.mark.parametrize( + ("analyzer_version", "git_tier", "refilled"), + [ + pytest.param(HEALTH_ANALYZER_VERSION, None, True, id="full"), + # The re-score a stale analyzer adds re-blames only Split File candidates. + pytest.param(HEALTH_ANALYZER_VERSION - 1, None, True, id="full-with-rescore"), + # Blame stays a FULL-tier signal. + pytest.param(HEALTH_ANALYZER_VERSION, "essential", False, id="essential"), + ], +) +def test_stale_stamp_refills_the_whole_function_blame( + tmp_path, analyzer_version, git_tier, refilled +) -> None: + """#5216: the forced walk re-blames every file, so an emptied rollup comes + back as init wrote it, not only for the files a commit changed.""" + repo = _repo_with_functions(tmp_path) + indexed = _function_blame(repo) + assert {sid.split("::")[0] for sid in indexed} == {"a.py", "b.py"} + _empty_function_blame(repo) + state = {**load_state(repo), "health_analyzer_version": analyzer_version} + state.pop(GIT_HISTORY_VERSION_KEY) + if git_tier is not None: + state["git_tier"] = git_tier + save_state(repo, state) + + assert "Git history rules changed" in _invoke_update(repo) + assert _function_blame(repo) == (indexed if refilled else {}) + + +def test_workspace_stale_stamp_refills_the_whole_function_blame(tmp_path) -> None: + from repowise.core.workspace.update import update_single_repo_index + + repo = _repo_with_functions(tmp_path) + indexed = _function_blame(repo) + _empty_function_blame(repo) + state = load_state(repo) + state.pop(GIT_HISTORY_VERSION_KEY) + save_state(repo, state) + + assert asyncio.run(update_single_repo_index(repo)).git_history_refreshed + assert _function_blame(repo) == indexed + + +def test_ordinary_update_reblames_only_the_changed_file(tmp_path) -> None: + """Without a forced walk the rollup is refreshed for the changed file alone.""" + repo = _repo_with_functions(tmp_path) + _empty_function_blame(repo) + (repo / "a.py").write_text("def alpha():\n return 100\n\n\ndef beta():\n return 2\n") + _git(repo, "commit", "-am", "c3") + + assert "Git history rules changed" not in _invoke_update(repo) + assert {sid.split("::")[0] for sid in _function_blame(repo)} == {"a.py"}