#!/usr/bin/env python3 """Sweep this coa-ace3 bundle into every Exiles fork that embeds Ace3 libs. Run from anywhere; the script locates itself and walks sibling repos at $REPOS_ROOT (default /home/sub/repos/coa). For each fork that bundles any AceXxx-3.0 library, the matching directory is rsynced from this bundle with --delete so the fork stays byte-identical to canonical. USAGE tools/sweep.py # apply sweep tools/sweep.py --dry-run # show what would change without writing WHY THIS SCRIPT EXISTS Previous sweeps were ad-hoc scripts living in /tmp during a session and re-derived by hand each time. That made it easy to forget the ElvUI exclusion (see EXCLUDED_FORKS below) and re-clobber ElvUI's customized lib stack on every sync. Committing the canonical script here keeps the exclusion list visible and reviewable. EXCLUDED_FORKS coa-elvui ships its OWN bundled Ace3 with ElvUI-specific patches inside otherwise-stock-named files (AceLocale, AceConfigDialog, the AceGUI widgets) PLUS files that don't exist in canonical at all (AceGUIWidget-Button-ElvUI.lua). rsync --delete obliterates the latter; an in-place sync overwrites the former. Either failure breaks /ec and floods locale errors. ElvUI maintains its own bundle on its own cadence and must never be swept by this tool. """ import argparse import os import re import subprocess import sys from pathlib import Path # --- configuration ----------------------------------------------------------- REPOS_ROOT = Path(os.environ.get("REPOS_ROOT", "/home/sub/repos/coa")) BUNDLE = Path(__file__).resolve().parent.parent / "Ace3" # canonical Ace3 lives under /Ace3/ # Forks that this sweep MUST NOT touch. Document the reason inline. EXCLUDED_FORKS = { "coa-elvui", # ships its own ElvUI-patched Ace3 stack; sweep would clobber it } # Lib names whose directories we sync from the bundle. LIB_NAMES = re.compile(r"^(LibStub|CallbackHandler-1\.0|Ace\w*-3\.0)$") # rsync excludes — repo metadata never deployed, plus a belt-and-braces # guard against deleting any -ElvUI file that might exist in destinations we # didn't intend to touch (e.g. a future fork that pulls in some ElvUI widget). RSYNC_EXCLUDES = [ ".git", ".gitattributes", ".gitignore", ".github", ".idea", ".editorconfig", ".luacheckrc", ".pkgmeta", "*-ElvUI*", # never delete or overwrite ElvUI-namespaced files ] # --- discovery --------------------------------------------------------------- def bundle_lib_sources(): """Return {libname: absolute path inside bundle} for every shippable lib. Handles the nested AceConfig children (AceConfigCmd/Dialog/Registry). """ out = {} for path in BUNDLE.rglob("*"): if not path.is_dir(): continue if path.name in {".git", "tools"}: continue if LIB_NAMES.match(path.name): out.setdefault(path.name, path) return out def fork_lib_targets(fork: Path, src_names): """Return {libname: absolute path inside fork} for each lib the fork bundles. A lib is "bundled" when there's a top-level directory containing /.lua. We don't add new libs to forks that didn't already ship them. """ targets = {} for path in fork.rglob("*.lua"): if "/.git/" in str(path): continue if "-ElvUI" in path.name: continue name = path.stem if name not in src_names: continue parent = path.parent if parent.name != name: continue targets.setdefault(name, parent) return targets # --- sweep ------------------------------------------------------------------- def sweep(dry_run: bool) -> int: src_map = bundle_lib_sources() if not src_map: print(f"no libs found inside {BUNDLE}; nothing to sweep", file=sys.stderr) return 2 forks = sorted(p for p in REPOS_ROOT.iterdir() if p.is_dir() and p.name.startswith("coa-") and p.name != "coa-ace3" and (p / ".git").exists()) print(f"bundle: {BUNDLE}") print(f"forks scan: {REPOS_ROOT}") print(f"excluded: {sorted(EXCLUDED_FORKS)}") print(f"libs: {sorted(src_map)}") print() total_synced = total_skipped = 0 for fork in forks: if fork.name in EXCLUDED_FORKS: print(f" skip {fork.name} (excluded — ships its own customized Ace3)") total_skipped += 1 continue targets = fork_lib_targets(fork, src_map.keys()) if not targets: print(f" no-libs {fork.name}") continue for libname in sorted(targets): src = src_map[libname] dst = targets[libname] cmd = ["rsync", "-a", "--delete"] if dry_run: cmd += ["--dry-run", "--itemize-changes"] for exc in RSYNC_EXCLUDES: cmd += ["--exclude", exc] cmd += [f"{src}/", f"{dst}/"] res = subprocess.run(cmd, capture_output=True, text=True) if res.returncode != 0: print(f" ! rsync failed for {fork.name}/{libname}: {res.stderr.strip()}") continue tag = "would-sync" if dry_run else "synced" print(f" {tag:<9} {fork.name}/{dst.relative_to(fork)}") total_synced += 1 print() print(f"summary: {total_synced} lib dirs {'would be synced' if dry_run else 'synced'}, " f"{total_skipped} forks excluded") return 0 def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--dry-run", action="store_true", help="show what would change without writing") args = ap.parse_args() sys.exit(sweep(args.dry_run)) if __name__ == "__main__": main()