From 60b24cc8a6f15096cdec1647059ef8b2d06098d9 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 16:59:38 +0530 Subject: [PATCH 01/12] Add marker constants for the merge hook Toward issue 2418: the union merge driver can resurrect a symbol deleted on one side of a merge, and neither installed hook ever runs an unscoped rebuild to heal it afterward, so the phantom node persists indefinitely. This adds the start and end markers a new hook that runs after a merge will use, with no behavior change yet; the hook script and its install and uninstall and status wiring land in the next commits. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/hooks.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/graphify/hooks.py b/graphify/hooks.py index e78e291f62..6581fc76f7 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -9,6 +9,8 @@ _HOOK_MARKER_END = "# graphify-hook-end" _CHECKOUT_MARKER = "# graphify-checkout-hook-start" _CHECKOUT_MARKER_END = "# graphify-checkout-hook-end" +_MERGE_HOOK_MARKER = "# graphify-merge-hook-start" +_MERGE_HOOK_MARKER_END = "# graphify-merge-hook-end" # __PINNED_PYTHON__ is replaced at install time with the absolute path of the # Python interpreter that ran `graphify hook install`. For uv-tool and pipx From 954a3164cd3909c80c2aec4fbc3153f60eb74c7e Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 17:00:13 +0530 Subject: [PATCH 02/12] Add the merge hook script body and its defensive state guard Mirrors the checkout hook's structure and reuses its unscoped rebuild body (that body already runs with no changed_paths), since the whole point of this hook is that a merge can touch symbols in files the merge diff never names, so scoping the rebuild to changed files would miss the exact resurrection this hook exists to heal. Also carries the same rebase and merge state guard the commit and checkout hooks already have, and the same GRAPHIFY_SKIP_HOOK opt out, matching their defensive posture even though this hook only ever fires after a merge has already completed cleanly. Not wired into install, uninstall, or status yet; that lands next. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/hooks.py | 54 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/graphify/hooks.py b/graphify/hooks.py index 6581fc76f7..5516aaa4c4 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -513,6 +513,60 @@ def _detached_launch(rebuild_body: str) -> str: """ +_MERGE_SCRIPT = """\ +# graphify-merge-hook-start +# Auto-rebuilds the knowledge graph (code only) after a merge, unscoped. +# The git merge driver graphify registers for graph.json does a pure union +# of both sides, which can resurrect a symbol that was deleted on one side +# (#2418). Nothing else ever re-extracts that symbol's file unless it is +# touched again, so the phantom node would otherwise persist indefinitely; +# an unscoped rebuild here re-derives the graph from the merged tree and +# drops it like any other stale node. +# Installed by: graphify hook install +( + +# Deterministic clustering: networkx louvain iterates string-keyed sets whose +# order is randomized per-process by PYTHONHASHSEED, so community assignments +# churn run-to-run. Pinning it makes graphify-out reproducible. +export PYTHONHASHSEED=0 +__VIZ_LIMIT_EXPORT__ +# Git for Windows/MSYS hooks can inherit fragile pipe handles from GUI clients +# and agent shells. Keep hook-triggered rebuilds sequential by default there; +# explicit GRAPHIFY_MAX_WORKERS still wins for users who want parallelism. +if [ -n "${WINDIR:-}" ] || [ -n "${MSYSTEM:-}" ]; then + export GRAPHIFY_MAX_WORKERS="${GRAPHIFY_MAX_WORKERS:-1}" +fi + +# Only run if graphify-out/ exists (graph has been built before) +if [ ! -d "graphify-out" ]; then + exit 0 +fi + +# This hook only fires after a merge completed cleanly, so MERGE_HEAD is +# already gone by the time it runs -- these checks are belt and braces for +# any git version/workflow quirk that lands here mid rebase or cherry-pick +# anyway, matching the commit and checkout hooks' own defensive posture. +# git exports GIT_DIR to hooks; the rev-parse fallback only runs when invoked +# by hand (each git exec costs 1s+ on AV-scanned Windows machines). +GIT_DIR=${GIT_DIR:-$(git rev-parse --git-dir 2>/dev/null)} +[ -d "$GIT_DIR/rebase-merge" ] && exit 0 +[ -d "$GIT_DIR/rebase-apply" ] && exit 0 +[ -f "$GIT_DIR/MERGE_HEAD" ] && exit 0 +[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0 + +# Honor the same opt out the commit and checkout hooks already respect. +[ "${GRAPHIFY_SKIP_HOOK:-0}" = "1" ] && exit 0 + +""" + _WORKTREE_GUARD + _PYTHON_DETECT + """ +_GRAPHIFY_LOG="${HOME}/.cache/graphify-rebuild.log" +mkdir -p "$(dirname "$_GRAPHIFY_LOG")" +export GRAPHIFY_REBUILD_LOG="$_GRAPHIFY_LOG" +echo "[graphify] Merge completed - launching background rebuild (log: $_GRAPHIFY_LOG)" +""" + _detached_launch(_REBUILD_BODY_CHECKOUT) + """) +# graphify-merge-hook-end +""" + + def _load_graphifyrc(root: Path) -> dict[str, str | int]: """Load key/value options from /.graphifyrc if present. From 975ee908649ea7616af6612c3408a55e2cd8a8cb Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 17:00:41 +0530 Subject: [PATCH 03/12] Wire the merge hook into install() Fixes issue 2418. install() now writes the merge hook alongside the commit and checkout hooks, using the same _install_hook path so idempotent re install and appending to an existing hook file both work exactly like the other two hooks. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/hooks.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/graphify/hooks.py b/graphify/hooks.py index 5516aaa4c4..6cc986cf98 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -918,7 +918,7 @@ def _user_hooks_dir(hooks_dir: Path) -> Path: def install(path: Path = Path(".")) -> str: - """Install graphify post-commit and post-checkout hooks in the nearest git repo.""" + """Install graphify's post-commit, post-checkout, and post-merge hooks in the nearest git repo.""" root = _git_root(path) if root is None: raise RuntimeError(f"No git repository found at or above {path.resolve()}") @@ -939,12 +939,19 @@ def install(path: Path = Path(".")) -> str: pinned = _pinned_python() hook = _HOOK_SCRIPT.replace("__PINNED_PYTHON__", pinned).replace("__VIZ_LIMIT_EXPORT__", viz_export) checkout = _CHECKOUT_SCRIPT.replace("__PINNED_PYTHON__", pinned).replace("__VIZ_LIMIT_EXPORT__", viz_export) + merge_hook = _MERGE_SCRIPT.replace("__PINNED_PYTHON__", pinned).replace("__VIZ_LIMIT_EXPORT__", viz_export) commit_msg = _install_hook(hooks_dir, "post-commit", hook, _HOOK_MARKER, _HOOK_MARKER_END) checkout_msg = _install_hook(hooks_dir, "post-checkout", checkout, _CHECKOUT_MARKER, _CHECKOUT_MARKER_END) + post_merge_msg = _install_hook( + hooks_dir, "post-merge", merge_hook, _MERGE_HOOK_MARKER, _MERGE_HOOK_MARKER_END + ) merge_msg = _register_merge_driver(root) - return f"post-commit: {commit_msg}\npost-checkout: {checkout_msg}\nmerge driver: {merge_msg}" + return ( + f"post-commit: {commit_msg}\npost-checkout: {checkout_msg}\n" + f"post-merge: {post_merge_msg}\nmerge driver: {merge_msg}" + ) def uninstall(path: Path = Path(".")) -> str: From 5b3b32a724686d1bb10bb36fd4dc33bc9814ef2e Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 17:01:08 +0530 Subject: [PATCH 04/12] Wire the merge hook into uninstall() uninstall() now removes the merge hook alongside the commit and checkout hooks, using the same _uninstall_hook path so it is cleaned up correctly whether it was the only content in the file, appended to existing content, or already absent. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/hooks.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/graphify/hooks.py b/graphify/hooks.py index 6cc986cf98..73c9b41bdf 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -955,7 +955,7 @@ def install(path: Path = Path(".")) -> str: def uninstall(path: Path = Path(".")) -> str: - """Remove graphify post-commit and post-checkout hooks.""" + """Remove graphify's post-commit, post-checkout, and post-merge hooks.""" root = _git_root(path) if root is None: raise RuntimeError(f"No git repository found at or above {path.resolve()}") @@ -963,9 +963,15 @@ def uninstall(path: Path = Path(".")) -> str: hooks_dir = _user_hooks_dir(_hooks_dir(root)) commit_msg = _uninstall_hook(hooks_dir, "post-commit", _HOOK_MARKER, _HOOK_MARKER_END) checkout_msg = _uninstall_hook(hooks_dir, "post-checkout", _CHECKOUT_MARKER, _CHECKOUT_MARKER_END) + post_merge_msg = _uninstall_hook( + hooks_dir, "post-merge", _MERGE_HOOK_MARKER, _MERGE_HOOK_MARKER_END + ) merge_msg = _unregister_merge_driver(root) - return f"post-commit: {commit_msg}\npost-checkout: {checkout_msg}\nmerge driver: {merge_msg}" + return ( + f"post-commit: {commit_msg}\npost-checkout: {checkout_msg}\n" + f"post-merge: {post_merge_msg}\nmerge driver: {merge_msg}" + ) def status(path: Path = Path(".")) -> str: From ece2fa7712c0e05a7b000f54d9643b4f2d458edf Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 17:01:27 +0530 Subject: [PATCH 05/12] Wire the merge hook into status() status() now reports whether the merge hook is installed alongside the commit and checkout hooks, reusing the same viz limit staleness check the other two hooks already get. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/hooks.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/graphify/hooks.py b/graphify/hooks.py index 73c9b41bdf..93b99e94cb 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -1015,9 +1015,13 @@ def _check(name: str, marker: str) -> str: commit = _check("post-commit", _HOOK_MARKER) checkout = _check("post-checkout", _CHECKOUT_MARKER) + post_merge = _check("post-merge", _MERGE_HOOK_MARKER) merge = _merge_driver_status(root) - res = f"post-commit: {commit}\npost-checkout: {checkout}\nmerge driver: {merge}" + res = ( + f"post-commit: {commit}\npost-checkout: {checkout}\n" + f"post-merge: {post_merge}\nmerge driver: {merge}" + ) if cfg_limit is not None: res += f"\nviz node limit: {cfg_limit}" return res From d20d6940e03754a2fa5c4eb6a094e6d2cf462198 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 17:01:51 +0530 Subject: [PATCH 06/12] Mention the merge hook in the CLI help and README Keeps both listings accurate now that hook install writes three hooks instead of two. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- README.md | 2 +- graphify/__main__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4db32086e7..cb4fd0882b 100644 --- a/README.md +++ b/README.md @@ -723,7 +723,7 @@ graphify uninstall # remove from all platforms in one shot graphify uninstall --purge # also delete graphify-out/ graphify uninstall --project --platform codex # remove project-scoped install files only -graphify hook install # post-commit + post-checkout hooks +graphify hook install # post-commit + post-checkout + post-merge hooks graphify hook uninstall graphify hook status diff --git a/graphify/__main__.py b/graphify/__main__.py index 4a68e7240f..f47da3010d 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -664,7 +664,7 @@ def _run_cli() -> None: print(" (or set NEO4J_PASSWORD instead of --password to keep it off argv)") print(" export falkordb emit Cypher or push to FalkorDB [--graph PATH] [--push URI] [--user U] [--password P]") print(" (or set FALKORDB_PASSWORD instead of --password to keep it off argv)") - print(" hook install install post-commit/post-checkout git hooks (all platforms)") + print(" hook install install post-commit/post-checkout/post-merge git hooks (all platforms)") print(" hook uninstall remove git hooks") print(" hook status check if git hooks are installed") print( From 2694b85c0e19dd598d758c600113a4817bf8ea17 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 17:02:08 +0530 Subject: [PATCH 07/12] Import the merge hook marker in the test module Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- tests/test_hooks.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_hooks.py b/tests/test_hooks.py index bd6d7e5216..c6e3f88a5c 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -7,7 +7,9 @@ from types import SimpleNamespace from pathlib import Path import pytest -from graphify.hooks import install, uninstall, status, _hooks_dir, _HOOK_MARKER, _CHECKOUT_MARKER +from graphify.hooks import ( + install, uninstall, status, _hooks_dir, _HOOK_MARKER, _CHECKOUT_MARKER, _MERGE_HOOK_MARKER, +) def _make_git_repo(tmp_path: Path) -> Path: From 2189f86fe56eef4497369eca4c03b12206389438 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 17:02:36 +0530 Subject: [PATCH 08/12] Add install and uninstall regression tests for the merge hook Mirrors the existing checkout hook test group: created with the right marker on install, executable, and cleanly removed on uninstall. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- tests/test_hooks.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_hooks.py b/tests/test_hooks.py index c6e3f88a5c..bb3334ccb6 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -116,6 +116,35 @@ def test_uninstall_removes_post_checkout_hook(tmp_path): assert not hook.exists() +def test_install_creates_post_merge_hook(tmp_path): + # #2418: a merge hook is needed to heal a symbol the union merge driver + # resurrects on one side of a merge, since nothing else ever + # re-extracts that file's nodes unless it is touched again. + repo = _make_git_repo(tmp_path) + install(repo) + hook = repo / ".git" / "hooks" / "post-merge" + assert hook.exists() + assert _MERGE_HOOK_MARKER in hook.read_text() + + +def test_install_post_merge_is_executable(tmp_path): + repo = _make_git_repo(tmp_path) + install(repo) + hook = repo / ".git" / "hooks" / "post-merge" + if os.name == "nt": + assert hook.read_text(encoding="utf-8").startswith("#!/bin/sh\n") + else: + assert hook.stat().st_mode & 0o111 + + +def test_uninstall_removes_post_merge_hook(tmp_path): + repo = _make_git_repo(tmp_path) + install(repo) + uninstall(repo) + hook = repo / ".git" / "hooks" / "post-merge" + assert not hook.exists() + + def test_status_shows_both_hooks(tmp_path): repo = _make_git_repo(tmp_path) install(repo) From 7a5652d5c1c0a6762024a0e5a522e3266bc4ad2c Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 17:12:30 +0530 Subject: [PATCH 09/12] Add status regression test for the merge hook Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- tests/test_hooks.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_hooks.py b/tests/test_hooks.py index bb3334ccb6..4b4d8b413d 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -154,6 +154,17 @@ def test_status_shows_both_hooks(tmp_path): assert result.count("installed") >= 2 +def test_status_shows_post_merge_hook(tmp_path): + repo = _make_git_repo(tmp_path) + install(repo) + result = status(repo) + assert "post-merge: installed" in result + + uninstall(repo) + result = status(repo) + assert "post-merge: not installed" in result + + def test_hooks_dir_resolves_relative_git_hooks_path(tmp_path, monkeypatch): repo = _make_git_repo(tmp_path) From 3fa9e007f5a5d84b61b77ae483212f883d53f8e3 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 17:12:54 +0530 Subject: [PATCH 10/12] Cover the merge hook by the shared generic hook test suite Adding it to _HOOK_SCRIPTS extends 8 existing parametrized tests to the new hook for free: no nohup/setsid/disown, cross platform detach, Windows worker limiting, GIT_DIR reuse, GRAPHIFY_SKIP_HOOK handling, shell quote safety, valid Python payloads, and the linked worktree skip. All 8 pass, confirming the merge hook script was built from the same verified building blocks as the other two. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- tests/test_hooks.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 4b4d8b413d..f493b1ce1a 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -271,12 +271,17 @@ def test_hook_check_no_additionalContext(tmp_path): from graphify.hooks import ( # noqa: E402 _HOOK_SCRIPT, _CHECKOUT_SCRIPT, + _MERGE_SCRIPT, _REBUILD_BODY_COMMIT, _REBUILD_BODY_CHECKOUT, _detached_launch, ) -_HOOK_SCRIPTS = [("post-commit", _HOOK_SCRIPT), ("post-checkout", _CHECKOUT_SCRIPT)] +_HOOK_SCRIPTS = [ + ("post-commit", _HOOK_SCRIPT), + ("post-checkout", _CHECKOUT_SCRIPT), + ("post-merge", _MERGE_SCRIPT), +] @pytest.mark.parametrize("name,script", _HOOK_SCRIPTS) From abf2d81dee899ad2d653e3b5a3b3b7e6dbff0973 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 17:13:28 +0530 Subject: [PATCH 11/12] Add end to end test proving the unscoped rebuild heals a phantom This is the property the whole fix exists for, tested directly against _rebuild_code rather than the shell script: a node shaped like a genuine merge driver resurrection (same _origin and source_location fields a real prior AST extraction carries) survives a rebuild scoped to an unrelated changed file, exactly like the scoped commit hook would run, and is dropped by an unscoped rebuild, exactly what the new merge hook runs. Building the phantom without those fields looked like it reproduced the healing on the first try, but it was actually hitting a different path: a node with no _origin/source_location classifies as semantic tier rather than AST tier, and the AST only rebuild preserves semantic tier nodes unconditionally regardless of scope, so that shape healed for the wrong reason and would have passed even against a hook that did nothing. Shaping the phantom to match a real AST node closes that gap. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- tests/test_watch.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/test_watch.py b/tests/test_watch.py index a056538f44..1e316fdad2 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -133,6 +133,47 @@ def test_doc_only_deletion_full_rebuild_evicts_md_nodes(tmp_path): assert "run()" in labels +def test_post_merge_style_unscoped_rebuild_heals_a_resurrected_node(tmp_path): + """#2418: the graphify merge driver union merges graph.json, which can + resurrect a node for a symbol that was deleted on one side of the merge. + A rebuild SCOPED to an unrelated changed file (what the commit hook + runs) must NOT heal it -- the resurrected symbol's own file was never + touched. The UNSCOPED rebuild the new merge hook performs (the same + body as the checkout hook: _rebuild_code with no changed_paths) must.""" + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + (corpus / "src").mkdir(parents=True) + (corpus / "src" / "util.py").write_text("def helper_alpha(): return 1\n", encoding="utf-8") + assert _rebuild_code(corpus, acquire_lock=False) is True + + graph_path = corpus / "graphify-out" / "graph.json" + data = json.loads(graph_path.read_text(encoding="utf-8")) + real_node = next(n for n in data["nodes"] if n["id"] == "src_util_helper_alpha") + # Simulate what the union merge driver does: resurrect a node dict shaped + # exactly like a real AST node (same _origin/source_location fields a + # genuine prior extraction on the other side of the merge would carry), + # for a symbol that was deleted on THIS side. + phantom = {**real_node, "id": "src_util_helper_doomed", "label": "helper_doomed()"} + data["nodes"].append(phantom) + graph_path.write_text(json.dumps(data), encoding="utf-8") + + def has_phantom() -> bool: + g = json.loads(graph_path.read_text(encoding="utf-8")) + return any(n["id"] == "src_util_helper_doomed" for n in g["nodes"]) + + assert has_phantom() + + (corpus / "src" / "unrelated.py").write_text("x = 1\n", encoding="utf-8") + assert _rebuild_code( + corpus, changed_paths=[corpus / "src" / "unrelated.py"], acquire_lock=False + ) is True + assert has_phantom(), "a rebuild scoped to an unrelated file must not have healed the phantom" + + assert _rebuild_code(corpus, acquire_lock=False) is True + assert not has_phantom(), "the unscoped rebuild must heal the resurrected node" + + def test_rebuild_code_reports_unclassified_files(tmp_path, capsys): """#3511: `graphify extract` has surfaced files it saw but could not classify (no supported extension/shebang) since #1692; the update/watch From 62eee1758cf5e485a991be44ce4c776c3284b36c Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Mon, 14 Sep 2026 17:13:46 +0530 Subject: [PATCH 12/12] Add changelog entry for issue 2418 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae58e4c560..601771052c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.61 (2026-09-12) +- Fix: `graphify hook install` now also installs a `post-merge` hook that runs an unscoped rebuild after a merge, healing a symbol the union merge driver resurrected on one side of the merge — nothing else ever re-extracted that file's nodes unless it was touched again, so the phantom node used to persist indefinitely (#2418, thanks @ayushcodes10). - Fix: `graphify.serve` now imports cleanly on Python 3.12 and 3.13. The `chinese` extra pins `jieba-py` from 3.12 onward (0.9.60 mistakenly kept the old `jieba` until 3.14, and its invalid regex escapes are a hard error on 3.12+), and the jieba import now suppresses the tokenizer's `SyntaxWarning` regardless of message or line so it never escalates under `-W error`. - Fix: the git hook's rebuild-root guard now rejects a symlink-loop or dangling `.graphify_root` on Python 3.13, whose `Path.resolve()` no longer raises on a loop — the saved root must resolve to a real directory inside the repo before it is adopted.