Skip to content

fix: handle read-only .git objects on Windows in shutil.rmtree calls#91

Open
ryanfk wants to merge 6 commits intomicrosoft:mainfrom
ryanfk:fix/windows-rmtree-readonly-git-objects
Open

fix: handle read-only .git objects on Windows in shutil.rmtree calls#91
ryanfk wants to merge 6 commits intomicrosoft:mainfrom
ryanfk:fix/windows-rmtree-readonly-git-objects

Conversation

@ryanfk
Copy link

@ryanfk ryanfk commented Feb 19, 2026

Summary

  • On Windows, Git marks pack files and loose objects as read-only. When APM tries to remove a previously-cloned apm_modules directory or strip the .git folder after cloning, shutil.rmtree raises [WinError 5] Access is denied.
  • Adds a _remove_readonly error handler that clears the read-only flag before retrying the delete.
  • Wires the handler into all five shutil.rmtree calls in github_downloader.py, replacing both bare calls and ignore_errors=True (which silently left files behind on Windows).

Reproduction

apm install dev.azure.com/myorg/myproject/myrepo
# First install fails for any reason (e.g. wrong branch)
# Second install attempt hits:
# ❌ Failed to install myorg/myproject/myrepo: [WinError 5] Access is denied:
#    'C:\...\apm_modules\myorg\myproject\myrepo\.git\objects\02\...'

Fix

def _remove_readonly(func, path, _excinfo):
    """Clear read-only flag on Windows before retrying delete."""
    os.chmod(path, stat.S_IWRITE)
    func(path)

# Before:
shutil.rmtree(target_path)
# After:
shutil.rmtree(target_path, onerror=_remove_readonly)

Test plan

  • Verified on Windows 11 — apm install now succeeds after a previously failed install leaves read-only .git objects in apm_modules/
  • No-op on Linux/macOS — the onerror handler only triggers when the default delete fails, which doesn't happen on systems without read-only git objects

Fixes #90

🤖 Generated with Claude Code

@danielmeppiel danielmeppiel added this to the 0.7.4 milestone Feb 23, 2026
@danielmeppiel danielmeppiel added the bug Something isn't working label Feb 23, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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_readonly error 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.rmtree calls to use onerror=_remove_readonly instead of bare calls or ignore_errors=True
  • Added explicit repo.close() call in resolve_git_reference method's finally block to release file handles before cleanup

Comment on lines +14 to +37
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)
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

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:

  1. It correctly handles WinError 5 (read-only files) by clearing the read-only flag
  2. It handles WinError 32 (file in use) with gc.collect() and retry logic
  3. 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.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines 1196 to +1199
# 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)
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
@danielmeppiel danielmeppiel self-requested a review February 24, 2026 08:16
Copy link
Collaborator

@danielmeppiel danielmeppiel left a comment

Choose a reason for hiding this comment

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

@ryanfk thank you for this fix - could you please address the review comments from Copilot? once that's done we are good to merge!

Ryan Fiust-Klink and others added 2 commits February 24, 2026 11:41
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>
@danielmeppiel danielmeppiel force-pushed the fix/windows-rmtree-readonly-git-objects branch from 977bb79 to be827be Compare February 24, 2026 19:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] apm install fails with [WinError 5] Access is denied on Windows when processing .git objects

3 participants