From 68d8f4e45db03dfb272094d95abb2120b38f0c2d Mon Sep 17 00:00:00 2001 From: Pavel Kudinov Date: Tue, 16 Jun 2026 19:15:58 -0700 Subject: [PATCH] fix incremental no-cluster graph updates --- graphify/__main__.py | 20 ++++++++++ graphify/watch.py | 78 ++++++++++++++++++++++++++++++++++----- tests/test_incremental.py | 24 ++++++++++++ tests/test_watch.py | 34 +++++++++++++++++ 4 files changed, 147 insertions(+), 9 deletions(-) diff --git a/graphify/__main__.py b/graphify/__main__.py index e122a5b6e..599ca4c05 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -4431,6 +4431,26 @@ def _progress(idx: int, total: int, _result: dict) -> 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) diff --git a/graphify/watch.py b/graphify/watch.py index 0a609ea08..0d08b1f38 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -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). diff --git a/tests/test_incremental.py b/tests/test_incremental.py index 69b226d83..8bb843b6c 100644 --- a/tests/test_incremental.py +++ b/tests/test_incremental.py @@ -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 diff --git a/tests/test_watch.py b/tests/test_watch.py index 8dcdf4999..d1fcfb151 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -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 ---