#!/usr/bin/env python3 # LowercaseDepotPaths.py # This script recursively renames files and directories whose basenames contain uppercase characters to their lowercase equivalents. # This is necessary for Perforce depot paths which have been converted from case sensitive to insensitive. from __future__ import annotations import argparse import os import subprocess import sys from collections import defaultdict from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from pathlib import Path @dataclass(frozen=True) class RenameOp: src: Path dst: Path depth: int is_dir: bool merge: bool = False # merge src files into existing leaf dst dir then rmdir src def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description=( "Recursively rename files and directories whose basenames contain " "uppercase characters to their lowercase equivalents." ) ) parser.add_argument( "target_dir", nargs="?", default=".", help="Directory tree to scan (default: current directory).", ) parser.add_argument( "-n", "--dry-run", action="store_true", help="Show planned renames without applying them.", ) parser.add_argument( "-j", "--jobs", type=int, default=max(1, min(32, (os.cpu_count() or 1))), help="Number of worker threads for independent parent directories.", ) parser.add_argument( "-s", "--serial", action="store_true", help="Force serial execution.", ) parser.add_argument( "-q", "--quiet", action="store_true", help="Only print errors and the final summary.", ) parser.add_argument( "--remove-empty-only", action="store_true", help="Only remove empty directory trees; skip lowercase renaming.", ) return parser.parse_args() def has_uppercase(name: str) -> bool: return any(char.isupper() for char in name) def collect_ops(root: Path) -> list[RenameOp]: ops: list[RenameOp] = [] for current_root, dirnames, filenames in os.walk(root, topdown=False): current_path = Path(current_root) for filename in filenames: lower_name = filename.lower() if lower_name != filename and has_uppercase(filename): src = current_path / filename ops.append( RenameOp( src=src, dst=current_path / lower_name, depth=len(src.relative_to(root).parts), is_dir=False, ) ) for dirname in dirnames: lower_name = dirname.lower() if lower_name != dirname and has_uppercase(dirname): src = current_path / dirname ops.append( RenameOp( src=src, dst=current_path / lower_name, depth=len(src.relative_to(root).parts), is_dir=True, ) ) return ops def same_path_entry(src: Path, dst: Path) -> bool: try: return dst.exists() and src.samefile(dst) except FileNotFoundError: return False def file_collision(src: Path, dst: Path) -> Path | None: source_files: dict[tuple[str, ...], Path] = {} for current_root, _, filenames in os.walk(src): current_path = Path(current_root) for filename in filenames: source_file = current_path / filename relative_parts = source_file.relative_to(src).parts normalized_parts = tuple(part.lower() for part in relative_parts) other_source = source_files.get(normalized_parts) if other_source is not None: return other_source source_files[normalized_parts] = source_file for current_root, _, filenames in os.walk(dst): current_path = Path(current_root) for filename in filenames: relative_parts = (current_path / filename).relative_to(dst).parts normalized_parts = tuple(part.lower() for part in relative_parts) source_file = source_files.get(normalized_parts) if source_file is not None: return source_file return None def validate_ops(ops: list[RenameOp]) -> tuple[list[str], list[str], list[RenameOp]]: warnings: list[str] = [] errors: list[str] = [] result: list[RenameOp] = [] destinations: dict[Path, Path] = {} for op in ops: other_src = destinations.get(op.dst) if other_src is not None and other_src != op.src: warnings.append(f"Collision: {op.src} and {other_src} both map to {op.dst}") continue destinations[op.dst] = op.src if op.dst.exists() and not same_path_entry(op.src, op.dst): if op.is_dir: collision = file_collision(op.src, op.dst) if collision is not None: warnings.append( f"Collision: {collision} conflicts with a file in {op.dst}" ) continue result.append(RenameOp( src=op.src, dst=op.dst, depth=op.depth, is_dir=True, merge=True, )) continue errors.append(f"Target already exists: {op.src} -> {op.dst}") result.append(op) continue result.append(op) return warnings, errors, result def print_op(prefix: str, op: RenameOp) -> None: kind = "dir " if op.is_dir else "file" print(f"{prefix} [{kind}] {op.src} -> {op.dst}") def remove_empty_dirs_recursive(root: Path, quiet: bool) -> int: """Remove all empty directory trees under root, depth-first. Returns count removed.""" removed = 0 # Iterate multiple times until no more empty dirs are found (handles nested empties). while True: dirs_this_pass = 0 for empty_dir in sorted(root.rglob("*"), key=lambda p: len(p.parts), reverse=True): if empty_dir.is_dir() and empty_dir != root: try: empty_dir.rmdir() dirs_this_pass += 1 removed += 1 if not quiet: print(f"Removed [dir ] {empty_dir}") except OSError: pass if dirs_this_pass == 0: break return removed def merge_dir_into(src: Path, dst: Path, dry_run: bool, quiet: bool) -> int: """Merge src tree into dst using rsync, then rmdir src. Returns number of entries moved.""" if dry_run: moved = sum(1 for _ in src.rglob("*")) if not quiet: print(f"Would merge [dir ] {src} -> {dst}") return moved try: result = subprocess.run( ["rsync", "--remove-source-files", "-a", str(src) + "/", str(dst) + "/"], capture_output=True, text=True, check=False, ) if result.returncode != 0: raise OSError(f"rsync failed: {result.stderr}") except FileNotFoundError: raise OSError("rsync not found; cannot perform merge operation") moved = 0 for entry in dst.rglob("*"): lower_name = entry.name.lower() if lower_name != entry.name: lower_path = entry.parent / lower_name try: os.rename(entry, lower_path) moved += 1 if not quiet: kind = "[dir ]" if entry.is_dir() else "[file]" print(f"Merged {kind} {entry} -> {lower_path}") except OSError as exc: print(f"Warning: could not lowercase {entry}: {exc}", file=sys.stderr) else: moved += 1 # Remove src and any now-empty parent directories depth-first (bottom-up). try: for empty_dir in sorted(src.rglob("*"), key=lambda p: len(p.parts), reverse=True): if empty_dir.is_dir(): try: empty_dir.rmdir() if not quiet: print(f"Removed [dir ] {empty_dir}") except OSError: pass src.rmdir() if not quiet: print(f"Removed [dir ] {src}") except OSError as exc: print(f"Warning: could not remove {src}: {exc}", file=sys.stderr) return moved def apply_group(ops: list[RenameOp], dry_run: bool, quiet: bool) -> int: renamed = 0 for op in ops: if op.merge: if not quiet: print_op("Would merge" if dry_run else "Merging ", op) renamed += merge_dir_into(op.src, op.dst, dry_run=dry_run, quiet=quiet) continue if dry_run: if not quiet: print_op("Would rename", op) renamed += 1 continue os.rename(op.src, op.dst) renamed += 1 if not quiet: print_op("Renamed ", op) return renamed def apply_ops(ops: list[RenameOp], dry_run: bool, quiet: bool, jobs: int) -> int: total = 0 by_depth: dict[int, list[RenameOp]] = defaultdict(list) for op in ops: by_depth[op.depth].append(op) for depth in sorted(by_depth, reverse=True): by_parent: dict[Path, list[RenameOp]] = defaultdict(list) for op in by_depth[depth]: by_parent[op.src.parent].append(op) groups = list(by_parent.values()) if jobs == 1 or len(groups) == 1: for group in groups: total += apply_group(group, dry_run=dry_run, quiet=quiet) continue with ThreadPoolExecutor(max_workers=jobs) as executor: futures = [ executor.submit(apply_group, group, dry_run=dry_run, quiet=quiet) for group in groups ] for future in futures: total += future.result() return total def main() -> int: args = parse_args() root = Path(args.target_dir).resolve() if not root.is_dir(): print(f"Target is not a directory: {root}", file=sys.stderr) return 1 if args.jobs < 1: print("--jobs must be a positive integer", file=sys.stderr) return 1 # Handle --remove-empty-only mode. if args.remove_empty_only: removed = remove_empty_dirs_recursive(root, quiet=args.quiet) print(f"Done. Removed {removed} empty director{'y' if removed == 1 else 'ies'}.") return 0 jobs = 1 if args.serial else args.jobs ops = collect_ops(root) if not ops: print("Done. No uppercase file or directory names found.") return 0 warnings, errors, ops = validate_ops(ops) for warning in warnings: print(warning, file=sys.stderr) if errors: for error in errors: print(error, file=sys.stderr) return 1 try: renamed = apply_ops(ops, dry_run=args.dry_run, quiet=args.quiet, jobs=jobs) except OSError as exc: print(f"Rename failed: {exc}", file=sys.stderr) return 1 mode = "Would rename" if args.dry_run else "Renamed" print(f"Done. {mode} {renamed} path(s).") return 0 if __name__ == "__main__": raise SystemExit(main())