Skip to content

fix(windows): read-only directory removal, and a CLI crash without a home dir - #2410

Open
NodirbekGaniyev14 wants to merge 3 commits into
Graphify-Labs:v8from
NodirbekGaniyev14:fix-windows-readonly-and-home
Open

fix(windows): read-only directory removal, and a CLI crash without a home dir#2410
NodirbekGaniyev14 wants to merge 3 commits into
Graphify-Labs:v8from
NodirbekGaniyev14:fix-windows-readonly-and-home

Conversation

@NodirbekGaniyev14

Copy link
Copy Markdown

Two independent Windows bugs, found while running the suite on Windows 11 with the
checkout under a OneDrive-synced folder. Both are invisible to CI, which only runs
ubuntu-latest.

Windows test failures go from 114 to 43. Nothing else changes: 3914 passed
before and after on the same host, no test newly fails.

A third, unrelated commit rides along: uv.lock still pinned graphifyy 0.9.31
while pyproject.toml declares 0.9.32, so every uv sync / uv run rewrote
the lockfile as a side effect and left the tree dirty. Regenerated with
uv lock — a single line, no other package re-resolved. Happy to drop it if you
would rather handle the lockfile separately.


1. Read-only directories break every tree removal

shutil.rmtree deletes the files inside a directory fine, then fails on the final
rmdir:

PermissionError: [WinError 5] Access is denied:
  '...\.claude\skills\graphify\references'

Windows derives FILE_ATTRIBUTE_READONLY from a missing owner-write bit, and
RemoveDirectoryW refuses any directory carrying it. OneDrive marks the
directories it syncs read-only, and copytree's copystat hands that attribute
to every copy — so a references/ sidecar installed out of a OneDrive-synced
package can never be removed again. graphify install (reinstall path),
graphify uninstall, and ~90 install tests all died on this.

The fix

clear_readonly() and rmtree() in graphify/paths.py — already the shared home
for filesystem helpers (_atomic_replace, write_text_atomic), and it already
documents a sibling Windows PermissionError workaround. The hook signature is
compatible with both the 3.12+ onexc parameter and the older onerror one, so
it works across the declared requires-python = ">=3.10" range.

Every removal in the package now routes through it:

graphify/install.py — all 7 rmtree call sites. _install_skill_references
additionally strips the attribute from the staged copy after copytree, so a
fresh install is writable in the first place rather than merely removable
afterwards.

graphify/cache.py_cleanup_stale_ast_entries was silently skipping
read-only entries in two places: rmtree(child, ignore_errors=True) for stale
v*/ dirs, and child.unlink() inside a bare except OSError: pass for the
pre-versioning flat *.json entries. Neither surfaced anything, so AST cache
entries written by older versions accumulated forever.

tests/test_install_references.py — this one is worth calling out separately,
because the test suite was deleting committed files from the working tree on
every run
. The fake_bundle fixture moves the real graphify/skills/claude/
bundle aside, stages a fake in its slot, then restores on teardown:

if bundle_dir.exists():
    shutil.rmtree(bundle_dir, ignore_errors=True)
if backup_dir is not None:
    shutil.move(str(backup_dir), str(bundle_dir))

On a read-only directory the rmtree fails silently (ignore_errors=True), so
bundle_dir still exists — and shutil.move onto an existing directory nests
the source inside it rather than replacing it. Net result on my checkout, twice:

D graphify/skills/claude/references/add-watch.md
D graphify/skills/claude/references/exports.md
... (all 8 files)

The teardown is now a shared _restore_bundle() helper used by both copies of
this pattern. It uses the robust rmtree and asserts the slot is actually empty
before moving, so if the clear ever fails again the real bundle stays safe in its
temp dir and the test fails loudly instead of destroying the checkout.


2. graphify --version crashes without a home directory

RuntimeError: Could not determine home directory.
  File "graphify/install.py", line 121, in _platform_skill_destination
    return Path.home() / cfg["skill_dst"]

_run_cli resolves every platform's skill destination up front to check for a
stale version stamp. Resolving one needs a home directory, and in an environment
without HOME/USERPROFILEenv -i, a bare CI container, a service account —
Path.home() raises. That propagated out of a purely advisory check and took down
the whole CLI, including graphify --version, which touches no filesystem at all.

The stamp check now degrades to a no-op when the destinations cannot be resolved.
_check_skill_version itself was already written this defensively (every
filesystem call guarded); only the destination resolution upstream of it was not.

Covered by a regression test in tests/test_skill_version_warning.py, verified to
fail without the fix.


