diff --git a/.github/workflows/branch-hygiene.yml b/.github/workflows/branch-hygiene.yml new file mode 100644 index 00000000..58bf292d --- /dev/null +++ b/.github/workflows/branch-hygiene.yml @@ -0,0 +1,101 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Weekly measurement of branch drift, reported in ONE rolling issue. +# +# On 2026-10-25 the remote held fifteen heads: `main`, a data branch the badge +# publisher force-pushes (`governance-badges`), and thirteen others that were +# either already contained in `main` or cut from a snapshot between 5 and 119 +# commits old. Every one of those thirteen had an open pull request, and every +# pull request started a full CI run. The cost was not the storage; it was that +# a genuinely fresh change could not be told apart from a fortnight-old one +# without running this comparison by hand. +# +# The reporting shape is deliberate: one issue, updated in place, closed when +# the branches are gone. A scheduled job that opens an issue every week is the +# mechanism that produced the pile it is meant to clear. +name: Branch hygiene + +on: + schedule: + # Mondays, after the Scorecard and secret-scanner slots. + - cron: '17 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + issues: write + +concurrency: + group: branch-hygiene + cancel-in-progress: false + +jobs: + triage: + name: Triage remote branches + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + ROLLING_TITLE: 'Branch hygiene: drifted and superseded branches' + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Check out the full history + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Triage every remote head against main + id: triage + run: | + set -euo pipefail + bash scripts/maintenance/triage-branches.sh \ + --base main --remote origin --format markdown > "$RUNNER_TEMP/branches.md" + cat "$RUNNER_TEMP/branches.md" >> "$GITHUB_STEP_SUMMARY" + # The script's own summary lines are the count; grep the classes + # rather than re-deriving them, so there is one definition. + stale="$(grep -c '^| .* | stale |' "$RUNNER_TEMP/branches.md" || true)" + salvage="$(grep -c '^| .* | salvage |' "$RUNNER_TEMP/branches.md" || true)" + { + printf 'stale=%s\n' "${stale:-0}" + printf 'salvage=%s\n' "${salvage:-0}" + } >> "$GITHUB_OUTPUT" + + - name: Upsert the rolling issue + env: + STALE: ${{ steps.triage.outputs.stale }} + SALVAGE: ${{ steps.triage.outputs.salvage }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + REPO_URL: ${{ github.server_url }}/${{ github.repository }} + run: | + set -euo pipefail + marker='' + existing="$(gh issue list --state open --limit 100 --json number,title \ + --jq ".[] | select(.title == \"$ROLLING_TITLE\") | .number" | head -1)" + + if [ "${STALE:-0}" -eq 0 ] && [ "${SALVAGE:-0}" -eq 0 ]; then + if [ -n "$existing" ]; then + gh issue comment "$existing" --body "No drifted or superseded branch was found on $(date -u +%Y-%m-%d). Closing." + gh issue close "$existing" --reason completed + fi + echo 'No drifted branches; nothing to report.' + exit 0 + fi + + { + printf '%s\n' "$marker" + printf 'Branches on `origin` measured against `main` on %s.\n\n' "$(date -u +%Y-%m-%d)" + printf 'Classes are defined in [docs/maintenance/repository-hygiene.md](%s/blob/main/docs/maintenance/repository-hygiene.md) and produced by `scripts/maintenance/triage-branches.sh`.\n\n' "$REPO_URL" + cat "$RUNNER_TEMP/branches.md" + printf '\nGenerated by [branch hygiene run](%s). This issue is updated in place and closes itself when the branches are gone.\n' "$RUN_URL" + } > "$RUNNER_TEMP/issue.md" + + gh label create hygiene --force \ + --description 'Repository maintenance work' --color ededed >/dev/null 2>&1 || true + + if [ -n "$existing" ]; then + gh issue edit "$existing" --body-file "$RUNNER_TEMP/issue.md" + echo "Updated issue #$existing" + else + gh issue create --title "$ROLLING_TITLE" --body-file "$RUNNER_TEMP/issue.md" --label hygiene + fi diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 5e841ce8..b307aa35 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -45,6 +45,10 @@ jobs: bash scripts/tests/test-ci-logging.sh bash scripts/tests/test-k9-log-copy.sh bash scripts/tests/test-scanner-boundaries.sh + # Branch triage and the pull-request freshness rule: the scripts the + # hygiene workflows run, tested where every other shell test runs. + bash scripts/tests/test-triage-branches.sh + bash scripts/tests/test-check-pr-freshness.sh deed: name: DEED syntax diff --git a/.github/workflows/pr-freshness.yml b/.github/workflows/pr-freshness.yml new file mode 100644 index 00000000..418ef045 --- /dev/null +++ b/.github/workflows/pr-freshness.yml @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# CONTRIBUTING.md states two rules a pull request is checked against before +# its diff is read: the branch starts at the tip of `main`, and it carries no +# merge commits from `main`. Nothing enforced them, so drifted branches +# accumulated — ten of them by 2026-10-25, cut from snapshots up to 119 commits +# old, each conflicting, each holding a pull request, each starting a full CI +# run that could never go green. +# +# This check turns those two sentences into a result that arrives in a minute, +# on the pull request, from a script that also runs at the desk: +# +# scripts/maintenance/check-pr-freshness.sh +# +# It is intentionally not a fixer. Rebasing is the author's decision, because +# only the author knows which commits on a drifted branch still apply. +name: PR freshness + +on: + pull_request: + # `opened` for the first push, `synchronize` for each new push, and + # `reopened` because a reopened branch may have drifted while it was + # closed. `edited` is absent: editing a description cannot change history. + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +concurrency: + # One result per pull request; a superseded run tells nobody anything. + group: pr-freshness-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + freshness: + name: Branch freshness + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Check out the pull request's history + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The branch tip, not GitHub's `refs/pull//merge` commit. The + # synthetic merge has two parents by construction, so checking it out + # makes rule 2 fail on every pull request - including this workflow's + # first run, which is how the mistake was found. + ref: ${{ github.event.pull_request.head.sha }} + # Full history: the check needs the real merge base, and a shallow + # clone would report its graft point as the base and pass everything. + fetch-depth: 0 + persist-credentials: false + + - name: Check the branch starts at the base tip and replays linearly + env: + # The pull request's base SHA is immutable; a branch name is not. + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: bash scripts/maintenance/check-pr-freshness.sh --base "$BASE_SHA" --head HEAD diff --git a/.gitignore b/.gitignore index 7df2318b..1794badb 100644 --- a/.gitignore +++ b/.gitignore @@ -247,11 +247,13 @@ vignettes/*.pdf # R Environment Variables .Renviron -## Documentation kept local except published release, cladistics, format, statistics, and schema notes +## Documentation kept local except published release, cladistics, format, maintenance, +## statistics, and schema notes docs/* !docs/release-notes/ !docs/cladistics/ !docs/format-migration.md +!docs/maintenance/ !docs/statistics/ !docs/schemas/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a21e6413..66389b31 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,6 +48,35 @@ Each requirement is checked before the code is read. 7. **Once review has started, add commits,** and rebase only when asked to. Squashing happens on merge. +## Branches and retention + +The two branch rules above are checked, not merely stated: +`scripts/maintenance/check-pr-freshness.sh` runs on every pull request through +`.github/workflows/pr-freshness.yml` and reports how far behind the branch is, +or which merge commit breaks rule 2. Run it yourself before pushing: + +``` +git fetch origin +bash scripts/maintenance/check-pr-freshness.sh --base origin/main --head HEAD +``` + +What happens to a branch once it is no longer needed: + +- **Merged into `main`** — delete it. The commits are in `main`; a kept branch + is a second answer to the same question. +- **Closed unmerged** — the pull request keeps the head reachable, so the branch + is deleted and the content is not lost: `git fetch origin pull//head`. +- **`archive/`** — a deliberate snapshot that is not proposed for merging. It + carries no pull request; opening one asks `main` to become older. +- **`governance-badges`** — not a working branch. The `Publish badges` job + force-pushes shields.io endpoint JSON to it on every push to `main`, and + `README.md` reads it through `img.shields.io/endpoint`. Never delete it. + +Drifted and superseded branches are measured weekly by +`.github/workflows/branch-hygiene.yml`, which updates one rolling issue; the +classes it reports, what each one means and how to recover closed content are in +[docs/maintenance/repository-hygiene.md](docs/maintenance/repository-hygiene.md). + ## Licence and copyright Source code is licensed under the AGPL-3.0 (`LICENSE`). `README.md` is licensed diff --git a/docs/maintenance/repository-hygiene.md b/docs/maintenance/repository-hygiene.md new file mode 100644 index 00000000..d822ba77 --- /dev/null +++ b/docs/maintenance/repository-hygiene.md @@ -0,0 +1,192 @@ + + +# Repository hygiene: branches, pull requests and CI cost + +This document records how branches are classed, what happens to each class, and +the measurement taken on 2026-10-25 with the actions that followed. It exists +because the repository reached a state where the cost of a stale branch was not +its storage but its silence: thirteen branches shared one list, each with an +open pull request, and none of them could be told apart from a fresh change +without doing the comparison by hand. + +The rule the repository already had is in [CONTRIBUTING.md](../../CONTRIBUTING.md): +a branch starts at the tip of `main` (rule 1) and carries no merge commits from +it (rule 2). Nothing enforced either sentence. This document describes the +enforcement added on 2026-10-25 and the cleanup it made possible. + +## Classes + +`scripts/maintenance/triage-branches.sh` measures every remote head against the +base branch and prints one class per branch. It never writes to the remote: the +reading is cheap and repeatable, the decision is not automatic. + +| Class | Meaning | What happens to it | +| --- | --- | --- | +| `base` | The base branch itself. | Nothing. | +| `unrelated` | No common ancestor with the base. In this repository that is `governance-badges` only, the orphan branch the `Publish badges` job force-pushes on every push to `main`. | Never delete. It holds shields.io endpoint JSON read through `img.shields.io/endpoint`; it has no pull request and never will. | +| `contained` | Every commit is already an ancestor of the base (`ahead` is 0). | The pull request is closed and the branch deleted. There is nothing to merge; the displayed diff is the base moving ahead. Any file it still "has" that the base does not was deliberately deleted from the base later. | +| `fresh` | Merge base is the base tip. It satisfies rule 1. | Eligible for review. Rule 2 is checked separately by `check-pr-freshness.sh`, which fails a `fresh` branch that carries a merge commit. | +| `salvage` | Behind the base, but it carries at least one file the base does not have. | Do **not** merge it: it would revert later work on every file it also touches. Its unique files are re-applied on the current tip, or the branch is deliberately abandoned. Closing the pull request preserves the content at `refs/pull//head`; see [Recovery](#recovery). | +| `stale` | Behind the base, and every file it touches also exists in the base. | Closed and deleted. Merging it would revert the commits that superseded it. | + +A shallow clone cannot answer any of this: it reports its graft point as the +merge base, which makes every branch look `fresh`. Both scripts refuse to run in +a shallow clone (`git fetch --unshallow`, or `fetch-depth: 0` in CI) rather than +print a number they cannot stand behind. + +## Enforced, not just written down + +| Where | What it does | +| --- | --- | +| `.github/workflows/pr-freshness.yml` | Runs `scripts/maintenance/check-pr-freshness.sh` on every pull request open, push, reopen and ready-for-review. Fails with a check annotation naming how far behind the branch is, or which merge commit breaks rule 2. | +| `.github/workflows/branch-hygiene.yml` | Weekly, and on demand: runs the triage and updates **one** rolling issue, closing it when no drifted branch remains. One issue updated in place, because a job that opens an issue every week is the mechanism that produced the pile it is meant to clear. | +| `scripts/tests/test-triage-branches.sh`, `scripts/tests/test-check-pr-freshness.sh` | Both scripts are tested against fixture repositories: merged, drifted-with-nothing-new, drifted-with-a-dropped-file, orphan, unrelated-history and shallow cases. They run with the other shell tests in the `Advisory scan contract` job. | + +The pull-request check is advisory by configuration, not by intent: GitHub only +blocks a merge on a check named in a ruleset. Adding **Branch freshness** to the +`Branch-Floor` ruleset is an owner action (Settings → Rules → Branch-Floor), and +it can only be done *after* this workflow is on `main` — a required check that no +workflow produces blocks every pull request, including the one that would add it. + +## Recovery + +Deleting a branch does not delete its pull request, and a closed pull request +keeps its head reachable: + +``` +git fetch origin pull//head:review/pr- +git log --oneline main..review/pr- +``` + +That is the difference between cleaning up and losing work, and it is why the +2026-10-25 cleanup closed pull requests before deleting the branches instead of +deleting the branches and hoping. + +## Measurement, 2026-10-25 + +`main` at `f3816e5a`. Fifteen remote heads, thirteen of them with an open pull +request. Every one of the thirteen violated CONTRIBUTING rule 1; one also +violated rule 2; one was a second copy of a fix already on `main`. + +| Branch | Class | Ahead | Behind | Files only here | Pull request | Action | +| --- | --- | ---: | ---: | ---: | --- | --- | +| `arena/00d742e3-metamanifold-webui` | contained | 0 | 35 | 0 | #283 | closed, deleted | +| `archive/pre-sync-2026-10-17` | contained | 0 | 70 | 13 | #281 | closed, deleted | +| `work/glmgampoi-r-shrinkage` | stale | 41 | 119 | 0 | #284 | closed, deleted | +| `arena/b38170ca-metamanifold-webui` | stale | 43 | 119 | 0 | #280 | closed, deleted | +| `arena/bb2c7f54-metamanifold-webui` | stale | 43 | 119 | 0 | #279 | closed, deleted | +| `arena/1cf75265-metamanifold-webui` | stale | 4 | 7 | 0 | #278 | closed, deleted | +| `arena/57ce87e1-metamanifold-webui` | stale | 9 | 5 | 0 | #274 | closed, deleted | +| `arena/2fe2daad-metamanifold-webui` | fresh, 1 merge commit | 2 | 0 | 0 | #277 | closed, deleted | +| `arena/3bcf0318-metamanifold-webui` | salvage | 2 | 45 | 11 | #276 | closed, deleted | +| `prepare/lwr-63-lean` | salvage | 43 | 119 | 2 | #272 | closed, deleted | +| `upstream/placement-evidence` | salvage | 44 | 119 | 2 | #270 | closed, deleted | +| `wip/pr62-theta` | salvage | 45 | 119 | 1 | #269 | closed, deleted | +| `review/evidence-27-contract` | salvage | 42 | 119 | 2 | #271 | closed, deleted | +| `governance-badges` | unrelated | – | – | 5 | none | kept, by design | +| `main` | base | – | – | – | – | – | + +### What the four `salvage` branches actually hold + +Nothing on them is in `main`, and nothing on them is worth a merge either — the +branches are built on `257bcdec`, 119 commits before the current tip, so any +merge would revert the files that changed in between. The content is preserved +at its pull-request ref and worth a second look only if its feature is wanted: + +- `arena/3bcf0318` — the Full Evidence Mode surface (`frontend/src/evidence/*`, + `EvidenceModeToggle.tsx`, 11 files). Issue #7 was closed as parked: the + absorption register excludes `frontend/src/**` from the offer set until the + replacement work is ready. Parked, not lost: `git fetch origin pull/276/head`. +- `prepare/lwr-63-lean`, `upstream/placement-evidence` — `ComputingNote.tsx` and + `placementLwr.test.ts`, a component and a threshold test for the placement-LWR + display whose feature is already in `main`. If the test is wanted, it needs + re-writing against the current `frontend/src/tree/model.ts`, not cherry-picking. +- `wip/pr62-theta` — `ComputingNote.tsx` alone; its theta reporting is in + `main`. +- `review/evidence-27-contract` — `docs/reviews/evidence-27-contract.md` and a + probe script, reviewing a state of the code that has since been replaced. + +## The CI cost this created + +Thirteen open pull requests were not only untidy, they were expensive, and the +expense is what made the situation stable rather than self-correcting: + +- Every pull request triggers the full `CI` suite: `Julia tests` (about 30–45 + minutes), `Statistics standards and evidence` (up to 60), `Agda proofs`, + `Occupancy typed peak`, `Occupancy disagreement test`, plus `Governance (fork)`, + `DOI`, `Format readiness evidence` and `Secret Scanner`. `Code relevance` + skips the expensive jobs only for a diff that touches nothing the suite + exercises, and none of the thirteen was such a diff: #274 touched + `bench/null_calibration` and the workflow, #278 touched the workflows and + `scripts/`, the rest touched `src/`. +- Six of those jobs are required contexts in the `Branch-Floor` ruleset. A + branch that cannot pass them reports `BLOCKED` indefinitely, so a reviewer + sees thirteen blocked pull requests and no signal about which of them is + alive. +- On `main`, `concurrency` is deliberately *not* cancel-in-progress for pushes, + so that "each merge gets a test result". Two pushes forty seconds apart + therefore serialise: at the time of this measurement the run for `a3a4a8e` was + still in progress and the run for `f3816e5a` had not started, 25 minutes later. + The queue is correct by design; the pile-up it produces is the design's cost, + and it is worth a decision (see below). + +Two red checks on those pull requests were not caused by their content: + +- **SonarCloud Code Analysis** failed on most of them with `C Security Rating on + New Code`. It is not a required context; it is documented in + [docs/sonar-quality-gate.md](../sonar-quality-gate.md) and reproduced locally + by `scripts/lint-sonar-reliability.sh`. It analyses "new code" as the changed + lines, and a branch that appears to change 40,000 lines gets a large surface. +- **Hypatia** failed with "9 new alerts including 5 errors" on the largest ones, + for the same reason: an enormous, entirely stale diff is a large scan. + +Both are symptoms of the same root cause and cleared with the branches. + +## Order of work, and why that order + +The remediation was done in this order because each step depended on the one +before it: + +1. **Measure.** `triage-branches.sh` first, on a full clone. Every later + decision is a table row, not an impression. +2. **Enforce the rule that already existed.** A check that never existed is a + wish. `pr-freshness.yml` + `check-pr-freshness.sh` land first so that no new + drifted branch can be created while the old ones are being closed. +3. **Close the pull requests, then delete the branches.** In that order, because + the pull request is what keeps the head reachable. +4. **Leave the data branch alone.** `governance-badges` is not a spare branch; + it is an output. A triage that cannot tell the two apart deletes a badge + pipeline. +5. **Make the measurement recurring, in one issue.** `branch-hygiene.yml` + updates a single rolling issue and closes it when clean. +6. **Only then touch the ruleset.** Making **Branch freshness** required is + safe once the workflow is on `main`, and it cannot be done before then. + +## Decisions for the owner + +These are not cleanup, they are choices with trade-offs; the reasoning is +recorded here so the choice does not have to be rediscovered. + +1. **Make `Branch freshness` a required context.** Add it to the `Branch-Floor` + ruleset after this lands. Until then the check reports but does not block. +2. **Push-run concurrency on `main`.** `cancel-in-progress: false` for pushes + gives every merge its own result and serialises the queue. Setting it to + true would let the newest commit cancel an older validation; the newest tree + contains the older one, so the loss is a per-commit result rather than + coverage. Either answer is defensible; the current one is not wrong, only + slow. +3. **`governance-badges` never gets a pull request.** It is an orphan branch of + generated JSON, force-pushed by the `Publish badges` job. Excluding it from + branch listings (a `--exclude`-style pattern in whatever lists branches) or + publishing the JSON as a release asset would remove it from every human's + branch list without touching the pipeline. Not done here because it changes + where the badges are read from, and the `img.shields.io/endpoint` URLs in + `README.md` point at that branch. +4. **`docs/milestones/02-deferred-issues.md` is referenced by issue #4 and does + not exist on `main`.** The pre-sync archive carried it; the sync dropped it. + Either restore it or edit the issue's reference. Not done here because the + content overlaps `docs/statistics/method-catalogue-v1.md` and the owner may + prefer the issue to point at that instead. diff --git a/scripts/maintenance/check-pr-freshness.sh b/scripts/maintenance/check-pr-freshness.sh new file mode 100755 index 00000000..fcfe562e --- /dev/null +++ b/scripts/maintenance/check-pr-freshness.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Enforce the first two pull-request rules in CONTRIBUTING.md, before a +# reviewer spends time on the diff: +# +# 1. The merge base of the branch and the base branch is the base tip +# (`behind_by` is 0). A branch cut from an older snapshot is rebased onto +# the current base, not merged forward. +# 2. The branch carries no merge commit from the base branch and no +# conflict-resolution merge. Squashing happens on merge. +# +# Both rules exist because a stacked or drifted branch does not get reviewed +# twice; it gets merged by accident or closed after weeks of conflicts. Each +# violating branch also costs a full CI run to discover, so the check is cheap, +# local and identical in CI and at the desk: +# +# scripts/maintenance/check-pr-freshness.sh # HEAD vs origin/main +# scripts/maintenance/check-pr-freshness.sh --base origin/main --head HEAD +# +# Under GitHub Actions it reads the pull request's base SHA from BASE_SHA and +# writes annotations, so a failure is readable from the checks API rather than +# only from the log. +# +# Exit status: 0 clean, 1 rule violation, 2 the question cannot be answered +# (shallow clone, unknown ref) - never a silent pass. +set -euo pipefail + +base_ref=origin/main +head_ref=HEAD + +usage() { + sed -n '5,28p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' +} + +while [ $# -gt 0 ]; do + case "$1" in + --base) base_ref="${2:?--base needs a value}"; shift 2 ;; + --head) head_ref="${2:?--head needs a value}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) printf 'check-pr-freshness: unknown argument: %s\n' "$1" >&2; exit 2 ;; + esac +done + +in_actions=false +if [ "${GITHUB_ACTIONS:-}" = "true" ]; then + in_actions=true +fi + +# The base SHA of a pull request is immutable and is what the merge-base test +# has to be measured against; a branch name moves under a running job. +if [ -n "${BASE_SHA:-}" ]; then + base_ref="$BASE_SHA" +fi +if [ -n "${HEAD_SHA:-}" ]; then + head_ref="$HEAD_SHA" +fi + +annotate() { # level message + if [ "$in_actions" = true ]; then + printf '::%s title=Branch freshness::%s\n' "$1" "$2" + fi +} + +die_cannot_check() { + printf 'check-pr-freshness: %s\n' "$1" >&2 + annotate error "cannot evaluate branch freshness: $1" + exit 2 +} + +git rev-parse --git-dir >/dev/null 2>&1 || die_cannot_check "not inside a git repository" + +# A shallow clone has no merge base to speak of: it would report the graft +# point as the base and pass every branch. Refuse instead of passing. +if [ "$(git rev-parse --is-shallow-repository)" = true ]; then + die_cannot_check "shallow clone; check out with fetch-depth: 0 (or run git fetch --unshallow)" +fi + +git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null \ + || die_cannot_check "base ref $base_ref does not exist" +git rev-parse --verify --quiet "$head_ref^{commit}" >/dev/null \ + || die_cannot_check "head ref $head_ref does not exist" + +base_tip="$(git rev-parse "$base_ref^{commit}")" +head_tip="$(git rev-parse "$head_ref^{commit}")" +short_base="${base_tip:0:8}" +short_head="${head_tip:0:8}" + +merge_base="$(git merge-base "$base_tip" "$head_tip" || true)" +if [ -z "$merge_base" ]; then + printf 'check-pr-freshness: %s shares no history with %s\n' "$short_head" "$short_base" >&2 + annotate error "the branch shares no history with the base; it cannot be merged without replaying its commits" + exit 1 +fi + +failures=0 +behind="$(git rev-list --count "$head_tip..$base_tip")" + +if [ "$merge_base" != "$base_tip" ]; then + failures=$((failures + 1)) + printf 'check-pr-freshness: branch is %s commit(s) behind %s (merge base %s)\n' \ + "$behind" "$short_base" "${merge_base:0:8}" >&2 + annotate error "branch is $behind commit(s) behind the base ($short_base); rebase onto the base instead of merging it forward (CONTRIBUTING.md rule 1)" +else + printf 'ok - branch starts at the base tip %s\n' "$short_base" +fi + +merge_commits="$(git rev-list --merges --count "$merge_base..$head_tip" || true)" +if [ "${merge_commits:-0}" -gt 0 ]; then + failures=$((failures + 1)) + printf 'check-pr-freshness: %s merge commit(s) in the branch:\n' "$merge_commits" >&2 + git rev-list --merges --format=' %h %s' "$merge_base..$head_tip" | grep -v '^commit ' >&2 || true + annotate error "$merge_commits merge commit(s) in the branch; rebase so the branch replays linearly (CONTRIBUTING.md rule 2)" + # The head being a merge is the signature of two very different situations, + # and the message has to help tell them apart: a contributor who merged the + # base into their branch (a real rule 2 violation) and CI that checked out + # GitHub's synthetic `refs/pull//merge` commit (no violation at all - the + # merge exists only to test the merge result). + if [ "$merge_commits" -eq 1 ] \ + && [ "$(git rev-parse "$head_tip^{commit}")" = "$(git rev-list --merges -1 "$merge_base..$head_tip")" ] \ + && [ "$(git rev-list --parents -n 1 "$head_tip" | wc -w | tr -d ' ')" -eq 3 ]; then + printf 'note: the head commit is itself a merge; if this is GitHub refs/pull//merge,\n' >&2 + printf ' check out the pull request head commit instead of the merge commit\n' >&2 + annotate error "the head commit is itself a merge; if this is refs/pull//merge, check out github.event.pull_request.head.sha instead" + fi +else + printf 'ok - no merge commits in the branch\n' +fi + +if [ "$failures" -gt 0 ]; then + printf 'check-pr-freshness: %d rule violation(s)\n' "$failures" >&2 + exit 1 +fi + +printf 'check-pr-freshness: OK\n' +exit 0 diff --git a/scripts/maintenance/triage-branches.sh b/scripts/maintenance/triage-branches.sh new file mode 100755 index 00000000..e714c25a --- /dev/null +++ b/scripts/maintenance/triage-branches.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Read-only triage of the remote's branches against the base branch. +# +# The repository's rule is narrow: a pull request's branch must start at the +# tip of `main` (CONTRIBUTING.md, "Branch from the current `main`"), and its +# commits must contain no merge from `main`. When that rule is not enforced, a +# cron job or an agent can leave dozens of branches cut from an old snapshot; +# each one then conflicts, each one opens a pull request, and each pull request +# starts a full CI run. This script is the measurement, not the enforcement: +# it says of every branch which of five things it is, so cleanup is a decision +# made on evidence rather than on a guess about the branch name. +# +# Classes, and what each one means for cleanup: +# +# base the base branch itself. +# unrelated no common ancestor with the base. Expected for the orphan +# data branch `governance-badges`, which the "Publish badges" +# job force-pushes on every push to `main`; it holds shields.io +# endpoint JSON and is never merged. Do not delete it. +# contained every commit is already in the base (`ahead == 0`). The branch +# has nothing to merge and its pull request can be closed. +# fresh merge base is the base tip, so it satisfies CONTRIBUTING rule 1. +# salvage behind the base, but it carries files the base does not have. +# Closing it would lose work, so it has to be re-applied on top of +# the current base or deliberately abandoned first. +# stale behind the base and every file it carries also exists in the +# base. The content was superseded by later commits; merging it +# would revert them. +# +# A shallow clone cannot answer any of this: `--is-shallow-repository` is +# checked and the script refuses to print numbers it cannot stand behind. +# +# Usage: +# scripts/maintenance/triage-branches.sh # table on stdout +# scripts/maintenance/triage-branches.sh --format markdown # for an issue body +# scripts/maintenance/triage-branches.sh --format json +# scripts/maintenance/triage-branches.sh --fail-on stale +# +# Exit status: 0 on success, 2 if triage cannot be computed (shallow clone, +# missing refs), 3 if --fail-on matched at least one branch. +set -euo pipefail + +base=main +remote=origin +format=table +fail_on= + +usage() { + sed -n '5,45p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' +} + +while [ $# -gt 0 ]; do + case "$1" in + --base) base="${2:?--base needs a value}"; shift 2 ;; + --remote) remote="${2:?--remote needs a value}"; shift 2 ;; + --format) format="${2:?--format needs a value}"; shift 2 ;; + --fail-on) fail_on="${2:?--fail-on needs a value}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) printf 'triage-branches: unknown argument: %s\n' "$1" >&2; exit 2 ;; + esac +done + +case "$format" in + table|markdown|json) ;; + *) printf 'triage-branches: --format must be table, markdown or json\n' >&2; exit 2 ;; +esac +case "$fail_on" in + ''|stale|salvage|any) ;; + *) printf 'triage-branches: --fail-on must be stale, salvage or any\n' >&2; exit 2 ;; +esac + +if ! git rev-parse --git-dir >/dev/null 2>&1; then + printf 'triage-branches: not inside a git repository\n' >&2 + exit 2 +fi + +# A shallow clone reports `ahead`/`behind` against an arbitrary graft point, +# so the numbers would be plausible and wrong. Refusing is the only honest +# answer; `git fetch --unshallow` (or fetch-depth: 0 in CI) fixes it. +if [ "$(git rev-parse --is-shallow-repository)" = true ]; then + printf 'triage-branches: this is a shallow clone; run "git fetch --unshallow" first\n' >&2 + exit 2 +fi + +base_ref="refs/remotes/$remote/$base" +if ! git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null; then + printf 'triage-branches: %s does not exist; fetch %s first\n' "$base_ref" "$remote" >&2 + exit 2 +fi + +# Refuse to report on a stale view: the base tip is what "behind" is measured +# against, and a triage run against last week's cache is worse than none. +base_tip="$(git rev-parse "$base_ref^{commit}")" + +rows=() +count_base=0 +count_unrelated=0 +count_contained=0 +count_fresh=0 +count_salvage=0 +count_stale=0 +matched_fail_on=0 + +while IFS= read -r ref; do + [ -n "$ref" ] || continue + branch="${ref#"refs/remotes/$remote/"}" + [ "$branch" = "HEAD" ] && continue + git rev-parse --verify --quiet "$ref^{commit}" >/dev/null || continue + + class= + ahead=0 + behind=0 + merges=0 + unique=0 + merge_base= + + if [ "$branch" = "$base" ]; then + class=base + count_base=$((count_base + 1)) + elif ! merge_base="$(git merge-base "$base_ref" "$ref" 2>/dev/null)"; then + class=unrelated + count_unrelated=$((count_unrelated + 1)) + else + ahead="$(git rev-list --count "$base_ref..$ref")" + behind="$(git rev-list --count "$ref..$base_ref")" + merges="$(git rev-list --merges --count "$base_ref..$ref")" + unique="$(git diff --diff-filter=A --name-only "$base_ref" "$ref" | wc -l | tr -d ' ')" + if [ "$ahead" -eq 0 ]; then + class=contained + count_contained=$((count_contained + 1)) + elif [ "$merge_base" = "$base_tip" ]; then + class=fresh + count_fresh=$((count_fresh + 1)) + elif [ "$unique" -gt 0 ]; then + class=salvage + count_salvage=$((count_salvage + 1)) + else + class=stale + count_stale=$((count_stale + 1)) + fi + fi + + case "$fail_on" in + '' ) ;; + any) matched_fail_on=$((matched_fail_on + 1)) ;; + stale) [ "$class" = stale ] && matched_fail_on=$((matched_fail_on + 1)) ;; + salvage) [ "$class" = salvage ] && matched_fail_on=$((matched_fail_on + 1)) ;; + esac + + tip_date="$(git log -1 --format=%cs "$ref")" + tip_subject="$(git log -1 --format=%s "$ref" | cut -c1-60)" + rows+=("$branch|$class|$ahead|$behind|$unique|$merges|$tip_date|$tip_subject") +done < <(git for-each-ref --format='%(refname)' "refs/remotes/$remote" | LC_ALL=C sort) + +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + printf '%s' "$s" +} + +case "$format" in + json) + printf '{"base":"%s","base_tip":"%s","remote":"%s","branches":[' \ + "$(json_escape "$base")" "$base_tip" "$(json_escape "$remote")" + first=1 + for row in "${rows[@]}"; do + IFS='|' read -r branch class ahead behind unique merges tip_date tip_subject <<<"$row" + [ "$first" -eq 1 ] || printf ',' + first=0 + printf '{"branch":"%s","class":"%s","ahead":%s,"behind":%s,"unique_files":%s,"merge_commits":%s,"tip_date":"%s","tip_subject":"%s"}' \ + "$(json_escape "$branch")" "$class" "$ahead" "$behind" "$unique" "$merges" \ + "$tip_date" "$(json_escape "$tip_subject")" + done + printf ']}\n' + ;; + markdown) + printf '| Branch | Class | Ahead | Behind | Files only here | Merge commits | Tip |\n' + printf '| --- | --- | ---: | ---: | ---: | ---: | --- |\n' + for row in "${rows[@]}"; do + IFS='|' read -r branch class ahead behind unique merges tip_date tip_subject <<<"$row" + printf '| `%s` | %s | %s | %s | %s | %s | %s %s |\n' \ + "$branch" "$class" "$ahead" "$behind" "$unique" "$merges" "$tip_date" "$tip_subject" + done + printf '\n' + printf '%s\n' \ + "- base: $count_base" \ + "- unrelated (never merged, expected for the badge data branch): $count_unrelated" \ + "- contained (nothing to merge): $count_contained" \ + "- fresh (satisfies CONTRIBUTING rule 1): $count_fresh" \ + "- salvage (holds files the base lacks): $count_salvage" \ + "- stale (superseded by later commits): $count_stale" + ;; + table) + printf '%-46s %-10s %6s %7s %6s %6s %s\n' \ + BRANCH CLASS AHEAD BEHIND ONLY-HERE MERGES TIP + for row in "${rows[@]}"; do + IFS='|' read -r branch class ahead behind unique merges tip_date tip_subject <<<"$row" + printf '%-46s %-10s %6s %7s %6s %6s %s %s\n' \ + "$branch" "$class" "$ahead" "$behind" "$unique" "$merges" "$tip_date" "$tip_subject" + done + printf '\n%d branch(es): %d contained, %d stale, %d salvage, %d fresh, %d unrelated\n' \ + "$((count_base + count_contained + count_stale + count_salvage + count_fresh + count_unrelated))" \ + "$count_contained" "$count_stale" "$count_salvage" "$count_fresh" "$count_unrelated" + ;; +esac + +if [ -n "$fail_on" ] && [ "$matched_fail_on" -gt 0 ]; then + printf 'triage-branches: %d branch(es) matched --fail-on %s\n' "$matched_fail_on" "$fail_on" >&2 + exit 3 +fi +exit 0 diff --git a/scripts/tests/test-check-pr-freshness.sh b/scripts/tests/test-check-pr-freshness.sh new file mode 100755 index 00000000..2d95e48c --- /dev/null +++ b/scripts/tests/test-check-pr-freshness.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Tests for scripts/maintenance/check-pr-freshness.sh: the two CONTRIBUTING +# rules it enforces, plus the cases where it must refuse to answer rather than +# pass a branch it cannot judge. +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT="$ROOT/scripts/maintenance/check-pr-freshness.sh" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +export GIT_CONFIG_NOSYSTEM=1 +export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=test@example.invalid +export GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@example.invalid + +pass=0 +fail=0 +ok() { pass=$((pass + 1)); printf 'ok %d - %s\n' "$pass" "$1"; } +no() { fail=$((fail + 1)); printf 'not ok - %s\n' "$1"; } +check_contains() { # haystack needle description + if printf '%s' "$1" | grep -Fq -- "$2"; then ok "$3"; else no "$3 (missing: $2)"; fi +} +run() { # expected-status description -- args... + local want="$1" desc="$2" + shift 2 + set +e + out="$(cd "$work/repo" && bash "$SCRIPT" "$@" 2>&1)" + local status=$? + set -e + if [ "$status" -eq "$want" ]; then ok "$desc"; else no "$desc (exit $status, wanted $want): $out"; fi +} + +git init -q --bare "$work/origin.git" +git init -q "$work/repo" +cd "$work/repo" +git symbolic-ref HEAD refs/heads/main +printf 'a\n' > a.txt +git add a.txt +git commit -qm 'add a.txt' +git remote add origin "$work/origin.git" +git push -q origin main +git -C "$work/origin.git" symbolic-ref HEAD refs/heads/main +git fetch -q origin + +# clean: new branch at the base tip +git checkout -qb clean +printf 'b\n' > b.txt +git add b.txt +git commit -qm 'add b.txt' +run 0 'a branch at the base tip passes' --base origin/main + +# drifted: one commit behind the base, no merge commit +git checkout -q main +printf 'c\n' > c.txt +git add c.txt +git commit -qm 'advance main' +git push -q origin main +git fetch -q origin +run 1 'a branch behind the base fails' --base origin/main --head clean +check_contains "$out" 'commit(s) behind' 'the failure names how far behind the branch is' + +# merge commit: the branch is at the base tip but carries a merge from it, +# which is the shape CONTRIBUTING rule 2 forbids even though rule 1 is satisfied +git checkout -q -b merges main +printf 'd\n' > d.txt +git add d.txt +git commit -qm 'add d.txt' +git checkout -q main +printf 'e\n' > e.txt +git add e.txt +git commit -qm 'advance main again' +git checkout -q merges +git merge -q --no-ff -m "Merge branch 'main' into merges" main +run 1 'a merge commit in the branch fails even at the base tip' --base origin/main --head merges +check_contains "$out" 'merge commit' 'the failure names the merge commit rule' +check_contains "$out" 'check out the pull request head commit' 'a merge-commit head also explains the refs/pull//merge case' + +# unrelated history: an orphan branch cannot be merged, so it cannot pass +git checkout -q --orphan orphan +git rm -rqf . +printf 'x\n' > x.txt +git add x.txt +git commit -qm 'orphan root' +run 1 'an unrelated history fails' --base origin/main --head orphan + +# a missing ref is a question that cannot be answered, not a pass +run 2 'an unknown head ref exits 2' --base origin/main --head nosuchbranch + +# Actions annotations are emitted when GitHub asks for them +set +e +out="$(cd "$work/repo" && GITHUB_ACTIONS=true bash "$SCRIPT" --base origin/main --head clean 2>&1)" +status=$? +set -e +if [ "$status" -eq 1 ]; then ok 'the check still fails under Actions'; else no "Actions run exited $status"; fi +check_contains "$out" '::error title=Branch freshness::' 'a failure is written as a check annotation' + +# A shallow clone would report its graft point as the merge base. +git clone -q --depth 1 --branch main "file://$work/origin.git" "$work/shallow" +set +e +shallow_out="$(cd "$work/shallow" && bash "$SCRIPT" --base origin/main --head origin/main 2>&1)" +shallow_status=$? +set -e +if [ "$shallow_status" -eq 2 ]; then ok 'a shallow clone is refused with exit 2'; else no "shallow clone exited $shallow_status: $shallow_out"; fi +check_contains "$shallow_out" 'shallow clone' 'the refusal names the cause' + +printf '\n%d passed, %d failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ] diff --git a/scripts/tests/test-triage-branches.sh b/scripts/tests/test-triage-branches.sh new file mode 100755 index 00000000..5f2eaae6 --- /dev/null +++ b/scripts/tests/test-triage-branches.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Tests for scripts/maintenance/triage-branches.sh, including the cases a +# real repository produces: a merged branch, a drifted branch that adds +# nothing, a drifted branch that carries a file the base dropped, an orphan +# data branch, and the shallow clone where no answer is possible. +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT="$ROOT/scripts/maintenance/triage-branches.sh" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +export GIT_CONFIG_NOSYSTEM=1 +export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=test@example.invalid +export GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@example.invalid + +pass=0 +fail=0 +ok() { pass=$((pass + 1)); printf 'ok %d - %s\n' "$pass" "$1"; } +no() { fail=$((fail + 1)); printf 'not ok - %s\n' "$1"; } +check_contains() { # haystack needle description + if printf '%s' "$1" | grep -Fq -- "$2"; then ok "$3"; else no "$3 (missing: $2)"; fi +} + +git init -q --bare "$work/origin.git" +git init -q "$work/repo" +cd "$work/repo" +git symbolic-ref HEAD refs/heads/main +printf 'a\n' > base.txt +git add base.txt +git commit -qm 'base: add base.txt' +printf 'b\n' > dropped.txt +git add dropped.txt +git commit -qm 'base: add dropped.txt' +git branch -m main + +# salvage: branched before the base moved, adds a file the base never has +git checkout -q main~1 +git checkout -qb salvage +printf 's\n' > salvage.txt +git add salvage.txt +git commit -qm 'salvage: add salvage.txt' + +# stale: branched before the base moved, only touches a file the base has +git checkout -q main~1 +git checkout -qb stale +printf 'a changed on the branch\n' > base.txt +git commit -qam 'stale: edit base.txt' + +# contained: fully merged into main, then main moves on without it +git checkout -q main +git checkout -qb merged-branch +printf 'm\n' > merged.txt +git add merged.txt +git commit -qm 'merged: add merged.txt' +git checkout -q main +git merge -q --ff-only merged-branch +git branch -m merged-branch contained +printf 'd\n' > later.txt +git add later.txt +git commit -qm 'base: add later.txt' + +# fresh: branched at the base tip, one commit ahead +git checkout -qb fresh +printf 'c\n' > fresh.txt +git add fresh.txt +git commit -qm 'fresh: add fresh.txt' + +# unrelated: an orphan data branch, as the badge publisher creates +git checkout -q --orphan governance-badges +git rm -rqf . +printf '{ "schemaVersion": 1 }\n' > deed.json +git add deed.json +git commit -qm 'badges: endpoint json' + +git checkout -q main +git remote add origin "$work/origin.git" +git push -q origin main fresh salvage stale contained governance-badges +# The bare repository's HEAD is still on an unborn `master`, so `git clone` +# would call it empty; point it at main, as a real default branch would be. +git -C "$work/origin.git" symbolic-ref HEAD refs/heads/main +git fetch -q origin + +out="$(bash "$SCRIPT" --base main --remote origin)" +rows="$(printf '%s\n' "$out" | grep -E 'origin/|^[a-z]' || true)" +check_contains "$out" 'fresh' 'a branch at the base tip appears' +if printf '%s\n' "$out" | grep -Eq '^fresh +fresh '; then ok 'a branch at the base tip is classed fresh'; else no "fresh branch class: $out"; fi +if printf '%s\n' "$out" | grep -Eq '^salvage +salvage '; then ok 'a drifted branch with an unknown file is classed salvage'; else no "salvage class: $out"; fi +if printf '%s\n' "$out" | grep -Eq '^stale +stale '; then ok 'a drifted branch that adds nothing is classed stale'; else no "stale class: $out"; fi +if printf '%s\n' "$out" | grep -Eq '^contained +contained '; then ok 'a fully merged branch is classed contained'; else no "contained class: $out"; fi +if printf '%s\n' "$out" | grep -Eq '^governance-badges +unrelated '; then ok 'an orphan branch is classed unrelated'; else no "unrelated class: $out"; fi + +markdown="$(bash "$SCRIPT" --base main --remote origin --format markdown)" +check_contains "$markdown" '| `salvage` | salvage |' 'the markdown table carries the class' +check_contains "$markdown" 'salvage (holds files the base lacks)' 'the markdown summary explains salvage' + +set +e +bash "$SCRIPT" --base main --remote origin --fail-on stale >/dev/null 2>&1 +status=$? +set -e +if [ "$status" -eq 3 ]; then ok '--fail-on stale exits 3 when a stale branch exists'; else no "--fail-on stale exited $status"; fi + +set +e +bash "$SCRIPT" --base main --remote origin --fail-on salvage >/dev/null 2>&1 +status=$? +set -e +if [ "$status" -eq 3 ]; then ok '--fail-on salvage exits 3 when a salvage branch exists'; else no "--fail-on salvage exited $status"; fi + +json="$(bash "$SCRIPT" --base main --remote origin --format json)" +if printf '%s' "$json" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["base"]=="main"; assert {b["class"] for b in d["branches"]} >= {"stale","salvage","contained","unrelated","fresh"}' 2>/dev/null; then + ok '--format json emits the documented classes as valid JSON' +else + no "--format json output: $json" +fi + +# A shallow clone cannot answer the question, so it must not answer at all. +git clone -q --depth 1 --branch main "file://$work/origin.git" "$work/shallow" +set +e +shallow_out="$(cd "$work/shallow" && bash "$SCRIPT" --base main --remote origin 2>&1)" +status=$? +set -e +if [ "$status" -eq 2 ]; then ok 'a shallow clone is refused with exit 2'; else no "shallow clone exited $status: $shallow_out"; fi +check_contains "$shallow_out" 'shallow clone' 'the refusal names the cause' + +printf '\n%d passed, %d failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ]