#!/usr/bin/env python3 # ============================================================================= # fix-recipes-cd.py – analiza i bezpieczna normalizacja komend `cd` w recepturach # ============================================================================= # PAGBUILD.yaml (fazy build/package). Nie USUWA `cd` (build.sh wchodzi tylko do # kontenera src-*, a receptura musi sama wejść do katalogu źródeł – usunięcie # `cd ${pkgname}` zepsułoby build). Zamiast tego: # # --check (domyślnie) – klasyfikuje każdy `cd`: # OK kanoniczne (${pkgname}, ${pkgname}-${pkgver}, generyczne) # HARDCODED wersja wpisana na sztywno == aktualny pkgver (bezpieczny fix) # STALE wersja na sztywno != pkgver (po bumpie katalog nie istnieje!) # SUSPICIOUS nie da się dopasować do żadnego wzorca # --fix – przepisuje HARDCODED/STALE na ${pkgname}-${pkgver} # (zachowuje cudzysłowy; --dry-run domyślnie pokazuje diff) # --no-git – NIE commituj/pushuj zmian (domyślnie: po zapisie zmian # robi commit + pull --rebase + push do origin, bo inaczej # pagsync przy następnym syncu odłoży je na stash, a przy # konflikcie CICHO WYMAŻE przez `git checkout -- .`) # # Użycie: # fix-recipes-cd.py [katalog_receptur] [--fix] [--no-dry-run] [--no-git] # Domyślny katalog: $PAGAN_RECIPES lub /var/lib/pagan-sync/recipes # ============================================================================= import os import re import subprocess import sys import yaml _GENERIC = {"build", "builddir", "bld", "src", "source", "sources", "work", "dist", "tmp", "out", "debug", "release", ".", ".."} def phase_text(phase): """Faza build/package z YAML (string lub lista stringów) -> tekst.""" if isinstance(phase, list): return "\n".join(str(x) for x in phase) return str(phase or "") def expand(w, pkgname, pkgver, pkgrel): w = w.replace("${pkgname}", pkgname).replace("$pkgname", pkgname) w = w.replace("${pkgver}", pkgver).replace("$pkgver", pkgver) w = w.replace("${pkgrel}", pkgrel).replace("$pkgrel", pkgrel) return w def classify(raw, wanted, pkgname, pkgver): """Zwraca (status, sugestia). status ∈ OK/HARDCODED/STALE/SUSPICIOUS. raw – token przed rozwinięciem (czy zawiera $), wanted – po rozwinięciu. """ if (not wanted or wanted in _GENERIC or "*" in wanted or "$" in wanted or "/" in wanted or wanted in (".", "..")): return "OK", "" if wanted == pkgname: return "OK", "" if wanted == f"{pkgname}-{pkgver}": # To samo co ${pkgname}-${pkgver}, ale na sztywno – po bumpie wersji # katalog nie będzie istniał, dlatego szablon jest bezpieczniejszy. if "$" in raw: return "OK", "" return "HARDCODED", "${pkgname}-${pkgver}" if wanted.startswith(pkgname + "-"): # Zastała wersja (nie == pkgver) – katalog nie istnieje po bumpie. return "STALE", "${pkgname}-${pkgver}" return "SUSPICIOUS", "" def scan_recipe(path): """Zwraca listę (faza, linia_nr, raw_token, status, sugestia).""" with open(path, encoding="utf-8") as f: data = yaml.safe_load(f) or {} pkgname = str(data.get("pkgname", "")) pkgver = str(data.get("pkgver", "")) pkgrel = str(data.get("pkgrel", 1)) out = [] for phase_key in ("build", "package"): text = phase_text(data.get(phase_key)) for ln, line in enumerate(text.splitlines(), 1): m = re.match(r'^\s*cd\s+["\']?([^"\'\s;]+)', line) if not m: continue raw = m.group(1) status, suggestion = classify(raw, expand(raw, pkgname, pkgver, pkgrel), pkgname, pkgver) if status != "OK": out.append((phase_key, ln, raw, status, suggestion)) return pkgname, pkgver, out def git_commit_and_push(recipes_dir, files_changed): """Commit + push zmian receptur do origin (wzorzec jak auto_fix_sha256sums w pagsync). Bez tego zmiany siedziałyby lokalnie, a następny pagsync odłożyłby je na stash i mógł CICHO WYMAZAĆ przy konflikcie (checkout -- .).""" if not os.path.isdir(os.path.join(recipes_dir, ".git")): print(" ⚠ katalog nie jest repo git – zmiany zostały TYLKO lokalnie") return False run = lambda cmd, **kw: subprocess.run(cmd, capture_output=True, text=True, errors="replace", timeout=60, **kw) run(["git", "-C", recipes_dir, "add", "-A"]) commit_r = run(["git", "-C", recipes_dir, "commit", "-m", f"fix: normalizacja cd w recepturach ({files_changed})"]) if commit_r.returncode != 0: print(f" ⚠ commit nieudany: {commit_r.stderr.strip()[:200]}") return False # Rebase lokalnych commitów na origin – push nie zostanie odrzucony # jako non-fast-forward (jak w pagsync auto_fix_sha256sums). rebase_r = run(["git", "-C", recipes_dir, "pull", "--rebase", "origin", "main"]) if rebase_r.returncode != 0: subprocess.run(["git", "-C", recipes_dir, "rebase", "--abort"], capture_output=True, timeout=10) print(" ⚠ Rebase przed pushem nieudany – zmiany zostają lokalnie") print(f" {rebase_r.stderr.strip()[:200]}") return False push_r = run(["git", "-C", recipes_dir, "push", "origin", "main"]) if push_r.returncode != 0: print(f" ⚠ Push do origin NIEUDANY: {push_r.stderr.strip()[:200]}") print(" Commit został lokalnie – następny pull zrobi rebase i wypchnie.") return False print(f" 📤 Wypchnięto do origin ({recipes_dir})") return True def main(): args = [a for a in sys.argv[1:] if not a.startswith("--")] do_fix = "--fix" in sys.argv dry_run = "--no-dry-run" not in sys.argv no_git = "--no-git" in sys.argv recipes_dir = (args[0] if args else os.environ.get("PAGAN_RECIPES", "/var/lib/pagan-sync/recipes")) if not os.path.isdir(recipes_dir): print(f"❌ Brak katalogu receptur: {recipes_dir}") print(" Podaj ścieżkę jako argument albo ustaw PAGAN_RECIPES.") sys.exit(2) found = fixable = stale = suspicious = 0 written = 0 for root, _dirs, files in os.walk(recipes_dir): if "PAGBUILD.yaml" not in files: continue path = os.path.join(root, "PAGBUILD.yaml") try: pkgname, pkgver, issues = scan_recipe(path) except Exception as e: print(f"⚠ {path}: błąd parsowania YAML – {e}") continue if not issues: continue found += 1 print(f"\n📄 {path} ({pkgname}-{pkgver})") fix_items = [] for phase, ln, raw, status, suggestion in issues: if status == "HARDCODED": fixable += 1 elif status == "STALE": stale += 1 else: suspicious += 1 print(f" [{phase}:{ln}] cd {raw:.<40} {status}" + (f" → {suggestion}" if suggestion else "")) if do_fix and status in ("HARDCODED", "STALE"): fix_items.append((raw, suggestion)) if do_fix and fix_items: # Celowana podmiana tekstu (bez przebudowy YAML – safe_dump m.in. # zamieniał nowe linie na literalne \\n i psuł recepturę). with open(path, encoding="utf-8") as f: content = f.read() for raw, suggestion in fix_items: # (?:\)? – obsługa escapowanych cudzysłowów w YAML-owych # stringach (faza jako lista: - "cd \"baz-3.1\""). pattern = re.compile(r'(\bcd\s+(?:\\)?["\']?)' + re.escape(raw) + r'(?=(?:\\)?["\'\s;]|$)') content, n = pattern.subn(lambda m: m.group(1) + suggestion, content) if n: print(f" ✏️ cd {raw} → cd {suggestion} ({n}×)") else: print(f" ⚠ nie znaleziono w pliku: cd {raw} (pominięto)") if dry_run: print(" (dry-run – nie zapisano; użyj --no-dry-run, by zapisać)") else: with open(path, "w", encoding="utf-8") as f: f.write(content) written += 1 print(" ✅ zapisano") print(f"\n📊 Podsumowanie: {found} receptur z problematycznym cd | " f"HARDCODED: {fixable} | STALE: {stale} | SUSPICIOUS: {suspicious}") if not do_fix: print("Uruchom z --fix (--no-dry-run, by zapisać), aby przepisać HARDCODED/STALE.") return if written and not dry_run and not no_git: git_commit_and_push(recipes_dir, written) elif written and dry_run: print("(dry-run – nie zapisano nic, brak commita)") elif written and no_git: print(" ⚠ --no-git: zmiany zostały lokalnie, NIE wypchnięte do origin!") if __name__ == "__main__": main()