Testing

uv sync --all-extras
uv run pytest tests/ -q
before after
failed 114 43
passed 3841 3914

Host: Windows 11, Python 3.13.14, uv 0.11.19, checkout under OneDrive.

The remaining 43 failures are pre-existing and unrelated to this change — all
platform assumptions in the tests rather than product bugs. For the record, since
they are equally invisible to your CI:

cause count example
symlink creation needs Developer Mode / admin 15 OSError: [WinError 1314]
\ vs / path separator in assertions 9 assert 'sub\\b.py' == 'sub/b.py'
platform-specific install destinations 6 hermes uses %LOCALAPPDATA%, gemini uses ~/.agents on Windows — deliberate in install.py, but the tests assert the POSIX paths
MAX_PATH 260 limit 3 long Obsidian filenames
no POSIX file modes 1 assert (33206 & 511) == 420
console encoding 1 'mod_处理数据' != 'mod_处理数据'
other path/cache details 8

Happy to split this into separate PRs if you would rather review them
individually — they are already three independent commits:

chore: sync uv.lock with the pyproject version
fix(windows): survive read-only directories when removing trees
fix(cli): do not crash when the home directory cannot be resolved

NodirbekGaniyev14 and others added 3 commits August 3, 2026 06:40
The lockfile still pinned graphifyy 0.9.31 while pyproject.toml declares
0.9.32, so every `uv sync` / `uv run` rewrote uv.lock as a side effect and
left the working tree dirty. Regenerated with `uv lock`; the resolution is
otherwise unchanged (single line).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
shutil.rmtree fails with PermissionError (WinError 5) on the final rmdir when a
directory carries FILE_ATTRIBUTE_READONLY, which Windows derives from a missing
owner-write bit. OneDrive marks the directories it syncs read-only, and
copytree's copystat hands that attribute to every copy, so a references/ sidecar
installed from a OneDrive-synced package could never be removed again.

Add clear_readonly() and rmtree() to graphify.paths (already the shared home for
filesystem helpers) and route every removal through them:

- install.py: all 7 rmtree call sites. _install_skill_references also strips the
  attribute from the staged copy, so a fresh install is writable in the first
  place rather than merely removable afterwards.
- cache.py: _cleanup_stale_ast_entries silently skipped read-only entries
  (rmtree with ignore_errors=True, unlink inside `except OSError: pass`), so AST
  cache entries written by older versions accumulated forever.
- test_install_references.py: the fake_bundle teardown cleared the staged bundle
  with ignore_errors=True, then shutil.move'd the real one back. On a read-only
  dir the clear failed silently and move NESTED the backup instead of replacing
  it, deleting the committed graphify/skills/claude/references/ from the working
  tree on every run. It now uses the robust rmtree and asserts the slot is empty
  before moving, so the real bundle stays safe in its temp dir if it is not.

Windows test failures drop from 114 to 43.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_run_cli resolves every platform's skill destination up front to check for a
stale version stamp. Resolving one needs a home directory, and in an environment
without HOME/USERPROFILE (`env -i`, a bare CI container, a service account)
Path.home() raises RuntimeError. That propagated out of an advisory check and
killed even `graphify --version`, a command that touches no filesystem at all.

Skip the stamp check when the destinations cannot be resolved, and pin the
behaviour with a regression test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).

Graphify reviewed this change.

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).


Graphify review — findings

This PR replaces direct shutil.rmtree and file-deletion calls across cache.py, install.py, and a test helper with new shared helpers (rmtree and clear_readonly) added to graphify/paths.py, which strip the read-only attribute before deleting to handle Windows/OneDrive-synced directories. It also strips the read-only bit from staged skill-reference copies during install, and wraps the CLI's per-platform skill-version stamp check in a try/except so a missing home directory no longer aborts commands like --version. The test surface adds a _restore_bundle helper and related test updates covering reference installation and version-warning behavior in environments without a resolvable home directory.

No blocking issues surfaced. 6 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 1325 functions depend on the 295 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: _copy_skill_file() — 8 callers, 4 callees
  • worse: uninstall_all() — 2 callers, 13 callees

Verification — 1325 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 1064 function(s) in the blast radius were not formally verified this run

· 1 grounded finding(s) anchored inline below; 1 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/install.py
shutil.rmtree(refs_staged, ignore_errors=True)
_rmtree(refs_staged, ignore_errors=True)
raise
def _copy_skill_file(platform_name: str, *, project: bool = False, project_dir: Path | None = None) -> Path:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_copy_skill_file()

8 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant