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 @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
83 changes: 78 additions & 5 deletions graphify/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -511,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 <root>/.graphifyrc if present.

Expand Down Expand Up @@ -862,7 +918,7 @@ def _user_hooks_dir(hooks_dir: Path) -> Path:


def install(path: Path = Path(".")) -> str:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — install()

fans out to 7 callees (efferent coupling); 41 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — install()

fans out to 7 callees (efferent coupling); 41 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

"""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()}")
Expand All @@ -883,26 +939,39 @@ 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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — uninstall()

11 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — uninstall()

11 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

"""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()}")

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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — status()

fans out to 6 callees (efferent coupling); 9 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — status()

fans out to 6 callees (efferent coupling); 9 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Expand Down Expand Up @@ -946,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
51 changes: 49 additions & 2 deletions tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -114,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)
Expand All @@ -123,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)
Expand Down Expand Up @@ -229,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)
Expand Down
41 changes: 41 additions & 0 deletions tests/test_watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading