git/orchard/main
updater/changes.py
324 lines11 KB last change raw · history
"""Turn a cloned AUR package's git history into an audit `Change`.

paru clones each AUR package as a git repository, so "what changed since I last
built this" is a git question, not a scraping question. We record the commit we
built at in the AUR state file and diff HEAD against it.

A package with no recorded commit has no baseline: we have never built it, or we
have lost the record. That is reported as such rather than pretending the current
PKGBUILD is unchanged.
"""

from __future__ import annotations

import subprocess
from dataclasses import dataclass
from pathlib import Path

from .audit import Change
from .srcinfo import parse_pkgbuild, parse_srcinfo

FILES = ("PKGBUILD", ".SRCINFO", ".install")


@dataclass(frozen=True)
class Commit:
    sha: str
    summary: str = ""
    when: str = ""


def git(repo: Path, *args: str, timeout: float = 20.0) -> str:
    """Run git in a clone dir. Returns stdout, or '' if the command fails."""
    try:
        proc = subprocess.run(
            ["git", "-C", str(repo), *args],
            capture_output=True,
            text=True,
            errors="replace",
            timeout=timeout,
            check=False,
        )
    except (OSError, subprocess.SubprocessError):
        return ""
    return proc.stdout if proc.returncode == 0 else ""


def show(repo: Path, rev: str, path: str) -> str:
    return git(repo, "show", f"{rev}:{path}")


def head(repo: Path, path: str = "PKGBUILD") -> Commit:
    """The most recent commit that touched `path`."""
    out = git(repo, "log", "-1", "--format=%H%x1f%s%x1f%cI", "--", path)
    if not out.strip():
        return Commit(sha="")
    parts = out.strip().split("\x1f")
    return Commit(
        sha=parts[0],
        summary=parts[1] if len(parts) > 1 else "",
        when=parts[2] if len(parts) > 2 else "",
    )


def history(repo: Path, path: str = "PKGBUILD", limit: int = 2) -> list[Commit]:
    out = git(repo, "log", f"-{limit}", "--format=%H%x1f%s%x1f%cI", "--", path)
    commits = []
    for line in out.splitlines():
        if not line.strip():
            continue
        parts = line.split("\x1f")
        commits.append(
            Commit(
                sha=parts[0],
                summary=parts[1] if len(parts) > 1 else "",
                when=parts[2] if len(parts) > 2 else "",
            )
        )
    return commits


def has_commit(repo: Path, sha: str) -> bool:
    """Whether the clone still contains this commit.

    paru may re-clone or garbage-collect a clone dir, so a recorded baseline can
    legitimately have disappeared; that must read as "no baseline", not as
    "nothing changed".
    """
    if not sha:
        return False
    try:
        proc = subprocess.run(
            ["git", "-C", str(repo), "cat-file", "-e", f"{sha}^{{commit}}"],
            capture_output=True,
            text=True,
            errors="replace",
            timeout=10,
            check=False,
        )
    except (OSError, subprocess.SubprocessError):
        return False
    return proc.returncode == 0


def change_from_clone(
    clone_dir: Path,
    package: str,
    *,
    old_commit: str = "",
    old_maintainer: str | None = None,
    new_maintainer: str | None = None,
) -> Change:
    """A `Change` describing HEAD against `old_commit`.

    With no usable baseline the old side is left empty, which the audit treats as
    "nothing to compare" rather than "nothing changed" -- the difference matters,
    because the second would silently report a rewrite as a no-op.
    """
    repo = clone_dir / package
    new_pkgbuild = (
        (repo / "PKGBUILD").read_text(errors="replace") if (repo / "PKGBUILD").is_file() else ""
    )
    new_srcinfo = (
        (repo / ".SRCINFO").read_text(errors="replace") if (repo / ".SRCINFO").is_file() else ""
    )

    old_pkgbuild = old_srcinfo = ""
    if old_commit and has_commit(repo, old_commit):
        old_pkgbuild = show(repo, old_commit, "PKGBUILD")
        old_srcinfo = show(repo, old_commit, ".SRCINFO")

    # `.install` scriptlets ship beside the PKGBUILD and run as root, so read
    # them here, where the clone directory is known. The rules never touch the
    # filesystem themselves.
    def scriptlets(pkgbuild_text: str, revision: str) -> dict[str, str]:
        if not pkgbuild_text:
            return {}
        out: dict[str, str] = {}
        for name in parse_pkgbuild(pkgbuild_text).vars.get("install", []):
            if not name.endswith(".install"):
                continue
            if revision:
                text = show(repo, revision, name)
            else:
                path = repo / name
                text = path.read_text(errors="replace") if path.is_file() else ""
            if text:
                out[name] = text
        return out

    return Change(
        package=package,
        old_srcinfo=parse_srcinfo(old_srcinfo) if old_srcinfo else None,
        new_srcinfo=parse_srcinfo(new_srcinfo) if new_srcinfo else None,
        old_pkgbuild=parse_pkgbuild(old_pkgbuild) if old_pkgbuild else None,
        new_pkgbuild=parse_pkgbuild(new_pkgbuild) if new_pkgbuild else None,
        old_maintainer=old_maintainer,
        new_maintainer=new_maintainer,
        old_install=scriptlets(old_pkgbuild, old_commit) if old_commit else {},
        new_install=scriptlets(new_pkgbuild, ""),
    )


def change_for_audit(
    clone_dir: Path,
    package: str,
    *,
    network: bool = True,
    old_commit: str = "",
    old_maintainer: str | None = None,
    new_maintainer: str | None = None,
) -> Change:
    """A `Change` for auditing, falling back to the AUR when nothing is cloned.

    paru only clones what it has built. A package it has never built had no
    PKGBUILD on disk, so every rule examined nothing and the package was
    reported as "not audited" -- for a file that is one HTTP request away.

    There is still no old side to compare against, so the result is a NEW_ONLY
    reading. That is a weaker answer than a comparison, but it is a real answer:
    the patterns that need no baseline still apply.
    """
    change = change_from_clone(
        clone_dir,
        package,
        old_commit=old_commit,
        old_maintainer=old_maintainer,
        new_maintainer=new_maintainer,
    )
    if change.new_pkgbuild is not None or not network:
        return change

    from . import fetch

    body, _error = fetch.pkgbuild(package)
    if not body:
        return change

    old_pkgbuild = change.old_pkgbuild
    scriptlets: dict[str, str] = {}
    for name in parse_pkgbuild(body).vars.get("install", []):
        if not name.endswith(".install"):
            continue
        text = show(clone_dir / package, old_commit, name) if old_commit else ""
        if text:
            scriptlets[name] = text
        else:
            remote_text, _ = fetch.pkgbuild_file(package, name)
            if remote_text:
                scriptlets[name] = remote_text

    return Change(
        package=package,
        old_srcinfo=change.old_srcinfo,
        new_srcinfo=parse_srcinfo(fetch.pkgbuild_file(package, ".SRCINFO")[0] or "") or None,
        old_pkgbuild=old_pkgbuild,
        new_pkgbuild=parse_pkgbuild(body),
        old_maintainer=old_maintainer,
        new_maintainer=new_maintainer,
        old_install=change.old_install,
        new_install=scriptlets,
    )


# --------------------------------------------------------------------------
# baselines
# --------------------------------------------------------------------------


def split_version(version: str) -> tuple[str, str, str]:
    """`1:2.3.4-5` -> (pkgver, pkgrel, epoch)."""
    epoch = ""
    rest = version
    if ":" in rest:
        epoch, rest = rest.split(":", 1)
    pkgrel = ""
    if "-" in rest:
        rest, pkgrel = rest.rsplit("-", 1)
    return rest, pkgrel, epoch


def commit_for_version(repo: Path, version: str, *, limit: int = 40) -> str:
    """The most recent commit whose PKGBUILD declares this exact version.

    This is what makes a diff possible on a machine that has never run an update:
    the recorded build baseline does not exist yet, but the version sitting in the
    package database does, and the AUR git history can be searched for it.

    A pickaxe search (`-S`) does it in one git call. It falls back to walking the
    log because `-S` is a text match and a PKGBUILD may spell the version through
    a variable.
    """
    pkgver, _pkgrel, _epoch = split_version(version)
    if not pkgver:
        return ""

    found = git(repo, "log", "--format=%H", f"-S{pkgver}", "--", "PKGBUILD")
    for sha in found.split():
        # the newest commit that introduced it, confirmed by parsing rather than
        # trusting the text match
        shown = show(repo, sha, "PKGBUILD")
        if shown and declared_version(shown) == version:
            return sha

    for commit in history(repo, "PKGBUILD", limit=limit):
        shown = show(repo, commit.sha, "PKGBUILD")
        if shown and declared_version(shown) == version:
            return commit.sha
    return ""


def declared_version(pkgbuild: str) -> str:
    """The full version a PKGBUILD declares, as `[epoch:]pkgver[-pkgrel]`.

    Matching on `pkgver` alone is not enough: a maintainer bumps `pkgrel` for a
    dependency rebuild without touching `pkgver`, so `1.0-1` and `1.0-2` both
    match the commit that first declared `1.0`. The diff is then taken against
    a revision the machine never had, and everything the packager changed
    between the two looks like a fresh finding.
    """
    parsed = parse_pkgbuild(pkgbuild)
    version = parsed.one("pkgver")
    if not version:
        return ""
    if rel := parsed.one("pkgrel"):
        version += f"-{rel}"
    if epoch := parsed.one("epoch"):
        version = f"{epoch}:{version}"
    return version


def baseline(
    clone_dir: Path, package: str, installed_version: str = "", recorded_commit: str = ""
) -> tuple[str, str]:
    """Pick something to diff against. Returns (commit, how) where how is one of
    "recorded", "version", or "" when nothing usable was found.

    Recorded wins because it is what you actually reviewed and built last time.
    The version match is the fallback, and the caller is expected to say which
    one it used rather than presenting them as equivalent.

    `how` is one of "recorded", "version", "missing" (no local clone) or "" (a
    clone exists but the installed version is not in its history).
    """
    repo = clone_dir / package
    if not repo.is_dir():
        # distinct from "the version is not in the history": there is no local
        # clone at all, so there is nothing here to compare against
        return "", "missing"
    if recorded_commit and has_commit(repo, recorded_commit):
        return recorded_commit, "recorded"
    if installed_version:
        sha = commit_for_version(repo, installed_version)
        if sha:
            return sha, "version"
    return "", ""


def diff_against(clone_dir: Path, package: str, commit: str) -> tuple[str, str]:
    """(old PKGBUILD, new PKGBUILD) for a baseline. Either may be empty."""
    repo = clone_dir / package
    new = (repo / "PKGBUILD").read_text(errors="replace") if (repo / "PKGBUILD").is_file() else ""
    old = show(repo, commit, "PKGBUILD") if commit and has_commit(repo, commit) else ""
    return old, new