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) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-07-15 00:41:36 +01:00
co-authored by Claude Opus 4.8
parent 107cea044c
commit 876c043c2f
2 changed files with 80 additions and 0 deletions
+22
View File
@@ -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.
+58
View File
@@ -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():