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:
co-authored by
Claude Opus 4.8
parent
0e8d92cf5f
commit
8b177cb33d
@@ -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
@@ -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(
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user