diff --git a/CHANGELOG.md b/CHANGELOG.md index 40c2fc5..3969593 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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=`; `GRAPHIFY_QUERY_LOG_DISABLE=1` still forces it off. All the query-log env vars are now documented in the README. diff --git a/graphify/detect.py b/graphify/detect.py index 3f68a81..20399eb 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -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: ``) 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 diff --git a/graphify/hooks.py b/graphify/hooks.py index e4289ad..14f8b8f 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -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" diff --git a/tests/test_detect.py b/tests/test_detect.py index ec53166..1d31fa7 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -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" diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 6a37bd9..88199a5 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -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"