#!/usr/bin/env python3
"""AG-03 CI gate — fail if any commit on this branch lacks both required trailers.

Every commit MUST carry **both** ``Signed-off-by:`` and ``Co-Authored-By:``
trailers (AGENTS **AG-03**). The ``.gitmessage`` template + the
``prepare-commit-msg`` hook (AG-18) handle the *local* side, but those depend on
each contributor's git config; this is the **authoritative server-side gate** —
it runs in CI, depends on no local setup, and catches **both** trailers (the
hook only auto-fills the constant ``Signed-off-by``).

Run on ``push`` / ``pull_request``. It checks every non-merge commit unique to
the current branch relative to its base and exits non-zero listing any commit
missing a required trailer. The base is resolved by
:func:`_base_candidates` — ``CI_COMMIT_TARGET_BRANCH``, then ``origin/HEAD``,
then ``main`` and ``master`` — because hardcoding one conventional name makes
the gate a silent no-op on repositories that chose the other.

Stdlib-only. The pure :func:`missing_trailers` predicate is unit-tested in
``tests/unit/test_check_commit_trailers.py``, shipped alongside this file by the
mgf-standard scaffold seam; the git plumbing around it is a thin shell.
"""

from __future__ import annotations

import os
import shutil
import subprocess
import sys

# Trailer keys every commit MUST carry, compared case-insensitively (git
# trailers are conventionally case-insensitive: ``Co-authored-by`` == ``Co-Authored-By``).
REQUIRED_TRAILERS: tuple[str, ...] = ("signed-off-by", "co-authored-by")

# Absolute path, resolved once: ruff S607 rejects a partial executable path,
# and rightly — a bare "git" resolves against whatever PATH the runner has.
# Same treatment the corpus gives every other resolved external binary.
GIT = shutil.which("git") or "git"


def missing_trailers(trailers_block: str) -> list[str]:
    """Return the required trailer keys absent from a commit's trailer block.

    Args:
        trailers_block: the unfolded trailer lines of one commit (e.g. the
            output of ``git log -1 --format='%(trailers:only,unfold)'``).

    Returns:
        The subset of :data:`REQUIRED_TRAILERS` not present, in canonical order
        (empty when the commit carries both).
    """
    present = {
        line.split(":", 1)[0].strip().lower() for line in trailers_block.splitlines() if ":" in line
    }
    return [key for key in REQUIRED_TRAILERS if key not in present]


def _require_git() -> None:
    """Exit loudly (not a traceback) when the git binary is absent.

    A runner image without git is a BROKEN STEP, not an unresolvable
    range — failing open here would make the gate a silent no-op on
    every run, so this stays a hard failure with a pointed message.
    """
    if shutil.which("git") is None:
        print(
            "check_commit_trailers: `git` not found on PATH — install git in "
            "this CI step (apt-get install git) or run from a dev shell.",
            file=sys.stderr,
        )
        raise SystemExit(2)


def _run(args: list[str]) -> str:
    # S603: fixed argv list, shell=False, executable is the resolved absolute
    # GIT path. The only caller-varied elements are refs we then hand to git
    # as data, never as the program to run.
    return subprocess.run(args, capture_output=True, text=True, check=True).stdout


def _ref_exists(ref: str) -> bool:
    """True if ``ref`` resolves in this clone."""
    try:
        subprocess.run(  # see _run
            [GIT, "rev-parse", "--verify", "--quiet", ref], capture_output=True, check=True
        )
    except subprocess.CalledProcessError:
        return False
    return True


class _NoBaseError(RuntimeError):
    """No base branch could be resolved, so there is nothing to check."""


def _base_candidates() -> list[str]:
    """Base-branch names to try, most authoritative first.

    ``CI_COMMIT_TARGET_BRANCH`` is what Woodpecker sets on a pull_request
    event and is always right when present. Outside CI it is unset, so fall
    back to whatever ``origin/HEAD`` points at, and only then to the two
    conventional names.

    Both conventional names are tried because the default branch is a
    per-repo choice: this repository uses ``master``, while most of the
    federation uses ``main``. Hardcoding either one makes the check a no-op
    on the repos that chose the other — and since :func:`main` fails open on
    an unresolvable range, that no-op is *silent*.
    """
    candidates = []
    if env_branch := os.environ.get("CI_COMMIT_TARGET_BRANCH"):
        candidates.append(env_branch)
    try:
        head_ref = _run([GIT, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]).strip()
    except subprocess.CalledProcessError:
        head_ref = ""
    if head_ref:
        candidates.append(head_ref.rpartition("/")[2])
    candidates += ["main", "master"]
    # Preserve order, drop duplicates.
    return list(dict.fromkeys(candidates))


def _in_ci() -> bool:
    """Woodpecker sets ``CI``; so does every other forge runner."""
    return bool(os.environ.get("CI"))


def _resolve_base() -> str:
    """The ref to diff against, resolved by PROVENANCE — never by name in CI.

    A local branch named after the default branch is **not evidence** of what is
    on the default branch. The runner does ``git init -b <default>`` and then
    ``reset --hard <PR head>``, so in CI a ref called ``main`` *is the branch
    under review*. Accepting it makes ``main..HEAD`` empty by construction, and
    the gate then reports "all 0 commit(s) carry both trailers" — a pass over an
    empty set. Measured 2026-08-02: that is what 39 of 40 repos were doing, on
    every event, since this gate was added.

    Fetched ``origin/*`` refs are witnesses; local names are not. Outside CI the
    local name is fine — a developer running this by hand has a real ``main``.
    """
    for branch in _base_candidates():
        refs = (f"origin/{branch}",) if _in_ci() else (f"origin/{branch}", branch)
        for ref in refs:
            if _ref_exists(ref):
                return ref
    # Nothing resolved. This used to return "main" so that main() would
    # fail OPEN on the bad range — and that is exactly how this gate came
    # to skip silently in CI for its whole life, reporting
    # "cannot compute main..HEAD … skipping" and exiting 0 on every run.
    # A gate that cannot determine what to check MUST NOT report success.
    raise _NoBaseError(
        "no base branch resolved (tried, in order: "
        + ", ".join(_base_candidates())
        + "). In CI this means the clone has no default-branch ref — fetch "
        "it. This check does not pass by skipping."
    )


def main() -> int:
    _require_git()
    try:
        base = _resolve_base()
    except _NoBaseError:
        # No default-branch ref. CI checks out only the branch under test and
        # a later step cannot fetch one — the clone plugin's SSH credentials
        # do not survive into it.
        #
        # This used to return "main" and then SKIP, which is how the gate ran
        # green in CI for its whole life while checking nothing. It must not
        # skip — but it must not hard-fail either, or CI is permanently red
        # for a checkout shape CI always has.
        #
        # So: check the tip commit. Reduced scope, announced, and a real
        # check — the commit actually being pushed is the one a missing
        # trailer would be on.
        print(
            "check-commit-trailers: no default-branch ref in this checkout, so "
            "the range cannot be computed — checking HEAD's own commit instead. "
            "Reduced scope, not a skip.",
            file=sys.stderr,
        )
        # `--no-merges`, exactly as the range path uses — and this is not
        # hypothetical here: a forge checks out a synthetic MERGE ref for a
        # pull request, and a plugin-created merge commit carries no trailers
        # by construction. Checking it would fail every PR for a commit no
        # human wrote.
        tip = _run([GIT, "rev-list", "--no-merges", "-1", "HEAD"]).strip()
        if not tip:
            print(
                "check-commit-trailers: HEAD has no non-merge commit to check.",
                file=sys.stderr,
            )
            return 0
        return _check_shas([tip])
    try:
        shas = [
            s
            for s in _run([GIT, "log", "--no-merges", "--format=%H", f"{base}..HEAD"]).split()
            if s
        ]
    except subprocess.CalledProcessError as exc:
        # FAIL CLOSED. This branch used to print "skipping" and return 0,
        # and that is how the gate ran green in CI for its whole life while
        # checking nothing: the clone had no default-branch ref, the base
        # fell through to a non-existent `main`, and every push "passed".
        #
        # If the base resolved, `base..HEAD` computing is not an edge case —
        # it is a broken checkout, and a gate that cannot compute what it
        # exists to check must not report success. A loud failure is
        # fixable; a silent skip is not.
        print(
            f"check-commit-trailers: cannot compute {base}..HEAD ({exc}). "
            "This check does not pass by skipping — fetch the default "
            "branch, or fix the checkout.",
            file=sys.stderr,
        )
        return 2

    return _check_shas(shas)


def _check_shas(shas: list[str]) -> int:
    """Report any commit in ``shas`` missing a required trailer."""
    bad = [
        (sha, absent)
        for sha in shas
        if (
            absent := missing_trailers(
                _run([GIT, "log", "-1", "--format=%(trailers:only,unfold)", sha])
            )
        )
    ]
    if bad:
        print("AG-03: commit(s) missing a required trailer:", file=sys.stderr)
        for sha, absent in bad:
            print(f"  {sha[:12]} — missing {', '.join(absent)}", file=sys.stderr)
        print("Every commit MUST carry Signed-off-by AND Co-Authored-By (AG-03).", file=sys.stderr)
        return 1

    print(f"AG-03: all {len(shas)} commit(s) on this branch carry both trailers.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
