diff --git a/graphify/__main__.py b/graphify/__main__.py index 93a36f266..5f5750b88 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -760,6 +760,9 @@ def _run_cli() -> None: print(" merge-driver git merge driver: union-merge two graph.json files (set up via hook install)") print(" merge-graphs merge two or more graph.json files into one cross-repo graph") print(" --out output path (default: graphify-out/merged-graph.json)") + print(" --previous restore the previous merged clustering's community ids") + print(" (by node id) instead of each input's own per-source ids,") + print(" so a later cluster-only run reuses labels correctly") print(" --branch checkout a specific branch (default: repo default)") print(" --out clone to a custom directory (default: ~/.graphify/repos//)") print(" add fetch a URL and save it to ./raw, then update the graph") diff --git a/graphify/cli.py b/graphify/cli.py index c0bc76547..5480078df 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -2663,20 +2663,44 @@ def dispatch_command(cmd: str) -> None: args = sys.argv[2:] graph_paths: list[Path] = [] out_path = Path(_GRAPHIFY_OUT) / "merged-graph.json" + previous_path: "Path | None" = None i = 0 while i < len(args): if args[i] == "--out" and i + 1 < len(args): out_path = Path(args[i + 1]) i += 2 + elif args[i] == "--previous" and i + 1 < len(args): + previous_path = Path(args[i + 1]) + i += 2 else: graph_paths.append(Path(args[i])) i += 1 if len(graph_paths) < 2: print( - "Usage: graphify merge-graphs [...] [--out merged.json]", + "Usage: graphify merge-graphs [...] " + "[--out merged.json] [--previous previous-merged-graph.json]", file=sys.stderr, ) sys.exit(1) + # Each input numbers its own communities from 0, so the merged output + # carries per-source ids (offset since #3014) that have no relation to + # the previous MERGED clustering .graphify_labels.json was written + # against. cluster-only's label-reuse remap reads this file's + # `community` field as "the previous clustering" and silently mismatches + # almost every community when it is actually per-source ids (#3858). + # --previous restores the real previous merged `community` onto each + # node that survives the merge (by id), so a subsequent cluster-only + # run remaps against the clustering its labels were actually built + # from. Per-source ids stay available in `local_community` regardless. + previous_community: dict[str, int] = {} + if previous_path is not None: + if not previous_path.exists(): + print(f"error: --previous file not found: {previous_path}", file=sys.stderr) + sys.exit(1) + _prev_raw = json.loads(previous_path.read_text(encoding="utf-8")) + for _n in _prev_raw.get("nodes", []): + if isinstance(_n, dict) and isinstance(_n.get("community"), int) and _n.get("id"): + previous_community[_n["id"]] = _n["community"] import networkx as _nx from networkx.readwrite import json_graph as _jg from graphify.build import prefix_graph_for_global as _prefix, distinct_repo_tags as _repo_tags @@ -2781,6 +2805,18 @@ def dispatch_command(cmd: str) -> None: call_links = _link_calls(merged) if call_links: print(f" resolved {call_links} member call(s) across repos") + if previous_path is not None: + _restored = 0 + for _nid, _data in merged.nodes(data=True): + if _nid in previous_community: + _data["community"] = previous_community[_nid] + _restored += 1 + else: + _data.pop("community", None) + print( + f" restored the previous merged community on {_restored} node(s) " + f"from {previous_path}" + ) # Drop whatever compose left behind (the last input's list, possibly # with internal duplicates) so attach_hyperedges dedups the full # collection by id from a clean slate.