mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-22 21:45:58 +00:00
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:
co-authored by
Claude Opus 4.8
parent
107cea044c
commit
876c043c2f
@@ -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.
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user