From 8b177cb33da3a9a41b20d34a315fd7cd9f6fe197 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Sun, 28 Jun 2026 10:56:39 +0100 Subject: [PATCH] fix(export): don't overwrite user notes or .obsidian config in an existing vault (#1506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 4 +++ graphify/export.py | 61 ++++++++++++++++++++++++++++++++++++++------ tests/test_export.py | 52 +++++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba85e0b3e..e9df9317c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` / `implements List` 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). diff --git a/graphify/export.py b/graphify/export.py index 7e98f47fc..052dbcc6a 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -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( diff --git a/tests/test_export.py b/tests/test_export.py index 15c3c5bdf..6a1a439d1 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -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():