diff --git a/graphify/cli.py b/graphify/cli.py index fb4d04ae4..833341587 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -82,7 +82,54 @@ def _default_graph_path() -> str: - return str(Path(_GRAPHIFY_OUT) / "graph.json") + """Default graph.json for the read commands (query/explain/path/affected). + + Normally ``/graph.json`` relative to CWD. When run from a linked + git worktree that has no graph of its own, fall back to the primary checkout's + graph — worktrees share one build and agent workflows increasingly run each + task in its own worktree (#2008). An explicit ``--graph`` still overrides this, + and writers (extract/build) resolve through graphify.paths, so a write is never + redirected. + """ + local = Path(_GRAPHIFY_OUT) / "graph.json" + if local.is_file(): + return str(local) + shared = _worktree_primary_graph() + if shared is not None: + return shared + return str(local) + + +def _worktree_primary_graph() -> str | None: + """The primary checkout's ``graph.json`` when CWD is inside a linked git + worktree, else ``None``. + + A linked worktree has ``--git-dir`` != the shared ``--git-common-dir`` (whose + parent is the primary checkout) — the same absolute-path compare the commit and + post-checkout hooks use to skip worktrees (#1809). Returns ``None`` when git is + unavailable, we are not in a linked worktree, ``GRAPHIFY_OUT`` is absolute (an + absolute override already points every worktree at one graph), or the primary + checkout has no graph yet. + """ + if os.path.isabs(_GRAPHIFY_OUT): + return None + import subprocess + try: + git_dir = subprocess.run(["git", "rev-parse", "--git-dir"], + capture_output=True, text=True, timeout=3) + common = subprocess.run(["git", "rev-parse", "--git-common-dir"], + capture_output=True, text=True, timeout=3) + except (OSError, subprocess.SubprocessError): + return None + if git_dir.returncode != 0 or common.returncode != 0: + return None + cwd = os.getcwd() + git_dir_abs = Path(cwd, git_dir.stdout.strip()).resolve() + common_abs = Path(cwd, common.stdout.strip()).resolve() + if git_dir_abs == common_abs: + return None # primary checkout (or a subdir of it), not a linked worktree + candidate = common_abs.parent / _GRAPHIFY_OUT / "graph.json" + return str(candidate) if candidate.is_file() else None def _stamped_manifest_files( diff --git a/tests/test_worktree_graph_path.py b/tests/test_worktree_graph_path.py new file mode 100644 index 000000000..2a489cd88 --- /dev/null +++ b/tests/test_worktree_graph_path.py @@ -0,0 +1,86 @@ +"""#2008: read commands resolve the graph from the primary checkout when they +are run from a linked git worktree that has no ``graphify-out/`` of its own. + +Agent workflows (Claude Code etc.) increasingly run every task in its own +worktree, where ``graphify explain``/``query``/``path``/``affected`` used to fail +hard with "graph file not found" because the graph lives in the primary checkout. +""" +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + + +def _git(*args, cwd): + subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True) + + +def _init_repo(tmp_path) -> Path: + primary = tmp_path / "primary" + primary.mkdir() + _git("init", "-q", ".", cwd=primary) + _git("config", "user.email", "t@t.co", cwd=primary) + _git("config", "user.name", "t", cwd=primary) + (primary / "f.txt").write_text("x", encoding="utf-8") + _git("add", "-A", cwd=primary) + _git("commit", "-qm", "init", cwd=primary) + return primary + + +def _write_graph(where: Path) -> Path: + graph = where / "graphify-out" / "graph.json" + graph.parent.mkdir(parents=True) + graph.write_text("{}", encoding="utf-8") + return graph + + +def test_default_graph_path_resolves_from_linked_worktree(tmp_path, monkeypatch): + """From a linked worktree with no local graphify-out, the read-command default + graph path falls back to the primary checkout's graph.""" + if shutil.which("git") is None: # pragma: no cover + pytest.skip("git not available") + primary = _init_repo(tmp_path) + graph = _write_graph(primary) + linked = tmp_path / "linked" + _git("worktree", "add", "-q", str(linked), "-b", "feature", cwd=primary) + + monkeypatch.chdir(linked) + from graphify.cli import _default_graph_path + assert Path(_default_graph_path()).resolve() == graph.resolve() + + +def test_default_graph_path_prefers_local_graph_in_worktree(tmp_path, monkeypatch): + """A worktree that built its own graph keeps using it (local wins).""" + if shutil.which("git") is None: # pragma: no cover + pytest.skip("git not available") + primary = _init_repo(tmp_path) + _write_graph(primary) + linked = tmp_path / "linked" + _git("worktree", "add", "-q", str(linked), "-b", "feature", cwd=primary) + local_graph = _write_graph(linked) + + monkeypatch.chdir(linked) + from graphify.cli import _default_graph_path + assert Path(_default_graph_path()).resolve() == local_graph.resolve() + + +def test_default_graph_path_primary_checkout_unaffected(tmp_path, monkeypatch): + """In the primary checkout the default stays the local graphify-out path, even + when the graph does not exist yet (normal 'run extract' error path preserved).""" + if shutil.which("git") is None: # pragma: no cover + pytest.skip("git not available") + primary = _init_repo(tmp_path) + + monkeypatch.chdir(primary) + from graphify.cli import _default_graph_path + assert Path(_default_graph_path()) == Path("graphify-out") / "graph.json" + + +def test_default_graph_path_outside_git_repo_unaffected(tmp_path, monkeypatch): + """Outside any git repo the resolver must not crash and returns the local default.""" + monkeypatch.chdir(tmp_path) + from graphify.cli import _default_graph_path + assert Path(_default_graph_path()) == Path("graphify-out") / "graph.json"