From feb9a90afbddfeb02b05a4faeb5e54cfaa97b715 Mon Sep 17 00:00:00 2001 From: rarebuffalo Date: Sat, 19 Sep 2026 19:43:12 +0530 Subject: [PATCH] fix: canonicalize graphify root marker --- graphify/cli.py | 13 +++++----- graphify/paths.py | 13 ++++++++++ graphify/watch.py | 16 +++++++----- tests/test_watch.py | 59 +++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 83 insertions(+), 18 deletions(-) diff --git a/graphify/cli.py b/graphify/cli.py index 5503a9185a..a120e1c5aa 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -11,7 +11,10 @@ import re import sys import time -from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT +from graphify.paths import ( + GRAPHIFY_OUT as _GRAPHIFY_OUT, + write_root_marker, +) from pathlib import Path, PurePosixPath, PureWindowsPath @@ -4352,9 +4355,7 @@ def _invalidate_file_manifest_for_db_graph() -> None: # relativize deleted-file paths correctly even for a custom --out # (its grandparent-of-graph.json fallback points at the wrong dir # otherwise, and deleted files never prune — #2012/#1571). - (graphify_out / ".graphify_root").write_text( - str(Path(target).resolve()), encoding="utf-8" - ) + write_root_marker(graphify_out, target) except OSError: pass stages.mark("write") @@ -4523,9 +4524,7 @@ def _invalidate_file_manifest_for_db_graph() -> None: try: # See the --no-cluster path above: persist the scan root so build_merge # can relativize deleted-file paths under a custom --out (#2012/#1571). - (graphify_out / ".graphify_root").write_text( - str(Path(target).resolve()), encoding="utf-8" - ) + write_root_marker(graphify_out, target) except OSError: pass stages.mark("export") diff --git a/graphify/paths.py b/graphify/paths.py index c4286583f1..688e95c9d7 100644 --- a/graphify/paths.py +++ b/graphify/paths.py @@ -164,6 +164,19 @@ def write_json_atomic(path: "str | Path", obj, *, indent: "int | None" = None, e UTF-8 (non-ASCII labels/paths) keep byte-for-byte output. See :func:`_atomic_replace`.""" _atomic_replace(path, lambda f: json.dump(obj, f, indent=indent, ensure_ascii=ensure_ascii)) + +def write_root_marker(out_dir: "str | Path", scan_path: "str | Path") -> Path: + """Atomically record the canonical scan-root path in ``/.graphify_root``. + + Resolves ``scan_path`` to its absolute canonical form so subsequent updates, + Git hooks, and build passes recover the true scan root regardless of the + caller's working directory (#2012, #3375). + """ + marker = Path(out_dir) / ".graphify_root" + resolved = Path(scan_path).resolve() + write_text_atomic(marker, str(resolved)) + return marker + # Directory segments that, when they appear as a whole path component, mark the # whole path as a test location. Matched against path *segments* (not raw # substrings) so "src/contest.py" / "latest/x.py" / "src/greatest/x.py" do NOT diff --git a/graphify/watch.py b/graphify/watch.py index f5fd10c2f1..4f9825df19 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -16,6 +16,7 @@ GRAPHIFY_OUT as _GRAPHIFY_OUT, is_absolute_any_platform, os_replace_with_fallback, + write_root_marker, ) logger = logging.getLogger(__name__) @@ -386,7 +387,12 @@ def __init__( if root_marker.exists(): try: saved_root = Path(root_marker.read_text(encoding="utf-8-sig").strip()) - if saved_root.is_absolute(): + resolved = saved_root.resolve() + invocation_root = Path.cwd().resolve() + if resolved == watch_root and _is_relative_to(resolved, invocation_root) and resolved != invocation_root: + self.existing_source_root = invocation_root + relative_marker_prefix = posixpath.normpath(resolved.relative_to(invocation_root).as_posix()) + elif saved_root.is_absolute(): # #2603: the marker holds the SCAN root, but stored # source_file values are relative to the BUILD's cwd # (the skill builds from the repo root scoped to a @@ -397,7 +403,6 @@ def __init__( # paths actually resolve under; when none does, keep the # marker (previous behavior) so a fully-deleted corpus # still evicts. - resolved = saved_root.resolve() if self._anchors_stored_sources(existing, resolved): self.existing_source_root = resolved else: @@ -414,7 +419,6 @@ def __init__( else: self.existing_source_root = resolved else: - invocation_root = Path.cwd().resolve() if (invocation_root / saved_root).resolve() == watch_root: self.existing_source_root = invocation_root relative_marker_prefix = posixpath.normpath(saved_root.as_posix()) @@ -1928,9 +1932,9 @@ def _failed(f: str) -> bool: graph_tmp.write_text(candidate_graph_text, encoding="utf-8") os_replace_with_fallback(graph_tmp, existing_graph) - # Write the user-supplied path only after the candidate graph is + # Record the canonical scan root only after the candidate graph is # accepted, so a refused shrink cannot mismatch graph and marker. - (out / ".graphify_root").write_text(str(watch_path), encoding="utf-8") + write_root_marker(out, watch_root) try: from graphify.detect import save_manifest @@ -2147,7 +2151,7 @@ def _failed(f: str) -> bool: sig_file.write_text( json.dumps({str(k): v for k, v in cur_sigs.items()}), encoding="utf-8") - (out / ".graphify_root").write_text(str(watch_path), encoding="utf-8") + write_root_marker(out, watch_root) try: from graphify.detect import save_manifest diff --git a/tests/test_watch.py b/tests/test_watch.py index a056538f44..5722358e1b 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -264,9 +264,9 @@ def test_rebuild_lock_does_not_accumulate_pids_across_runs(tmp_path): def test_graphify_root_preserves_relative_when_invoked_with_relative_path(tmp_path, monkeypatch): - """#777: ``.graphify_root`` stores the user-supplied path (``.``), not the - resolved absolute, so a committed ``graphify-out/.graphify_root`` is - portable across clones and CI runners.""" + """#777 / #3375: ``.graphify_root`` records the canonical scan-root path + even when invoked with a relative path (``.``), so subsequent recovery + from another CWD or Git hook reliably resolves the original scan root.""" from graphify.watch import _rebuild_code corpus = tmp_path / "corpus" @@ -277,8 +277,57 @@ def test_graphify_root_preserves_relative_when_invoked_with_relative_path(tmp_pa assert _rebuild_code(Path("."), acquire_lock=False) is True saved = (corpus / "graphify-out" / ".graphify_root").read_text(encoding="utf-8") - assert saved == ".", ( - f".graphify_root must preserve the user-supplied path; got {saved!r}" + assert saved == str(corpus.resolve()), ( + f".graphify_root must record canonical absolute scan root; got {saved!r}" + ) + + +def test_graphify_root_relative_subfolder_records_canonical_root(tmp_path, monkeypatch): + """#3375: invoking with a relative subfolder (``./src``) stores the canonical + absolute path to the subfolder rather than raw ``./src``.""" + from graphify.watch import _rebuild_code + + repo = tmp_path / "repo" + src = repo / "src" + src.mkdir(parents=True) + (src / "lib.py").write_text("def f(): pass\n", encoding="utf-8") + + monkeypatch.chdir(repo) + assert _rebuild_code(Path("./src"), acquire_lock=False) is True + + saved = (src / "graphify-out" / ".graphify_root").read_text(encoding="utf-8") + assert saved == str(src.resolve()), ( + f".graphify_root must store canonical subfolder root; got {saved!r}" + ) + assert saved != "./src" and saved != "src" + + +def test_graphify_root_recovery_from_different_cwd(tmp_path, monkeypatch): + """#3375: after building with a relative path (``.``), scan-root recovery + from a different CWD must resolve to the original corpus root, not the new CWD.""" + from graphify.watch import _rebuild_code + + corpus = tmp_path / "corpus" + corpus.mkdir() + (corpus / "lib.py").write_text("def f(): pass\n", encoding="utf-8") + subdir = corpus / "nested" / "deep" + subdir.mkdir(parents=True) + + monkeypatch.chdir(corpus) + assert _rebuild_code(Path("."), acquire_lock=False) is True + + marker = corpus / "graphify-out" / ".graphify_root" + assert marker.exists() + + # Change working directory to a completely different location + monkeypatch.chdir(subdir) + + # Recover the scan root as cli.py does + recovered = Path(marker.read_text(encoding="utf-8-sig").strip()) + assert recovered.exists(), f"recovered marker path {recovered} must exist from CWD {subdir}" + assert recovered.resolve() == corpus.resolve(), ( + f"recovered scan root must resolve to original corpus {corpus.resolve()}, " + f"not {recovered.resolve()} from CWD {subdir.resolve()}" )