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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Name>` 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).
Expand Down
37 changes: 36 additions & 1 deletion graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
107 changes: 107 additions & 0 deletions tests/test_worktree_graph_fallback.py
Original file line number Diff line number Diff line change
@@ -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")
Loading