Skip to content
Closed
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
62 changes: 49 additions & 13 deletions graphify/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
convert_google_workspace_file,
google_workspace_enabled,
)
from graphify.paths import GRAPHIFY_OUT, out_path
from graphify.paths import GRAPHIFY_OUT, GRAPHIFY_OUT_NAME, out_path


class FileType(str, Enum):
Expand Down Expand Up @@ -2397,6 +2397,27 @@ def _collapse_manifest_duplicates(
return result


def _manifest_storage_anchor(manifest_path: str | Path | None, root: Path | None) -> Path | None:
"""When a manifest lives under <repo>/graphify-out/manifest.json and root
is a subfolder of <repo>, stored relative keys are anchored to <repo> (#3785)."""
if root is None or not manifest_path:
return root
try:
mp = Path(manifest_path).resolve()
if mp.parent.name in ("graphify-out", GRAPHIFY_OUT_NAME):
manifest_base = mp.parent.parent
root_resolved = Path(root).resolve()
if root_resolved != manifest_base:
try:
root_resolved.relative_to(manifest_base)
return manifest_base
except ValueError:
pass
except (OSError, RuntimeError):
pass
return root


def load_manifest(
Comment thread
harshaygadekar marked this conversation as resolved.
manifest_path: str = _MANIFEST_PATH,
*,
Expand All @@ -2421,8 +2442,9 @@ def load_manifest(
return raw
if root is None:
return _collapse_manifest_duplicates(raw.items(), _nfc)
anchor = _manifest_storage_anchor(manifest_path, root) or root
return _collapse_manifest_duplicates(
raw.items(), lambda k: _nfc(_to_absolute_from_storage(k, root))
raw.items(), lambda k: _nfc(_to_absolute_from_storage(k, anchor))
)


Expand Down Expand Up @@ -2624,18 +2646,10 @@ def _normalise_entry(entry):
"semantic_hash": sem_h,
}
manifest[key] = entry
if root is not None:
# Persist in portable form: forward-slash relative paths. Keys outside
# ``root`` (out-of-tree symlinked corpora, --include sources) keep
# their absolute form so the manifest round-trips on the saving
# machine even when not every entry can be portably encoded.
# NFC after relativize so on-disk keys match what load_manifest
# re-anchors and compares against (#2221). A seeded absolute key and
# a seeded relative key for the same file collapse here too (#1964)
# -- _collapse_manifest_duplicates keeps whichever is more recently
# seen instead of whichever the dict comprehension iterates last.
storage_root = _manifest_storage_anchor(manifest_path, root) if root is not None else None
if storage_root is not None:
manifest = _collapse_manifest_duplicates(
manifest.items(), lambda k: _nfc(_to_relative_for_storage(k, root))
manifest.items(), lambda k: _nfc(_to_relative_for_storage(k, storage_root))
)
else:
manifest = _collapse_manifest_duplicates(manifest.items(), _nfc)
Expand Down Expand Up @@ -2813,10 +2827,32 @@ def detect_incremental(
# current scan was EXCLUDED (ignore rules / --exclude changed) and must
# not be reported as deleted. Mirrors the watch-side excluded-vs-deleted
# distinction (#1795).
try:
root_res: Path | None = Path(root).resolve() if root is not None else None
except (OSError, RuntimeError):
root_res = Path(root) if root is not None else None

def _in_root(path_str: str) -> bool:
if root_res is None:
return True
p = Path(path_str)
try:
p.relative_to(root_res)
return True
except ValueError:
pass
try:
p.resolve().relative_to(root_res)
return True
except (ValueError, OSError, RuntimeError):
return False

current_files = {_nfc(f) for flist in full["files"].values() for f in flist}
deleted_files: list[str] = []
excluded_files: list[str] = []
for f in manifest:
if not _in_root(f):
continue
if _nfc(f) in current_files:
continue
try:
Expand Down
61 changes: 61 additions & 0 deletions tests/test_detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -3652,6 +3652,67 @@ def test_detect_incremental_exclusion_stable_across_runs(tmp_path):
assert inc2["excluded_files"] == []


# ── #3785: Multi-directory manifest scoping and out-of-root deletion filter ──

def test_detect_incremental_subfolder_ignores_out_of_root_manifest_entries(tmp_path):
"""#3785: detect_incremental() on a subfolder must ignore manifest entries
belonging to other subdirectories or parent directories, neither reporting
them as deleted nor as excluded."""
workspace = tmp_path / "workspace"
sub_a = workspace / "workflows"
sub_b = workspace / "experts"
sub_a.mkdir(parents=True)
sub_b.mkdir(parents=True)

wf_file = sub_a / "workflow.py"
exp_file = sub_b / "expert.py"
wf_file.write_text("def run(): pass\n", encoding="utf-8")
exp_file.write_text("def consult(): pass\n", encoding="utf-8")

manifest_path = str(workspace / "graphify-out" / "manifest.json")

# Step 1: Workspace root or sibling subfolder is graphed
full = detect(workspace)
save_manifest(full["files"], manifest_path=manifest_path, root=workspace)

# Step 2: Incremental update on subfolder A only
inc_a = detect_incremental(sub_a, manifest_path=manifest_path)
assert inc_a["deleted_files"] == [], f"Unexpected false deletions: {inc_a['deleted_files']}"
assert inc_a["excluded_files"] == [], f"Unexpected false exclusions: {inc_a['excluded_files']}"


def test_detect_incremental_cross_subfolder_shared_manifest(tmp_path):
"""#3785: Sequential runs on distinct subfolders against a shared manifest
must preserve each other's entries so neither run clobbers the other or
reports false deletions."""
workspace = tmp_path / "workspace"
sub_a = workspace / "workflows"
sub_b = workspace / "experts"
sub_a.mkdir(parents=True)
sub_b.mkdir(parents=True)

(sub_a / "workflow.py").write_text("def run(): pass\n", encoding="utf-8")
(sub_b / "expert.py").write_text("def consult(): pass\n", encoding="utf-8")

manifest_path = str(workspace / "graphify-out" / "manifest.json")

# Run 1: Graph sub_b
full_b = detect(sub_b)
save_manifest(full_b["files"], manifest_path=manifest_path, root=sub_b)

# Run 2: Incremental update on sub_a
inc_a = detect_incremental(sub_a, manifest_path=manifest_path)
assert inc_a["deleted_files"] == []
assert inc_a["excluded_files"] == []
save_manifest(inc_a["files"], manifest_path=manifest_path, root=sub_a)

# Run 3: Incremental update on sub_b must still find its files unchanged
inc_b2 = detect_incremental(sub_b, manifest_path=manifest_path)
assert inc_b2["deleted_files"] == []
assert inc_b2["excluded_files"] == []
assert inc_b2["new_files"]["code"] == []


# ── #2838: manifest seen timestamps preserved for unchanged entries ──

def test_save_manifest_unchanged_file_preserves_seen(tmp_path):
Expand Down
Loading