diff --git a/tools/scripts/sync_editorial_bundles.py b/tools/scripts/sync_editorial_bundles.py index 46bb7708..d1f04cce 100644 --- a/tools/scripts/sync_editorial_bundles.py +++ b/tools/scripts/sync_editorial_bundles.py @@ -2,9 +2,11 @@ from __future__ import annotations import argparse +import errno import json import re import shutil +import time from pathlib import Path from typing import Any @@ -568,11 +570,27 @@ def _render_codex_marketplace( } +def _remove_tree(path: Path, retries: int = 3, delay_seconds: float = 0.1) -> None: + last_error: OSError | None = None + for attempt in range(retries): + try: + shutil.rmtree(path) + return + except OSError as exc: + if exc.errno != errno.ENOTEMPTY or attempt == retries - 1: + raise + last_error = exc + time.sleep(delay_seconds * (attempt + 1)) + + if last_error is not None: + raise last_error + + def _materialize_plugin_skills(root: Path, destination_root: Path, skill_ids: list[str]) -> None: if destination_root.is_symlink() or destination_root.is_file(): destination_root.unlink() elif destination_root.exists(): - shutil.rmtree(destination_root) + _remove_tree(destination_root) destination_root.mkdir(parents=True, exist_ok=True) for skill_id in skill_ids: @@ -628,7 +646,7 @@ def _sync_bundle_plugin_directory( plugin_name = _bundle_plugin_name(bundle["id"]) plugin_root = root / "plugins" / plugin_name if plugin_root.exists(): - shutil.rmtree(plugin_root) + _remove_tree(plugin_root) bundle_skills_root = plugin_root / "skills" bundle_skills_root.mkdir(parents=True, exist_ok=True) diff --git a/tools/scripts/tests/test_editorial_bundles.py b/tools/scripts/tests/test_editorial_bundles.py index c57e1ba6..c30089a3 100644 --- a/tools/scripts/tests/test_editorial_bundles.py +++ b/tools/scripts/tests/test_editorial_bundles.py @@ -1,6 +1,8 @@ import importlib.util +import errno import pathlib import sys +from unittest import mock import unittest @@ -142,6 +144,22 @@ class EditorialBundlesTests(unittest.TestCase): f"Claude root plugin inclusion mismatch for {skill_id}", ) + def test_remove_tree_retries_on_enotempty(self): + target = REPO_ROOT / "plugins" / "antigravity-awesome-skills" / "skills" + calls = {"count": 0} + + def flaky_rmtree(path): + calls["count"] += 1 + if calls["count"] == 1: + raise OSError(errno.ENOTEMPTY, "Directory not empty") + + with mock.patch.object(editorial_bundles.shutil, "rmtree", side_effect=flaky_rmtree): + with mock.patch.object(editorial_bundles.time, "sleep") as sleep_mock: + editorial_bundles._remove_tree(target) + + self.assertEqual(calls["count"], 2) + sleep_mock.assert_called_once() + if __name__ == "__main__": unittest.main()