[READ-ONLY] Mirror of https://github.com/mrgnw/scripts.
0

Configure Feed

Select the types of activity you want to include in your feed.

scripts / _devcleanup
9.5 kB 291 lines
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()