diff --git a/CHANGELOG.md b/CHANGELOG.md index c2adb14fb..ec00776c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.22 (unreleased) - Fix: a node whose `source_file` is a URL/virtual scheme (`gdoc://`, `s3://`, `http://`, ...) is no longer evicted on the second `graphify update` (follow-up to #2051). The #2051 disk-absence sweep guarded such sources with a literal `"://"` check, but write-side path normalization collapses the double slash (`gdoc://x` becomes `gdoc:/x`), so the guard missed the node on the next run and dropped it into the disk-absence eviction branch. The scheme is now matched tolerantly (and a Windows drive letter like `C:/` is not misread as remote). +- Fix: the installed `post-checkout` hook no longer rebuilds the graph on a branch switch that doesn't actually move HEAD, e.g. `git checkout -b ` (#2060). Git still passes `BRANCH_SWITCH=1` for this case even though `PREV_HEAD`/`NEW_HEAD` are identical, so the hook launched a full rebuild for a switch with no underlying code change; since community detection isn't fully deterministic across runs, that rebuild produced pure reordering churn in `graph.json` that teams ended up discarding with `git stash`. The hook now exits early when `PREV_HEAD` equals `NEW_HEAD`. ## 0.9.21 (2026-07-20) diff --git a/graphify/hooks.py b/graphify/hooks.py index 77ef39cd3..9fa74fd94 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -339,6 +339,14 @@ def _detached_launch(rebuild_body: str) -> str: exit 0 fi +# Skip when the checkout didn't move HEAD to a different commit (e.g. +# `git checkout -b ` off the current HEAD). No code actually +# changed, so a rebuild only produces non-deterministic community-detection +# reordering churn in graph.json (#2060). +if [ "$PREV_HEAD" = "$NEW_HEAD" ]; then + exit 0 +fi + # Only run if graphify-out/ exists (graph has been built before) if [ ! -d "graphify-out" ]; then exit 0 diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 8e95aabbe..9f0fafefc 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -490,6 +490,49 @@ def _git(*args, cwd): assert "RAN" not in r_linked.stdout, "guard failed to skip the linked worktree" +# ── #2060: `checkout -b` off the current HEAD must not trigger a rebuild ───── + +def test_checkout_script_has_same_head_guard(): + """The post-checkout template must skip the rebuild when PREV_HEAD and + NEW_HEAD are identical (e.g. `git checkout -b `), since no + commit actually changed and community detection isn't fully deterministic + run-to-run -- the old behaviour produced pure reordering churn in + graph.json that teams had to `git stash` away (#2060).""" + assert 'if [ "$PREV_HEAD" = "$NEW_HEAD" ]; then' in _CHECKOUT_SCRIPT + # post-commit has no PREV_HEAD/NEW_HEAD concept; guard must not leak there. + assert 'if [ "$PREV_HEAD" = "$NEW_HEAD" ]; then' not in _HOOK_SCRIPT + + +def _checkout_same_head_guard_snippet() -> str: + """Slice _CHECKOUT_SCRIPT up through the new same-head guard and append a + marker, mirroring the _worktree_guard_snippet() pattern above.""" + marker = 'if [ "$PREV_HEAD" = "$NEW_HEAD" ]; then\n exit 0\nfi\n' + idx = _CHECKOUT_SCRIPT.index(marker) + len(marker) + return _CHECKOUT_SCRIPT[:idx] + "echo RAN\n" + + +def test_checkout_guard_skips_rebuild_when_head_unchanged(): + """`checkout -b` off HEAD: PREV_HEAD == NEW_HEAD, BRANCH_SWITCH=1 -> the + guard must exit before reaching past it (#2060).""" + snippet = _checkout_same_head_guard_snippet() + result = subprocess.run( + ["sh", "-c", snippet, "sh", "deadbeef", "deadbeef", "1"], + capture_output=True, text=True, + ) + assert "RAN" not in result.stdout + + +def test_checkout_guard_runs_when_head_changed(): + """A real branch switch (different commits) must still fall through past + the guard (#2060).""" + snippet = _checkout_same_head_guard_snippet() + result = subprocess.run( + ["sh", "-c", snippet, "sh", "aaaaaaa", "bbbbbbb", "1"], + capture_output=True, text=True, + ) + assert "RAN" in result.stdout + + # ── #1907: duplicate keys in .git/config must not trigger spurious warnings ── def _append_duplicate_config_entries(repo: Path) -> None: