xcode-cleaner 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xcode_cleaner-0.1.0/LICENSE +21 -0
- xcode_cleaner-0.1.0/PKG-INFO +88 -0
- xcode_cleaner-0.1.0/README.md +61 -0
- xcode_cleaner-0.1.0/pyproject.toml +40 -0
- xcode_cleaner-0.1.0/setup.cfg +4 -0
- xcode_cleaner-0.1.0/src/xcode_cleaner/__init__.py +1 -0
- xcode_cleaner-0.1.0/src/xcode_cleaner/__main__.py +3 -0
- xcode_cleaner-0.1.0/src/xcode_cleaner/cleaner.py +65 -0
- xcode_cleaner-0.1.0/src/xcode_cleaner/cli.py +65 -0
- xcode_cleaner-0.1.0/src/xcode_cleaner/constants.py +28 -0
- xcode_cleaner-0.1.0/src/xcode_cleaner/display.py +86 -0
- xcode_cleaner-0.1.0/src/xcode_cleaner/interactive.py +75 -0
- xcode_cleaner-0.1.0/src/xcode_cleaner/models.py +47 -0
- xcode_cleaner-0.1.0/src/xcode_cleaner/scanner.py +89 -0
- xcode_cleaner-0.1.0/src/xcode_cleaner/simulators.py +147 -0
- xcode_cleaner-0.1.0/src/xcode_cleaner/sizes.py +33 -0
- xcode_cleaner-0.1.0/src/xcode_cleaner.egg-info/PKG-INFO +88 -0
- xcode_cleaner-0.1.0/src/xcode_cleaner.egg-info/SOURCES.txt +20 -0
- xcode_cleaner-0.1.0/src/xcode_cleaner.egg-info/dependency_links.txt +1 -0
- xcode_cleaner-0.1.0/src/xcode_cleaner.egg-info/entry_points.txt +2 -0
- xcode_cleaner-0.1.0/src/xcode_cleaner.egg-info/requires.txt +3 -0
- xcode_cleaner-0.1.0/src/xcode_cleaner.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cdunkel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xcode-cleaner
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI tool to reclaim disk space from Xcode build artifacts, caches, and simulators
|
|
5
|
+
Author: cdunkel
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/cdunkel/xcode-cleaner
|
|
8
|
+
Project-URL: Repository, https://github.com/cdunkel/xcode-cleaner
|
|
9
|
+
Project-URL: Issues, https://github.com/cdunkel/xcode-cleaner/issues
|
|
10
|
+
Keywords: xcode,macos,disk-space,cleanup,cli,simulators,derived-data
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: System :: Filesystems
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: click>=8.0
|
|
24
|
+
Requires-Dist: rich>=13.0
|
|
25
|
+
Requires-Dist: InquirerPy>=0.3.4
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# xcode-cleaner
|
|
29
|
+
|
|
30
|
+
A CLI tool to reclaim disk space from Xcode build artifacts, caches, and simulators.
|
|
31
|
+
|
|
32
|
+
## What It Scans
|
|
33
|
+
|
|
34
|
+
| Category | Path |
|
|
35
|
+
|----------|------|
|
|
36
|
+
| Derived Data | `~/Library/Developer/Xcode/DerivedData` |
|
|
37
|
+
| Archives | `~/Library/Developer/Xcode/Archives` |
|
|
38
|
+
| iOS Device Support | `~/Library/Developer/Xcode/iOS DeviceSupport` |
|
|
39
|
+
| watchOS Device Support | `~/Library/Developer/Xcode/watchOS DeviceSupport` |
|
|
40
|
+
| tvOS Device Support | `~/Library/Developer/Xcode/tvOS DeviceSupport` |
|
|
41
|
+
| Simulators | Managed via `xcrun simctl` |
|
|
42
|
+
| Xcode Caches | `~/Library/Caches/com.apple.dt.Xcode` |
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
From PyPI:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
pip install xcode-cleaner
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
From source:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
git clone https://github.com/cdunkel/xcode-cleaner.git
|
|
56
|
+
cd xcode-cleaner
|
|
57
|
+
pip install .
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
**Scan and display a disk usage report:**
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
xcode-cleaner scan
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Interactively select and delete items to free space:**
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
xcode-cleaner clean
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Preview what would be deleted without actually deleting:**
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
xcode-cleaner clean --dry-run
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Requirements
|
|
81
|
+
|
|
82
|
+
- macOS
|
|
83
|
+
- Python 3.9+
|
|
84
|
+
- Xcode (for simulator management)
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# xcode-cleaner
|
|
2
|
+
|
|
3
|
+
A CLI tool to reclaim disk space from Xcode build artifacts, caches, and simulators.
|
|
4
|
+
|
|
5
|
+
## What It Scans
|
|
6
|
+
|
|
7
|
+
| Category | Path |
|
|
8
|
+
|----------|------|
|
|
9
|
+
| Derived Data | `~/Library/Developer/Xcode/DerivedData` |
|
|
10
|
+
| Archives | `~/Library/Developer/Xcode/Archives` |
|
|
11
|
+
| iOS Device Support | `~/Library/Developer/Xcode/iOS DeviceSupport` |
|
|
12
|
+
| watchOS Device Support | `~/Library/Developer/Xcode/watchOS DeviceSupport` |
|
|
13
|
+
| tvOS Device Support | `~/Library/Developer/Xcode/tvOS DeviceSupport` |
|
|
14
|
+
| Simulators | Managed via `xcrun simctl` |
|
|
15
|
+
| Xcode Caches | `~/Library/Caches/com.apple.dt.Xcode` |
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
From PyPI:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
pip install xcode-cleaner
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
From source:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
git clone https://github.com/cdunkel/xcode-cleaner.git
|
|
29
|
+
cd xcode-cleaner
|
|
30
|
+
pip install .
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
**Scan and display a disk usage report:**
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
xcode-cleaner scan
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Interactively select and delete items to free space:**
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
xcode-cleaner clean
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Preview what would be deleted without actually deleting:**
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
xcode-cleaner clean --dry-run
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Requirements
|
|
54
|
+
|
|
55
|
+
- macOS
|
|
56
|
+
- Python 3.9+
|
|
57
|
+
- Xcode (for simulator management)
|
|
58
|
+
|
|
59
|
+
## License
|
|
60
|
+
|
|
61
|
+
MIT
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xcode-cleaner"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CLI tool to reclaim disk space from Xcode build artifacts, caches, and simulators"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "cdunkel" }]
|
|
13
|
+
keywords = ["xcode", "macos", "disk-space", "cleanup", "cli", "simulators", "derived-data"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Operating System :: MacOS",
|
|
18
|
+
"Programming Language :: Python :: 3.9",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: System :: Filesystems",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"click>=8.0",
|
|
27
|
+
"rich>=13.0",
|
|
28
|
+
"InquirerPy>=0.3.4",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/cdunkel/xcode-cleaner"
|
|
33
|
+
Repository = "https://github.com/cdunkel/xcode-cleaner"
|
|
34
|
+
Issues = "https://github.com/cdunkel/xcode-cleaner/issues"
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
xcode-cleaner = "xcode_cleaner.cli:main"
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.packages.find]
|
|
40
|
+
where = ["src"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
|
|
5
|
+
from rich.progress import Progress
|
|
6
|
+
|
|
7
|
+
from xcode_cleaner.constants import CATEGORY_PATHS, DEVICE_SUPPORT_PATHS, Category
|
|
8
|
+
from xcode_cleaner.display import console
|
|
9
|
+
from xcode_cleaner.models import DeletionResult, ScanItem
|
|
10
|
+
from xcode_cleaner.simulators import delete_runtime, delete_simulator
|
|
11
|
+
from xcode_cleaner.sizes import format_size
|
|
12
|
+
|
|
13
|
+
# Resolved allowed base directories for path validation
|
|
14
|
+
_ALLOWED_BASES = tuple(
|
|
15
|
+
p.resolve() for p in (*CATEGORY_PATHS.values(), *DEVICE_SUPPORT_PATHS)
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def confirm_deletion(items: list[ScanItem]) -> bool:
|
|
20
|
+
"""Ask the user to confirm deletion."""
|
|
21
|
+
total = sum(item.size_bytes for item in items)
|
|
22
|
+
console.print(
|
|
23
|
+
f"\n[bold yellow]Delete {len(items)} item(s) ({format_size(total)})? "
|
|
24
|
+
f"This cannot be undone.[/bold yellow]"
|
|
25
|
+
)
|
|
26
|
+
answer = console.input("[bold]Proceed? (y/N): [/bold]").strip().lower()
|
|
27
|
+
return answer in ("y", "yes")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def delete_items(items: list[ScanItem]) -> list[DeletionResult]:
|
|
31
|
+
"""Delete the selected items, returning results for each."""
|
|
32
|
+
results: list[DeletionResult] = []
|
|
33
|
+
|
|
34
|
+
with Progress(console=console) as progress:
|
|
35
|
+
task = progress.add_task("Deleting...", total=len(items))
|
|
36
|
+
for item in items:
|
|
37
|
+
try:
|
|
38
|
+
if item.item_id is not None:
|
|
39
|
+
if item.category == Category.SIMULATOR_RUNTIMES:
|
|
40
|
+
delete_runtime(item.item_id)
|
|
41
|
+
else:
|
|
42
|
+
delete_simulator(item.item_id)
|
|
43
|
+
elif item.path is not None:
|
|
44
|
+
if item.path.is_symlink():
|
|
45
|
+
item.path.unlink()
|
|
46
|
+
else:
|
|
47
|
+
resolved = item.path.resolve()
|
|
48
|
+
if not any(
|
|
49
|
+
resolved == base or str(resolved).startswith(str(base) + "/")
|
|
50
|
+
for base in _ALLOWED_BASES
|
|
51
|
+
):
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"Refusing to delete {resolved}: outside expected Xcode directories"
|
|
54
|
+
)
|
|
55
|
+
shutil.rmtree(item.path)
|
|
56
|
+
else:
|
|
57
|
+
raise ValueError("Item has no path or item_id")
|
|
58
|
+
results.append(DeletionResult(item=item, success=True))
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
results.append(
|
|
61
|
+
DeletionResult(item=item, success=False, error=str(exc))
|
|
62
|
+
)
|
|
63
|
+
progress.advance(task)
|
|
64
|
+
|
|
65
|
+
return results
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from xcode_cleaner import __version__
|
|
7
|
+
from xcode_cleaner.cleaner import confirm_deletion, delete_items
|
|
8
|
+
from xcode_cleaner.display import (
|
|
9
|
+
display_deletion_summary,
|
|
10
|
+
display_dry_run,
|
|
11
|
+
display_scan_result,
|
|
12
|
+
)
|
|
13
|
+
from xcode_cleaner.interactive import prompt_selection
|
|
14
|
+
from xcode_cleaner.scanner import scan_all
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _run_scan():
|
|
20
|
+
"""Run a full scan with a spinner and return the result."""
|
|
21
|
+
with console.status("[bold cyan]Scanning for Xcode files...[/bold cyan]"):
|
|
22
|
+
result = scan_all()
|
|
23
|
+
return result
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@click.group()
|
|
27
|
+
@click.version_option(version=__version__)
|
|
28
|
+
def main():
|
|
29
|
+
"""Reclaim disk space from Xcode build artifacts, caches, and simulators."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@main.command()
|
|
33
|
+
def scan():
|
|
34
|
+
"""Scan and display Xcode disk usage report."""
|
|
35
|
+
result = _run_scan()
|
|
36
|
+
display_scan_result(result)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@main.command()
|
|
40
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be deleted without deleting.")
|
|
41
|
+
def clean(dry_run: bool):
|
|
42
|
+
"""Interactively select and delete Xcode files to free disk space."""
|
|
43
|
+
result = _run_scan()
|
|
44
|
+
|
|
45
|
+
if result.grand_total == 0:
|
|
46
|
+
console.print("[green]Nothing to clean — all categories are empty.[/green]")
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
display_scan_result(result)
|
|
50
|
+
|
|
51
|
+
selected = prompt_selection(result)
|
|
52
|
+
if not selected:
|
|
53
|
+
console.print("No items selected.")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
if dry_run:
|
|
57
|
+
display_dry_run(selected)
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
if not confirm_deletion(selected):
|
|
61
|
+
console.print("Cancelled.")
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
results = delete_items(selected)
|
|
65
|
+
display_deletion_summary(results)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Category(Enum):
|
|
8
|
+
DERIVED_DATA = "Derived Data"
|
|
9
|
+
ARCHIVES = "Archives"
|
|
10
|
+
DEVICE_SUPPORT = "Device Support"
|
|
11
|
+
SIMULATORS = "Simulators"
|
|
12
|
+
SIMULATOR_RUNTIMES = "Simulator Runtimes"
|
|
13
|
+
CACHES = "Xcode Caches"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
XCODE_BASE = Path.home() / "Library" / "Developer" / "Xcode"
|
|
17
|
+
|
|
18
|
+
CATEGORY_PATHS: dict[Category, Path] = {
|
|
19
|
+
Category.DERIVED_DATA: XCODE_BASE / "DerivedData",
|
|
20
|
+
Category.ARCHIVES: XCODE_BASE / "Archives",
|
|
21
|
+
Category.CACHES: Path.home() / "Library" / "Caches" / "com.apple.dt.Xcode",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
DEVICE_SUPPORT_PATHS: list[Path] = [
|
|
25
|
+
XCODE_BASE / "iOS DeviceSupport",
|
|
26
|
+
XCODE_BASE / "watchOS DeviceSupport",
|
|
27
|
+
XCODE_BASE / "tvOS DeviceSupport",
|
|
28
|
+
]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.panel import Panel
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
from xcode_cleaner.constants import Category
|
|
8
|
+
from xcode_cleaner.models import DeletionResult, ScanResult
|
|
9
|
+
from xcode_cleaner.sizes import format_size
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def display_scan_result(result: ScanResult) -> None:
|
|
15
|
+
"""Display the scan result as Rich tables grouped by category."""
|
|
16
|
+
for scan_cat in result.categories:
|
|
17
|
+
if not scan_cat.items:
|
|
18
|
+
console.print(f"[bold cyan]{scan_cat.category.value}[/bold cyan]")
|
|
19
|
+
if not scan_cat.found:
|
|
20
|
+
if scan_cat.category in (Category.SIMULATORS, Category.SIMULATOR_RUNTIMES):
|
|
21
|
+
console.print(" [dim]Could not list (xcrun unavailable)[/dim]")
|
|
22
|
+
else:
|
|
23
|
+
console.print(" [dim]Directory not found — skipped[/dim]")
|
|
24
|
+
else:
|
|
25
|
+
console.print(" [dim]Empty — nothing to clean[/dim]")
|
|
26
|
+
console.print()
|
|
27
|
+
continue
|
|
28
|
+
|
|
29
|
+
table = Table(
|
|
30
|
+
title=f"{scan_cat.category.value}",
|
|
31
|
+
title_style="bold cyan",
|
|
32
|
+
show_lines=False,
|
|
33
|
+
)
|
|
34
|
+
table.add_column("Name", style="white", no_wrap=False, ratio=3)
|
|
35
|
+
table.add_column("Size", style="green", justify="right", ratio=1)
|
|
36
|
+
|
|
37
|
+
for item in scan_cat.items:
|
|
38
|
+
table.add_row(item.name, format_size(item.size_bytes))
|
|
39
|
+
|
|
40
|
+
table.add_section()
|
|
41
|
+
table.add_row(
|
|
42
|
+
f"[bold]{scan_cat.item_count} items[/bold]",
|
|
43
|
+
f"[bold]{format_size(scan_cat.total_size)}[/bold]",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
console.print(table)
|
|
47
|
+
console.print()
|
|
48
|
+
|
|
49
|
+
console.print(
|
|
50
|
+
Panel(
|
|
51
|
+
f"[bold]Total reclaimable space: {format_size(result.grand_total)}[/bold]",
|
|
52
|
+
style="green",
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def display_deletion_summary(results: list[DeletionResult]) -> None:
|
|
58
|
+
"""Display summary of deletion results."""
|
|
59
|
+
succeeded = [r for r in results if r.success]
|
|
60
|
+
failed = [r for r in results if not r.success]
|
|
61
|
+
freed = sum(r.item.size_bytes for r in succeeded)
|
|
62
|
+
|
|
63
|
+
console.print()
|
|
64
|
+
if succeeded:
|
|
65
|
+
console.print(
|
|
66
|
+
f"[green]Deleted {len(succeeded)} item(s), freed {format_size(freed)}[/green]"
|
|
67
|
+
)
|
|
68
|
+
if failed:
|
|
69
|
+
console.print(f"[red]Failed to delete {len(failed)} item(s):[/red]")
|
|
70
|
+
for r in failed:
|
|
71
|
+
console.print(f" [red]• {r.item.name}: {r.error}[/red]")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def display_dry_run(items: list) -> None:
|
|
75
|
+
"""Display what would be deleted in dry-run mode."""
|
|
76
|
+
from xcode_cleaner.models import ScanItem
|
|
77
|
+
|
|
78
|
+
total = sum(item.size_bytes for item in items)
|
|
79
|
+
console.print(
|
|
80
|
+
Panel("[bold yellow]Dry run — nothing will be deleted[/bold yellow]")
|
|
81
|
+
)
|
|
82
|
+
for item in items:
|
|
83
|
+
console.print(f" Would delete: {item.name} ({format_size(item.size_bytes)})")
|
|
84
|
+
console.print(
|
|
85
|
+
f"\n[bold]Would free {format_size(total)} across {len(items)} item(s)[/bold]"
|
|
86
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from InquirerPy import inquirer
|
|
4
|
+
from InquirerPy.base.control import Choice
|
|
5
|
+
from InquirerPy.separator import Separator
|
|
6
|
+
|
|
7
|
+
from xcode_cleaner.models import ScanItem, ScanResult
|
|
8
|
+
from xcode_cleaner.sizes import format_size
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def prompt_selection(result: ScanResult) -> list[ScanItem]:
|
|
12
|
+
"""Show an interactive checkbox prompt grouped by category. Returns selected items."""
|
|
13
|
+
choices: list[Choice | Separator] = []
|
|
14
|
+
items_by_index: dict[int, ScanItem] = {}
|
|
15
|
+
index = 0
|
|
16
|
+
|
|
17
|
+
for scan_cat in result.categories:
|
|
18
|
+
if not scan_cat.items:
|
|
19
|
+
continue
|
|
20
|
+
|
|
21
|
+
choices.append(
|
|
22
|
+
Separator(
|
|
23
|
+
f"── {scan_cat.category.value} ({scan_cat.item_count} items, {format_size(scan_cat.total_size)}) ──"
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Compute column widths for alignment within this category
|
|
28
|
+
name_width = 0
|
|
29
|
+
state_width = 0
|
|
30
|
+
size_width = 0
|
|
31
|
+
for item in scan_cat.items:
|
|
32
|
+
name_width = max(name_width, len(item.name))
|
|
33
|
+
state = item.metadata.get("state", "")
|
|
34
|
+
if state:
|
|
35
|
+
state_width = max(state_width, len(f"[{state}]"))
|
|
36
|
+
size_width = max(size_width, len(format_size(item.size_bytes)))
|
|
37
|
+
|
|
38
|
+
for item in scan_cat.items:
|
|
39
|
+
items_by_index[index] = item
|
|
40
|
+
state = item.metadata.get("state", "")
|
|
41
|
+
state_str = f"[{state}]" if state else ""
|
|
42
|
+
size_str = format_size(item.size_bytes)
|
|
43
|
+
|
|
44
|
+
name_col = item.name.ljust(name_width)
|
|
45
|
+
state_col = state_str.ljust(state_width) if state_width else ""
|
|
46
|
+
size_col = size_str.rjust(size_width)
|
|
47
|
+
|
|
48
|
+
parts = [name_col]
|
|
49
|
+
if state_width:
|
|
50
|
+
parts.append(state_col)
|
|
51
|
+
parts.append(size_col)
|
|
52
|
+
|
|
53
|
+
choices.append(
|
|
54
|
+
Choice(
|
|
55
|
+
value=index,
|
|
56
|
+
name=" ".join(parts),
|
|
57
|
+
enabled=False,
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
index += 1
|
|
61
|
+
|
|
62
|
+
if not choices:
|
|
63
|
+
return []
|
|
64
|
+
|
|
65
|
+
selected = inquirer.checkbox(
|
|
66
|
+
message="Select items to delete (Space to toggle, Enter to confirm):",
|
|
67
|
+
choices=choices,
|
|
68
|
+
cycle=True,
|
|
69
|
+
instruction="(↑/↓ navigate, Space toggle, Enter confirm)",
|
|
70
|
+
).execute()
|
|
71
|
+
|
|
72
|
+
if not selected:
|
|
73
|
+
return []
|
|
74
|
+
|
|
75
|
+
return [items_by_index[i] for i in selected]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from xcode_cleaner.constants import Category
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ScanItem:
|
|
11
|
+
name: str
|
|
12
|
+
size_bytes: int
|
|
13
|
+
category: Category
|
|
14
|
+
path: Path | None = None
|
|
15
|
+
item_id: str | None = None # UDID for simulators
|
|
16
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ScanCategory:
|
|
21
|
+
category: Category
|
|
22
|
+
items: list[ScanItem] = field(default_factory=list)
|
|
23
|
+
found: bool = True # Whether the source directory/data was found
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def total_size(self) -> int:
|
|
27
|
+
return sum(item.size_bytes for item in self.items)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def item_count(self) -> int:
|
|
31
|
+
return len(self.items)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ScanResult:
|
|
36
|
+
categories: list[ScanCategory] = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def grand_total(self) -> int:
|
|
40
|
+
return sum(cat.total_size for cat in self.categories)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class DeletionResult:
|
|
45
|
+
item: ScanItem
|
|
46
|
+
success: bool
|
|
47
|
+
error: str | None = None
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from xcode_cleaner.constants import CATEGORY_PATHS, DEVICE_SUPPORT_PATHS, Category
|
|
6
|
+
from xcode_cleaner.models import ScanCategory, ScanItem, ScanResult
|
|
7
|
+
from xcode_cleaner.simulators import list_runtimes, list_simulators
|
|
8
|
+
from xcode_cleaner.sizes import dir_size
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _scan_flat(category: Category, base: Path) -> list[ScanItem]:
|
|
12
|
+
"""Scan a directory where each subdirectory is one item."""
|
|
13
|
+
if not base.is_dir():
|
|
14
|
+
return []
|
|
15
|
+
items: list[ScanItem] = []
|
|
16
|
+
try:
|
|
17
|
+
for entry in sorted(base.iterdir()):
|
|
18
|
+
if entry.is_dir() and not entry.is_symlink():
|
|
19
|
+
size = dir_size(entry)
|
|
20
|
+
items.append(
|
|
21
|
+
ScanItem(
|
|
22
|
+
name=entry.name,
|
|
23
|
+
size_bytes=size,
|
|
24
|
+
category=category,
|
|
25
|
+
path=entry,
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
except PermissionError:
|
|
29
|
+
pass
|
|
30
|
+
return items
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _scan_archives(base: Path) -> list[ScanItem]:
|
|
34
|
+
"""Scan Archives — date folders containing .xcarchive bundles."""
|
|
35
|
+
if not base.is_dir():
|
|
36
|
+
return []
|
|
37
|
+
items: list[ScanItem] = []
|
|
38
|
+
try:
|
|
39
|
+
for date_folder in sorted(base.iterdir()):
|
|
40
|
+
if not date_folder.is_dir() or date_folder.is_symlink():
|
|
41
|
+
continue
|
|
42
|
+
for archive in sorted(date_folder.iterdir()):
|
|
43
|
+
if archive.is_dir() and not archive.is_symlink() and archive.suffix == ".xcarchive":
|
|
44
|
+
size = dir_size(archive)
|
|
45
|
+
items.append(
|
|
46
|
+
ScanItem(
|
|
47
|
+
name=archive.name,
|
|
48
|
+
size_bytes=size,
|
|
49
|
+
category=Category.ARCHIVES,
|
|
50
|
+
path=archive,
|
|
51
|
+
metadata={"date_folder": date_folder.name},
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
except PermissionError:
|
|
55
|
+
pass
|
|
56
|
+
return items
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def scan_category(category: Category) -> ScanCategory:
|
|
60
|
+
"""Scan a single category and return results."""
|
|
61
|
+
if category == Category.SIMULATORS:
|
|
62
|
+
items, found = list_simulators()
|
|
63
|
+
elif category == Category.SIMULATOR_RUNTIMES:
|
|
64
|
+
items, found = list_runtimes()
|
|
65
|
+
elif category == Category.DEVICE_SUPPORT:
|
|
66
|
+
items = []
|
|
67
|
+
found = False
|
|
68
|
+
for base in DEVICE_SUPPORT_PATHS:
|
|
69
|
+
if base.is_dir():
|
|
70
|
+
found = True
|
|
71
|
+
items.extend(_scan_flat(category, base))
|
|
72
|
+
elif category == Category.ARCHIVES:
|
|
73
|
+
base = CATEGORY_PATHS[Category.ARCHIVES]
|
|
74
|
+
found = base.is_dir()
|
|
75
|
+
items = _scan_archives(base)
|
|
76
|
+
else:
|
|
77
|
+
base = CATEGORY_PATHS[category]
|
|
78
|
+
found = base.is_dir()
|
|
79
|
+
items = _scan_flat(category, base)
|
|
80
|
+
|
|
81
|
+
# Sort by size descending
|
|
82
|
+
items.sort(key=lambda x: x.size_bytes, reverse=True)
|
|
83
|
+
return ScanCategory(category=category, items=items, found=found)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def scan_all() -> ScanResult:
|
|
87
|
+
"""Scan all categories and return a full result."""
|
|
88
|
+
categories = [scan_category(cat) for cat in Category]
|
|
89
|
+
return ScanResult(categories=categories)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
from xcode_cleaner.constants import Category
|
|
7
|
+
from xcode_cleaner.models import ScanItem
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def list_simulators() -> tuple[list[ScanItem], bool]:
|
|
11
|
+
"""List all simulators via xcrun simctl and return (items, success).
|
|
12
|
+
|
|
13
|
+
The bool indicates whether the scan succeeded. False means xcrun was
|
|
14
|
+
unavailable or timed out — callers can distinguish "no simulators"
|
|
15
|
+
from "couldn't check."
|
|
16
|
+
"""
|
|
17
|
+
try:
|
|
18
|
+
result = subprocess.run(
|
|
19
|
+
["xcrun", "simctl", "list", "devices", "--json"],
|
|
20
|
+
capture_output=True,
|
|
21
|
+
text=True,
|
|
22
|
+
timeout=30,
|
|
23
|
+
)
|
|
24
|
+
except FileNotFoundError:
|
|
25
|
+
return [], False
|
|
26
|
+
except subprocess.TimeoutExpired:
|
|
27
|
+
return [], False
|
|
28
|
+
|
|
29
|
+
if result.returncode != 0:
|
|
30
|
+
return [], False
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
data = json.loads(result.stdout)
|
|
34
|
+
except json.JSONDecodeError:
|
|
35
|
+
return [], False
|
|
36
|
+
|
|
37
|
+
items: list[ScanItem] = []
|
|
38
|
+
for runtime, devices in data.get("devices", {}).items():
|
|
39
|
+
# runtime looks like "com.apple.CoreSimulator.SimRuntime.iOS-17-5"
|
|
40
|
+
runtime_name = runtime.rsplit(".", 1)[-1].replace("-", " ")
|
|
41
|
+
for device in devices:
|
|
42
|
+
udid = device.get("udid", "")
|
|
43
|
+
if not udid:
|
|
44
|
+
continue
|
|
45
|
+
name = device.get("name", "Unknown")
|
|
46
|
+
state = device.get("state", "Unknown")
|
|
47
|
+
size_bytes = device.get("dataPathSize", 0) or 0
|
|
48
|
+
|
|
49
|
+
items.append(
|
|
50
|
+
ScanItem(
|
|
51
|
+
name=f"{name} ({runtime_name})",
|
|
52
|
+
size_bytes=size_bytes,
|
|
53
|
+
category=Category.SIMULATORS,
|
|
54
|
+
item_id=udid,
|
|
55
|
+
metadata={"state": state, "runtime": runtime_name},
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
return items, True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def list_runtimes() -> tuple[list[ScanItem], bool]:
|
|
62
|
+
"""List installed simulator runtimes via xcrun simctl and return (items, success).
|
|
63
|
+
|
|
64
|
+
The bool indicates whether the scan succeeded. False means xcrun was
|
|
65
|
+
unavailable or timed out.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
result = subprocess.run(
|
|
69
|
+
["xcrun", "simctl", "runtime", "list", "-j"],
|
|
70
|
+
capture_output=True,
|
|
71
|
+
text=True,
|
|
72
|
+
timeout=30,
|
|
73
|
+
)
|
|
74
|
+
except FileNotFoundError:
|
|
75
|
+
return [], False
|
|
76
|
+
except subprocess.TimeoutExpired:
|
|
77
|
+
return [], False
|
|
78
|
+
|
|
79
|
+
if result.returncode != 0:
|
|
80
|
+
return [], False
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
data = json.loads(result.stdout)
|
|
84
|
+
except json.JSONDecodeError:
|
|
85
|
+
return [], False
|
|
86
|
+
|
|
87
|
+
items: list[ScanItem] = []
|
|
88
|
+
entries = data.values() if isinstance(data, dict) else data if isinstance(data, list) else []
|
|
89
|
+
for entry in entries:
|
|
90
|
+
identifier = entry.get("identifier")
|
|
91
|
+
if not identifier:
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
# Build display name from runtimeIdentifier
|
|
95
|
+
# e.g. "com.apple.CoreSimulator.SimRuntime.iOS-26-2" -> "iOS 26.2"
|
|
96
|
+
runtime_id = entry.get("runtimeIdentifier", "")
|
|
97
|
+
if "." in runtime_id:
|
|
98
|
+
short = runtime_id.rsplit(".", 1)[-1]
|
|
99
|
+
parts = short.split("-", 1)
|
|
100
|
+
if len(parts) == 2:
|
|
101
|
+
platform = parts[0]
|
|
102
|
+
version = parts[1].replace("-", ".")
|
|
103
|
+
display_name = f"{platform} {version}"
|
|
104
|
+
else:
|
|
105
|
+
display_name = short.replace("-", " ")
|
|
106
|
+
else:
|
|
107
|
+
display_name = runtime_id or identifier
|
|
108
|
+
|
|
109
|
+
size_bytes = entry.get("sizeBytes", 0) or 0
|
|
110
|
+
|
|
111
|
+
items.append(
|
|
112
|
+
ScanItem(
|
|
113
|
+
name=display_name,
|
|
114
|
+
size_bytes=size_bytes,
|
|
115
|
+
category=Category.SIMULATOR_RUNTIMES,
|
|
116
|
+
item_id=identifier,
|
|
117
|
+
metadata={
|
|
118
|
+
"state": entry.get("state", "Unknown"),
|
|
119
|
+
"build": entry.get("build", ""),
|
|
120
|
+
"kind": entry.get("kind", ""),
|
|
121
|
+
"deletable": entry.get("deletable", False),
|
|
122
|
+
},
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
return items, True
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def delete_simulator(udid: str) -> None:
|
|
129
|
+
"""Delete a simulator by UDID via xcrun simctl."""
|
|
130
|
+
subprocess.run(
|
|
131
|
+
["xcrun", "simctl", "delete", udid],
|
|
132
|
+
capture_output=True,
|
|
133
|
+
text=True,
|
|
134
|
+
timeout=60,
|
|
135
|
+
check=True,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def delete_runtime(identifier: str) -> None:
|
|
140
|
+
"""Delete a simulator runtime by identifier via xcrun simctl."""
|
|
141
|
+
subprocess.run(
|
|
142
|
+
["xcrun", "simctl", "runtime", "delete", identifier],
|
|
143
|
+
capture_output=True,
|
|
144
|
+
text=True,
|
|
145
|
+
timeout=120,
|
|
146
|
+
check=True,
|
|
147
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def dir_size(path: Path) -> int:
|
|
8
|
+
"""Calculate total size of a directory using os.scandir for performance."""
|
|
9
|
+
total = 0
|
|
10
|
+
try:
|
|
11
|
+
with os.scandir(path) as entries:
|
|
12
|
+
for entry in entries:
|
|
13
|
+
try:
|
|
14
|
+
if entry.is_file(follow_symlinks=False):
|
|
15
|
+
total += entry.stat(follow_symlinks=False).st_size
|
|
16
|
+
elif entry.is_dir(follow_symlinks=False):
|
|
17
|
+
total += dir_size(Path(entry.path))
|
|
18
|
+
except (PermissionError, OSError):
|
|
19
|
+
continue
|
|
20
|
+
except (PermissionError, OSError):
|
|
21
|
+
pass
|
|
22
|
+
return total
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def format_size(size_bytes: int) -> str:
|
|
26
|
+
"""Format byte count as human-readable string."""
|
|
27
|
+
if size_bytes < 1024:
|
|
28
|
+
return f"{size_bytes} B"
|
|
29
|
+
for unit in ("KB", "MB", "GB", "TB"):
|
|
30
|
+
size_bytes /= 1024
|
|
31
|
+
if size_bytes < 1024 or unit == "TB":
|
|
32
|
+
return f"{size_bytes:.1f} {unit}"
|
|
33
|
+
return f"{size_bytes:.1f} TB" # unreachable but satisfies type checker
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xcode-cleaner
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI tool to reclaim disk space from Xcode build artifacts, caches, and simulators
|
|
5
|
+
Author: cdunkel
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/cdunkel/xcode-cleaner
|
|
8
|
+
Project-URL: Repository, https://github.com/cdunkel/xcode-cleaner
|
|
9
|
+
Project-URL: Issues, https://github.com/cdunkel/xcode-cleaner/issues
|
|
10
|
+
Keywords: xcode,macos,disk-space,cleanup,cli,simulators,derived-data
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: System :: Filesystems
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: click>=8.0
|
|
24
|
+
Requires-Dist: rich>=13.0
|
|
25
|
+
Requires-Dist: InquirerPy>=0.3.4
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# xcode-cleaner
|
|
29
|
+
|
|
30
|
+
A CLI tool to reclaim disk space from Xcode build artifacts, caches, and simulators.
|
|
31
|
+
|
|
32
|
+
## What It Scans
|
|
33
|
+
|
|
34
|
+
| Category | Path |
|
|
35
|
+
|----------|------|
|
|
36
|
+
| Derived Data | `~/Library/Developer/Xcode/DerivedData` |
|
|
37
|
+
| Archives | `~/Library/Developer/Xcode/Archives` |
|
|
38
|
+
| iOS Device Support | `~/Library/Developer/Xcode/iOS DeviceSupport` |
|
|
39
|
+
| watchOS Device Support | `~/Library/Developer/Xcode/watchOS DeviceSupport` |
|
|
40
|
+
| tvOS Device Support | `~/Library/Developer/Xcode/tvOS DeviceSupport` |
|
|
41
|
+
| Simulators | Managed via `xcrun simctl` |
|
|
42
|
+
| Xcode Caches | `~/Library/Caches/com.apple.dt.Xcode` |
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
From PyPI:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
pip install xcode-cleaner
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
From source:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
git clone https://github.com/cdunkel/xcode-cleaner.git
|
|
56
|
+
cd xcode-cleaner
|
|
57
|
+
pip install .
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
**Scan and display a disk usage report:**
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
xcode-cleaner scan
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Interactively select and delete items to free space:**
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
xcode-cleaner clean
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Preview what would be deleted without actually deleting:**
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
xcode-cleaner clean --dry-run
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Requirements
|
|
81
|
+
|
|
82
|
+
- macOS
|
|
83
|
+
- Python 3.9+
|
|
84
|
+
- Xcode (for simulator management)
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/xcode_cleaner/__init__.py
|
|
5
|
+
src/xcode_cleaner/__main__.py
|
|
6
|
+
src/xcode_cleaner/cleaner.py
|
|
7
|
+
src/xcode_cleaner/cli.py
|
|
8
|
+
src/xcode_cleaner/constants.py
|
|
9
|
+
src/xcode_cleaner/display.py
|
|
10
|
+
src/xcode_cleaner/interactive.py
|
|
11
|
+
src/xcode_cleaner/models.py
|
|
12
|
+
src/xcode_cleaner/scanner.py
|
|
13
|
+
src/xcode_cleaner/simulators.py
|
|
14
|
+
src/xcode_cleaner/sizes.py
|
|
15
|
+
src/xcode_cleaner.egg-info/PKG-INFO
|
|
16
|
+
src/xcode_cleaner.egg-info/SOURCES.txt
|
|
17
|
+
src/xcode_cleaner.egg-info/dependency_links.txt
|
|
18
|
+
src/xcode_cleaner.egg-info/entry_points.txt
|
|
19
|
+
src/xcode_cleaner.egg-info/requires.txt
|
|
20
|
+
src/xcode_cleaner.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
xcode_cleaner
|