fix(export): don't overwrite user notes or .obsidian config in an existing vault (#1506)

to_obsidian wrote one note per node straight into the target directory and
unconditionally replaced .obsidian/graph.json. Pointing --obsidian-dir at a real
vault could therefore clobber a user note whose name matched a graph node
(Database.md) and destroy the user's graph-view settings — silently, no backup,
irreversible.

graphify now records the files it owns in .graphify_obsidian_manifest.json and
refuses to overwrite any pre-existing file it didn't create: such a file is skipped
and reported in a single aggregated warning. A re-run still updates graphify's own
notes (they're in the manifest), and .obsidian/graph.json is only written when it
doesn't already exist or graphify owns it. The default graphify-out/obsidian output
and the flat note layout are unchanged.

Added regression tests: existing-vault preserves user note + .obsidian settings,
empty dir still gets the full vault, and a re-run updates own notes but not a
user-added file. The CHANGELOG also records the @oleksii-tumanov Java fixes
(#1512/#1510) and the @nuthalapativarun Windows GBK fix (#1505) committed just prior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-06-28 10:56:39 +01:00
co-authored by Claude Opus 4.8
parent 0e8d92cf5f
commit 8b177cb33d
3 changed files with 109 additions and 8 deletions
+4
View File
@@ -4,6 +4,10 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
- Fix: the Obsidian export (`--obsidian` / `to_obsidian`) no longer overwrites a user's own notes or `.obsidian/` config when pointed at an existing vault (#1506). It wrote one note per node straight into the target dir and unconditionally replaced `.obsidian/graph.json`, so `--obsidian-dir ~/my-vault` could clobber a same-named note (`Database.md`) and the user's graph-view settings — silently, no backup. graphify now records the files it owns in a `.graphify_obsidian_manifest.json` and refuses to overwrite any pre-existing file it didn't create (skipping it with one aggregated warning); a re-run still updates graphify's own notes. The default `graphify-out/obsidian` output is unchanged.
- Fix: Java enum and annotation (`@interface`) declarations are now emitted as type nodes (#1512, thanks @oleksii-tumanov), so a field typed as an enum or a class annotated with a project annotation resolves to a real node instead of a dangling reference.
- Fix: Java generic parent relationships are no longer dropped (#1510, thanks @oleksii-tumanov) — `class Foo extends Bar<T>` / `implements List<T>` now emit the `inherits`/`implements` edge to the base type, with the type arguments as `generic_arg` references.
- Fix: the `claude-cli` backend no longer crashes with `UnicodeDecodeError` on Windows systems where `claude.cmd` emits GBK/cp936 bytes (#1505, thanks @nuthalapativarun) — both subprocess calls decode with `errors="replace"`.
- Fix: `graphify explain` and `graphify affected` now resolve a query given as a source-file path even when the graph has multiple nodes from that file (#1503, thanks @behavio1). A path like `app/api/route.ts` tokenized to terms that matched no node, so explain returned "No node matching"; source-file paths are now indexed and matched exactly, and when several nodes share the file the lookup prefers the file-level node (the `L1` node whose name matches the file). Trailing-separator handling is aligned between the two commands.
- Docs: clearer install/PATH guidance for `uv tool install graphifyy` on macOS (#1471, thanks @Patsch36). Two expected uv behaviors read as bugs: (1) after `uv tool install`, the `graphify` command lands in uv's tool bin dir (`~/.local/bin`), which a fresh macOS/zsh shell often doesn't have on `PATH` — the README now points to `uv tool update-shell` instead of implying uv always wires `PATH`; (2) `uvx graphify …` / `uv tool run graphify …` resolve the first word as a *package* and fail, because the package is `graphifyy` and `graphify` is only its console script — the docs now show `uvx --from graphifyy graphify install`. README install note + Troubleshooting only; no code change.
- Fix: imported type stubs with the same label no longer falsely merge across files when there is no project definition to rewire onto (#1462, thanks @jiangyq9). Two files that both `from pathlib import Path` and use `Path` as a type previously collapsed into one node; the referencing file is now kept as an internal disambiguator (`origin_file`) used only when splitting colliding ids, while `source_file` stays empty so a real project definition can still be rewired onto (the #1402 path is unaffected).
+53 -8
View File
@@ -7,6 +7,7 @@ import math
import os
import re
import shutil
import sys
from collections import Counter
from datetime import date
from pathlib import Path
@@ -880,6 +881,30 @@ def to_obsidian(
out = Path(output_dir)
out.mkdir(parents=True, exist_ok=True)
# #1506: when the export target is an existing Obsidian vault (a user pointed
# --obsidian-dir at one), we must not clobber the user's own notes or their
# .obsidian/ config. Track the files graphify owns in a manifest; a pre-existing
# file NOT in the manifest is the user's and is never overwritten.
_manifest_path = out / ".graphify_obsidian_manifest.json"
try:
_owned: set[str] = set(json.loads(_manifest_path.read_text(encoding="utf-8")).get("files", []))
except (OSError, ValueError):
_owned = set()
_written: list[str] = []
_skipped: list[str] = []
def _owned_write(rel_name: str, content: str) -> bool:
"""Write a graphify-owned file, refusing to overwrite a pre-existing file
graphify didn't create. Returns True if written."""
target = out / rel_name
if target.exists() and rel_name not in _owned:
_skipped.append(rel_name)
return False
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8") # nosec
_written.append(rel_name)
return True
node_community = _node_community_map(communities)
# Map node_id → safe filename so wikilinks stay consistent.
@@ -917,6 +942,7 @@ def to_obsidian(
}
# Write one .md file per node
node_notes_written = 0
for node_id, data in G.nodes(data=True):
label = data.get("label", node_id)
cid = node_community.get(node_id)
@@ -970,7 +996,8 @@ def to_obsidian(
lines.append(inline_tags)
fname = node_filename[node_id] + ".md"
(out / fname).write_text("\n".join(lines), encoding="utf-8") # nosec
if _owned_write(fname, "\n".join(lines)):
node_notes_written += 1
# Write one _COMMUNITY_name.md overview note per community
# Build inter-community edge counts for "Connections to other communities"
@@ -1107,12 +1134,13 @@ def to_obsidian(
)
fname = community_filename[cid] + ".md"
(out / fname).write_text("\n".join(lines), encoding="utf-8") # nosec
community_notes_written += 1
if _owned_write(fname, "\n".join(lines)):
community_notes_written += 1
# Improvement 4: write .obsidian/graph.json to color nodes by community in graph view
obsidian_dir = out / ".obsidian"
obsidian_dir.mkdir(exist_ok=True)
# Improvement 4: write .obsidian/graph.json to color nodes by community in graph
# view — but never clobber an existing .obsidian/graph.json graphify doesn't own
# (the user's graph-view settings live there). _owned_write handles that and
# creates the .obsidian/ dir only when it actually writes.
graph_config = {
"colorGroups": [
{
@@ -1122,9 +1150,26 @@ def to_obsidian(
for cid, label in sorted((community_labels or {}).items())
]
}
(obsidian_dir / "graph.json").write_text(json.dumps(graph_config, indent=2), encoding="utf-8") # nosec
_owned_write(".obsidian/graph.json", json.dumps(graph_config, indent=2))
return G.number_of_nodes() + community_notes_written
# 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.
try:
_manifest_path.write_text(json.dumps({"files": sorted(set(_written))}, indent=2), encoding="utf-8")
except OSError:
pass
if _skipped:
shown = ", ".join(_skipped[:5]) + (f" (+{len(_skipped) - 5} more)" if len(_skipped) > 5 else "")
print(
f"[graphify] WARNING: skipped {len(_skipped)} pre-existing file(s) graphify "
f"did not create, to avoid overwriting your notes: {shown}. "
f"Export into an empty directory (or the default graphify-out/obsidian) "
f"to get the full vault.",
file=sys.stderr,
)
return node_notes_written + community_notes_written
def to_canvas(
+52
View File
@@ -280,6 +280,58 @@ def test_to_canvas_never_emits_punctuation_only_filenames():
assert not bad, f"punctuation-only canvas filenames: {bad}"
# ── Existing-vault safety: graphify must not clobber user notes / .obsidian (#1506) ──
def _two_node_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_edge("n1", "n2")
return G, {0: ["n1", "n2"]}
def test_to_obsidian_preserves_existing_user_notes_and_obsidian_config():
"""#1506: exporting into an existing vault must not overwrite a user's note that
collides with a graphify node name, nor their .obsidian/ graph settings."""
G, communities = _two_node_graph()
with tempfile.TemporaryDirectory() as tmp:
vault = Path(tmp)
(vault / "Database.md").write_text("# MY NOTES\nkeep me\n", encoding="utf-8")
(vault / ".obsidian").mkdir()
(vault / ".obsidian" / "graph.json").write_text('{"USER":"settings"}', encoding="utf-8")
to_obsidian(G, communities, str(vault), community_labels={0: "Backend"})
# user content untouched
assert "MY NOTES" in (vault / "Database.md").read_text()
assert json.loads((vault / ".obsidian" / "graph.json").read_text()) == {"USER": "settings"}
# non-colliding graphify note still written
assert (vault / "Server.md").exists()
def test_to_obsidian_empty_dir_writes_full_vault():
"""No regression: a fresh/empty dir still gets every note + .obsidian/graph.json."""
G, communities = _two_node_graph()
with tempfile.TemporaryDirectory() as tmp:
out = Path(tmp) / "obsidian"
n = to_obsidian(G, communities, str(out), community_labels={0: "Backend"})
assert (out / "Database.md").exists() and (out / "Server.md").exists()
assert (out / ".obsidian" / "graph.json").exists()
assert n == 3 # 2 nodes + 1 community note
def test_to_obsidian_rerun_updates_own_notes_but_not_user_files():
"""A re-run overwrites graphify's own prior notes (via the manifest) but leaves a
user-added note in the same dir alone."""
G, communities = _two_node_graph()
with tempfile.TemporaryDirectory() as tmp:
out = Path(tmp) / "obsidian"
to_obsidian(G, communities, str(out), community_labels={0: "Backend"})
(out / "UserNote.md").write_text("mine\n", encoding="utf-8")
to_obsidian(G, communities, str(out), community_labels={0: "Backend2"})
assert (out / "Database.md").exists() # graphify re-wrote its own
assert (out / "UserNote.md").read_text().strip() == "mine" # user's untouched
# ── Case-only-distinct labels must not collide on case-insensitive filesystems ──
def _case_collision_graph():