Skip to content
Merged
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
20 changes: 20 additions & 0 deletions graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
78 changes: 69 additions & 9 deletions graphify/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, []):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
24 changes: 24 additions & 0 deletions tests/test_incremental.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 34 additions & 0 deletions tests/test_watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---


Expand Down