diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a0daf515f..04c8b5a000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.63 (2026-09-16) +- Feature: `query`/`explain`/`path`/`affected`/`god-nodes` and other read-only graph commands now resolve `graphify-out/graph.json` from the main checkout when run inside a git worktree, instead of failing with "graph file not found". A worktree shares history but has no `graphify-out/` of its own; the fallback resolves git's shared common directory and looks in its parent, degrading silently to the ordinary path on any failure (#2008, thanks @uitholland). + - Feature: Elixir `alias`/`import`/`require`/`use` targets now resolve onto the module's `defmodule` node across files, so the internal module dependency graph is no longer dropped as dangling. Only top-level modules are indexed (a nested `defmodule`, labeled with its bare inner name, cannot capture an unrelated `use ` from another file), and a same-file reference is left unresolved so it cannot clobber the structural `contains` edge (#3603, thanks @ayushcodes10). - Feature: a Rust `self.method()` call now resolves to a method defined on the same type in another file (the common split-`impl`-block layout), pooling methods across every `impl` of one type and refusing to link when two unrelated types share a bare name (#3602, thanks @ayushcodes10). - Feature: a Ruby member call `obj.foo` on a known-type receiver now resolves to a method `foo` inherited from a superclass, including across files, using the same conservative promotion as the implicit-self resolver — a single owning class, matching method kind, and one unambiguous ancestry chain, or it stays dangling (#3585, thanks @oleksii-tumanov). diff --git a/graphify/cli.py b/graphify/cli.py index 5503a9185a..521821ff8c 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -81,8 +81,43 @@ ) +def _worktree_graph_fallback() -> Path | None: + """Locate the main checkout's graph.json from inside a git worktree (#2008). + + A worktree has its own working tree but no graphify-out/ of its own — the + graph lives in whichever checkout built it, almost always the main one. + ``git rev-parse --git-common-dir`` resolves to the shared ``.git`` both the + main checkout and every worktree point at; its parent is the main + checkout's root in both cases, so this is a no-op (returns the same path) + when run from the main checkout itself. Read-only: never writes here, and + silently returns None on any failure (not a git repo, git not on PATH, a + bare repo with no working tree) so a plain "graph file not found" error + still surfaces from the normal path. + """ + import subprocess as _sp + + try: + result = _sp.run( + ["git", "rev-parse", "--path-format=absolute", "--git-common-dir"], + capture_output=True, text=True, timeout=5, + ) + except (OSError, _sp.SubprocessError): + return None + if result.returncode != 0: + return None + common_dir = result.stdout.strip() + if not common_dir: + return None + candidate = Path(common_dir).parent / _GRAPHIFY_OUT / "graph.json" + return candidate if candidate.is_file() else None + + def _default_graph_path() -> str: - return str(Path(_GRAPHIFY_OUT) / "graph.json") + candidate = Path(_GRAPHIFY_OUT) / "graph.json" + if candidate.exists(): + return str(candidate) + fallback = _worktree_graph_fallback() + return str(fallback) if fallback is not None else str(candidate) def _stamped_manifest_files( diff --git a/tests/test_worktree_graph_fallback.py b/tests/test_worktree_graph_fallback.py new file mode 100644 index 0000000000..c359812338 --- /dev/null +++ b/tests/test_worktree_graph_fallback.py @@ -0,0 +1,107 @@ +"""`_default_graph_path` falls back to the main checkout's graph from a git +worktree (#2008). + +A worktree shares history with the main checkout but has no graphify-out/ of +its own, so a bare `graphify explain`/`query`/etc. run from inside one used +to fail with "graph file not found" even though the graph exists one git +worktree away. These tests build real git repos and worktrees under tmp_path +rather than mocking subprocess, since the whole point is git's own +`--git-common-dir` resolution behaving as expected. +""" +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +from graphify.cli import _default_graph_path, _worktree_graph_fallback + +pytestmark = pytest.mark.skipif( + subprocess.run(["git", "--version"], capture_output=True).returncode != 0, + reason="git not available", +) + + +def _git(*args, cwd): + subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) + + +def _make_repo(root: Path) -> Path: + root.mkdir(parents=True, exist_ok=True) + _git("init", "-q", cwd=root) + _git("config", "user.email", "test@test.com", cwd=root) + _git("config", "user.name", "test", cwd=root) + (root / "README.md").write_text("x") + _git("add", ".", cwd=root) + _git("commit", "-q", "-m", "init", cwd=root) + return root + + +@pytest.fixture +def cwd_guard(): + old = os.getcwd() + yield + os.chdir(old) + + +def test_worktree_falls_back_to_main_checkout_graph(tmp_path, cwd_guard): + main = _make_repo(tmp_path / "main") + (main / "graphify-out").mkdir() + graph = main / "graphify-out" / "graph.json" + graph.write_text('{"nodes": [], "links": []}') + + worktree = tmp_path / "wt" + _git("worktree", "add", "--detach", str(worktree), "HEAD", cwd=main) + + os.chdir(worktree) + result = _worktree_graph_fallback() + assert result is not None + assert result.resolve() == graph.resolve() + assert _default_graph_path() == str(result) + + +def test_main_checkout_with_no_graph_returns_plain_default(tmp_path, cwd_guard): + """Not a worktree scenario at all: no graphify-out/ anywhere. Must not + crash, and must return the ordinary (non-existent) default path.""" + main = _make_repo(tmp_path / "solo") + os.chdir(main) + assert _worktree_graph_fallback() is None + assert _default_graph_path() == str(Path("graphify-out") / "graph.json") + + +def test_worktree_with_no_graph_anywhere_returns_none(tmp_path, cwd_guard): + main = _make_repo(tmp_path / "main2") + worktree = tmp_path / "wt2" + _git("worktree", "add", "--detach", str(worktree), "HEAD", cwd=main) + + os.chdir(worktree) + assert _worktree_graph_fallback() is None + assert _default_graph_path() == str(Path("graphify-out") / "graph.json") + + +def test_not_a_git_repo_returns_none(tmp_path, cwd_guard): + plain = tmp_path / "not_a_repo" + plain.mkdir() + os.chdir(plain) + assert _worktree_graph_fallback() is None + assert _default_graph_path() == str(Path("graphify-out") / "graph.json") + + +def test_worktree_with_its_own_graph_prefers_it(tmp_path, cwd_guard): + """If the worktree DOES have its own graphify-out/graph.json (built + there directly), _default_graph_path must use that one, not fall back -- + the fallback only fires when the local candidate is absent.""" + main = _make_repo(tmp_path / "main3") + (main / "graphify-out").mkdir() + (main / "graphify-out" / "graph.json").write_text('{"nodes": [], "links": []}') + + worktree = tmp_path / "wt3" + _git("worktree", "add", "--detach", str(worktree), "HEAD", cwd=main) + (worktree / "graphify-out").mkdir() + own_graph = worktree / "graphify-out" / "graph.json" + own_graph.write_text('{"nodes": [], "links": []}') + + os.chdir(worktree) + assert _default_graph_path() == str(Path("graphify-out") / "graph.json")