#!/usr/bin/env python3 """ Reorganize Files
Safely reorganize files according to a reorganization plan. Uses git mv for all operations to preserve history.
Usage: python reorganize-files.py [plan.md] [--execute] [--dry-run]
Examples: python reorganize-files.py docs/reorganization-plans/root/PLAN.md --dry-run python reorganize-files.py --execute """
import argparse import json import os import re import subprocess import sys from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Optional, Tuple from datetime import datetime
ANSI colors
Shared Colors module (consolidates 36 duplicate definitions)
_script_dir = Path(file).parent sys.path.insert(0, str(_script_dir / "core")) from colors import Colors
class FileOperation: """Represents a file operation.""" type: str # move, rename, create, delete source: Path destination: Optional[Path] reason: str
@dataclass class ReorgPlan: """Reorganization plan.""" name: str operations: List[FileOperation] created: datetime backup_branch: str
def parse_plan_file(plan_path: Path) -> ReorgPlan: """Parse a reorganization plan markdown file.""" content = plan_path.read_text() operations = []
# Extract moves from "Files to Move" section
move_pattern = r'\|\s*`([^`]+)`\s*\|\s*`([^`]+)`\s*\|\s*([^|]+)\s*\|'
in_moves_section = False
for line in content.split('\n'):
if 'Files to Move' in line or 'Move' in line:
in_moves_section = True
continue
if in_moves_section and line.startswith('###'):
in_moves_section = False
if in_moves_section:
match = re.match(move_pattern, line)
if match:
source, dest, reason = match.groups()
operations.append(FileOperation(
type='move',
source=Path(source.strip()),
destination=Path(dest.strip()),
reason=reason.strip()
))
# Extract renames
in_renames_section = False
for line in content.split('\n'):
if 'Files to Rename' in line or 'Rename' in line:
in_renames_section = True
continue
if in_renames_section and line.startswith('###'):
in_renames_section = False
if in_renames_section:
match = re.match(move_pattern, line)
if match:
source, dest, reason = match.groups()
operations.append(FileOperation(
type='rename',
source=Path(source.strip()),
destination=Path(dest.strip()),
reason=reason.strip()
))
return ReorgPlan(
name=plan_path.stem,
operations=operations,
created=datetime.now(),
backup_branch=f"backup/reorg-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
)
def create_backup_branch(branch_name: str) -> bool: """Create a backup branch before operations.""" try: # Check if we're in a git repo result = subprocess.run( ['git', 'rev-parse', '--git-dir'], capture_output=True, text=True ) if result.returncode != 0: print(f"{Colors.YELLOW}Warning: Not in a git repository{Colors.RESET}") return False
# Create backup branch
result = subprocess.run(
['git', 'checkout', '-b', branch_name],
capture_output=True,
text=True
)
if result.returncode == 0:
print(f"{Colors.GREEN}Created backup branch: {branch_name}{Colors.RESET}")
# Switch back to original branch
subprocess.run(['git', 'checkout', '-'], capture_output=True)
return True
else:
print(f"{Colors.RED}Failed to create backup branch{Colors.RESET}")
return False
except Exception as e:
print(f"{Colors.RED}Error creating backup: {e}{Colors.RESET}")
return False
def execute_operation(op: FileOperation, dry_run: bool = False) -> bool: """Execute a single file operation.""" if op.type in ['move', 'rename']: if not op.source.exists(): print(f" {Colors.RED}Source not found: {op.source}{Colors.RESET}") return False
if op.destination.exists():
print(f" {Colors.RED}Destination exists: {op.destination}{Colors.RESET}")
return False
if dry_run:
print(f" Would {op.type}: {op.source} → {op.destination}")
return True
# Ensure destination directory exists
op.destination.parent.mkdir(parents=True, exist_ok=True)
# Try git mv first
result = subprocess.run(
['git', 'mv', str(op.source), str(op.destination)],
capture_output=True,
text=True
)
if result.returncode == 0:
print(f" {Colors.GREEN}{op.type.capitalize()}d:{Colors.RESET} {op.source} → {op.destination}")
return True
else:
# Try regular rename
try:
op.source.rename(op.destination)
print(f" {Colors.GREEN}{op.type.capitalize()}d:{Colors.RESET} {op.source} → {op.destination}")
return True
except Exception as e:
print(f" {Colors.RED}Failed: {e}{Colors.RESET}")
return False
elif op.type == 'create':
if dry_run:
print(f" Would create: {op.source}")
return True
try:
op.source.parent.mkdir(parents=True, exist_ok=True)
op.source.touch()
print(f" {Colors.GREEN}Created:{Colors.RESET} {op.source}")
return True
except Exception as e:
print(f" {Colors.RED}Failed to create: {e}{Colors.RESET}")
return False
return False
def execute_plan(plan: ReorgPlan, dry_run: bool = False) -> Tuple[int, int]: """Execute a reorganization plan.""" if not dry_run: # Create backup if not create_backup_branch(plan.backup_branch): print(f"{Colors.YELLOW}Proceeding without backup branch{Colors.RESET}")
success = 0
failed = 0
print(f"\n{Colors.BOLD}Executing: {plan.name}{Colors.RESET}")
print(f"Operations: {len(plan.operations)}\n")
for i, op in enumerate(plan.operations, 1):
print(f"[{i}/{len(plan.operations)}] ", end='')
if execute_operation(op, dry_run):
success += 1
else:
failed += 1
return success, failed
def find_plans(base_path: Path) -> List[Path]: """Find all reorganization plans.""" plans_dir = base_path / 'docs' / 'reorganization-plans' if not plans_dir.exists(): return []
plans = []
for f in plans_dir.rglob('PLAN.md'):
plans.append(f)
return plans
def main(): parser = argparse.ArgumentParser( description='Execute file reorganization plans' ) parser.add_argument('plan', nargs='?', help='Path to plan file') parser.add_argument('--execute', action='store_true', help='Execute the plan') parser.add_argument('--dry-run', action='store_true', help='Show what would be done') parser.add_argument('--list', action='store_true', help='List available plans')
args = parser.parse_args()
base_path = Path('.').resolve()
if args.list:
plans = find_plans(base_path)
if not plans:
print("No reorganization plans found.")
print(f"Create plans in: {base_path / 'docs' / 'reorganization-plans'}")
sys.exit(0)
print(f"\n{Colors.BOLD}Available Reorganization Plans:{Colors.RESET}\n")
for p in plans:
rel_path = p.relative_to(base_path)
print(f" • {rel_path}")
print()
sys.exit(0)
if not args.plan:
# Look for master plan
master_plan = base_path / 'docs' / 'reorganization-plans' / 'MASTER-REORGANIZATION-PLAN.md'
if master_plan.exists():
args.plan = str(master_plan)
else:
print("No plan specified. Use --list to see available plans.")
sys.exit(1)
plan_path = Path(args.plan)
if not plan_path.exists():
print(f"Plan not found: {plan_path}", file=sys.stderr)
sys.exit(1)
plan = parse_plan_file(plan_path)
if not plan.operations:
print(f"{Colors.YELLOW}No operations found in plan{Colors.RESET}")
sys.exit(0)
if args.execute or args.dry_run:
success, failed = execute_plan(plan, dry_run=args.dry_run)
print(f"\n{Colors.BOLD}Results:{Colors.RESET}")
print(f" {Colors.GREEN}Successful: {success}{Colors.RESET}")
if failed:
print(f" {Colors.RED}Failed: {failed}{Colors.RESET}")
if not args.dry_run and success > 0:
print(f"\n{Colors.CYAN}Backup branch: {plan.backup_branch}{Colors.RESET}")
print(f"To rollback: git checkout {plan.backup_branch}")
else:
print(f"\n{Colors.BOLD}Plan: {plan.name}{Colors.RESET}")
print(f"Operations: {len(plan.operations)}\n")
for op in plan.operations[:10]:
print(f" • {op.type}: {op.source} → {op.destination}")
if len(plan.operations) > 10:
print(f" ... and {len(plan.operations) - 10} more")
print(f"\nUse --dry-run to preview or --execute to run")
sys.exit(0)
if name == 'main': main()