🔒 Repository is read-only – file editing is disabled.

PaganLinux/fix-recipes-cd.py main

204 linii Raw ← Powrót
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
#!/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()