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 ]