From cc0ee60c409a191654ed3f4d6591c5f9ef698ef0 Mon Sep 17 00:00:00 2001 From: C0KERNEL Date: Thu, 13 Aug 2026 13:30:24 +0100 Subject: [PATCH] fix(export,cli): stamp graph provenance from the analysed repo, not the shell cwd (#2534 family) --- graphify/cli.py | 12 +++++++++++- graphify/export.py | 21 +++++++++++++++++---- tests/test_export.py | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/graphify/cli.py b/graphify/cli.py index 441e4ca..caec641 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -3924,7 +3924,17 @@ def dispatch_command(cmd: str) -> None: # passing --allow-partial (the good graph is preserved and the manifest # is not stamped, so the retry re-extracts). _force_write = cli_allow_partial or not _extraction_incomplete - _wrote = _to_json(G, communities, str(graph_json_path), force=_force_write) + # Stamp provenance from the ANALYSED repo, not the shell's cwd: without + # this, to_json's fallback asks `git rev-parse HEAD` in whatever repo the + # command was invoked from, so `graphify extract ` run from + # another repo's root stamped the invoker's commit into the target's + # graph.json — and cluster then propagates that stamp into + # GRAPH_REPORT.md (#2534 keeps the extract-time stamp by design). Same + # cwd-anchoring mistake #2316 fixed for watch/update, surviving in the + # extract path. + from graphify.watch import _git_head as _gh_target + _wrote = _to_json(G, communities, str(graph_json_path), force=_force_write, + built_at_commit=_gh_target(cwd=Path(target).resolve())) if not _wrote: # The shrink guard refused: this partial build is smaller than the # existing graph. Exit before writing the manifest/marker below, which diff --git a/graphify/export.py b/graphify/export.py index 0a1a0bb..03159ad 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -170,11 +170,21 @@ def attach_hyperedges(G: nx.Graph, hyperedges: list) -> None: G.graph["hyperedges"] = existing -def _git_head() -> str | None: - """Return the current git HEAD commit hash, or None if not in a git repo.""" +def _git_head(cwd: "str | Path | None" = None) -> str | None: + """Return git HEAD for the repo containing ``cwd``, or None outside a repo. + + ``cwd`` selects the repository to ask, exactly as in watch._git_head + (#2316). Without it the command inherits the caller's working directory, + which stamps the *invoking* repo's commit when the graph being written + describes a different repo — provenance must come from the repo the graph + describes, so callers pass the graph's own location. + """ import subprocess as _sp try: - r = _sp.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True, timeout=3) + r = _sp.run( + ["git", "rev-parse", "HEAD"], capture_output=True, text=True, timeout=3, + cwd=str(cwd) if cwd is not None else None, + ) return r.stdout.strip() if r.returncode == 0 else None except Exception: return None @@ -349,7 +359,10 @@ def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, if isinstance(data.get("graph"), dict) and "hyperedges" in data["graph"]: data["graph"]["hyperedges"] = hyperedges data["hyperedges"] = hyperedges - commit = built_at_commit if built_at_commit is not None else _git_head() + # Fallback provenance comes from the repo the graph is being written INTO + # (output_path lives in /graphify-out/), never the shell's cwd — + # the same cwd-anchoring mistake #2316 fixed for `update`. + commit = built_at_commit if built_at_commit is not None else _git_head(Path(output_path).resolve().parent) if commit: data["built_at_commit"] = commit from graphify.paths import write_json_atomic diff --git a/tests/test_export.py b/tests/test_export.py index 657856c..2cbd4d8 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -72,6 +72,43 @@ def test_to_json_sorts_graph_collections_across_insertion_order(tmp_path): assert outputs[0].read_bytes() == outputs[1].read_bytes() + +def test_to_json_commit_fallback_uses_output_repo_not_cwd(tmp_path, monkeypatch): + # Without an explicit built_at_commit, provenance must come from the repo + # the graph is written into, not from whatever repo the shell happens to + # be in — running `graphify extract ` from another repo's root + # used to stamp the invoker's HEAD into the target's graph.json. + import subprocess + import networkx as nx + + def git(cwd, *args): + subprocess.run( + ["git", "-c", "user.email=t@t", "-c", "user.name=t", *args], + cwd=cwd, check=True, capture_output=True, + ) + + target = tmp_path / "target" + (target / "graphify-out").mkdir(parents=True) + git(target, "init") + git(target, "commit", "--allow-empty", "-m", "target") + target_head = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=target, check=True, + capture_output=True, text=True, + ).stdout.strip() + + invoker = tmp_path / "invoker" + invoker.mkdir() + git(invoker, "init") + git(invoker, "commit", "--allow-empty", "-m", "invoker") + monkeypatch.chdir(invoker) + + G = nx.Graph() + G.add_node("n1", label="n1") + out = target / "graphify-out" / "graph.json" + assert to_json(G, {0: ["n1"]}, str(out), force=True) + assert json.loads(out.read_text())["built_at_commit"] == target_head + + def test_to_cypher_creates_file(): G = make_graph() with tempfile.TemporaryDirectory() as tmp: