Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <new-branch>` (#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)

Expand Down
8 changes: 8 additions & 0 deletions graphify/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <new-branch>` 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
Expand Down
43 changes: 43 additions & 0 deletions tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <new-branch>`), 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:
Expand Down