feat(scan): branch-aware lifecycle — computed membership + init-installed hooks - #41
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements
plans/branch-lifecycle-plan.mdend 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
GitCrawlerwalks everything reachable fromHEADand letson_default_branchfall to its1default, 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,PROriginEnricherskips the row because it already exists, and scan is insert-only.Separately,
git switchfired 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_refsresolves 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 localmainwould 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:
--depth=1is the GitHub Actions default;git rev-list origin/mainthere 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-reporewrite should demote in bulk. Instead every demotion surfaces as a yellow⚠row with the SHAs logged to.whygraph/scan.log.New nullable
first_seen_refcolumn 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. Thewhygraph hooksgroup is removed;[scan].hooks— a bool or an explicit list — is the only switch, andinitis the reconciler:sync_hooks()iterates all ofHOOK_NAMESevery 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-checkoutjoins 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.pyrule 1 explained flag-0as "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-0rows back in. The instruction is unchanged; only the reason is restated.mcp/path_history.pystated the invariant "flag-0 rows carry nocommit_file_changerows" out loud. Feature-branch commits break it.Verification
ruff check,ruff format --check, andpytestall 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)withfirst_seen_refretained as provenance; reset →⚠ 2 commits are no longer reachable from origin/main, mainwith both SHAs in the scan log; atrunkrepo with no remote → the yellowunresolvedrow and every commit1, exactly as before this existed.Reviewer notes
e4c1b9d72f3achains offc7d4a1e8b3f2. Additive and nullable — no data migration; the first post-upgrade scan's reconcile corrects every flag in one sweep.tests/test_db_plumbing.pydowngraded with a bare-1, correct only whilec7d4a1e8b3f2was head. Adding a revision on top made it drop the wrong column; now pinned to the owning migration's parent.whygraphCLI group silently disablescaplogfor the rest of the session — the group callback runsconfigure_logging, which replaces the root handlers.tests/test_init_agents.pyhas 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 autouseconftest.pyfixture or makingconfigure_loggingadditive — happy to split that out if you'd rather it land here.