hotspots diff: Compare Complexity Between Any Two Branches, Tags, or Commits

hotspots analyze --mode delta only compares HEAD to its parent. hotspots diff lets you compare any two git refs — main vs feature branch, v1.0.0 vs v2.0.0, or any two SHAs.

Stephen Collins ·
editorial hotspots ci git complexity code-health

Key Points

What's the difference between hotspots diff and hotspots analyze --mode delta?

hotspots analyze --mode delta always compares the current commit to its immediate git parent (parents[0]). hotspots diff <base> <head> compares any two git refs you specify — main vs a feature branch, v1.0.0 vs v2.0.0, or any two SHAs. The underlying diff engine is the same; the difference is which two snapshots get compared.

Why do both refs need snapshots? Why can't hotspots diff run the analysis on the fly?

Snapshots capture the analysis at a specific commit, including git touch metrics (how often each function was changed). Running analysis on the fly without git history at that exact ref would produce incomplete or incorrect touch counts. The --auto-analyze flag (coming in a future release) will handle this by spinning up temporary git worktrees automatically.

What happens if the base snapshot is missing in CI?

hotspots diff exits with code 3 and prints an actionable message telling you which ref is missing and how to create it. You can detect this exit code in CI and fall back to hotspots analyze --mode delta if needed. The first run after adopting hotspots diff will always hit this case for the base — it resolves once your main branch has had at least one push with snapshot creation.

Does --policy still work with hotspots diff?

Yes. Pass --policy to evaluate rules against the diff. Importantly, policy evaluation runs on the full changed set before any --top truncation — so a critical introduction in a lower-ranked function won't be silently missed.

Does hotspots diff work with annotated tags?

Yes. hotspots diff automatically peels annotated tags to their underlying commit SHA before looking up the snapshot. So hotspots diff v1.0.0 v2.0.0 works correctly even when those are annotated release tags.

The problem with delta mode in CI

hotspots analyze --mode delta is the right tool for local development. You commit, you run it, and you see what changed versus the commit before it. Simple.

In CI, the requirement is different. A PR doesn’t represent a single commit — it represents everything on a feature branch relative to main. If your PR is 12 commits deep, --mode delta compares the last commit to its immediate parent. That’s one commit. The other 11 are invisible to the policy check.

What you actually want is: compare main at the time this PR was opened against the merge commit.

That’s what hotspots diff does.

# Compare this PR against main
hotspots diff main HEAD --policy
# Compare two release tags
hotspots diff v1.0.0 v2.0.0

# Compare any two SHAs
hotspots diff abc123 def456 --format json

How it works

hotspots diff <base> <head> resolves both refs to their full SHAs, loads the corresponding snapshots from .hotspots/snapshots/, and runs the same diff engine used by --mode delta.

The key constraint: both refs need existing snapshots. Snapshots are created by hotspots analyze --mode snapshot. If a snapshot is missing, hotspots diff exits with code 3 and tells you exactly what to do:

error: no snapshot found for ref 'main' (3a8f12c)
  → run: git checkout main && hotspots analyze --mode snapshot

Once both snapshots exist, re-run: hotspots diff main HEAD

This is an intentional design choice. Snapshots include git touch metrics — how many times each function was changed in recent history. Those metrics can’t be computed correctly by checking out a ref mid-flight in an arbitrary working directory. The snapshot captures everything at the moment it was created.


Setting up the CI workflow

The workflow has two parts: create a snapshot on push to main, and diff against it on every PR.

Step 1 — Create and cache the snapshot on push to main

- name: Create snapshot
  run: hotspots analyze . --mode snapshot --force

- name: Cache snapshot
  uses: actions/cache/save@v4
  with:
    path: .hotspots/snapshots
    key: hotspots-snapshot-${{ github.sha }}

The --force flag overwrites any existing snapshot for this SHA (useful on reruns). The cache key is the commit SHA, so each push to main gets its own cached snapshot.

Step 2 — Restore the base snapshot and diff on PR

- name: Restore base snapshot
  id: base-snapshot
  uses: actions/cache/restore@v4
  with:
    path: .hotspots/snapshots
    key: hotspots-snapshot-${{ github.event.pull_request.base.sha }}

- name: Create HEAD snapshot
  run: hotspots analyze . --mode snapshot --force

