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:
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user