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
13 changes: 6 additions & 7 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
13 changes: 13 additions & 0 deletions graphify/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<out_dir>/.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
Expand Down
16 changes: 10 additions & 6 deletions graphify/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
GRAPHIFY_OUT as _GRAPHIFY_OUT,
is_absolute_any_platform,
os_replace_with_fallback,
write_root_marker,
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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())
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
59 changes: 54 additions & 5 deletions tests/test_watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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()}"
)


Expand Down