From a96308ff2c82a10d7304ed36dd843f5af3479e9e Mon Sep 17 00:00:00 2001 From: Florian Berthold Date: Mon, 25 May 2026 10:06:41 +0200 Subject: [PATCH] tools: add canonical sweep.py with explicit coa-elvui exclusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two failures that compounded in the last round of ElvUI breakage: 1. The sweep script only lived in /tmp during a session — re-derived from scratch each time, so the EXCLUDE_FORKS knowledge wasn't anywhere reviewable. 2. The old filename-only filter ('if -ElvUI in name skip') missed ElvUI's customizations inside otherwise-stock-named files AND rsync --delete killed -ElvUI suffixed widgets that exist only in ElvUI's bundle (e.g. AceGUIWidget-Button-ElvUI.lua). This tool fixes both: - Lives in the repo (tools/sweep.py), so the exclusion list is visible in version control and reviewable. - EXCLUDED_FORKS = {'coa-elvui'} hardcoded with an in-source comment explaining why. - --exclude='*-ElvUI*' passed to every rsync as belt-and-braces, so even if a future fork accidentally carries an ElvUI-namespaced file we never wanted to overwrite, the sweep won't touch it. - Refuses to add new lib dirs — only updates ones already present in the fork. - --dry-run flag for safe verification. README updated with a 'Forks excluded from sweep' section documenting the same. --- README.md | 17 ++++++ tools/sweep.py | 163 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100755 tools/sweep.py diff --git a/README.md b/README.md index 60b058d..7f8cb54 100644 --- a/README.md +++ b/README.md @@ -69,3 +69,20 @@ libs. Note the new upstream commit in the README's commit-pin line above, and re the CoA-compat patches listed above against the new revision (the FDID one is mechanical — see `/tmp/fix_fdid.py` history). Keep patches **minimal and documented in this README**; prefer fixing them upstream where reasonable. + +Run the sweep via `tools/sweep.py` from this repo — it walks every sibling `coa-*` fork +under `/home/sub/repos/coa`, finds each fork's bundled `LibStub` / `CallbackHandler-1.0` / +`Ace*-3.0` dirs, and rsyncs them from this bundle. Use `--dry-run` first. + +### Forks excluded from sweep + +`coa-elvui` is excluded from the sweep — see `EXCLUDED_FORKS` in `tools/sweep.py`. ElvUI +ships its own bundled Ace3 stack with ElvUI-specific patches inside otherwise-stock-named +files (`AceLocale-3.0.lua`, `AceConfigDialog-3.0.lua`, every `AceGUI-3.0/widgets/*.lua`) +**plus** `-ElvUI`-suffixed widgets that don't exist in canonical at all (e.g. +`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 touched by this tool. + +The sweep also passes `--exclude='*-ElvUI*'` to every rsync as a belt-and-braces guard +against future forks that happen to carry an ElvUI-namespaced file we didn't anticipate. diff --git a/tools/sweep.py b/tools/sweep.py new file mode 100755 index 0000000..c773894 --- /dev/null +++ b/tools/sweep.py @@ -0,0 +1,163 @@ +#!/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 # the coa-ace3 repo root + +# 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()