fix incremental no-cluster graph updates (#1350)

Harden incremental no-cluster updates: fixes empty-write graph wipeout on no-op update --no-cluster (#1347) and git-hook subdir path resolution (#1348). Complementary to #1317. Validated: full suite 2107 passed, no-op re-run no longer wipes graph. Thanks @pkudinov.
This commit is contained in:
Pavel Kudinov
2026-06-17 10:46:08 +01:00
committed by GitHub
parent 9a7dbfbb84
commit a16b3e1b45
4 changed files with 147 additions and 9 deletions
+20
View File
@@ -4431,6 +4431,26 @@ def main() -> None:
# anchors emitted per importing file, #1327).
from graphify.build import dedupe_edges as _dedupe_edges, dedupe_nodes as _dedupe_nodes
from graphify.export import backup_if_protected as _backup
if (
incremental_mode
and not code_files
and not semantic_files
and not deleted_files
and not pg_result.get("nodes")
and not pg_result.get("edges")
and not cargo_result.get("nodes")
and not cargo_result.get("edges")
):
print(
"[graphify extract] no incremental changes detected "
"(--no-cluster); outputs left untouched."
)
try:
_save_manifest(_manifest_files, manifest_path=str(manifest_path), kind="both", root=target)
except Exception as exc:
print(f"[graphify extract] warning: could not write manifest: {exc}", file=sys.stderr)
sys.exit(0)
merged["nodes"] = _dedupe_nodes(merged["nodes"])
merged["edges"] = _dedupe_edges(merged["edges"])
_backup(graphify_out)
+69 -9
View File
@@ -205,6 +205,36 @@ def _report_root_label(watch_path: Path) -> str:
return Path.cwd().name if watch_path == Path(".") else str(watch_path)
def _is_relative_to(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def _changed_path_candidates(raw: Path, *, change_root: Path, watch_root: Path) -> list[Path]:
"""Return plausible absolute locations for a hook-provided changed path.
Git hooks pass paths relative to the repository root. Watch callers may
also pass paths relative to the watched root. Keep both interpretations so
a graph rooted at ``src`` accepts ``src/app.py`` and ``app.py``.
"""
if raw.is_absolute():
return [raw.resolve()]
candidates: list[Path] = []
seen: set[str] = set()
for base in (change_root, watch_root):
cand = (base / raw).resolve()
key = os.fspath(cand)
if key in seen:
continue
seen.add(key)
candidates.append(cand)
return candidates
def _relativize_source_files(payload: dict, root: Path) -> None:
for bucket in ("nodes", "edges", "hyperedges"):
for item in payload.get(bucket, []):
@@ -477,18 +507,47 @@ def _rebuild_code(
# extract only changed-and-still-existing files. Deleted paths are
# tracked separately so their stale nodes can be evicted below.
deleted_paths: set[str] = set()
def _add_deleted_source(path: Path) -> None:
for root in (project_root, watch_root):
deleted_paths.add(_nsf(str(path), str(root)) or str(path))
if changed_paths is not None:
code_set = {p.resolve() for p in code_files}
wanted: list[Path] = []
change_root = Path.cwd().resolve()
for raw in changed_paths:
cand = (watch_root / raw).resolve() if not raw.is_absolute() else raw.resolve()
if cand.exists() and cand in code_set:
wanted.append(cand)
else:
# File was deleted, renamed away, or filtered out by detect
# (e.g. .gitignore, vendored). Either way, evict any
# preserved nodes that still claim this source path.
deleted_paths.add(_nsf(str(cand), str(project_root)) or str(cand))
candidates = _changed_path_candidates(
raw,
change_root=change_root,
watch_root=watch_root,
)
tracked = next((cand for cand in candidates if cand.exists() and cand in code_set), None)
if tracked is not None:
if tracked not in wanted:
wanted.append(tracked)
continue
existing_in_root = next(
(
cand for cand in candidates
if cand.exists() and _is_relative_to(cand, watch_root)
),
None,
)
if existing_in_root is not None:
# The path exists under the watched root but detect filtered
# it out. Evict any stale nodes that still claim it.
_add_deleted_source(existing_in_root)
continue
deleted_in_root = next(
(cand for cand in candidates if _is_relative_to(cand, watch_root)),
None,
)
if deleted_in_root is not None:
# File was deleted or renamed away inside the watched root.
# Evict preserved nodes that still claim this source path.
_add_deleted_source(deleted_in_root)
if not wanted and not deleted_paths:
print("[graphify watch] No tracked code files in change set - skipping rebuild.")
return True
@@ -522,7 +581,8 @@ def _rebuild_code(
evict_sources: set[str] = set(deleted_paths)
if changed_paths is not None:
for p in extract_targets:
evict_sources.add(_nsf(str(p), str(project_root)) or str(p))
for root in (project_root, watch_root):
evict_sources.add(_nsf(str(p), str(root)) or str(p))
else:
# Full re-extraction: reconcile against current code files to
# evict nodes from files deleted since the last run (#1007).
+24
View File
@@ -72,3 +72,27 @@ def test_no_incremental_without_manifest(tmp_path):
# which pytest derives from the test name and contains "incremental".
assert "incremental update" not in r.stdout.lower()
assert "incremental scan" not in r.stdout.lower()
def test_extract_no_cluster_incremental_noop_preserves_existing_graph(tmp_path):
"""#1347: no-op incremental no-cluster extract must not overwrite graph.json."""
project = tmp_path / "project"
project.mkdir()
(project / "app.py").write_text(
"def alpha():\n return 1\n", encoding="utf-8"
)
first = _run(["extract", str(project), "--no-cluster"], tmp_path)
assert first.returncode == 0, first.stderr
graph_path = project / "graphify-out" / "graph.json"
before_text = graph_path.read_text(encoding="utf-8")
before = json.loads(before_text)
assert before.get("nodes"), "first run should produce a non-empty code graph"
second = _run(["extract", str(project), "--no-cluster"], tmp_path)
assert second.returncode == 0, second.stderr
after_text = graph_path.read_text(encoding="utf-8")
after = json.loads(after_text)
assert after.get("nodes"), "no-op incremental run must not empty the graph"
assert after_text == before_text
+34
View File
@@ -654,6 +654,40 @@ def test_rebuild_code_prunes_deleted_file_nodes(tmp_path):
os.chdir(cwd)
def test_rebuild_code_accepts_repo_relative_changed_path_for_subdir_root(tmp_path):
"""#1348: git-hook paths are repo-root-relative even when the graph root is a subdir."""
from graphify.watch import _rebuild_code
src = tmp_path / "src"
src.mkdir()
app = src / "app.py"
app.write_text("def old_name():\n return 1\n", encoding="utf-8")
cwd = os.getcwd()
try:
os.chdir(tmp_path)
assert _rebuild_code(Path("src"), no_cluster=True, acquire_lock=False) is True
graph_path = src / "graphify-out" / "graph.json"
before = json.loads(graph_path.read_text(encoding="utf-8"))
assert "old_name()" in {n.get("label") for n in before.get("nodes", [])}
app.write_text("def new_name():\n return 2\n", encoding="utf-8")
assert _rebuild_code(
Path("src"),
changed_paths=[Path("src/app.py")],
no_cluster=True,
acquire_lock=False,
force=True,
) is True
after = json.loads(graph_path.read_text(encoding="utf-8"))
labels = {n.get("label") for n in after.get("nodes", [])}
assert "old_name()" not in labels
assert "new_name()" in labels
finally:
os.chdir(cwd)
# --- #1059: pending-changes queue prevents commit drops under lock contention ---