| 1 | #!/usr/bin/env bash |
| 2 | # Hermetic test for scripts/release/branch-hygiene.sh. |
| 3 | # |
| 4 | # Builds a throwaway git repo with a known branch topology and asserts that the |
| 5 | # hygiene script: |
| 6 | # * marks branches whose tip is contained in main/the release branch as |
| 7 | # "safe to delete", |
| 8 | # * keeps a branch with unique commits from a non-Hunter contributor as |
| 9 | # contributor work (never a safe delete), |
| 10 | # * flags a branch with only unmerged maintainer commits as needs-review, |
| 11 | # * detects a working checkout parked on an already-merged scratch branch, |
| 12 | # * honors --remote when the canonical release refs live outside origin, |
| 13 | # * actually deletes only safe branches under --prune --yes and never the |
| 14 | # contributor branch. |
| 15 | # |
| 16 | # Run: bash scripts/release/branch-hygiene.test.sh |
| 17 | set -euo pipefail |
| 18 | |
| 19 | script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
| 20 | hygiene="${script_dir}/branch-hygiene.sh" |
| 21 | |
| 22 | work="$(mktemp -d)" |
| 23 | cleanup() { rm -rf "${work}"; } |
| 24 | trap cleanup EXIT |
| 25 | |
| 26 | fail=0 |
| 27 | check() { |
| 28 | # check <description> <expected-substring> <<<haystack-on-stdin> |
| 29 | local desc="$1" needle="$2" hay |
| 30 | hay="$(cat)" |
| 31 | if grep -qF -- "${needle}" <<<"${hay}"; then |
| 32 | echo "ok - ${desc}" |
| 33 | else |
| 34 | echo "FAIL - ${desc}" |
| 35 | echo " expected to find: ${needle}" |
| 36 | echo "------ output ------" |
| 37 | echo "${hay}" |
| 38 | echo "--------------------" |
| 39 | fail=1 |
| 40 | fi |
| 41 | } |
| 42 | refute() { |
| 43 | # refute <description> <forbidden-substring> <<<haystack> |
| 44 | local desc="$1" needle="$2" hay |
| 45 | hay="$(cat)" |
| 46 | if grep -qF -- "${needle}" <<<"${hay}"; then |
| 47 | echo "FAIL - ${desc}" |
| 48 | echo " did NOT expect to find: ${needle}" |
| 49 | fail=1 |
| 50 | else |
| 51 | echo "ok - ${desc}" |
| 52 | fi |
| 53 | } |
| 54 | |
| 55 | # The script resolves its repo root as <script>/../.. and operates on *that* |
| 56 | # repo, not the current directory. So copy it into the throwaway repo at the |
| 57 | # same relative path and invoke the copy; that makes the temp repo its root. |
| 58 | mkdir -p "${work}/scripts/release" |
| 59 | cp "${hygiene}" "${work}/scripts/release/branch-hygiene.sh" |
| 60 | hygiene="${work}/scripts/release/branch-hygiene.sh" |
| 61 | |
| 62 | cd "${work}" |
| 63 | export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null |
| 64 | git init -q -b main . |
| 65 | git config user.name "Hunter Bown" |
| 66 | git config user.email "hmbown@gmail.com" |
| 67 | # Mirror the real repo's .mailmap canonicalization for Hunter. |
| 68 | cat >.mailmap <<'EOF' |
| 69 | Hunter Bown <hmbown@gmail.com> Claude <noreply@anthropic.com> |
| 70 | EOF |
| 71 | |
| 72 | commit() { |
| 73 | # commit <file> <content> <author-name> <author-email> |
| 74 | echo "$2" >"$1" |
| 75 | git add -A |
| 76 | git -c user.name="$3" -c user.email="$4" \ |
| 77 | commit -q --author="$3 <$4>" -m "touch $1" |
| 78 | } |
| 79 | |
| 80 | H_NAME="Hunter Bown"; H_EMAIL="hmbown@gmail.com" |
| 81 | |
| 82 | # main: base commit by Hunter. |
| 83 | commit base "v0" "${H_NAME}" "${H_EMAIL}" |
| 84 | |
| 85 | # release branch sits at main for this test. |
| 86 | git branch codex/v0.8.61 main |
| 87 | |
| 88 | # merged-scratch: branched and merged back into main (tip contained in main). |
| 89 | git switch -q -c merged-scratch |
| 90 | commit feat-a "a" "${H_NAME}" "${H_EMAIL}" |
| 91 | git switch -q main |
| 92 | git merge -q --no-ff merged-scratch -m "merge merged-scratch" |
| 93 | # fast-forward the release branch to include the merge too. |
| 94 | git branch -f codex/v0.8.61 main |
| 95 | |
| 96 | # contributor-branch: unique commit by a NON-Hunter contributor (must be kept). |
| 97 | git switch -q -c contributor-branch main |
| 98 | commit feat-contrib "c" "Jane Contributor" "jane@example.com" |
| 99 | |
| 100 | # maintainer-scratch: unique commit by Hunter, not merged (needs review). |
| 101 | git switch -q -c maintainer-scratch main |
| 102 | commit feat-h "h" "${H_NAME}" "${H_EMAIL}" |
| 103 | |
| 104 | # bot-folded: unique commit by Claude, which .mailmap folds into Hunter, so it |
| 105 | # must be treated as maintainer-only (needs review, NOT contributor work). |
| 106 | git switch -q -c bot-folded main |
| 107 | commit feat-bot "b" "Claude" "noreply@anthropic.com" |
| 108 | |
| 109 | # Park the working checkout on an already-merged scratch branch to exercise the |
| 110 | # "parked checkout" warning. Point HEAD at the merged-scratch tip but on a |
| 111 | # fresh non-release branch name. |
| 112 | git switch -q -c renovate/parked merged-scratch |
| 113 | |
| 114 | # --- Dry-run report ---------------------------------------------------------- |
| 115 | report="$(bash "${hygiene}" --release-branch codex/v0.8.61 --main-ref main 2>&1)" |
| 116 | |
| 117 | check "merged scratch branch is a safe delete" \ |
| 118 | "local : merged-scratch" <<<"${report}" |
| 119 | check "contributor branch is kept as contributor work" \ |
| 120 | "[local] contributor-branch:" <<<"${report}" |
| 121 | check "contributor branch names the contributor author" \ |
| 122 | "Jane Contributor" <<<"${report}" |
| 123 | check "contributor branch reason is KEEP" \ |
| 124 | "KEEP - unique contributor work" <<<"${report}" |
| 125 | check "maintainer-only scratch is flagged for review" \ |
| 126 | "[local] maintainer-scratch:" <<<"${report}" |
| 127 | check "maintainer-only scratch reason is REVIEW" \ |
| 128 | "REVIEW -" <<<"${report}" |
| 129 | check "mailmap-folded bot commit is treated as maintainer (review, not keep)" \ |
| 130 | "[local] bot-folded:" <<<"${report}" |
| 131 | check "parked working checkout warning fires" \ |
| 132 | "working checkout is parked on 'renovate/parked'" <<<"${report}" |
| 133 | |
| 134 | # The contributor branch must NEVER appear in the safe-delete list. |
| 135 | safe_section="$(awk '/^-- Safe to delete/{f=1;next} /^-- Keep/{f=0} f' <<<"${report}")" |
| 136 | refute "contributor branch is not in the safe-delete list" \ |
| 137 | "contributor-branch" <<<"${safe_section}" |
| 138 | refute "maintainer-only scratch is not in the safe-delete list" \ |
| 139 | "maintainer-scratch" <<<"${safe_section}" |
| 140 | |
| 141 | # --- Prune (local) ----------------------------------------------------------- |
| 142 | prune_out="$(bash "${hygiene}" --release-branch codex/v0.8.61 --main-ref main --prune --yes 2>&1)" |
| 143 | check "prune deletes the merged scratch branch" \ |
| 144 | "deleted local merged-scratch" <<<"${prune_out}" |
| 145 | |
| 146 | # After prune: contributor + maintainer + bot branches still exist; merged one |
| 147 | # is gone. |
| 148 | remaining="$(git for-each-ref --format='%(refname:short)' refs/heads/)" |
| 149 | check "contributor branch survives prune" "contributor-branch" <<<"${remaining}" |
| 150 | check "maintainer-only scratch survives prune" "maintainer-scratch" <<<"${remaining}" |
| 151 | refute "merged scratch branch is gone after prune" "merged-scratch" <<<"${remaining}" |
| 152 | |
| 153 | # --- Custom remote name ------------------------------------------------------ |
| 154 | git switch -q main |
| 155 | git switch -q -c remote-main-tip |
| 156 | commit remote-main-only "remote" "${H_NAME}" "${H_EMAIL}" |
| 157 | git branch "upstream/main" main^1 |
| 158 | git update-ref "refs/remotes/upstream/main" "$(git rev-parse remote-main-tip)" |
| 159 | git update-ref "refs/remotes/upstream/codex/v0.8.61" "$(git rev-parse codex/v0.8.61)" |
| 160 | git update-ref "refs/remotes/upstream/merged-remote" "$(git rev-parse main)" |
| 161 | git update-ref "refs/remotes/upstream/remote-main-only" "$(git rev-parse remote-main-tip)" |
| 162 | upstream_report="$(bash "${hygiene}" --remote upstream --release-branch codex/v0.8.61 2>&1)" |
| 163 | check "custom remote release tip is reported" \ |
| 164 | "upstream" <<<"${upstream_report}" |
| 165 | check "custom remote default main ref is fully qualified" \ |
| 166 | "Main ref : refs/remotes/upstream/main" <<<"${upstream_report}" |
| 167 | check "custom remote safe-delete command uses the selected remote" \ |
| 168 | "remote: upstream/merged-remote" <<<"${upstream_report}" |
| 169 | check "custom remote main ref is not confused with a same-named local branch" \ |
| 170 | "remote: upstream/remote-main-only" <<<"${upstream_report}" |
| 171 | refute "custom remote report does not hard-code origin in safe deletes" \ |
| 172 | "remote: origin/merged-remote" <<<"${upstream_report}" |
| 173 | |
| 174 | # --- State inconsistency: diverged local vs remote release branch ------------ |
| 175 | git switch -q main |
| 176 | # Simulate a remote release branch that has diverged from local. |
| 177 | git update-ref "refs/remotes/origin/codex/v0.8.61" "$(git rev-parse maintainer-scratch)" |
| 178 | git branch -f codex/v0.8.61 bot-folded |
| 179 | set +e |
| 180 | diverged_out="$(bash "${hygiene}" --release-branch codex/v0.8.61 --main-ref main 2>&1)" |
| 181 | diverged_ec=$? |
| 182 | set -e |
| 183 | check "divergence between local and remote release branch is reported" \ |
| 184 | "have DIVERGED" <<<"${diverged_out}" |
| 185 | if [[ "${diverged_ec}" -ne 1 ]]; then |
| 186 | echo "FAIL - diverged state should exit 1, got ${diverged_ec}" |
| 187 | fail=1 |
| 188 | else |
| 189 | echo "ok - diverged state exits non-zero" |
| 190 | fi |
| 191 | |
| 192 | echo |
| 193 | if [[ "${fail}" -eq 0 ]]; then |
| 194 | echo "branch-hygiene.test.sh: all checks passed" |
| 195 | else |
| 196 | echo "branch-hygiene.test.sh: FAILURES above" |
| 197 | fi |
| 198 | exit "${fail}" |
| 199 |