Skip to content

feat(scan): branch-aware lifecycle — computed membership + init-installed hooks - #41

Merged
cvetty merged 1 commit into
mainfrom
feat/branch-lifecycle
Aug 3, 2026
Merged

feat(scan): branch-aware lifecycle — computed membership + init-installed hooks#41
cvetty merged 1 commit into
mainfrom
feat/branch-lifecycle

Conversation

@cvetty

@cvetty cvetty commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Implements plans/branch-lifecycle-plan.md end to end — all 12 rollout steps. The plan went through five review rounds before any code; §0 records every decision (D1–D10) and §12 records the seven places the implementation deviated and why.

The problem

GitCrawler walks everything reachable from HEAD and lets on_default_branch fall to its 1 default, so unmerged WIP was indistinguishable from shipped history for every consumer that filters on it — velocity, area-history, evidence, scan/authors.py, and the chat statistics surface. Worse, it was permanent: once a branch is squash-merged, PROriginEnricher skips the row because it already exists, and scan is insert-only.

Separately, git switch fired no hook at all, and the hooks themselves sat behind a command (whygraph hooks install) that nothing in the documented install path mentioned.

What changed

Membership is computed, not assumed. Repository.default_branch_refs resolves the default branch (origin/HEAD<remote>/main<remote>/master, overridable via [scan].default_branch) and unions it with the same-named local branch. That union is load-bearing: without it, unpushed commits on local main would be flagged off-branch and vanish from every velocity query until pushed and fetched — a visible regression for solo developers.

The DB self-heals. A reconcile pass recomputes the flag for existing rows on every scan, so a merged branch promotes and a force-pushed commit demotes, with no separate command. Rows are never deleted — an unreachable commit is still valid evidence for why the code looks the way it does, which is WhyGraph's whole premise.

Two guards make a mass-demotion impossible, and both are mandatory:

  • an unresolvable default branch → skip (empty set means "cannot judge", not "nothing is on it")
  • a shallow clone → skip (--depth=1 is the GitHub Actions default; git rev-list origin/main there returns one SHA)

Deliberately threshold-free: a cutoff would be a magic number simultaneously too noisy on a 20-commit repo and too quiet on a 20 000-commit one, and a genuine git filter-repo rewrite should demote in bulk. Instead every demotion surfaces as a yellow row with the SHAs logged to .whygraph/scan.log.

New nullable first_seen_ref column distinguishes the three populations: NULL (was on the default branch), a branch name (unmerged local work), refs/pull/<N>/head (a squash-merge recovery). It has exactly one consumer beyond debugging, named deliberately in §4.4.

Hooks install from init. The whygraph hooks group is removed; [scan].hooks — a bool or an explicit list — is the only switch, and init is the reconciler: sync_hooks() iterates all of HOOK_NAMES every run, so shrinking the list removes the hooks it dropped. It's one function rather than an install/uninstall pair precisely so the removal half can't be forgotten. post-checkout joins the set, gated on git's own branch-checkout flag, closing the last uncovered git event (pulling, in all four forms, already fired a hook).

Rename aliases scope to default-branch or current-branch. The binary framing was the error: filtering blinds WhyGraph to the rename you're making right now — close to the most valuable moment for a rationale lookup — while leaving it unfiltered lets an abandoned branch pollute path history forever. The chosen predicate self-cleans: merging promotes the rows, switching away drops them.

Two prose fixes that aren't drive-by tidying

  • chat/stats_sql.py rule 1 explained flag-0 as "already on the main walk". After this change that's false — and an LLM reads it as ground truth before writing SQL on every chat turn, so a model reasoning from the old premise could union flag-0 rows back in. The instruction is unchanged; only the reason is restated.
  • mcp/path_history.py stated the invariant "flag-0 rows carry no commit_file_change rows" out loud. Feature-branch commits break it.

Verification

ruff check, ruff format --check, and pytest all green — 994 tests, up from 931.

Every §7 acceptance criterion is covered by tests; 1, 2, 6, 7, 8, 10, 11, 12 and 15 were additionally smoke-tested against a real clone: feature commit → 0 / feature/x; merge → 4 commits (1 new, 1 promoted) with first_seen_ref retained as provenance; reset → ⚠ 2 commits are no longer reachable from origin/main, main with both SHAs in the scan log; a trunk repo with no remote → the yellow unresolved row and every commit 1, exactly as before this existed.

Reviewer notes

  • Migration e4c1b9d72f3a chains off c7d4a1e8b3f2. Additive and nullable — no data migration; the first post-upgrade scan's reconcile corrects every flag in one sweep.
  • tests/test_db_plumbing.py downgraded with a bare -1, correct only while c7d4a1e8b3f2 was head. Adding a revision on top made it drop the wrong column; now pinned to the owning migration's parent.
  • Known landmine left in place, documented in §12: any test file invoking the whygraph CLI group silently disables caplog for the rest of the session — the group callback runs configure_logging, which replaces the root handlers. tests/test_init_agents.py has always done this but sorts last, so nothing downstream noticed. Worked around locally in two files rather than changing shared test infrastructure in this PR. Fix is an autouse conftest.py fixture or making configure_logging additive — happy to split that out if you'd rather it land here.

…lled hooks

Implements plans/branch-lifecycle-plan.md end to end.

`on_default_branch` becomes computed rather than assumed. `Repository`
resolves the default branch (origin/HEAD -> <remote>/main -> <remote>/master,
overridable via `[scan].default_branch`) and unions it with the same-named
local branch, so unpushed commits on local `main` still count as shipped.
`GitCrawler` flags new rows against that SHA set, records the new nullable
`first_seen_ref` provenance column, and runs a reconcile pass on every scan
so the DB self-heals as branches merge or get rewritten. Two guards make a
mass-demotion impossible: an unresolvable default branch and a shallow clone
both skip the pass. Rows are never deleted — an unreachable commit is still
evidence. Demotions surface as a yellow panel row with the SHAs logged to
`.whygraph/scan.log`.

The `whygraph hooks` command group is gone. `whygraph init` now installs the
auto-rescan hooks and reconciles `.git/hooks` to `[scan].hooks` in both
directions — a bool or an explicit list — so shrinking the list removes the
hooks it dropped. `post-checkout` joins the set, gated on git's own
branch-checkout flag, closing the last uncovered git event.

The rename-alias walks scope to default-branch-or-current-branch, keeping an
in-flight rename visible without letting an abandoned branch pollute path
history. `chat/stats_sql.py`'s rule 1 keeps its instruction but gets a reason
that is still true, since the chat model reads it as ground truth every turn.
@cvetty cvetty added the enhancement New feature or request label Aug 3, 2026
@cvetty cvetty self-assigned this Aug 3, 2026
@cvetty
cvetty merged commit abc4430 into main Aug 3, 2026
2 checks passed
@cvetty
cvetty deleted the feat/branch-lifecycle branch August 4, 2026 11:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant