From 3174d418be11fa6fd5133e068e267adbb2716a0b Mon Sep 17 00:00:00 2001 From: HerenderKumar Date: Mon, 20 Jul 2026 22:37:56 +0530 Subject: [PATCH] fix(cli): resolve the graph from the primary checkout in linked worktrees (#2008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read commands (query/explain/path/affected) resolve their default graph to /graph.json relative to CWD. Run from a linked git worktree — increasingly the default for AI-agent task isolation — that path does not exist, so the command failed hard with "graph file not found" and callers had to wrap every invocation in a `git rev-parse --git-common-dir` shim. When the local graph is absent and CWD is inside a linked worktree (git-dir != git-common-dir, the same absolute compare the commit/checkout hooks already use), fall back to the primary checkout's graph. Read-only by construction: writers resolve through graphify.paths, an explicit --graph still wins, an absolute GRAPHIFY_OUT short-circuits, and an absent primary graph keeps the original "run extract" error. The git probe runs only on the cold path (no local graph), leaving the common case untouched. --- graphify/cli.py | 49 +++++++++++++++++- tests/test_worktree_graph_path.py | 86 +++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 tests/test_worktree_graph_path.py 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"