diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 000000000..266f0646d --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,53 @@ +# Scripts + +## manage_aliases.py + +Unified tool for managing Homebrew aliases in the Gazebo tap. + +### Subcommands + +#### `bump` — Next-major-version aliases + +Creates aliases that point the *next* major version to the *current* latest +formula. For example, `Aliases/gz-cmake6 -> ../Formula/gz-cmake5.rb`. + +```bash +# Bump all discovered gz-*/sdformat libraries +python3 scripts/manage_aliases.py bump + +# Preview without creating anything +python3 scripts/manage_aliases.py bump --dry-run + +# Bump specific libraries only +python3 scripts/manage_aliases.py bump gz-cmake sdformat +``` + +Libraries are auto-discovered from `Formula/` — no hardcoded list. + +#### `collection-aliases` — Collection-scoped aliases + +Creates aliases that give a collection-prefixed name to each dependency of a +collection formula. For example, +`Aliases/gz-jetty-cmake -> ../Formula/gz-cmake5.rb`. + +```bash +# Create aliases for the Jetty collection +python3 scripts/manage_aliases.py collection-aliases jetty + +# Preview first +python3 scripts/manage_aliases.py collection-aliases jetty --dry-run +``` + +Dependencies are parsed from `Formula/gz-{collection}.rb`. + +### Behavior + +- **Auto-discovery**: `bump` scans `Formula/` so new libraries are picked up + automatically. +- **Idempotent**: re-running reports "already exists, correct" for valid + aliases. +- **Auto-fix**: wrong symlink targets are corrected automatically (the old + script required manual intervention). +- **`--dry-run`**: preview all changes without touching the filesystem. +- Alias files have no `.rb` extension (Homebrew convention). +- Symlink targets are relative (`../Formula/…`). diff --git a/scripts/manage_aliases.py b/scripts/manage_aliases.py new file mode 100755 index 000000000..0d9650ba5 --- /dev/null +++ b/scripts/manage_aliases.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 + +"""Unified alias management for the Gazebo Homebrew tap. + +Subcommands: + bump Create next-major-version aliases for gz-*/sdformat libraries. + collection-aliases Create collection-scoped aliases (e.g. gz-jetty-cmake). +""" + +import argparse +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +FORMULA_DIR = REPO_ROOT / "Formula" +ALIAS_DIR = REPO_ROOT / "Aliases" + +# Matches versioned gz-* and sdformat* formulas, excluding collection formulas +# (gz-jetty.rb has no trailing digits) and ignition-* formulas. +VERSIONED_FORMULA_RE = re.compile(r"^((?:gz-[a-z][-a-z]*|sdformat))(\d+)\.rb$") + +# Matches gz-* and sdformat* dependency strings inside a collection formula. +DEPENDS_ON_RE = re.compile(r'depends_on\s+"((?:gz-[a-z][-a-z]*|sdformat)\d+)"') + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def discover_libraries(formula_dir: Path) -> dict[str, int]: + """Scan Formula/ and return {library_name: latest_version}.""" + libs: dict[str, int] = {} + for path in sorted(formula_dir.iterdir()): + m = VERSIONED_FORMULA_RE.match(path.name) + if m: + name, version = m.group(1), int(m.group(2)) + if name not in libs or version > libs[name]: + libs[name] = version + return libs + + +def dep_to_short_name(dep: str) -> str: + """Strip version and gz- prefix to get a short name. + + gz-fuel-tools11 -> fuel-tools + sdformat16 -> sdformat + """ + m = re.match(r"^(?:gz-)?(.+?)\d+$", dep) + return m.group(1) if m else dep + + +def parse_collection_deps(formula_path: Path) -> list[str]: + """Extract gz-*/sdformat* dependency names from a collection formula.""" + text = formula_path.read_text() + return DEPENDS_ON_RE.findall(text) + + +def create_symlink(alias_path: Path, target: str, *, dry_run: bool) -> None: + """Create, validate, or fix a symlink at *alias_path* -> *target*.""" + if alias_path.is_symlink(): + current = alias_path.readlink().as_posix() + if current == target: + print(f" {alias_path.name}: already exists, correct") + return + # Wrong target — fix it. + if dry_run: + print(f" {alias_path.name}: would fix ({current} -> {target})") + else: + alias_path.unlink() + alias_path.symlink_to(target) + print(f" {alias_path.name}: fixed ({current} -> {target})") + return + + if alias_path.exists(): + print(f" {alias_path.name}: WARNING — exists but is not a symlink, skipping", + file=sys.stderr) + return + + if dry_run: + print(f" {alias_path.name}: would create -> {target}") + else: + alias_path.symlink_to(target) + print(f" {alias_path.name}: created -> {target}") + + +# --------------------------------------------------------------------------- +# Subcommands +# --------------------------------------------------------------------------- + +def cmd_bump(args: argparse.Namespace) -> int: + """Create next-major-version aliases for libraries.""" + libs = discover_libraries(FORMULA_DIR) + + if args.libraries: + # Filter to requested libraries only. + selected = {} + for name in args.libraries: + if name in libs: + selected[name] = libs[name] + else: + print(f"Warning: no versioned formula found for '{name}', skipping", + file=sys.stderr) + libs = selected + + if not libs: + print("No libraries to process.", file=sys.stderr) + return 1 + + if not args.dry_run: + ALIAS_DIR.mkdir(exist_ok=True) + + print(f"Processing {len(libs)} libraries (dry_run={args.dry_run}):\n") + for name in sorted(libs): + current_ver = libs[name] + next_ver = current_ver + 1 + alias_name = f"{name}{next_ver}" + target = f"../Formula/{name}{current_ver}.rb" + create_symlink(ALIAS_DIR / alias_name, target, dry_run=args.dry_run) + + return 0 + + +def cmd_collection_aliases(args: argparse.Namespace) -> int: + """Create collection-scoped aliases for a given collection.""" + collection = args.collection + formula_path = FORMULA_DIR / f"gz-{collection}.rb" + + if not formula_path.exists(): + print(f"Error: formula not found: {formula_path}", file=sys.stderr) + return 1 + + deps = parse_collection_deps(formula_path) + if not deps: + print(f"No gz-*/sdformat* dependencies found in {formula_path.name}", + file=sys.stderr) + return 1 + + if not args.dry_run: + ALIAS_DIR.mkdir(exist_ok=True) + + print(f"Creating {len(deps)} collection aliases for gz-{collection} " + f"(dry_run={args.dry_run}):\n") + for dep in deps: + short = dep_to_short_name(dep) + alias_name = f"gz-{collection}-{short}" + formula_path = FORMULA_DIR / f"{dep}.rb" + alias_target_path = ALIAS_DIR / dep + + if formula_path.exists(): + target = f"../Formula/{dep}.rb" + elif alias_target_path.is_symlink(): + target = f"../Aliases/{dep}" + else: + print(f" {alias_name}: ERROR — no Formula or Alias found for '{dep}'", + file=sys.stderr) + return 1 + + create_symlink(ALIAS_DIR / alias_name, target, dry_run=args.dry_run) + + return 0 + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main() -> int: + parser = argparse.ArgumentParser( + description="Manage Homebrew aliases for the Gazebo tap.") + sub = parser.add_subparsers(dest="command", required=True) + + # -- bump -- + bump_p = sub.add_parser( + "bump", + help="Create next-major-version aliases (e.g. Aliases/gz-cmake6 -> Formula/gz-cmake5.rb)") + bump_p.add_argument( + "libraries", nargs="*", metavar="LIBRARY", + help="Versionless library names to bump (default: all discovered libraries)") + bump_p.add_argument( + "--dry-run", action="store_true", + help="Print what would be done without creating symlinks") + + # -- collection-aliases -- + col_p = sub.add_parser( + "collection-aliases", + help="Create collection-scoped aliases (e.g. Aliases/gz-jetty-cmake -> Formula/gz-cmake5.rb)") + col_p.add_argument( + "collection", metavar="COLLECTION", + help="Collection name without gz- prefix (e.g. jetty, ionic, harmonic)") + col_p.add_argument( + "--dry-run", action="store_true", + help="Print what would be done without creating symlinks") + + args = parser.parse_args() + + if args.command == "bump": + return cmd_bump(args) + elif args.command == "collection-aliases": + return cmd_collection_aliases(args) + + return 0 + + +if __name__ == "__main__": + sys.exit(main())