From c03f050e4c3f24d847f25b5009afd0b66aa3f8a3 Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 25 Oct 2026 10:52:38 +0000 Subject: [PATCH] fix(hygiene): lanes are not drift, and keep Dependabot PRs fresh Three follow-ups to #285, one of them a correction of a mistake it made. 1. Lanes. The deed (MetaManifold-WebUI_chora.deed, routing :upstream-candidates) declares upstream/* a lane, CONTRIBUTING.md declares archive/* and governance-badges. The triage classed branches by commit graph alone, so it reported the upstream candidate upstream/placement-evidence as 'salvage' and invited its deletion - which discards a routing decision, not a commit. Lanes are now read from the deed (with the upstream/* value as a fallback for a bare clone) plus the two documented ones, printed as their own column and counted separately, and a laned branch does not raise --fail-on unless --include-lanes is passed. The branch-hygiene issue counts from the triage's own summary lines instead of grepping table rows, and says how many laned branches it is deliberately not counting. 2. Dependabot. It does not rebase by default, so with two open updates the second goes behind main the moment the first merges and the new Branch freshness check would fail a one-line dependency bump for a rule the bump did not break. Both ecosystems now set rebase-strategy: auto, which keeps the check's red meaning 'a conflict a person has to resolve'. 3. Pins and template. actions/checkout was v4.4.0 in the standards job and v7.0.1 in five others, and the occupancy job's copy carried no version comment at all, so Dependabot's diff for it was unreadable. Both now match. The pull request template names the Branch freshness check. docs/maintenance/repository-hygiene.md records the lane rules, the correction, and a new 'Looks wrong, is not' section: JoshuaJewell/MetaManifold-WebUI is the upstream this repository forks (0-AI-MANIFEST.a2ml, the deed, must.k9.ncl say so), not a stale owner, so the CI, Codecov and CITATION references to it must not be 'fixed' to hyperpolymath. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/dependabot.yml | 8 ++ .github/pull_request_template.md | 2 + .github/workflows/branch-hygiene.yml | 21 ++++- .github/workflows/ci.yml | 4 +- CONTRIBUTING.md | 4 + docs/maintenance/repository-hygiene.md | 78 +++++++++++++-- scripts/maintenance/triage-branches.sh | 126 +++++++++++++++++++------ scripts/tests/test-triage-branches.sh | 98 +++++++++++++++++-- 8 files changed, 292 insertions(+), 49 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b8dc3611..5017e7f3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,6 +6,13 @@ updates: schedule: interval: weekly open-pull-requests-limit: 5 + # A dependency pull request is a one-line diff that becomes stale the + # moment anything else merges, and the Branch freshness check then fails it + # on a rule the dependency did not break. Dependabot's default is not to + # rebase, so with more than one open update at a time every one but the + # first would show red for the wrong reason. Rebasing makes the freshness + # check mean what it says: red here is a conflict that needs a person. + rebase-strategy: auto # Dependabot's npm ecosystem supports bun.lock. Julia and R pins remain # maintainer-reviewed in tool_versions.yml, Manifest.toml and renv.lock. - package-ecosystem: npm @@ -13,6 +20,7 @@ updates: schedule: interval: weekly open-pull-requests-limit: 5 + rebase-strategy: auto groups: development-tooling: dependency-type: development diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d7f121ff..3768a0fc 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -15,5 +15,7 @@ - [ ] I followed [CONTRIBUTING.md](../CONTRIBUTING.md). - [ ] This branch starts at current upstream main and contains no merges from fork main. + The **Branch freshness** check reports this within a minute of a push; run it + yourself with `bash scripts/maintenance/check-pr-freshness.sh --base origin/main`. - [ ] CI has run and passes. - [ ] Existing licensing, citation and acknowledgement text is unchanged. diff --git a/.github/workflows/branch-hygiene.yml b/.github/workflows/branch-hygiene.yml index 58bf292d..ba36a3d2 100644 --- a/.github/workflows/branch-hygiene.yml +++ b/.github/workflows/branch-hygiene.yml @@ -52,19 +52,29 @@ jobs: 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)" + # The script's own summary lines are the counts. Grepping the table + # would break the moment a column is added, and would count a branch + # in a declared lane (upstream/*, archive/*, the badge data branch) + # as drift - which is the mistake the lane column exists to prevent. + count_for() { # class + awk -v cls="$1" ' + index($0, "- " cls " (") == 1 { n = $0; sub(/.*: /, "", n); print n } + ' "$RUNNER_TEMP/branches.md" + } + stale="$(count_for stale)" + salvage="$(count_for salvage)" + laned="$(awk 'index($0, "- in a declared lane") == 1 { n = $0; sub(/.*: /, "", n); print n }' "$RUNNER_TEMP/branches.md")" { printf 'stale=%s\n' "${stale:-0}" printf 'salvage=%s\n' "${salvage:-0}" + printf 'laned=%s\n' "${laned:-0}" } >> "$GITHUB_OUTPUT" - name: Upsert the rolling issue env: STALE: ${{ steps.triage.outputs.stale }} SALVAGE: ${{ steps.triage.outputs.salvage }} + LANED: ${{ steps.triage.outputs.laned }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} REPO_URL: ${{ github.server_url }}/${{ github.repository }} run: | @@ -88,6 +98,9 @@ jobs: 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" + if [ "${LANED:-0}" -gt 0 ]; then + printf '\n%d branch(es) in a declared lane (`upstream/*`, `archive/*`, `governance-badges`) are listed for their shape but are not counted as drift: a lane is a routing decision, and deleting one discards that decision rather than a commit.\n' "$LANED" + fi } > "$RUNNER_TEMP/issue.md" gh label create hygiene --force \ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92e99015..ec5ab4ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -533,7 +533,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 60 steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Julia run: bash scripts/install-ci-julia.sh @@ -678,7 +678,7 @@ jobs: container: debian:trixie-20250929-slim@sha256:1caf1c703c8f7e15dcf2e7769b35000c764e6f50e4d7401c355fb0248f3ddfdb timeout-minutes: 30 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install proof tools run: | apt-get update -qq diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66389b31..debddfca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,6 +68,10 @@ What happens to a branch once it is no longer needed: 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. +- **`upstream/*`** — the upstream-candidate lane declared by the repository deed + (`:upstream-candidates`). These are proposed to the upstream remote and + squash-merged there, not here; the triage script reports them but does not + count them as drift. - **`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. diff --git a/docs/maintenance/repository-hygiene.md b/docs/maintenance/repository-hygiene.md index d822ba77..e63ef223 100644 --- a/docs/maintenance/repository-hygiene.md +++ b/docs/maintenance/repository-hygiene.md @@ -37,12 +37,40 @@ 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. +## Declared lanes are not spare branches + +A branch name can be a *routing decision* rather than a topic. Three such lanes +exist here, and a class computed from the commit graph knows nothing about them: + +| Lane | Declared in | What it is | +| --- | --- | --- | +| `upstream/*` | `MetaManifold-WebUI_chora.deed`, routing `:upstream-candidates` | Work destined for the upstream remote (`JoshuaJewell/MetaManifold-WebUI`), squash-merged there. `notes/commit-signing.md` records #192 landing that way as `upstream/glmgampoi-r`. | +| `archive/*` | `CONTRIBUTING.md`, retention | A deliberate snapshot that is not proposed for merging. | +| `governance-badges` | `CONTRIBUTING.md`, retention | The orphan branch the `Publish badges` job force-pushes; `README.md` reads it through `img.shields.io/endpoint`. | + +The triage prints a lane column and a count, but a laned branch is **not** +counted by `--fail-on` unless `--include-lanes` is passed. This is not +pedantry: it is a mistake this document had to absorb. + +**Correction, 2026-10-25.** The first pass of this cleanup classed +`upstream/placement-evidence` as `salvage` and closed #270 with it, reasoning +only from the commit graph. The deed says that name is a lane: the branch was a +candidate for the upstream remote, not a spare working branch. The content is +not lost — it is at `refs/pull/270/head`, and the same two files +(`frontend/src/components/ComputingNote.tsx`, `frontend/src/tree/placementLwr.test.ts`) +are recorded in the table below — but closing it removed a candidate from a +declared lane, which is a decision that belonged to the owner. Whether to +re-cut that candidate from the current tip is recorded under +[Decisions for the owner](#decisions-for-the-owner). The other twelve closes +are unaffected: none of them was in a lane. + ## 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. | +| `.github/dependabot.yml` | Both ecosystems set `rebase-strategy: auto`. Dependabot does not rebase by default, so with two open updates the second becomes stale the moment the first merges and **Branch freshness** would fail a one-line dependency bump on a rule the bump did not break. Rebasing keeps that check meaning what it says. | | `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 @@ -165,6 +193,30 @@ before it: 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. +## Looks wrong, is not + +Recorded because a re-review of this repository reaches both conclusions and +acts on them; the first one was reached, wrongly, during the 2026-10-25 pass. + +- **`JoshuaJewell/MetaManifold-WebUI` in `README.md`, `CITATION.cff`, + `.github/workflows/ci.yml` (Codecov `slug`), `CONTRIBUTING.md`, + `frontend/src/about.ts` and `SECURITY.md` is not a stale owner.** This + repository is a fork of it, and says so in three machine-readable places: + `0-AI-MANIFEST.a2ml` (`(fork-of …)`), `MetaManifold-WebUI_chora.deed` + (`:fork-of`, `:upstream`) and `.machine_readable/contractiles/must/must.k9.ncl` + (`upstream_repository`). Those references point at the upstream project's + Actions, Codecov project and citation on purpose. "Fixing" them to + `hyperpolymath` would silently repoint the coverage upload at a Codecov + project that may not have the token, and with `fail_ci_if_error: false` the + failure would be invisible. +- **The `hyperpolymath/standards` pins differ by design**, in part. See decision + 6 below for which ones are contract-bound. +- **`governance-badges` looks like an abandoned branch** and is an output. +- **`docs/` ignoring everything except a whitelist** is deliberate + (`.gitignore`: `docs/*` with `!docs/release-notes/`, `!docs/statistics/`, …). + A new directory under `docs/` needs a whitelist line or it will not be + committed; `!docs/maintenance/` was added for this document. + ## Decisions for the owner These are not cleanup, they are choices with trade-offs; the reasoning is @@ -179,14 +231,28 @@ recorded here so the choice does not have to be rediscovered. 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. + generated JSON, force-pushed by the `Publish badges` job. The triage now + classes it `badge-data` and excludes it from `--fail-on`, so it no longer + invites a deletion. Publishing the JSON as a release asset instead would + remove it from every human's branch list, but it changes where the badges are + read from: the `img.shields.io/endpoint` URLs in `README.md` point at that + branch. Left as it is. 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. +5. **The `upstream/*` candidate that was closed (see the correction above).** + `prepare/lwr-63-lean`, `upstream/placement-evidence` and `wip/pr62-theta` + carried the same residue — `ComputingNote.tsx` and `placementLwr.test.ts`. + If an upstream candidate is still wanted, the honest move is to re-cut one + small branch from the current tip containing only those two files, tested + against the current `frontend/src/tree/model.ts`, rather than to restore a + six-day-old tree 119 commits behind. That is owner work, not cleanup. +6. **Reconciling the `hyperpolymath/standards` pins.** Seven workflows reference + that repository at four different commits, two of them inside + `hypatia-scan-reusable.yml` itself. Some pins are deliberate and pinned by + `scripts/check-governance-contract.sh` (`c6d78606…` for K9, the secret + scanner and Scorecard; `01c9b0c9…` for the verdict-grid renderer), so a + reconciliation has to move the contract's constants and the allowlist digest + together, and is a review of its own rather than a tidy. diff --git a/scripts/maintenance/triage-branches.sh b/scripts/maintenance/triage-branches.sh index e714c25a..545480e2 100755 --- a/scripts/maintenance/triage-branches.sh +++ b/scripts/maintenance/triage-branches.sh @@ -16,10 +16,7 @@ # 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. +# unrelated no common ancestor with the base. # 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. @@ -30,6 +27,23 @@ # base. The content was superseded by later commits; merging it # would revert them. # +# Lanes. A branch name can also be a *declared routing decision*, and those are +# not spare branches whatever their commit graph says. The lanes are read from +# the repository deed (`MetaManifold-WebUI_chora.deed` names the upstream +# candidates) plus the two declared in CONTRIBUTING.md's retention section: +# +# upstream/* upstream-candidate work, squash-merged on the upstream +# remote (deed routing `:upstream-candidates`). +# archive/* a deliberate snapshot, not proposed for merging. +# governance-badges orphan branch of shields.io endpoint JSON, force-pushed by +# the Publish badges job and read by README.md. +# +# A laned branch is still classed and still printed, because its shape is worth +# knowing, but it is NOT counted by --fail-on unless --include-lanes is given. +# This distinction is not academic: a triage that classes by graph shape alone +# reports an upstream candidate as `salvage` and invites its deletion, which +# discards the routing decision rather than a commit. +# # A shallow clone cannot answer any of this: `--is-shallow-repository` is # checked and the script refuses to print numbers it cannot stand behind. # @@ -38,6 +52,7 @@ # 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 +# scripts/maintenance/triage-branches.sh --fail-on salvage --include-lanes # # Exit status: 0 on success, 2 if triage cannot be computed (shallow clone, # missing refs), 3 if --fail-on matched at least one branch. @@ -47,9 +62,10 @@ base=main remote=origin format=table fail_on= +include_lanes=false usage() { - sed -n '5,45p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + sed -n '5,58p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' } while [ $# -gt 0 ]; do @@ -58,6 +74,7 @@ while [ $# -gt 0 ]; do --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 ;; + --include-lanes) include_lanes=true; shift ;; -h|--help) usage; exit 0 ;; *) printf 'triage-branches: unknown argument: %s\n' "$1" >&2; exit 2 ;; esac @@ -95,6 +112,43 @@ fi # against, and a triage run against last week's cache is worse than none. base_tip="$(git rev-parse "$base_ref^{commit}")" +# --- lanes ----------------------------------------------------------------- +# The deed is the authority for the upstream-candidate lane; the other two are +# stated in CONTRIBUTING.md. Fall back to the deed's value if the file is not +# checked out (a caller may run this against a bare clone). +lane_globs=() +lane_names=() +add_lane() { lane_globs+=("$1"); lane_names+=("$2"); } + +deed=MetaManifold-WebUI_chora.deed +if [ -f "$deed" ]; then + while IFS= read -r declared; do + if [ -n "$declared" ]; then + add_lane "$declared" upstream + fi + # Anchored to the key, tolerant of what follows the value: the real deed puts + # the closing parens on later lines, a hand-written one may not. + done < <(sed -n 's/^[[:space:]]*:upstream-candidates[[:space:]]*"\([^"]*\)".*$/\1/p' "$deed") +fi +if [ "${#lane_globs[@]}" -eq 0 ]; then + add_lane 'upstream/*' upstream +fi +add_lane 'archive/*' archive +add_lane 'governance-badges' badge-data + +lane_of() { # branch -> lane name, or empty + local branch="$1" i=0 + while [ "$i" -lt "${#lane_globs[@]}" ]; do + # Intentional unquoted expansion: the lane is a glob pattern. + # shellcheck disable=SC2254 + case "$branch" in + ${lane_globs[$i]}) printf '%s' "${lane_names[$i]}"; return 0 ;; + esac + i=$((i + 1)) + done + return 0 +} + rows=() count_base=0 count_unrelated=0 @@ -102,6 +156,7 @@ count_contained=0 count_fresh=0 count_salvage=0 count_stale=0 +count_laned=0 matched_fail_on=0 while IFS= read -r ref; do @@ -110,6 +165,7 @@ while IFS= read -r ref; do [ "$branch" = "HEAD" ] && continue git rev-parse --verify --quiet "$ref^{commit}" >/dev/null || continue + lane="$(lane_of "$branch")" class= ahead=0 behind=0 @@ -142,17 +198,23 @@ while IFS= read -r ref; do count_stale=$((count_stale + 1)) fi fi + [ -n "$lane" ] && count_laned=$((count_laned + 1)) - 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 + # A laned branch is reported but does not raise --fail-on: its shape is not + # evidence that anyone should delete it. + if [ -z "$fail_on" ] || { [ -n "$lane" ] && [ "$include_lanes" != true ]; }; then + : + else + 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 + fi 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") + rows+=("$branch|$lane|$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() { @@ -168,43 +230,49 @@ case "$format" in "$(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" + IFS='|' read -r branch lane 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" \ + if [ -n "$lane" ]; then + lane_json="\"$(json_escape "$lane")\"" + else + lane_json=null + fi + printf '{"branch":"%s","lane":%s,"class":"%s","ahead":%s,"behind":%s,"unique_files":%s,"merge_commits":%s,"tip_date":"%s","tip_subject":"%s"}' \ + "$(json_escape "$branch")" "$lane_json" "$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' + printf '| Branch | Lane | 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" + IFS='|' read -r branch lane class ahead behind unique merges tip_date tip_subject <<<"$row" + printf '| `%s` | %s | %s | %s | %s | %s | %s | %s %s |\n' \ + "$branch" "${lane:--}" "$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" \ + "- unrelated (no common ancestor): $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" + "- stale (superseded by later commits): $count_stale" \ + "- in a declared lane, so not counted as drift: $count_laned" ;; table) - printf '%-46s %-10s %6s %7s %6s %6s %s\n' \ - BRANCH CLASS AHEAD BEHIND ONLY-HERE MERGES TIP + printf '%-44s %-11s %-10s %6s %7s %6s %6s %s\n' \ + BRANCH LANE 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" + IFS='|' read -r branch lane class ahead behind unique merges tip_date tip_subject <<<"$row" + printf '%-44s %-11s %-10s %6s %7s %6s %6s %s %s\n' \ + "$branch" "${lane:--}" "$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' \ + printf '\n%d branch(es): %d contained, %d stale, %d salvage, %d fresh, %d unrelated, %d in a declared lane\n' \ "$((count_base + count_contained + count_stale + count_salvage + count_fresh + count_unrelated))" \ - "$count_contained" "$count_stale" "$count_salvage" "$count_fresh" "$count_unrelated" + "$count_contained" "$count_stale" "$count_salvage" "$count_fresh" "$count_unrelated" "$count_laned" ;; esac diff --git a/scripts/tests/test-triage-branches.sh b/scripts/tests/test-triage-branches.sh index 5f2eaae6..42383a71 100755 --- a/scripts/tests/test-triage-branches.sh +++ b/scripts/tests/test-triage-branches.sh @@ -66,6 +66,18 @@ printf 'c\n' > fresh.txt git add fresh.txt git commit -qm 'fresh: add fresh.txt' +# upstream lane: a candidate for the upstream remote, per the deed's routing +git checkout -q -b upstream/placement-evidence main~1 +printf 'u\n' > upstream-candidate.txt +git add upstream-candidate.txt +git commit -qm 'upstream: candidate file' + +# archive lane: a deliberate snapshot +git checkout -q -b archive/pre-sync main~1 +printf 'k\n' > archive-only.txt +git add archive-only.txt +git commit -qm 'archive: snapshot file' + # unrelated: an orphan data branch, as the badge publisher creates git checkout -q --orphan governance-badges git rm -rqf . @@ -75,7 +87,8 @@ 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 +git push -q origin main fresh salvage stale contained governance-badges \ + upstream/placement-evidence archive/pre-sync # 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 @@ -84,14 +97,29 @@ 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 +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 +badge-data +unrelated '; then ok 'an orphan branch is classed unrelated and laned badge-data'; else no "unrelated class: $out"; fi +if printf '%s\n' "$out" | grep -Eq '^upstream/placement-evidence +upstream +salvage '; then ok 'an upstream candidate keeps its upstream lane'; else no "upstream lane: $out"; fi +if printf '%s\n' "$out" | grep -Eq '^archive/pre-sync +archive +salvage '; then ok 'an archive snapshot keeps its archive lane'; else no "archive lane: $out"; fi + +# A laned branch is reported but does not raise --fail-on; the plain branch does. +set +e +bash "$SCRIPT" --base main --remote origin --fail-on salvage >/dev/null 2>&1 +status_plain=$? +bash "$SCRIPT" --base main --remote origin --fail-on salvage --include-lanes >/dev/null 2>&1 +status_lanes=$? +set -e +if [ "$status_plain" -eq 3 ]; then ok 'a laned salvage still counts when unlane branches match'; else no "--fail-on salvage exited $status_plain"; fi +if [ "$status_lanes" -eq 3 ]; then ok '--include-lanes counts the laned branches too'; else no "--include-lanes exited $status_lanes"; 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` | - | salvage |' 'the markdown table carries the lane and class' +check_contains "$markdown" '| `upstream/placement-evidence` | upstream | salvage |' 'the markdown table names the upstream lane' +check_contains "$markdown" 'in a declared lane, so not counted as drift' 'the markdown summary counts laned branches separately' check_contains "$markdown" 'salvage (holds files the base lacks)' 'the markdown summary explains salvage' set +e @@ -107,12 +135,51 @@ 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 +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"}; assert {"upstream","archive","badge-data"} <= {b["lane"] for b in d["branches"] if b["lane"]}; assert any(b["lane"] is None for b in d["branches"])' 2>/dev/null; then ok '--format json emits the documented classes as valid JSON' else no "--format json output: $json" fi +# The deed, when checked out, is the authority for the lane pattern; without it +# the built-in `upstream/*` fallback applies. Both are asserted, because the +# fallback is what a bare clone gets and the deed is what a checkout gets. +git checkout -q -b candidates/one main~1 +printf 'c\n' > candidate.txt +git add candidate.txt +git commit -qm 'candidate: file' +git push -q origin candidates/one +git checkout -q main +git fetch -q origin + +printf ';; fixture deed\n(repo-deed\n (routing\n :upstream-candidates "candidates/*"))\n' \ + > MetaManifold-WebUI_chora.deed +deed_out="$(bash "$SCRIPT" --base main --remote origin)" +if printf '%s\n' "$deed_out" | grep -Eq '^candidates/one +upstream '; then + ok 'a lane declared in the deed is recognised' +else + no "deed-declared lane: $deed_out" +fi +if printf '%s\n' "$deed_out" | grep -Eq '^upstream/placement-evidence +- +salvage '; then + ok 'the deed pattern replaces the built-in upstream/* fallback' +else + no "deed override of the fallback: $deed_out" +fi + +rm -f MetaManifold-WebUI_chora.deed +nodeed_out="$(bash "$SCRIPT" --base main --remote origin)" +if printf '%s\n' "$nodeed_out" | grep -Eq '^upstream/placement-evidence +upstream '; then + ok 'without a deed the upstream/* fallback still lanes the branch' +else + no "fallback lane: $nodeed_out" +fi + +# The candidate is not in a lane without the deed, so it must be cleared before +# the "laned work alone" assertion below, which needs no unlane drift left. +git push -q origin :candidates/one +git branch -q -D candidates/one +git fetch -q --prune origin + # 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 @@ -122,5 +189,20 @@ 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' + + +# With only laned work left, --fail-on must not fire. +git branch -q -D salvage 2>/dev/null || true +git push -q origin :salvage +git fetch -q --prune origin +set +e +bash "$SCRIPT" --base main --remote origin --fail-on salvage >/dev/null 2>&1 +status_lanebranch=$? +bash "$SCRIPT" --base main --remote origin --fail-on salvage --include-lanes >/dev/null 2>&1 +status_lanebranch_included=$? +set -e +if [ "$status_lanebranch" -eq 0 ]; then ok 'laned branches alone do not raise --fail-on'; else no "unlane-only --fail-on exited $status_lanebranch"; fi +if [ "$status_lanebranch_included" -eq 3 ]; then ok '--include-lanes reproduces the laned match'; else no "unlane-only --include-lanes exited $status_lanebranch_included"; fi + printf '\n%d passed, %d failed\n' "$pass" "$fail" [ "$fail" -eq 0 ]