from __future__ import annotations import json import hashlib import sys from datetime import datetime, timezone from pathlib import Path import networkx as nx from networkx.readwrite import json_graph as _jg _GLOBAL_DIR = Path.home() / ".graphify" _GLOBAL_GRAPH = _GLOBAL_DIR / "global-graph.json" _GLOBAL_MANIFEST = _GLOBAL_DIR / "global-manifest.json" def _load_manifest() -> dict: if _GLOBAL_MANIFEST.exists(): try: return json.loads(_GLOBAL_MANIFEST.read_text(encoding="utf-8")) except Exception as exc: # Don't silently wipe the user's manifest on a parse error: that # deletes every tracked repo. Back the bad file up and surface the # error so the user can recover or report it. backup = _GLOBAL_MANIFEST.with_suffix( _GLOBAL_MANIFEST.suffix + f".corrupt.{int(datetime.now(timezone.utc).timestamp())}" ) try: _GLOBAL_MANIFEST.rename(backup) print( f"[graphify global] manifest at {_GLOBAL_MANIFEST} failed to parse ({exc}); " f"moved to {backup} and starting fresh. Restore from the backup if this was " f"unexpected.", file=sys.stderr, ) except Exception as rename_exc: print( f"[graphify global] manifest at {_GLOBAL_MANIFEST} failed to parse ({exc}) " f"and could not be backed up ({rename_exc}). Starting fresh.", file=sys.stderr, ) return {"version": 1, "repos": {}} def _save_manifest(manifest: dict) -> None: _GLOBAL_DIR.mkdir(parents=True, exist_ok=True) _GLOBAL_MANIFEST.write_text(json.dumps(manifest, indent=2), encoding="utf-8") def _load_global_graph() -> nx.Graph: if _GLOBAL_GRAPH.exists(): from graphify.security import check_graph_file_size_cap check_graph_file_size_cap(_GLOBAL_GRAPH) data = json.loads(_GLOBAL_GRAPH.read_text(encoding="utf-8")) if "links" not in data and "edges" in data: data = dict(data, links=data["edges"]) try: return _jg.node_link_graph(data, edges="links") except TypeError: return _jg.node_link_graph(data) return nx.Graph() def _save_global_graph(G: nx.Graph) -> None: _GLOBAL_DIR.mkdir(parents=True, exist_ok=True) try: data = _jg.node_link_data(G, edges="links") except TypeError: data = _jg.node_link_data(G) _GLOBAL_GRAPH.write_text(json.dumps(data, indent=2), encoding="utf-8") def _file_hash(path: Path) -> str: h = hashlib.sha256() h.update(path.read_bytes()) return h.hexdigest()[:16] def global_add(source_path: Path, repo_tag: str) -> dict: """Add or update a project graph in the global graph. Returns a summary dict with keys: repo_tag, nodes_added, nodes_removed, skipped. Skipped=True means the source graph hasn't changed since last add. """ from graphify.build import prefix_graph_for_global, prune_repo_from_graph if not source_path.exists(): raise FileNotFoundError(f"graph not found: {source_path}") manifest = _load_manifest() src_hash = _file_hash(source_path) existing = manifest["repos"].get(repo_tag, {}) existing_path = existing.get("source_path", "") if existing_path and existing_path != str(source_path.resolve()): print( f"[graphify global] warning: repo tag '{repo_tag}' previously pointed to " f"{existing_path!r}, now updating to {str(source_path.resolve())!r}. " f"Use --as to give it a different name.", file=sys.stderr, ) if existing.get("source_hash") == src_hash: return {"repo_tag": repo_tag, "nodes_added": 0, "nodes_removed": 0, "skipped": True} # Load source graph from graphify.security import check_graph_file_size_cap check_graph_file_size_cap(source_path) data = json.loads(source_path.read_text(encoding="utf-8")) if "links" not in data and "edges" in data: data = dict(data, links=data["edges"]) try: src_G = _jg.node_link_graph(data, edges="links") except TypeError: src_G = _jg.node_link_graph(data) # Prefix IDs for cross-project isolation prefixed = prefix_graph_for_global(src_G, repo_tag) # Load global graph and prune stale nodes for this repo G = _load_global_graph() removed = prune_repo_from_graph(G, repo_tag) # Merge external-library nodes (no source_file) by label to avoid duplication external_labels = { d.get("label", ""): n for n, d in G.nodes(data=True) if not d.get("source_file") and d.get("label") } # Map each deduplicated external onto the existing global node so that # edges incident to it can be rewired instead of dropped. remap = {} for node, data in prefixed.nodes(data=True): if not data.get("source_file") and data.get("label") in external_labels: remap[node] = external_labels[data["label"]] # Compose: add prefixed nodes (except deduplicated externals) into global graph for node, data in prefixed.nodes(data=True): if node not in remap: G.add_node(node, **data) for u, v, data in prefixed.edges(data=True): u = remap.get(u, u) v = remap.get(v, v) if u != v: # don't introduce self-loops via remapping G.add_edge(u, v, **data) added = prefixed.number_of_nodes() - len(remap) _save_global_graph(G) manifest["repos"][repo_tag] = { "added_at": datetime.now(timezone.utc).isoformat(), "source_path": str(source_path.resolve()), "node_count": added, "edge_count": prefixed.number_of_edges(), "source_hash": src_hash, } _save_manifest(manifest) return {"repo_tag": repo_tag, "nodes_added": added, "nodes_removed": removed, "skipped": False} def global_remove(repo_tag: str) -> int: """Remove all nodes for repo_tag from the global graph. Returns count removed.""" from graphify.build import prune_repo_from_graph manifest = _load_manifest() if repo_tag not in manifest["repos"]: raise KeyError(f"repo '{repo_tag}' not in global graph") G = _load_global_graph() removed = prune_repo_from_graph(G, repo_tag) _save_global_graph(G) del manifest["repos"][repo_tag] _save_manifest(manifest) return removed def global_list() -> dict: """Return the manifest repos dict.""" return _load_manifest().get("repos", {}) def global_path() -> Path: return _GLOBAL_GRAPH