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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -254,6 +255,7 @@ def _run_partial_analysis(
log=console.print,
timings=timings,
dead_code=dead_code,
full_git_history=full_git_history,
)


Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/repowise/core/analysis/health/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand Down
37 changes: 27 additions & 10 deletions packages/core/src/repowise/core/pipeline/incremental/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
return out


def run_partial_analysis(

Check warning on line 40 in packages/core/src/repowise/core/pipeline/incremental/analysis.py

View workflow job for this annotation

GitHub Actions / Repowise gates

Health: Complex method

run_partial_analysis has cyclomatic complexity 32 (worsened from critical to critical)
repo_path: Any,
graph_builder: Any,
git_meta_map: dict,
Expand All @@ -52,6 +52,7 @@
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.

Expand Down Expand Up @@ -91,6 +92,12 @@

``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

Expand Down Expand Up @@ -133,18 +140,16 @@
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(

Check warning on line 147 in packages/core/src/repowise/core/pipeline/incremental/analysis.py

View workflow job for this annotation

GitHub Actions / Repowise coverage

Uncovered change

Changed line 147 not covered by tests
[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,
Expand Down Expand Up @@ -187,6 +192,18 @@
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:
Expand Down
23 changes: 15 additions & 8 deletions packages/core/src/repowise/core/pipeline/incremental/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/repowise/core/workspace/update/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand Down
126 changes: 125 additions & 1 deletion tests/unit/cli/test_update_git_history_stamp.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations

import asyncio
from pathlib import Path
from types import SimpleNamespace

import pytest
Expand All @@ -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"

Expand Down Expand Up @@ -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"}
Loading