#!/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())