Skip to content

export wiki: a community relabelled only by case loses its article on case-insensitive filesystems (orphan sweep compares names case-sensitively) #4508

Open

Description

@nzabate

What happened?

On a case-insensitive filesystem (the macOS APFS default; also NTFS), to_wiki deletes a community's article when the community's label changes only in letter case between two exports, for example groups → Groups. index.md still links to Groups.md, which no longer exists.

The cause is the end-of-run orphan sweep in graphify/wiki.py (to_wiki, around line 451):

for old_article in out.glob("*.md"):
    if old_article.name not in produced:
        old_article.unlink()

produced contains Groups.md. On a case-preserving, case-insensitive filesystem, the existing directory entry keeps its old name, groups.md: the atomic write replaces the file's contents but not the name's case. "groups.md" not in {"Groups.md"} is true, so the sweep unlinks groups.md, which is the very file this run just wrote.

Expected: the article survives, ideally under the new case, and the link in index.md resolves.

_unique_slug already folds case when it checks for collisions, so the sweep looks like the one remaining case-sensitive comparison. Possible fixes:

  • compare folded names, e.g. old_article.name.casefold() not in {p.casefold() for p in produced};
  • or skip any path that os.path.samefiles a produced one.

To get the new case onto disk as well, rename case-only changes before writing.

The end-of-run sweep arrived with the #3060 fix. The earlier up-front unlink, as I understand it, didn't have this problem.

Steps to reproduce

import pathlib, tempfile
import networkx as nx
from graphify.wiki import to_wiki

G = nx.Graph()
G.add_node("a", label="parse()", source_file="src/a.py", file_type="code")
G.add_node("b", label="tokenize()", source_file="src/b.py", file_type="code")
G.add_edge("a", "b", relation="calls")
out = pathlib.Path(tempfile.mkdtemp()) / "wiki"

to_wiki(G, {0: ["a", "b"]}, out, community_labels={0: "groups"})
print(sorted(p.name for p in out.glob("*.md")))
to_wiki(G, {0: ["a", "b"]}, out, community_labels={0: "Groups"})   # case-only relabel
print(sorted(p.name for p in out.glob("*.md")))
print([l for l in (out / "index.md").read_text().splitlines() if "roups" in l])
print((out / "Groups.md").exists())
to_wiki(G, {0: ["a", "b"]}, out, community_labels={0: "Groups"})   # same labels again
print(sorted(p.name for p in out.glob("*.md")))

In practice it happens when graphify label or a manual relabel changes only the case of a community name, or when re-clustering gives a community a hub-derived name that differs only in case.

Output

['groups.md', 'index.md']
['index.md']
['- [Groups](Groups.md) — 2 nodes']
False
['Groups.md', 'index.md']

Only the export that changes the case loses the article. The next export with the same labels writes it again, so the wiki stays broken until another export runs.

Environment

  • Graphify version: 0.9.91 (graphifyy[sql,terraform])
  • OS: macOS 27.0.1, APFS (case-insensitive, case-preserving)
  • Python: 3.14.8, networkx 3.7
  • Installation method: uv tool install
  • No provider keys are involved; this is a pure export with no LLM calls. The snippet reproduces it in a fresh temp directory.

Related: #2650, the same class of case-only collision in to_obsidian.

Activity

  1. github-actions commented on Oct 17, 2026

    @github-actions

    Thanks for opening this issue, @nzabate. A maintainer will take a look soon.

    If you would like to discuss it in real time, come say hi on our Discord server. For longer-form questions and ideas there is also GitHub Discussions.

    To help us triage, please make sure the report includes what you expected, what actually happened, and the steps (and a small sample) to reproduce it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions