fix(export): stop rewriting unchanged wiki/Obsidian pages on every run (#3060)

`to_wiki()` and `to_obsidian()` regenerate the full page set on every call and
write every page unconditionally — `to_wiki` even unlinks every `*.md` up front,
guaranteeing a full rewrite. An export re-runs on any graph.json change, so on
an always-on `watch`/export setup this rewrites tens of thousands of identical
pages per run (measured: 31,417 pages / 137 MB per export, ~7 GB/day), and each
rewrite is also a modification event that re-fires Obsidian re-indexing, sync
clients, and downstream inotify pipelines for content that did not change.

Compare before writing:

- New `paths.write_text_atomic_if_changed(path, text) -> bool` writes (and
  reports True) only when the content differs from what is on disk, comparing
  DECODED text — text-mode writes translate `\n`↔`\r\n` on Windows, so a byte
  compare would report every file changed.
- `export._owned_write` uses it and still records the note in `_written`
  (the ownership + prune-exclusion set, #1896), so an unchanged note stays owned
  and is neither pruned nor dropped from the manifest.
- `to_wiki` drops the up-front unlink-all — which forced the rewrite and opened
  a window where a reader saw an empty `wiki/` mid-export — for an end-of-run
  sweep that removes only the `*.md` this run did not produce, keeping the same
  orphan guarantee for relabelled/removed communities.

Tests: the helper skips an identical write (mtime untouched) and writes on a
diff or a missing file; an Obsidian re-run leaves an unchanged note's mtime
untouched yet still owned (not pruned) and still rewrites a note whose body
changed; a wiki re-run leaves unchanged articles untouched and still sweeps a
relabelled community's stale page. The wiki/export/obsidian suites stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJbLfztSxm2tH5cJwBbe9q
This commit is contained in:
abhay-codes07andClaude Opus 4.8 committed 2026-09-23 00:04:59 +05:30
1 parent 20a20d30d8
commit 91546de3b4
4 files changed
+166 -15

No files matched your search

+10 -2
View File
@@ -21,6 +21,7 @@ from graphify.paths import (
stem_filename_budget,
write_json_atomic,
write_text_atomic,
write_text_atomic_if_changed,
)
from graphify.exporters.graphdb import push_to_falkordb, push_to_neo4j # noqa: E402,F401
@@ -725,13 +726,20 @@ def to_obsidian(
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."""
graphify didn't create. Returns True if the note is owned/current (whether
or not it was physically written).
The disk write is skipped when the content is byte-identical to what is
already there (#3060) — an export re-runs on every graph.json change, and
rewriting an unchanged note churns disk and fires inotify / re-index /
sync for nothing. The note is still recorded in ``_written`` so it stays
owned and is neither pruned as stale nor dropped from the manifest."""
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)
write_text_atomic(target, content)
write_text_atomic_if_changed(target, content)
_written.append(rel_name)
return True
+23
View File
@@ -157,6 +157,29 @@ def write_text_atomic(path: "str | Path", text: str) -> None:
_atomic_replace(path, lambda f: f.write(text))
def write_text_atomic_if_changed(path: "str | Path", text: str) -> bool:
"""Atomically write ``text`` only if it differs from what is already on disk;
return ``True`` if a write happened, ``False`` if the file was left untouched.
Exporters regenerate their whole page set on every ``graph.json`` change and
used to rewrite every file unconditionally — tens of thousands of identical
pages per run, which also fires inotify / re-index / sync on unchanged
content (#3060). Skipping the atomic replace when nothing changed avoids the
rename entirely, so mtime and inode are preserved.
The comparison is on DECODED text, never bytes: :func:`write_text_atomic`
writes in text mode and (on Windows) translates ``\\n`` to ``\\r\\n`` while
:func:`Path.read_text` translates it back, so a byte compare would report
every file as changed. A missing or non-UTF-8 destination counts as changed."""
try:
if Path(os.path.realpath(str(path))).read_text(encoding="utf-8") == text:
return False
except (OSError, UnicodeDecodeError):
pass
write_text_atomic(path, text)
return True
def write_json_atomic(path: "str | Path", obj, *, indent: "int | None" = None, ensure_ascii: bool = True) -> None:
"""Atomically write ``obj`` as JSON to ``path``, streaming the encode into the
temp file rather than materializing the whole string first (matters for very
+25 -13
View File
@@ -7,7 +7,7 @@ import re
import networkx as nx
from graphify.build import edge_data
from graphify.paths import stem_filename_budget
from graphify.paths import stem_filename_budget, write_text_atomic_if_changed
# Room _unique_slug needs for the collision suffix ("_2" … "_9999") it appends
# after _safe_filename has already capped the slug. The suffix is technically
@@ -319,14 +319,15 @@ def to_wiki(
"Re-run `graphify extract .` to regenerate .graphify_analysis.json."
)
# Clear stale .md files from previous runs to prevent orphan accumulation.
# Community labels are LLM-generated (per skill.md Step 5) and non-deterministic
# across runs — the same conceptual community may be named differently each time
# (e.g. "AutoAgent Skills" → "AutoAgent Methodology"), leaving the previous file
# as an orphan. Since to_wiki() owns wiki/ entirely (always writes the full set),
# it can safely clear .md files at the start of each call.
for old_article in out.glob("*.md"):
old_article.unlink()
# Orphans (from LLM-generated community labels that drift across runs, e.g.
# "AutoAgent Skills" → "AutoAgent Methodology") are swept at the END of the
# run, once the produced set is known — not by an up-front unlink of every
# *.md. Unlinking first forced a full rewrite of the identical page set on
# every export (an export re-runs on any graph.json change: tens of thousands
# of unchanged pages, disk + inotify + re-index churn, #3060) and opened a
# window where a reader saw an empty wiki/ mid-export. to_wiki() still owns
# wiki/ entirely, so anything it did not produce this run is still removed.
produced: set[str] = set()
labels = community_labels or {cid: f"Community {cid}" for cid in communities}
cohesion = cohesion or {}
@@ -388,18 +389,29 @@ def to_wiki(
for cid, nodes in communities.items():
label = labels.get(cid, f"Community {cid}")
article = _community_article(G, cid, nodes, label, labels, cohesion.get(cid), node_community, resolver)
(out / f"{community_slugs[cid]}.md").write_text(article, encoding="utf-8")
fname = f"{community_slugs[cid]}.md"
write_text_atomic_if_changed(out / fname, article)
produced.add(fname)
count += 1
for nid, slug in god_articles:
article = _god_node_article(G, nid, labels, node_community, resolver)
(out / f"{slug}.md").write_text(article, encoding="utf-8")
fname = f"{slug}.md"
write_text_atomic_if_changed(out / fname, article)
produced.add(fname)
count += 1
# Index
(out / "index.md").write_text(
write_text_atomic_if_changed(
out / "index.md",
_index_md(communities, labels, god_nodes_data, G.number_of_nodes(), G.number_of_edges(), resolver),
encoding="utf-8",
)
produced.add("index.md")
# Sweep orphans: remove only the *.md this run did not produce, so a relabelled
# community's stale page is still cleaned up without rewriting the survivors.
for old_article in out.glob("*.md"):
if old_article.name not in produced:
old_article.unlink()
return count
+108
View File
@@ -0,0 +1,108 @@
"""Exporters must not rewrite a page whose content did not change (#3060).
An export re-runs on every graph.json change, and the wiki/Obsidian writers used
to regenerate and rewrite the entire page set unconditionally — tens of thousands
of identical files per run, each rewrite also firing inotify / re-index / sync.
"""
from __future__ import annotations
import os
from pathlib import Path
import networkx as nx
from graphify.paths import write_text_atomic_if_changed
from graphify.export import to_obsidian
from graphify.wiki import to_wiki
def _mtime(p: Path) -> int:
return os.stat(p).st_mtime_ns
def test_write_text_atomic_if_changed_skips_identical(tmp_path):
target = tmp_path / "note.md"
# Missing file -> writes, reports True.
assert write_text_atomic_if_changed(target, "hello\nworld\n") is True
assert target.read_text(encoding="utf-8") == "hello\nworld\n"
before = _mtime(target)
# Identical content -> no write, reports False, mtime untouched.
assert write_text_atomic_if_changed(target, "hello\nworld\n") is False
assert _mtime(target) == before
# Different content -> writes, reports True.
assert write_text_atomic_if_changed(target, "hello\nworld!\n") is True
assert target.read_text(encoding="utf-8") == "hello\nworld!\n"
def _two_node_graph():
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_rerun_does_not_rewrite_unchanged_notes(tmp_path):
"""A second identical export leaves each note's bytes and mtime untouched,
yet the note stays owned (not pruned)."""
G, communities = _two_node_graph()
out = tmp_path / "obsidian"
to_obsidian(G, communities, str(out), community_labels={0: "Backend"})
note = out / "Database.md"
assert note.exists()
before = _mtime(note)
to_obsidian(G, communities, str(out), community_labels={0: "Backend"})
assert note.exists(), "an unchanged, owned note must not be pruned"
assert _mtime(note) == before, "an unchanged note must not be rewritten (#3060)"
def test_to_obsidian_rerun_still_rewrites_changed_notes(tmp_path):
"""Skipping identical writes must not skip real changes: giving Database a new
neighbor changes Database.md's body (same filename), so it is rewritten."""
G, communities = _two_node_graph()
out = tmp_path / "obsidian"
to_obsidian(G, communities, str(out), community_labels={0: "Backend"})
note = out / "Database.md"
before_text = note.read_text(encoding="utf-8")
G.add_node("n3", label="Cache", community=0, source_file="app/cache.py", type="code")
G.add_edge("n1", "n3")
to_obsidian(G, {0: ["n1", "n2", "n3"]}, str(out), community_labels={0: "Backend"})
assert note.read_text(encoding="utf-8") != before_text, "a changed note must be rewritten"
def test_to_wiki_rerun_does_not_rewrite_unchanged_articles(tmp_path):
G, communities = _two_node_graph()
out = tmp_path / "wiki"
to_wiki(G, communities, str(out), community_labels={0: "Backend"})
index = out / "index.md"
community = out / "Backend.md"
assert index.exists() and community.exists()
i_before, c_before = _mtime(index), _mtime(community)
to_wiki(G, communities, str(out), community_labels={0: "Backend"})
assert index.exists() and community.exists()
assert _mtime(index) == i_before, "unchanged index must not be rewritten (#3060)"
assert _mtime(community) == c_before, "unchanged article must not be rewritten (#3060)"
def test_to_wiki_rerun_sweeps_orphaned_relabelled_article(tmp_path):
"""The end-of-run orphan sweep still removes a page the run did not produce
(a community relabelled to a new slug)."""
G, communities = _two_node_graph()
out = tmp_path / "wiki"
to_wiki(G, communities, str(out), community_labels={0: "Backend"})
assert (out / "Backend.md").exists()
to_wiki(G, communities, str(out), community_labels={0: "Infrastructure"})
assert (out / "Infrastructure.md").exists()
assert not (out / "Backend.md").exists(), "the relabelled community's stale page must be swept"