fix(detect,hooks): honor .git/info/exclude; guard worktrees (#1810, #1809)

#1810 — detection read only .gitignore/.graphifyignore, never
.git/info/exclude, which is where git records local-only excludes and
where `git worktree add` writes nested worktree paths. graphify walked
into those worktree copies and the graph exploded (one 5-worktree repo:
9.4k nodes/10MB -> 210k nodes/311MB, ~77% duplicate). detect now loads
info/exclude at lowest precedence (below every per-dir .gitignore, per
git, so a nearer `!` still wins) and resolves the linked-worktree /
submodule case where `.git` is a file to the shared common git dir.

#1809 — two git-hook gaps: (a) post-checkout never honored
GRAPHIFY_SKIP_HOOK, so the var stopped commit rebuilds but not
branch-switch ones; now checked in both. (b) with core.hooksPath shared
across worktrees, a commit in any linked worktree fired post-commit,
which wrote a rogue delta-only graph.json into it and raced deploy/CI
`git clean` against the detached rebuild. Both hooks now short-circuit
in a linked worktree (git-dir != git-common-dir), comparing ABSOLUTE
paths so the primary checkout (where --git-common-dir is the relative
".git") is never false-positived and skipped.

Adds regression tests: info/exclude honored + negation precedence;
both hooks honor the skip env and carry the worktree guard; and an
end-to-end guard check against a real `git worktree`.

Reported by @cdahl86-cyber (#1810, #1809); the worktree guard was
co-developed with @Claude-Madera's PR #1806.

Co-Authored-By: cdahl86-cyber <cdahl86-cyber@users.noreply.github.com>
Co-Authored-By: Claude-Madera <Claude-Madera@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-07-12 16:15:24 +01:00
co-authored by cdahl86-cyber Claude-Madera Claude Opus 4.8
parent 90b545c609
commit 5f57c46b92
5 changed files with 171 additions and 1 deletions
+4
View File
@@ -10,6 +10,10 @@ Full release notes with details on each version: [GitHub Releases](https://githu
- Fix: `.nox/` (nox virtualenvs) is now skipped during detection alongside `.tox/` (#1804, thanks @igorregoir-lgtm). nox is tox's successor and creates a `.nox/` tree of the same shape, but only `.tox` was in the skip set — so a repo with a nox env got its site-packages fully indexed (one real repo came out 91% venv noise: 6,720 of 7,365 nodes from `.nox/`) and semantic extraction burned tokens reading venv docs.
- Fix: detection now honors `.git/info/exclude`, not just `.gitignore`/`.graphifyignore` (#1810, thanks @cdahl86-cyber). `info/exclude` is where git records local-only excludes and where `git worktree add` writes nested worktree paths, so a repo can exclude a directory without any `.gitignore` entry. graphify walked straight into those worktree copies and the graph exploded (one repo with 5 worktrees went from ~9,400 nodes / 10 MB to ~210,000 nodes / 311 MB, ~77% duplicate worktree nodes, near the 512 MB cap — regenerated on every commit by the auto-rebuild hooks). The exclude file is loaded at lowest precedence (below every per-directory `.gitignore`/`.graphifyignore`), matching git, so a nearer `!` re-include still wins; the linked-worktree/submodule case (where `.git` is a file) is resolved to the shared common git dir.
- Fix: the git hooks no longer misbehave inside linked worktrees, and `GRAPHIFY_SKIP_HOOK` now suppresses both hooks (#1809, thanks @cdahl86-cyber; worktree guard co-developed with @Claude-Madera's #1806). Two gaps: (1) `post-checkout` never checked `GRAPHIFY_SKIP_HOOK`, so the var stopped commit-triggered rebuilds but not branch-switch ones; it now honors it like `post-commit`. (2) With `core.hooksPath` shared across worktrees, a commit in any linked worktree fired `post-commit`, which wrote a rogue delta-only `graph.json` into that worktree and raced deploy/CI `git clean` against the detached rebuild (`failed to remove graphify-out/: Directory not empty`). Both hooks now short-circuit in a linked worktree (git-dir != git-common-dir), comparing absolute paths so the primary checkout — where `--git-common-dir` is the relative `.git` — is never false-positived and wrongly skipped.
## 0.9.13 (2026-07-12)
- Fix: the query log is now opt-in (off by default) (#1797, thanks @adam-pond-agent). `querylog` wrote every `query`/`path`/`explain` question and corpus path (and full responses if `GRAPHIFY_QUERY_LOG_RESPONSES`) to a default-on, unbounded, fail-silent plaintext file at `~/.cache/graphify-queries.log` — outside any repo's .gitignore/retention, and undocumented, which contradicts graphify's on-device / no-telemetry posture. Logging is now OFF unless you opt in with `GRAPHIFY_QUERY_LOG_ENABLE=1` (default path) or `GRAPHIFY_QUERY_LOG=<path>`; `GRAPHIFY_QUERY_LOG_DISABLE=1` still forces it off. All the query-log env vars are now documented in the README.
+54
View File
@@ -790,6 +790,48 @@ def _find_vcs_root(start: Path) -> Path | None:
current = parent
def _git_info_exclude(vcs_root: Path) -> Path | None:
"""Resolve ``$GIT_DIR/info/exclude`` for the repo rooted at ``vcs_root``.
``info/exclude`` is where git records local-only, uncommitted excludes — and
where ``git worktree add`` writes nested worktree paths — so a repo can ignore
a directory without any ``.gitignore`` entry. graphify only read
``.gitignore``/``.graphifyignore``, so it walked into those worktree copies and
the graph exploded (#1810). Handles the linked-worktree/submodule case where
``.git`` is a file (``gitdir: <path>``) and the real excludes live in the
shared common git dir. Returns None when there is no readable exclude file.
"""
dot_git = vcs_root / ".git"
git_dir: Path | None = None
if dot_git.is_dir():
git_dir = dot_git
elif dot_git.is_file():
try:
content = dot_git.read_text(encoding="utf-8", errors="ignore").strip()
except OSError:
content = ""
if content.startswith("gitdir:"):
gd = Path(content[len("gitdir:"):].strip())
if not gd.is_absolute():
gd = (vcs_root / gd).resolve()
git_dir = gd
# A linked worktree's gitdir holds a `commondir` file pointing at the
# shared git dir, where info/exclude actually lives.
commondir = gd / "commondir"
if commondir.exists():
try:
cd_raw = commondir.read_text(encoding="utf-8", errors="ignore").strip()
except OSError:
cd_raw = ""
if cd_raw:
cd = Path(cd_raw)
git_dir = cd if cd.is_absolute() else (gd / cd).resolve()
if git_dir is None:
return None
exclude = git_dir / "info" / "exclude"
return exclude if exclude.is_file() else None
def _load_graphifyignore(root: Path) -> list[tuple[Path, str]]:
"""Read .graphifyignore files and return (anchor_dir, pattern) pairs.
@@ -814,6 +856,18 @@ def _load_graphifyignore(root: Path) -> list[tuple[Path, str]]:
dirs.reverse() # ceiling first, scan root last
patterns: list[tuple[Path, str]] = []
# $GIT_DIR/info/exclude is repo-root-scoped and, per git, ranks below every
# per-directory .gitignore/.graphifyignore — so load it first (lowest priority
# under last-match-wins) anchored at the VCS root, letting a nearer `!`
# re-include still override it (#1810).
info_exclude = _git_info_exclude(ceiling)
if info_exclude is not None:
for raw in info_exclude.read_text(encoding="utf-8", errors="ignore").splitlines():
line = _parse_gitignore_line(raw)
if line:
patterns.append((ceiling, line))
for d in dirs:
# Merge .gitignore and .graphifyignore for this dir (#1363). Previously
# the presence of a .graphifyignore made graphify skip that dir's
+25 -1
View File
@@ -240,6 +240,25 @@ def _detached_launch(rebuild_body: str) -> str:
return '"$GRAPHIFY_PYTHON" -c "' + launcher + '"\n'
# Skip the rebuild inside a linked worktree (git worktree add), shared by both
# hooks. With core.hooksPath shared across worktrees a commit in any worktree
# fires these hooks; the canonical graphify-out/ belongs to the primary checkout,
# so rebuilding from a worktree is wasteful, writes a rogue delta-only graph the
# user never asked for, and races deploy/CI `git clean` against the detached
# rebuild ("failed to remove graphify-out/: Directory not empty") (#1809, #1806).
# A linked worktree has git-dir != git-common-dir. Both are resolved to absolute
# via `cd ... && pwd` before comparing: git's exported GIT_DIR / --git-dir can be
# absolute while --git-common-dir is the relative ".git", and a raw compare would
# false-positive on the PRIMARY checkout and wrongly skip it.
_WORKTREE_GUARD = """\
_GFY_GITDIR=$(cd "$(git rev-parse --git-dir 2>/dev/null)" 2>/dev/null && pwd)
_GFY_COMMONDIR=$(cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd)
if [ -n "$_GFY_COMMONDIR" ] && [ "$_GFY_GITDIR" != "$_GFY_COMMONDIR" ]; then
exit 0
fi
"""
_HOOK_SCRIPT = """\
# graphify-hook-start
# Auto-rebuilds the knowledge graph after each commit (code files only, no LLM needed).
@@ -268,6 +287,7 @@ GIT_DIR=${GIT_DIR:-$(git rev-parse --git-dir 2>/dev/null)}
[ "${GRAPHIFY_SKIP_HOOK:-0}" = "1" ] && exit 0
""" + _WORKTREE_GUARD + """
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || git diff --name-only HEAD 2>/dev/null)
if [ -z "$CHANGED" ]; then
exit 0
@@ -334,7 +354,11 @@ GIT_DIR=${GIT_DIR:-$(git rev-parse --git-dir 2>/dev/null)}
[ -f "$GIT_DIR/MERGE_HEAD" ] && exit 0
[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0
""" + _PYTHON_DETECT + """
# Honor the same opt-out as post-commit: without this, GRAPHIFY_SKIP_HOOK=1
# suppressed commit-triggered rebuilds but not branch-switch ones (#1809).
[ "${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"
+30
View File
@@ -549,6 +549,36 @@ def test_detect_skips_nox_virtualenv(tmp_path):
assert any("app.py" in f for f in all_files)
def test_detect_honors_git_info_exclude(tmp_path):
""".git/info/exclude (where `git worktree add` records nested worktree paths,
and where local-only excludes live) must be honored, not just .gitignore /
.graphifyignore — otherwise nested worktree copies get fully indexed (#1810)."""
(tmp_path / ".git" / "info").mkdir(parents=True)
(tmp_path / ".git" / "info" / "exclude").write_text("worktrees/\n")
wt = tmp_path / "worktrees" / "foo"
wt.mkdir(parents=True)
(wt / "dupe.py").write_text("def dupe(): pass")
(tmp_path / "real.py").write_text("def real(): pass")
result = detect(tmp_path)
all_files = [f for files in result["files"].values() for f in files]
assert not any("dupe.py" in f for f in all_files), "worktree dir was not excluded"
assert any("real.py" in f for f in all_files), "real source was dropped"
def test_git_info_exclude_ranks_below_gitignore_negation(tmp_path):
"""info/exclude is loaded at lowest priority, so a later .gitignore `!` negation
of the same (non-directory) pattern still wins under last-match-wins (#1810)."""
from graphify.detect import _load_graphifyignore, _is_ignored
(tmp_path / ".git" / "info").mkdir(parents=True)
(tmp_path / ".git" / "info" / "exclude").write_text("secret*.txt\n")
(tmp_path / ".gitignore").write_text("!secret-ok.txt\n")
(tmp_path / "secret-bad.txt").write_text("x")
(tmp_path / "secret-ok.txt").write_text("x")
patterns = _load_graphifyignore(tmp_path)
assert _is_ignored(tmp_path / "secret-bad.txt", tmp_path, patterns)
assert not _is_ignored(tmp_path / "secret-ok.txt", tmp_path, patterns)
def test_detect_skips_graphify_own_cache(tmp_path):
""".graphify/ (extraction cache) must never be re-indexed as source (#873)."""
cache = tmp_path / ".graphify" / "cache"
+58
View File
@@ -1,5 +1,6 @@
"""Tests for hooks.py - git hook install/uninstall."""
import os
import shutil
import subprocess
from types import SimpleNamespace
from pathlib import Path
@@ -430,3 +431,60 @@ def test_hooks_reuse_git_dir_from_env(name, script):
when the script is invoked by hand — each extra git exec costs 1s+ on
AV-scanned Windows machines and lands in the commit's foreground."""
assert "GIT_DIR=${GIT_DIR:-" in script, f"{name} always re-runs git rev-parse"
@pytest.mark.parametrize("name,script", _HOOK_SCRIPTS)
def test_hooks_honor_skip_env(name, script):
"""GRAPHIFY_SKIP_HOOK=1 must suppress BOTH hooks. post-checkout previously
lacked the check, so the var stopped commit rebuilds but not branch-switch
ones (#1809)."""
assert '[ "${GRAPHIFY_SKIP_HOOK:-0}" = "1" ] && exit 0' in script, (
f"{name} does not honor GRAPHIFY_SKIP_HOOK"
)
@pytest.mark.parametrize("name,script", _HOOK_SCRIPTS)
def test_hooks_skip_linked_worktrees(name, script):
"""Both hooks must short-circuit in a linked worktree (git-dir != common-dir),
and must compare ABSOLUTE paths so the primary checkout (where --git-common-dir
is the relative ".git") is not false-positived and wrongly skipped (#1809, #1806)."""
assert script.count("_GFY_GITDIR=") == 1, f"{name} guard not present exactly once"
assert "git rev-parse --git-common-dir" in script
# absolute-normalized compare, not a raw string compare of git output
assert 'cd "$(git rev-parse --git-dir 2>/dev/null)" 2>/dev/null && pwd' in script
assert '[ "$_GFY_GITDIR" != "$_GFY_COMMONDIR" ]' in script
def _worktree_guard_snippet() -> str:
from graphify.hooks import _WORKTREE_GUARD
return _WORKTREE_GUARD + "echo RAN\n"
def test_worktree_guard_runs_on_primary_skips_linked(tmp_path):
"""End-to-end against a real `git worktree`: the guard falls through on the
primary checkout and exits early inside a linked worktree (#1809, #1806)."""
if shutil.which("git") is None: # pragma: no cover
pytest.skip("git not available")
primary = tmp_path / "primary"
primary.mkdir()
def _git(*args, cwd):
subprocess.run(["git", *args], cwd=cwd, check=True,
capture_output=True, text=True)
_git("init", "-q", ".", cwd=primary)
_git("config", "user.email", "t@t.co", cwd=primary)
_git("config", "user.name", "t", cwd=primary)
(primary / "a.txt").write_text("x")
_git("add", "-A", cwd=primary)
_git("commit", "-qm", "init", cwd=primary)
linked = tmp_path / "linked"
_git("worktree", "add", "-q", str(linked), "-b", "feature", cwd=primary)
snippet = _worktree_guard_snippet()
r_primary = subprocess.run(["sh", "-c", snippet], cwd=primary,
capture_output=True, text=True)
r_linked = subprocess.run(["sh", "-c", snippet], cwd=linked,
capture_output=True, text=True)
assert "RAN" in r_primary.stdout, "guard wrongly skipped the primary checkout"
assert "RAN" not in r_linked.stdout, "guard failed to skip the linked worktree"