impact.py
python
sha256:f9828efc523c2f8ccaee12ae76086a09a9a6a10d6dd20e53f0b13793f0fdcf50
docs: add symlog (#53) and reflog (#54) follow-up issue files
Sonnet 4.6
18 days ago
| 1 | """muse code impact — transitive blast-radius analysis. |
| 2 | |
| 3 | Answers the question every engineer asks before touching a function: |
| 4 | *"If I change this, what else could break?"* |
| 5 | |
| 6 | ``muse code impact`` builds the reverse call graph for the committed snapshot, |
| 7 | then performs a BFS from the target symbol's bare name through every caller, |
| 8 | then every caller's callers, until the full transitive closure is reached. |
| 9 | |
| 10 | The result is a depth-ordered blast-radius map: depth 1 = direct callers, |
| 11 | depth 2 = callers of callers, and so on. This tells you exactly how far a |
| 12 | change propagates through the codebase. |
| 13 | |
| 14 | With ``--compare REF``, the command diffs the blast radius between two commits, |
| 15 | showing exactly which callers were *added* (new risk) and which were *removed* |
| 16 | (coupling reduced) — the ideal pre-merge signal for any proposal that touches a |
| 17 | widely-called symbol. |
| 18 | |
| 19 | With ``--forward``, the direction reverses: instead of "who calls this?", it |
| 20 | answers "what does this call?" — the full transitive dependency fan-out of the |
| 21 | target symbol. |
| 22 | |
| 23 | This is structurally impossible in Git. Git stores files as blobs — it has |
| 24 | no concept of call relationships between functions. You would need an |
| 25 | external static-analysis tool and a separate dependency graph. In Muse, |
| 26 | the symbol graph is a first-class citizen of every committed snapshot. |
| 27 | |
| 28 | Usage:: |
| 29 | |
| 30 | muse code impact "src/billing.py::compute_invoice_total" |
| 31 | muse code impact "src/billing.py::compute_invoice_total" --depth 2 |
| 32 | muse code impact "src/auth.py::validate_token" --commit HEAD~5 |
| 33 | muse code impact "src/core.py::content_hash" --json |
| 34 | muse code impact "src/billing.py::process_order" --compare HEAD~10 |
| 35 | muse code impact "src/billing.py::process_order" --forward --depth 3 |
| 36 | muse code impact "src/api.py::handle_request" --file src/billing.py |
| 37 | muse code impact "src/core.py::content_hash" --count |
| 38 | |
| 39 | Output:: |
| 40 | |
| 41 | Impact analysis: src/billing.py::compute_invoice_total |
| 42 | ────────────────────────────────────────────────────────────── |
| 43 | |
| 44 | Depth 1 — direct callers (2): |
| 45 | src/api.py::create_invoice |
| 46 | src/billing.py::process_order |
| 47 | |
| 48 | Depth 2 — callers of callers (1): |
| 49 | src/api.py::handle_request |
| 50 | |
| 51 | ────────────────────────────────────────────────────────────── |
| 52 | Total blast radius: 3 symbols across 2 files |
| 53 | 🔴 High impact — add tests before changing this symbol. |
| 54 | |
| 55 | Flags: |
| 56 | |
| 57 | ``--depth, -d N`` |
| 58 | Stop BFS after N levels (default: 0 = unlimited). |
| 59 | |
| 60 | ``--commit, -c REF`` |
| 61 | Analyse a historical snapshot instead of HEAD. |
| 62 | |
| 63 | ``--compare REF`` |
| 64 | Diff blast radius between HEAD (or ``--commit``) and REF. |
| 65 | Shows added callers (new risk) and removed callers (reduced coupling). |
| 66 | |
| 67 | ``--forward`` |
| 68 | Show callees (dependencies) instead of callers. Answers: "what does |
| 69 | this symbol depend on?" rather than "what depends on this symbol?" |
| 70 | |
| 71 | ``--file PATH`` |
| 72 | Restrict the blast-radius output to callers from this file only. |
| 73 | |
| 74 | ``--count`` |
| 75 | Print only the total count of callers (scriptable). |
| 76 | |
| 77 | ``--json`` |
| 78 | Emit the full blast-radius map as JSON. |
| 79 | """ |
| 80 | |
| 81 | import argparse |
| 82 | import json |
| 83 | import logging |
| 84 | import pathlib |
| 85 | import sys |
| 86 | |
| 87 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 88 | from muse.core.errors import ExitCode |
| 89 | from muse.core.repo import require_repo |
| 90 | from muse.core.timing import start_timer |
| 91 | from typing import TypedDict |
| 92 | |
| 93 | from muse.core.refs import read_current_branch |
| 94 | from muse.core.commits import ( |
| 95 | CommitRecord, |
| 96 | resolve_commit_ref, |
| 97 | ) |
| 98 | from muse.core.snapshots import get_commit_snapshot_manifest |
| 99 | from muse.core.symbol_cache import load_symbol_cache |
| 100 | from muse.core.callgraph_cache import load_callgraph_cache |
| 101 | from muse.core.implicit_edge_cache import load_implicit_edge_cache |
| 102 | from muse.plugins.code._callgraph import ( |
| 103 | ForwardGraph, |
| 104 | ReverseGraph, |
| 105 | build_forward_graph, |
| 106 | build_reverse_graph, |
| 107 | transitive_callees, |
| 108 | transitive_callers, |
| 109 | ) |
| 110 | from muse.plugins.code._framework import ( |
| 111 | ImplicitEntryEdge, |
| 112 | ImplicitEdgeGraph, |
| 113 | build_implicit_edge_graph, |
| 114 | ) |
| 115 | from muse.plugins.code._query import dir_of, language_of |
| 116 | from muse.core.validation import clamp_int, sanitize_display |
| 117 | |
| 118 | type _BlastRadius = dict[str, list[str]] |
| 119 | type _StrMeta = dict[str, str] |
| 120 | logger = logging.getLogger(__name__) |
| 121 | |
| 122 | class _EntryPointJson(TypedDict): |
| 123 | """One framework entry-point edge in JSON output.""" |
| 124 | |
| 125 | framework_id: str |
| 126 | kind: str |
| 127 | metadata: _StrMeta |
| 128 | |
| 129 | class _ImpactJsonBase(EnvelopeJson): |
| 130 | """Required fields for ``muse code impact`` JSON output.""" |
| 131 | |
| 132 | mode: str |
| 133 | address: str |
| 134 | target_name: str |
| 135 | commit_id: str |
| 136 | depth_limit: int |
| 137 | total: int |
| 138 | |
| 139 | class _ImpactJson(_ImpactJsonBase, total=False): |
| 140 | """Full JSON payload for ``muse code impact`` output. |
| 141 | |
| 142 | Inherits required envelope + domain fields from :class:`_ImpactJsonBase`. |
| 143 | |
| 144 | Fields (all optional) |
| 145 | --------------------- |
| 146 | file_filter File scope applied, or ``None``. |
| 147 | blast_radius Depth-keyed map of caller addresses (reverse mode). |
| 148 | callees Depth-keyed map of callee names (forward mode). |
| 149 | entry_points Framework entry-point edges, if any. |
| 150 | compare_commit_id Commit ID of the ``--compare`` snapshot. |
| 151 | added_callers Callers present in HEAD but not in compare. |
| 152 | removed_callers Callers present in compare but not in HEAD. |
| 153 | net_change ``len(added_callers) - len(removed_callers)``. |
| 154 | """ |
| 155 | |
| 156 | file_filter: str | None |
| 157 | blast_radius: _BlastRadius |
| 158 | callees: dict[str, list[str]] |
| 159 | entry_points: list[_EntryPointJson] |
| 160 | compare_commit_id: str |
| 161 | added_callers: list[str] |
| 162 | removed_callers: list[str] |
| 163 | net_change: int |
| 164 | |
| 165 | def _flat_addrs(blast: dict[int, list[str]]) -> set[str]: |
| 166 | """Return the flat set of all addresses across all depths.""" |
| 167 | return {addr for addrs in blast.values() for addr in addrs} |
| 168 | |
| 169 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 170 | """Register the impact subcommand.""" |
| 171 | parser = subparsers.add_parser( |
| 172 | "impact", |
| 173 | help="Show the transitive blast-radius of changing a symbol.", |
| 174 | description=__doc__, |
| 175 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 176 | ) |
| 177 | parser.add_argument( |
| 178 | "address", metavar="ADDRESS", |
| 179 | help='Symbol address, e.g. "src/billing.py::compute_invoice_total".', |
| 180 | ) |
| 181 | parser.add_argument( |
| 182 | "--depth", "-d", type=int, default=None, metavar="N", |
| 183 | help="Maximum BFS depth (default: 5, max: 50). Use --transitive for full traversal.", |
| 184 | ) |
| 185 | parser.add_argument( |
| 186 | "--commit", "-c", default=None, metavar="REF", dest="ref", |
| 187 | help="Analyse a historical snapshot instead of HEAD.", |
| 188 | ) |
| 189 | parser.add_argument( |
| 190 | "--compare", default=None, metavar="REF", dest="compare_ref", |
| 191 | help="Diff blast radius against this commit reference.", |
| 192 | ) |
| 193 | parser.add_argument( |
| 194 | "--forward", action="store_true", dest="forward", |
| 195 | help="Show callees (dependencies) instead of callers.", |
| 196 | ) |
| 197 | parser.add_argument( |
| 198 | "--file", "-f", default=None, metavar="PATH", dest="file_filter", |
| 199 | help="Restrict blast-radius output to callers from this file.", |
| 200 | ) |
| 201 | parser.add_argument( |
| 202 | "--count", action="store_true", dest="count_only", |
| 203 | help="Print only the total count of matching symbols.", |
| 204 | ) |
| 205 | parser.add_argument( |
| 206 | "--roll-up-to", |
| 207 | default=None, choices=("directory",), metavar="LEVEL", dest="roll_up_to", |
| 208 | help="Roll up blast-radius addresses to the given granularity (e.g. 'directory').", |
| 209 | ) |
| 210 | parser.add_argument( |
| 211 | "--json", "-j", action="store_true", dest="json_out", |
| 212 | help="Emit results as JSON.", |
| 213 | ) |
| 214 | parser.set_defaults(func=run) |
| 215 | |
| 216 | def run(args: argparse.Namespace) -> None: |
| 217 | """Show the transitive blast-radius of changing a symbol. |
| 218 | |
| 219 | Builds the reverse call graph for the committed snapshot, then BFS-walks |
| 220 | it outward from the target symbol. Depth 1 = direct callers; depth 2 = |
| 221 | callers of callers; and so on. Use ``--compare`` to diff the blast radius |
| 222 | against another commit; ``--forward`` to walk callees instead of callers. |
| 223 | |
| 224 | Agent quickstart |
| 225 | ---------------- |
| 226 | :: |
| 227 | |
| 228 | muse code impact "src/billing.py::compute_total" --json |
| 229 | muse code impact "src/billing.py::compute_total" --depth 3 --json |
| 230 | muse code impact "src/billing.py::compute_total" --forward --json |
| 231 | muse code impact "src/billing.py::compute_total" --compare HEAD~10 --json |
| 232 | |
| 233 | JSON fields (reverse / blast-radius) |
| 234 | ------------------------------------- |
| 235 | mode ``"reverse"``. |
| 236 | address Symbol address analysed. |
| 237 | commit_id Commit snapshot used. |
| 238 | blast_radius Depth-keyed map of caller addresses (strings ``"1"``, ``"2"``…). |
| 239 | total Total caller count across all depths. |
| 240 | |
| 241 | JSON fields (``--forward`` / callees) |
| 242 | --------------------------------------- |
| 243 | mode ``"forward"``. |
| 244 | callees Depth-keyed map of callee names. |
| 245 | total Total callee count. |
| 246 | |
| 247 | Exit codes |
| 248 | ---------- |
| 249 | 0 Analysis complete. |
| 250 | 1 Symbol not found or invalid arguments. |
| 251 | 2 Not inside a Muse repository. |
| 252 | """ |
| 253 | elapsed = start_timer() |
| 254 | address: str = args.address |
| 255 | _depth_raw: int | None = args.depth |
| 256 | depth: int = clamp_int(_depth_raw, 1, 50, "depth") if _depth_raw is not None else 5 |
| 257 | ref: str | None = args.ref |
| 258 | compare_ref: str | None = args.compare_ref |
| 259 | forward: bool = args.forward |
| 260 | file_filter: str | None = args.file_filter |
| 261 | count_only: bool = args.count_only |
| 262 | json_out: bool = args.json_out |
| 263 | roll_up_to: str | None = getattr(args, "roll_up_to", None) |
| 264 | |
| 265 | if forward and compare_ref: |
| 266 | print("❌ --forward and --compare are mutually exclusive.", file=sys.stderr) |
| 267 | raise SystemExit(ExitCode.USER_ERROR) |
| 268 | |
| 269 | root = require_repo() |
| 270 | branch = read_current_branch(root) |
| 271 | |
| 272 | lang = language_of(address.split("::")[0]) if "::" in address else "" |
| 273 | if lang and lang != "Python": |
| 274 | print( |
| 275 | f"⚠️ Impact analysis is currently Python-only. '{address}' is {lang}.", |
| 276 | file=sys.stderr, |
| 277 | ) |
| 278 | raise SystemExit(ExitCode.USER_ERROR) |
| 279 | |
| 280 | commit = resolve_commit_ref(root, branch, ref) |
| 281 | if commit is None: |
| 282 | print(f"❌ Commit '{ref or 'HEAD'}' not found.", file=sys.stderr) |
| 283 | raise SystemExit(ExitCode.USER_ERROR) |
| 284 | |
| 285 | manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {} |
| 286 | |
| 287 | cache = load_symbol_cache(root) |
| 288 | cg_cache = load_callgraph_cache(root) |
| 289 | implicit_cache = load_implicit_edge_cache(root) |
| 290 | |
| 291 | target_name = address.split("::")[-1].split(".")[-1] if "::" in address else address |
| 292 | |
| 293 | if forward: |
| 294 | fwd: ForwardGraph = build_forward_graph(root, manifest, cache=cache, callgraph_cache=cg_cache) |
| 295 | callee_map = transitive_callees(address, fwd, max_depth=depth) |
| 296 | cache.save() |
| 297 | cg_cache.save() |
| 298 | _render_forward( |
| 299 | address=address, |
| 300 | target_name=target_name, |
| 301 | commit=commit, |
| 302 | callee_map=callee_map, |
| 303 | depth_limit=depth, |
| 304 | count_only=count_only, |
| 305 | json_out=json_out, |
| 306 | duration_ms=elapsed(), |
| 307 | ) |
| 308 | return |
| 309 | |
| 310 | rev: ReverseGraph = build_reverse_graph(root, manifest, cache=cache, callgraph_cache=cg_cache) |
| 311 | implicit: ImplicitEdgeGraph = build_implicit_edge_graph(root, manifest, cache=cache, implicit_cache=implicit_cache) |
| 312 | blast = transitive_callers(target_name, rev, max_depth=depth) |
| 313 | |
| 314 | if file_filter: |
| 315 | blast = { |
| 316 | d: [a for a in addrs if "::" in a and a.split("::")[0] == file_filter] |
| 317 | for d, addrs in blast.items() |
| 318 | } |
| 319 | blast = {d: addrs for d, addrs in blast.items() if addrs} |
| 320 | |
| 321 | compare_commit: CommitRecord | None = None |
| 322 | added: set[str] = set() |
| 323 | removed: set[str] = set() |
| 324 | |
| 325 | if compare_ref: |
| 326 | compare_commit = resolve_commit_ref(root, branch, compare_ref) |
| 327 | if compare_commit is None: |
| 328 | print(f"❌ --compare commit '{compare_ref}' not found.", file=sys.stderr) |
| 329 | raise SystemExit(ExitCode.USER_ERROR) |
| 330 | compare_manifest = get_commit_snapshot_manifest(root, compare_commit.commit_id) or {} |
| 331 | compare_rev: ReverseGraph = build_reverse_graph(root, compare_manifest, cache=cache, callgraph_cache=cg_cache) |
| 332 | compare_blast = transitive_callers(target_name, compare_rev, max_depth=depth) |
| 333 | |
| 334 | if file_filter: |
| 335 | compare_blast = { |
| 336 | d: [a for a in addrs if "::" in a and a.split("::")[0] == file_filter] |
| 337 | for d, addrs in compare_blast.items() |
| 338 | } |
| 339 | compare_blast = {d: addrs for d, addrs in compare_blast.items() if addrs} |
| 340 | |
| 341 | current_flat = _flat_addrs(blast) |
| 342 | compare_flat = _flat_addrs(compare_blast) |
| 343 | added = current_flat - compare_flat |
| 344 | removed = compare_flat - current_flat |
| 345 | |
| 346 | cache.save() |
| 347 | cg_cache.save() |
| 348 | implicit_cache.save() |
| 349 | |
| 350 | entry_points = implicit.get(address, []) |
| 351 | |
| 352 | _render_reverse( |
| 353 | address=address, |
| 354 | target_name=target_name, |
| 355 | commit=commit, |
| 356 | blast=blast, |
| 357 | entry_points=entry_points, |
| 358 | depth_limit=depth, |
| 359 | file_filter=file_filter, |
| 360 | compare_commit=compare_commit, |
| 361 | added=added, |
| 362 | removed=removed, |
| 363 | count_only=count_only, |
| 364 | json_out=json_out, |
| 365 | roll_up_to=roll_up_to, |
| 366 | duration_ms=elapsed(), |
| 367 | ) |
| 368 | |
| 369 | # --------------------------------------------------------------------------- |
| 370 | # Renderers |
| 371 | # --------------------------------------------------------------------------- |
| 372 | |
| 373 | def _render_forward( |
| 374 | *, |
| 375 | address: str, |
| 376 | target_name: str, |
| 377 | commit: CommitRecord, |
| 378 | callee_map: dict[int, list[str]], |
| 379 | depth_limit: int, |
| 380 | count_only: bool, |
| 381 | json_out: bool, |
| 382 | duration_ms: float = 0.0, |
| 383 | ) -> None: |
| 384 | """Render forward (callees) analysis.""" |
| 385 | total = sum(len(v) for v in callee_map.values()) |
| 386 | |
| 387 | if count_only and not json_out: |
| 388 | print(total) |
| 389 | return |
| 390 | |
| 391 | if json_out: |
| 392 | out = _ImpactJson( |
| 393 | **make_envelope(lambda: duration_ms), |
| 394 | mode="forward", |
| 395 | address=address, |
| 396 | target_name=target_name, |
| 397 | commit_id=commit.commit_id, |
| 398 | depth_limit=depth_limit, |
| 399 | total=total, |
| 400 | ) |
| 401 | out["callees"] = {str(d): names for d, names in sorted(callee_map.items())} |
| 402 | print(json.dumps(out)) |
| 403 | return |
| 404 | |
| 405 | print(f"\nDependency fan-out: {sanitize_display(address)}") |
| 406 | print("─" * 62) |
| 407 | |
| 408 | if not callee_map: |
| 409 | print(f"\n ('{target_name}' calls nothing detectable — leaf function or dynamic dispatch)") |
| 410 | return |
| 411 | |
| 412 | for d in sorted(callee_map.keys()): |
| 413 | label = "direct callees" if d == 1 else f"depth-{d} callees" |
| 414 | print(f"\nDepth {d} — {label} ({len(callee_map[d])}):") |
| 415 | for name in sorted(callee_map[d]): |
| 416 | print(f" {sanitize_display(name)}") |
| 417 | |
| 418 | print(f"\n{'─' * 62}") |
| 419 | print(f"Total dependency fan-out: {total} callee(s)") |
| 420 | print("\nNote: analysis covers Python call-sites only.") |
| 421 | |
| 422 | def _render_reverse( |
| 423 | *, |
| 424 | address: str, |
| 425 | target_name: str, |
| 426 | commit: CommitRecord, |
| 427 | blast: dict[int, list[str]], |
| 428 | entry_points: list[ImplicitEntryEdge], |
| 429 | depth_limit: int, |
| 430 | file_filter: str | None, |
| 431 | compare_commit: CommitRecord | None, |
| 432 | added: set[str], |
| 433 | removed: set[str], |
| 434 | count_only: bool, |
| 435 | json_out: bool, |
| 436 | roll_up_to: str | None = None, |
| 437 | duration_ms: float = 0.0, |
| 438 | ) -> None: |
| 439 | """Render reverse (callers / blast-radius) analysis.""" |
| 440 | total = sum(len(v) for v in blast.values()) |
| 441 | |
| 442 | if count_only and not json_out: |
| 443 | print(total) |
| 444 | return |
| 445 | |
| 446 | if json_out: |
| 447 | payload = _ImpactJson( |
| 448 | **make_envelope(lambda: duration_ms), |
| 449 | mode="reverse", |
| 450 | address=address, |
| 451 | target_name=target_name, |
| 452 | commit_id=commit.commit_id, |
| 453 | depth_limit=depth_limit, |
| 454 | total=total, |
| 455 | ) |
| 456 | payload["file_filter"] = file_filter |
| 457 | payload["blast_radius"] = {str(d): list(addrs) for d, addrs in sorted(blast.items())} |
| 458 | if roll_up_to == "directory": |
| 459 | dir_counts: dict[str, int] = {} |
| 460 | for addrs in blast.values(): |
| 461 | for addr in addrs: |
| 462 | d = dir_of(addr.split("::")[0]) if "::" in addr else dir_of(addr) |
| 463 | dir_counts[d] = dir_counts.get(d, 0) + 1 |
| 464 | payload["directory_blast_radius"] = dir_counts |
| 465 | if entry_points: |
| 466 | payload["entry_points"] = [ |
| 467 | _EntryPointJson( |
| 468 | framework_id=ep.framework_id, |
| 469 | kind=ep.kind, |
| 470 | metadata=ep.metadata, |
| 471 | ) |
| 472 | for ep in entry_points |
| 473 | ] |
| 474 | if compare_commit is not None: |
| 475 | payload["compare_commit_id"] = compare_commit.commit_id |
| 476 | payload["added_callers"] = sorted(added) |
| 477 | payload["removed_callers"] = sorted(removed) |
| 478 | payload["net_change"] = len(added) - len(removed) |
| 479 | print(json.dumps(payload)) |
| 480 | return |
| 481 | |
| 482 | print(f"\nImpact analysis: {sanitize_display(address)}") |
| 483 | if file_filter: |
| 484 | print(f"(filtered to callers in: {file_filter})") |
| 485 | print("─" * 62) |
| 486 | |
| 487 | if entry_points: |
| 488 | print("\n Framework entry point — externally reachable via:") |
| 489 | for ep in entry_points: |
| 490 | meta_str = " ".join(f"{k}={v}" for k, v in ep.metadata.items() if v) |
| 491 | print(f" [{ep.framework_id}] kind={ep.kind} {meta_str}") |
| 492 | |
| 493 | if not blast: |
| 494 | if entry_points: |
| 495 | print("\n (no explicit callers in user code — wired by the framework above)") |
| 496 | else: |
| 497 | print( |
| 498 | f"\n (no callers detected — '{target_name}' may be an entry point or dead code)" |
| 499 | ) |
| 500 | print("\n Note: analysis covers Python only; external callers are not detected.") |
| 501 | else: |
| 502 | all_files: set[str] = set() |
| 503 | for d in sorted(blast.keys()): |
| 504 | callers = blast[d] |
| 505 | if d == 1: |
| 506 | label = "direct callers" |
| 507 | elif d == 2: |
| 508 | label = "callers of callers" |
| 509 | else: |
| 510 | label = f"depth-{d} callers" |
| 511 | print(f"\nDepth {d} — {label} ({len(callers)}):") |
| 512 | for addr in sorted(callers): |
| 513 | marker = " ← NEW" if addr in added else "" |
| 514 | print(f" {sanitize_display(addr)}{marker}") |
| 515 | if "::" in addr: |
| 516 | all_files.add(addr.split("::")[0]) |
| 517 | |
| 518 | print(f"\n{'─' * 62}") |
| 519 | file_label = "file" if len(all_files) == 1 else "files" |
| 520 | print(f"Total blast radius: {total} symbol(s) across {len(all_files)} {file_label}") |
| 521 | if total >= 10: |
| 522 | print("🔴 High impact — add tests before changing this symbol.") |
| 523 | elif total >= 3: |
| 524 | print("🟡 Medium impact — review callers before changing this symbol.") |
| 525 | else: |
| 526 | print("🟢 Low impact — change is well-contained.") |
| 527 | print( |
| 528 | "\nNote: analysis covers Python call-sites only." |
| 529 | " Dynamic dispatch (getattr, decorators) is not detected." |
| 530 | ) |
| 531 | |
| 532 | if compare_commit is not None: |
| 533 | print(f"\nBlast-radius diff vs {compare_commit.commit_id}:") |
| 534 | print("─" * 62) |
| 535 | if not added and not removed: |
| 536 | print(" No change in blast radius.") |
| 537 | else: |
| 538 | net = len(added) - len(removed) |
| 539 | sign = "+" if net >= 0 else "" |
| 540 | print(f" Net change: {sign}{net} caller(s)") |
| 541 | if added: |
| 542 | print(f"\n Added ({len(added)}):") |
| 543 | for a in sorted(added): |
| 544 | print(f" + {a}") |
| 545 | if removed: |
| 546 | print(f"\n Removed ({len(removed)}):") |
| 547 | for r in sorted(removed): |
| 548 | print(f" - {r}") |
File History
1 commit
sha256:f9828efc523c2f8ccaee12ae76086a09a9a6a10d6dd20e53f0b13793f0fdcf50
docs: add symlog (#53) and reflog (#54) follow-up issue files
Sonnet 4.6
18 days ago