fix: handle read-only .git objects on Windows in shutil.rmtree calls#91
fix: handle read-only .git objects on Windows in shutil.rmtree calls#91ryanfk wants to merge 6 commits intomicrosoft:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes a Windows-specific issue where shutil.rmtree fails with [WinError 5] Access is denied when trying to delete .git directories. Git marks pack files and objects as read-only on Windows, which prevents standard deletion. The fix introduces a _remove_readonly error handler that clears read-only flags and handles file-in-use errors, then applies it to all five shutil.rmtree calls in github_downloader.py.
Changes:
- Added
_remove_readonlyerror handler function that clears read-only flags (WinError 5) and handles file-in-use errors (WinError 32) with gc.collect() and retry logic - Updated all 5
shutil.rmtreecalls to useonerror=_remove_readonlyinstead of bare calls orignore_errors=True - Added explicit
repo.close()call inresolve_git_referencemethod's finally block to release file handles before cleanup
| def _remove_readonly(func, path, excinfo): | ||
| """Error handler for shutil.rmtree on Windows. | ||
|
|
||
| Git marks pack files and objects as read-only. On Windows this causes | ||
| ``shutil.rmtree`` to raise ``[WinError 5] Access is denied``. Clearing | ||
| the read-only flag before retrying the removal resolves the issue. | ||
|
|
||
| Also handles ``[WinError 32]`` (file in use) by closing stale handles | ||
| from GitPython via ``gc.collect()`` before retrying. | ||
| """ | ||
| exc_value = excinfo[1] if excinfo else None | ||
| if exc_value and getattr(exc_value, 'winerror', None) == 32: | ||
| # WinError 32: file in use — nudge GitPython to release handles | ||
| import gc | ||
| gc.collect() | ||
| import time | ||
| time.sleep(0.1) | ||
| try: | ||
| func(path) | ||
| return | ||
| except Exception: | ||
| pass | ||
| os.chmod(path, stat.S_IWRITE) | ||
| func(path) |
There was a problem hiding this comment.
The new _remove_readonly error handler lacks test coverage. According to the project's testing principle ("When modifying existing code, add tests for the code paths you touch, on top of tests for the new functionality"), this new function should have tests that verify:
- It correctly handles WinError 5 (read-only files) by clearing the read-only flag
- It handles WinError 32 (file in use) with gc.collect() and retry logic
- It works correctly on non-Windows systems (no-op when error handler isn't triggered)
Consider adding unit tests in tests/test_github_downloader.py that mock the error conditions and verify the handler's behavior.
| # Remove .git directory to save space and prevent treating as a Git repository | ||
| git_dir = target_path / ".git" | ||
| if git_dir.exists(): | ||
| shutil.rmtree(git_dir, ignore_errors=True) | ||
| shutil.rmtree(git_dir, onerror=_remove_readonly) |
There was a problem hiding this comment.
The repo object created by _clone_with_fallback is not explicitly closed before attempting to delete the .git directory at line 1199. This can cause WinError 32 (file in use) on Windows, as GitPython may hold file handles open. The same issue exists in resolve_git_reference (which you've addressed with repo.close() at lines 445-448), but it's missing here. Consider adding repo.close() before the shutil.rmtree(git_dir, ...) call, wrapped in a try-except to handle cases where repo might not be defined.
danielmeppiel
left a comment
There was a problem hiding this comment.
@ryanfk thank you for this fix - could you please address the review comments from Copilot? once that's done we are good to merge!
Git marks pack files and loose objects as read-only. On Windows this causes shutil.rmtree to raise [WinError 5] Access is denied when APM tries to remove a previously-cloned apm_modules directory or strip the .git folder after cloning. Add a _remove_readonly error handler that clears the read-only flag before retrying the delete, and wire it into every shutil.rmtree call in github_downloader.py. Fixes microsoft#90 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
On Windows, GitPython may hold file handles on .git objects after cloning. This causes shutil.rmtree to fail with [WinError 32] when cleaning up temporary directories. Extend _remove_readonly to handle WinError 32 by triggering gc.collect() to release GitPython handles, and explicitly close Repo objects before temp directory cleanup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
977bb79 to
be827be
Compare
Summary
apm_modulesdirectory or strip the.gitfolder after cloning,shutil.rmtreeraises[WinError 5] Access is denied._remove_readonlyerror handler that clears the read-only flag before retrying the delete.shutil.rmtreecalls ingithub_downloader.py, replacing both bare calls andignore_errors=True(which silently left files behind on Windows).Reproduction
Fix
Test plan
apm installnow succeeds after a previously failed install leaves read-only.gitobjects inapm_modules/onerrorhandler only triggers when the default delete fails, which doesn't happen on systems without read-only git objectsFixes #90
🤖 Generated with Claude Code