[READ-ONLY] Mirror of https://github.com/mrgnw/scripts.
1#!/usr/bin/env -S uv run --script
2# /// script
3# requires-python = ">=3.10"
4# dependencies = [
5# "rich>=13.0.0",
6# "questionary>=1.10.0",
7# ]
8# ///
9
10import os
11import shutil
12import subprocess
13import sys
14from dataclasses import dataclass
15from pathlib import Path
16from typing import List, Dict
17
18from rich.console import Console
19from rich.filesize import decimal
20from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
21import questionary
22
23console = Console()
24
25
26@dataclass
27class DirectorySize:
28 path: Path
29 size_bytes: int
30 parent: Path
31 dtype: str
32
33 @property
34 def size_mb(self) -> float:
35 return self.size_bytes / (1024 * 1024)
36
37 @property
38 def size_display(self) -> str:
39 return decimal(self.size_bytes)
40
41
42def get_dir_size(path: Path) -> int:
43 """Get total size of a directory in bytes."""
44 try:
45 # macOS uses BSD du which doesn't support -b, use -A -s instead
46 result = subprocess.run(["du", "-A", "-s", str(path)], capture_output=True, text=True, timeout=10)
47 if result.returncode == 0:
48 return int(result.stdout.split()[0]) * 512 # BSD du uses 512-byte blocks by default
49 except (subprocess.TimeoutExpired, ValueError, IndexError):
50 pass
51 return 0
52
53
54def find_target_dirs(root: Path, target_names: List[str]) -> List[DirectorySize]:
55 """Find all target directories under root."""
56 results = []
57
58 with Progress(
59 SpinnerColumn(),
60 TextColumn("[progress.description]{task.description}"),
61 console=console,
62 transient=True,
63 ) as progress:
64 task = progress.add_task("Scanning directory tree...", total=None)
65
66 for current_dir, dirs, files in os.walk(root):
67 progress.update(task, description=f"Scanning {current_dir}...")
68
69 # Check for target directories in current location
70 for target_name in target_names:
71 target_path = Path(current_dir) / target_name
72 if target_path.exists() and target_path.is_dir():
73 size = get_dir_size(target_path)
74 if size > 0:
75 results.append(
76 DirectorySize(path=target_path, size_bytes=size, parent=Path(current_dir), dtype=target_name)
77 )
78
79 # Don't recurse into target directories themselves to avoid nested scanning
80 dirs[:] = [d for d in dirs if d not in target_names]
81
82 return results
83
84
85def format_path(dir_info: DirectorySize, root: Path) -> str:
86 """Format path relative to root, showing the full path to target dir."""
87 try:
88 rel_parent = dir_info.parent.relative_to(root)
89 return f"{rel_parent}/{dir_info.path.name}"
90 except ValueError:
91 return str(dir_info.path)
92
93
94def create_bar(size_bytes: int, max_size_bytes: int, width: int = 20) -> str:
95 """Create a simple ASCII bar chart."""
96 if max_size_bytes == 0:
97 return "█" * 0
98 percentage = size_bytes / max_size_bytes
99 filled = int(percentage * width)
100 return "█" * filled + "░" * (width - filled)
101
102
103def interactive_selection(filtered_dirs: List[DirectorySize], root: Path, total_size: int, by_type: Dict[str, List[DirectorySize]]) -> List[DirectorySize]:
104 """Interactive selection UI for choosing directories to delete."""
105
106 # Create checkbox list grouped by type
107 choices = []
108
109 for dtype in sorted(by_type.keys()):
110 dirs_of_type = sorted(by_type[dtype], key=lambda x: x.size_bytes, reverse=True)
111 type_total = sum(d.size_bytes for d in dirs_of_type)
112
113 # Add category header
114 choices.append(questionary.Separator(f"\n{dtype.upper()} ({len(dirs_of_type)} dirs, {decimal(type_total)})"))
115
116 # Add directories under this category
117 for dir_info in dirs_of_type:
118 location = format_path(dir_info, root)
119 percentage = (dir_info.size_bytes / total_size) * 100
120 display = f"{location:50} {dir_info.size_display:>12} ({percentage:5.1f}%)"
121 # Use Choice with dir_info as the value
122 choices.append(questionary.Choice(display, value=dir_info))
123
124 # Show checkbox selection
125 selected = questionary.checkbox(
126 "Select directories to delete (use [SPACE] to select, [ENTER] to confirm):",
127 choices=choices,
128 ).ask()
129
130 if selected is None:
131 return []
132
133 return selected
134
135
136def confirm_deletion(dirs_to_delete: List[DirectorySize]) -> bool:
137 """Ask for confirmation before deletion/cleaning."""
138 total_size = sum(d.size_bytes for d in dirs_to_delete)
139
140 # Separate git directories from others
141 git_dirs = [d for d in dirs_to_delete if d.dtype == ".git"]
142 other_dirs = [d for d in dirs_to_delete if d.dtype != ".git"]
143
144 console.print(f"\n[bold red]Confirm Action[/bold red]")
145
146 if git_dirs:
147 console.print(f"[cyan]Git repositories to clean:[/cyan]")
148 for dir_info in git_dirs:
149 console.print(f" • {dir_info.path} ({dir_info.size_display})")
150
151 if other_dirs:
152 console.print(f"[red]Directories to delete:[/red]")
153 for dir_info in other_dirs:
154 console.print(f" • {dir_info.path} ({dir_info.size_display})")
155
156 console.print(f"\n[bold yellow]Total size affected: {decimal(total_size)}[/bold yellow]\n")
157
158 response = questionary.confirm("Proceed?").ask()
159 return response is True
160
161
162def delete_directories(dirs_to_delete: List[DirectorySize]) -> None:
163 """Delete or clean the selected directories."""
164 # Separate .git directories from others
165 git_dirs = [d for d in dirs_to_delete if d.dtype == ".git"]
166 other_dirs = [d for d in dirs_to_delete if d.dtype != ".git"]
167
168 with Progress(
169 SpinnerColumn(),
170 TextColumn("[progress.description]{task.description}"),
171 BarColumn(),
172 TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
173 console=console,
174 ) as progress:
175 task = progress.add_task("Processing...", total=len(dirs_to_delete))
176
177 # Handle .git directories with git gc
178 for dir_info in git_dirs:
179 try:
180 progress.update(task, description=f"Cleaning {dir_info.path.name}...")
181 if dir_info.path.exists():
182 # Run git gc to clean up dead/irrelevant files
183 result = subprocess.run(
184 ["git", "gc", "--aggressive", "--prune"],
185 cwd=dir_info.path.parent,
186 capture_output=True,
187 text=True,
188 timeout=120
189 )
190 if result.returncode == 0:
191 console.print(f"[green]✓ Cleaned {dir_info.path}[/green]")
192 else:
193 console.print(f"[yellow]⚠ Warning cleaning {dir_info.path}: {result.stderr}[/yellow]")
194 progress.advance(task)
195 except subprocess.TimeoutExpired:
196 console.print(f"[yellow]⚠ Timeout cleaning {dir_info.path}[/yellow]")
197 progress.advance(task)
198 except Exception as e:
199 console.print(f"[red]Error cleaning {dir_info.path}: {e}[/red]")
200 progress.advance(task)
201
202 # Delete other directories
203 for dir_info in other_dirs:
204 try:
205 progress.update(task, description=f"Deleting {dir_info.path.name}...")
206 if dir_info.path.exists():
207 shutil.rmtree(dir_info.path)
208 console.print(f"[green]✓ Deleted {dir_info.path}[/green]")
209 progress.advance(task)
210 except Exception as e:
211 console.print(f"[red]Error deleting {dir_info.path}: {e}[/red]")
212 progress.advance(task)
213
214 console.print("[bold green]✓ Processing complete![/bold green]")
215
216
217def main():
218 root = Path.cwd()
219
220 if not root.exists():
221 console.print(f"[red]Error: {root} does not exist[/red]")
222 sys.exit(1)
223
224 target_dirs = ["node_modules", ".venv", ".git", "docker"]
225 min_size_mb = 50
226
227 console.print(f"[cyan]Analyzing disk usage in {root}[/cyan]")
228 console.print(f"[cyan]Target directories: {', '.join(target_dirs)}[/cyan]")
229 console.print(f"[cyan]Minimum size filter: {min_size_mb}MB[/cyan]\n")
230
231 # Find all target directories
232 all_dirs = find_target_dirs(root, target_dirs)
233
234 # Filter by minimum size
235 filtered_dirs = [d for d in all_dirs if d.size_mb >= min_size_mb]
236 filtered_dirs.sort(key=lambda x: x.size_bytes, reverse=True)
237
238 if not filtered_dirs:
239 console.print(f"[yellow]No directories found larger than {min_size_mb}MB[/yellow]")
240 return
241
242 total_size = sum(d.size_bytes for d in filtered_dirs)
243 max_size = filtered_dirs[0].size_bytes
244
245 # Group by type
246 by_type = {}
247 for dir_info in filtered_dirs:
248 dtype = dir_info.dtype
249 if dtype not in by_type:
250 by_type[dtype] = []
251 by_type[dtype].append(dir_info)
252
253 # Display summary
254 console.print(f"[bold cyan]📊 Disk Space Usage Summary[/bold cyan]\n")
255
256 for dtype in sorted(by_type.keys()):
257 dirs_of_type = sorted(by_type[dtype], key=lambda x: x.size_bytes, reverse=True)
258 type_total = sum(d.size_bytes for d in dirs_of_type)
259 type_percentage = (type_total / total_size) * 100
260
261 console.print(f"[bold magenta]{dtype}[/bold magenta] ({len(dirs_of_type)} dirs) [yellow]{decimal(type_total)}[/yellow] ({type_percentage:.1f}%)")
262 for dir_info in dirs_of_type:
263 bar = create_bar(dir_info.size_bytes, max_size)
264 location = format_path(dir_info, root)
265 dir_percentage = (dir_info.size_bytes / total_size) * 100
266 console.print(f" {location} [cyan]{bar}[/cyan] {dir_info.size_display} ({dir_percentage:.1f}%)")
267
268 console.print()
269 console.print(f"[bold cyan]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold cyan]")
270 console.print(f"[bold]Total size (filtered): {decimal(total_size)}[/bold]")
271 console.print(f"[bold]Number of directories: {len(filtered_dirs)}[/bold]")
272 console.print(f"[bold cyan]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/bold cyan]\n")
273
274 # Interactive selection
275 selected = interactive_selection(filtered_dirs, root, total_size, by_type)
276
277 if not selected:
278 console.print("[yellow]No directories selected for deletion[/yellow]")
279 return
280
281 # Confirm deletion
282 if not confirm_deletion(selected):
283 console.print("[yellow]Deletion cancelled[/yellow]")
284 return
285
286 # Delete
287 delete_directories(selected)
288
289
290if __name__ == "__main__":
291 main()