From 876c043c2fe70cb84462c114dff733a4970a56fe Mon Sep 17 00:00:00 2001 From: safishamsi Date: Wed, 15 Jul 2026 00:33:14 +0100 Subject: [PATCH] fix(export): prune orphaned Obsidian notes on re-export (#1896) Re-exporting into an existing vault left notes for nodes that dropped out of the graph, and rewriting the manifest to only this run's files disowned those orphans - so a returning node's stale note became permanently unwritable. Before rewriting the manifest, delete stale = owned - written - skipped. This only ever touches files graphify itself wrote (foreign files go to _skipped, never the manifest), and each path is containment-checked against the vault dir to defuse a corrupt/hostile manifest with ../ entries. The manifest rewrite then correctly drops the pruned files. Co-Authored-By: Claude Opus 4.8 (1M context) --- graphify/export.py | 22 +++++++++++++++++ tests/test_export.py | 58 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/graphify/export.py b/graphify/export.py index 0e95b467..2490249e 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -690,6 +690,28 @@ def to_obsidian( } _owned_write(".obsidian/graph.json", json.dumps(graph_config, indent=2)) + # #1896: prune notes for nodes that dropped out of the graph. Only files the + # manifest says graphify owns are candidates, and anything written or skipped + # this run is excluded — so a user's own note is never touched (foreign files + # land in _skipped, never _owned). Guard each path to stay inside the vault in + # case a corrupt/hostile manifest contains `../` entries. + stale = _owned - set(_written) - set(_skipped) + pruned = 0 + for rel_name in sorted(stale): + target = (out / rel_name).resolve() + if out.resolve() not in target.parents: + continue + try: + target.unlink(missing_ok=True) + pruned += 1 + except OSError: + pass + if pruned: + print( + f"[graphify] pruned {pruned} note(s) for nodes no longer in the graph", + file=sys.stderr, + ) + # Persist the manifest of files graphify owns, so a re-run can safely update its # own notes while still refusing to touch the user's. Warn (once, aggregated) # about anything skipped to avoid clobbering a pre-existing file. diff --git a/tests/test_export.py b/tests/test_export.py index 645659ac..05262b46 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -481,6 +481,64 @@ def test_to_obsidian_rerun_updates_own_notes_but_not_user_files(): assert (out / "UserNote.md").read_text().strip() == "mine" # user's untouched +def _four_node_two_community_graph(): + import networkx as nx + G = nx.Graph() + G.add_node("n1", label="Database", community=0, source_file="app/db.py", type="code") + G.add_node("n2", label="Server", community=0, source_file="app/srv.py", type="code") + G.add_node("n3", label="Cache", community=1, source_file="infra/cache.py", type="code") + G.add_node("n4", label="Queue", community=1, source_file="infra/queue.py", type="code") + G.add_edge("n1", "n2") + G.add_edge("n3", "n4") + return G, {0: ["n1", "n2"], 1: ["n3", "n4"]} + + +def test_to_obsidian_rerun_prunes_removed_nodes(): + """#1896: re-exporting into the same vault must delete graphify's own notes for + nodes (and communities) that dropped out of the graph, so the vault mirrors the + current graph rather than old-union-new. User files are never touched.""" + G4, comm4 = _four_node_two_community_graph() + G2, comm2 = _two_node_graph() + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "obsidian" + to_obsidian(G4, comm4, str(out), community_labels={0: "Backend", 1: "Infra"}) + assert (out / "Cache.md").exists() and (out / "_COMMUNITY_Infra.md").exists() + (out / "MyOwnNote.md").write_text("mine\n", encoding="utf-8") + to_obsidian(G2, comm2, str(out), community_labels={0: "Backend"}) + # notes for removed nodes and the stale community overview are pruned + assert not (out / "Cache.md").exists() + assert not (out / "Queue.md").exists() + assert not (out / "_COMMUNITY_Infra.md").exists() + # surviving graphify notes and the user's own note remain + assert (out / "Database.md").exists() and (out / "Server.md").exists() + assert (out / "_COMMUNITY_Backend.md").exists() + assert (out / "MyOwnNote.md").read_text().strip() == "mine" + + +def test_to_obsidian_removed_node_returning_is_writable_again(capsys): + """#1896 follow-on: a node that disappears and later returns must be writable + again. Before the fix, the manifest was rewritten to only this run's files, so + the orphaned note was disowned and the returning node's write was skipped as a + 'pre-existing user file' forever.""" + import networkx as nx + GA, commA = _two_node_graph() + GB = nx.Graph() + GB.add_node("n1", label="Database", community=0, source_file="app/db.py", type="code") + commB = {0: ["n1"]} + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "obsidian" + to_obsidian(GA, commA, str(out), community_labels={0: "Backend"}) + to_obsidian(GB, commB, str(out), community_labels={0: "Backend"}) + assert not (out / "Server.md").exists() # pruned while absent + capsys.readouterr() + to_obsidian(GA, commA, str(out), community_labels={0: "Backend"}) + # returned node's note exists with current content, written this run + assert (out / "Server.md").exists() + assert "# Server" in (out / "Server.md").read_text() + captured = capsys.readouterr() + assert "skipped" not in captured.err.lower() + + # ── Case-only-distinct labels must not collide on case-insensitive filesystems ── def _case_collision_graph():