Skip to content

Commit de7d362

Browse files
safishamsiclaude
andcommitted
fix(build): relativize source_file across a symlinked root (#1571 follow-up)
Rigorous smoke testing surfaced an edge case the canonical-tmp unit tests couldn't reach: when the scan root is under a symlink (macOS /var -> /private/var, a symlinked home or git worktree), the absolute prune path and the resolved root differ by prefix, so _norm_source_file's lexical relative_to fails and the prune/replace match silently misses — deleted files' ghost nodes survive. Latent in the pre-existing #1007 path too, now that build_merge resolves the root. Fix: when lexical relative_to fails, retry with both sides fully resolved. Only the failure path resolves, so the common lexical match stays filesystem-free (no per-node stat on the hot replace-per-source loop). Adds a symlinked-root prune regression test (POSIX-only). Full suite 2768, and the full end-to-end smoke battery (indirect_call all contexts, JS, Ruby/Groovy inherits, hyperedge preservation, symlinked-root ghost prune, corrupt-json errors, dedup collision warning) is green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6eb7c01 commit de7d362

2 files changed

Lines changed: 35 additions & 1 deletion

File tree

graphify/build.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,16 @@ def _norm_source_file(p: str | None, root: str | None = None) -> str | None:
117117
try:
118118
p = Path(p).relative_to(root).as_posix()
119119
except ValueError:
120-
pass
120+
# Lexical relative_to failed. Retry with both sides fully resolved:
121+
# a symlinked scan root (macOS /var -> /private/var, or a symlinked
122+
# home/worktree) makes the raw prefixes differ even though they point
123+
# at the same dir, which otherwise silently defeats prune/replace
124+
# matching. Only the slow path resolves, so the common lexical match
125+
# stays filesystem-free.
126+
try:
127+
p = Path(p).resolve().relative_to(Path(root).resolve()).as_posix()
128+
except (ValueError, OSError):
129+
pass
121130
return p
122131

123132

tests/test_build_merge_hyperedges_and_prune.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@
1313
from __future__ import annotations
1414

1515
import json
16+
import os
1617
from pathlib import Path
1718

19+
import pytest
20+
1821
from graphify.build import build_merge, _infer_merge_root
1922

2023

@@ -124,3 +127,25 @@ def test_prune_without_root_uses_graphify_root_marker(tmp_path):
124127
assert _infer_merge_root(graph_path) == str(real_root.resolve())
125128
G = build_merge([], graph_path, prune_sources=[str(real_root / "HANDOFF.md")], dedup=False)
126129
assert "handoff" not in {d["label"] for _, d in G.nodes(data=True)}
130+
131+
132+
@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics")
133+
def test_prune_matches_across_symlinked_root(tmp_path):
134+
"""A symlinked scan root (macOS /var -> /private/var, symlinked home/worktree)
135+
makes the absolute prune path and the resolved root differ by prefix. The prune
136+
must still match — lexical relative_to fails, so normalization resolves both
137+
sides. Regression for the edge case a canonical-tmp unit test can't reach."""
138+
real = tmp_path / "real"
139+
(real / "graphify-out").mkdir(parents=True)
140+
link = tmp_path / "link"
141+
os.symlink(real, link)
142+
graph_path = real / "graphify-out" / "graph.json"
143+
_write_graph(graph_path, [
144+
{"id": "h1", "label": "handoff", "file_type": "document", "source_file": "HANDOFF.md"},
145+
{"id": "k1", "label": "keep", "file_type": "document", "source_file": "KEEP.md"},
146+
], [], [])
147+
# prune path addressed via the SYMLINK, root resolved to the real dir
148+
G = build_merge([], graph_path=graph_path,
149+
prune_sources=[str(link / "HANDOFF.md")], root=str(real), dedup=False)
150+
labels = {d["label"] for _, d in G.nodes(data=True)}
151+
assert "handoff" not in labels and "keep" in labels

0 commit comments

Comments
 (0)