- name: Diff PR vs base
  if: steps.base-snapshot.outputs.cache-hit == 'true'
  run: |
    hotspots diff \
      ${{ github.event.pull_request.base.sha }} \
      ${{ github.sha }} \
      --format text --policy

The PR HEAD snapshot is created fresh each time (hotspots analyze --mode snapshot --force). The base snapshot comes from the cache set on the last push to main.

The if: steps.base-snapshot.outputs.cache-hit == 'true' guard handles the cold-start case: the very first PR after adopting this workflow won’t have a base snapshot yet. Subsequent PRs against the same base will.


What the output looks like

Text output (--format text) gives a ranked table of changed functions:

2 modified, 3 new, 1 deleted
====================================================================================================
STATUS       FUNCTION                                 FILE                            LRS             CC              BAND
----------------------------------------------------------------------------------------------------
new          handleDiffOutput                         hotspots-cli/src/cmd/diff.rs    — → 3.12        — → 4           low → moderate
new          runPRAnalysis                            action/src/main.ts              — → 2.84        — → 6           low → low
new          generateDiffSummary                      action/src/main.ts              — → 1.41        — → 3           low → low
modified     render_policy_text_output                hotspots-cli/src/output/...     2.31 → 2.74     3 → 3           low → low
modified     print_policy_text_output                 hotspots-cli/src/output/...     1.18 → 0.41     2 → 1           low → low
deleted      runFaultline (delta branch)              action/src/main.ts              1.92 → —        4 → —           low → —

For scripting, --format json gives you the full delta structure with before/after metrics, band transitions, and policy results. --format jsonl gives one JSON object per changed function.


The —top flag and policy interaction

--top N limits the rendered output to the N highest-risk changed functions. The sort key is:

  • New functions: ranked by after.lrs
  • Deleted functions: ranked by before.lrs
  • Modified functions: ranked by |Δlrs|

One important detail: policy evaluation runs on the full changed set, then --top truncates the display. This means a critical introduction in a function ranked 11th won’t escape a --top 10 run. The policy check is complete; the display is filtered.

# Show top 10 riskiest changes, but check policy against all changes
hotspots diff main HEAD --top 10 --policy

Annotated tags just work

hotspots diff v1.0.0 v2.0.0

Annotated git tags point to tag objects, not commit objects. Passing a tag object SHA to snapshot lookup would always fail, since snapshots are keyed to commit SHAs. hotspots diff handles this automatically by peeling refs through git rev-parse <ref>^{commit} before the snapshot lookup. Annotated tags, lightweight tags, branches, full SHAs, abbreviated SHAs, and HEAD~N expressions all work.


HTML reports

hotspots diff main HEAD --format html --output reports/delta.html

The HTML delta report shows the same scatter plot and risk table as a snapshot report, but oriented around the diff: band transitions are highlighted, new functions are called out, and policy violations are rendered inline.

In CI you can upload this as a workflow artifact:

- name: Generate HTML delta report
  run: |
    hotspots diff \
      ${{ github.event.pull_request.base.sha }} \
      ${{ github.sha }} \
      --format html --output .hotspots/delta-report.html
  continue-on-error: true

- name: Upload delta report
  uses: actions/upload-artifact@v4
  with:
    name: hotspots-delta-report
    path: .hotspots/delta-report.html
    retention-days: 14
    if-no-files-found: ignore

Exit codes

CodeMeaning
0Success
1Blocking policy failure (--policy only)
3One or both snapshots missing

Exit code 3 is always recoverable — create the missing snapshot and rerun. It’s not a crash or a bug; it’s the diff telling you what it needs.


Upgrading from —mode delta in CI

If you’re already using hotspots analyze --mode delta in CI, the migration path is:

  1. Add the snapshot-on-push step to your mainline workflow
  2. Add the cache-restore + diff step to your PR workflow
  3. Keep the --mode delta run as a fallback while the cache warms up (the first PR against any new main SHA will miss the cache)

Once the first push to main has run, all subsequent PRs against that SHA will have the base snapshot available and the diff will run cleanly.

hotspots diff is available in v1.11.0 and later. If you’re not on that version yet, see the installation guide to upgrade. Check your current version with hotspots --version.