返回 CodeWhale
branch-hygiene.sh
根目录 / scripts / release / branch-hygiene.sh
1 #!/usr/bin/env bash
2 # Post-merge branch hygiene for release and scratch branches.
3 #
4 # After a release/integration merge it is easy to leave the working checkout
5 # parked on a stale feature branch (e.g. renovate/website-and-readmes) even
6 # though HEAD already matches main and the release tag. That creates release
7 # anxiety: contributors cannot tell whether their work actually landed. This
8 # script makes the current state obvious and recommends *safe* cleanup.
9 #
10 # It is read-only and dry-run by default. It never deletes anything unless you
11 # pass --prune, and even then it refuses to delete any branch that carries
12 # unique commits from a contributor other than Hunter unless that work is
13 # already contained in main/the release branch (i.e. merged).
14 #
15 # What it reports:
16 # 1. State check: current checkout branch, local + remote release branch
17 # tips, and the configured main ref, and whether they agree after an integration
18 # merge.
19 # 2. Safe deletes: local and remote branches whose tip is already contained
20 # in the main ref or the release branch.
21 # 3. Keep/review: branches with unique commits, naming the branch, the
22 # unique commit count, the contributor author(s), and the keep reason.
23 # Non-Hunter contributor work is always a keep/review, never a safe
24 # delete, unless it is already merged.
25 # 4. A summary line: deleted / kept-for-contributor / needs-human-decision.
26 #
27 # Usage:
28 # scripts/release/branch-hygiene.sh [--release-branch BRANCH]
29 # [--remote REMOTE]
30 # [--main-ref REF]
31 # [--maintainer "Name <email>"]...
32 # [--prune] [--prune-remote] [--yes]
33 #
34 # Examples:
35 # # Dry-run report against codex/v0.8.61 (default release branch is the
36 # # current branch if it looks like a release branch, else codex/<latest>):
37 # scripts/release/branch-hygiene.sh --release-branch codex/v0.8.61
38 #
39 # # Actually delete the local safe-delete branches (still skips remote and
40 # # still refuses unmerged contributor work):
41 # scripts/release/branch-hygiene.sh --release-branch codex/v0.8.61 --prune --yes
42 set -euo pipefail
43
44 script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
45 repo_root="$(cd "${script_dir}/../.." && pwd)"
46
47 usage() {
48 cat <<'EOF'
49 usage: scripts/release/branch-hygiene.sh [options]
50
51 Reports post-merge branch hygiene for release and scratch branches and
52 recommends safe cleanup. Read-only and dry-run by default.
53
54 Options:
55 --release-branch BRANCH Release branch to verify and prune against
56 (default: current branch if it matches
57 codex/* or work/*, else the highest codex/v* ref).
58 --remote REMOTE Remote whose release/scratch branches are checked
59 and pruned (default: origin).
60 --main-ref REF The "everything merged here" ref
61 (default: refs/remotes/REMOTE/main, falling back
62 to main).
63 --maintainer "N <e>" Treat this author as the maintainer (Hunter).
64 May be repeated. Defaults are derived from
65 .mailmap plus a built-in list.
66 --prune Delete the local safe-delete branches.
67 --prune-remote Also delete the remote safe-delete branches
68 (implies --prune). Requires push access.
69 --yes Do not prompt before deleting (for CI/automation).
70 -h, --help Show this help.
71
72 Exit status:
73 0 state is consistent (or pruning succeeded)
74 1 state is INCONSISTENT (tips disagree) or a delete failed
75 EOF
76 }
77
78 release_branch=""
79 remote_name="origin"
80 main_ref=""
81 prune=0
82 prune_remote=0
83 assume_yes=0
84 declare -a extra_maintainers=()
85
86 while (($# > 0)); do
87 case "$1" in
88 --release-branch)
89 [[ $# -ge 2 ]] || { usage >&2; exit 2; }
90 release_branch="$2"
91 shift
92 ;;
93 --remote)
94 [[ $# -ge 2 ]] || { usage >&2; exit 2; }
95 remote_name="$2"
96 shift
97 ;;
98 --main-ref)
99 [[ $# -ge 2 ]] || { usage >&2; exit 2; }
100 main_ref="$2"
101 shift
102 ;;
103 --maintainer)
104 [[ $# -ge 2 ]] || { usage >&2; exit 2; }
105 extra_maintainers+=("$2")
106 shift
107 ;;
108 --prune)
109 prune=1
110 ;;
111 --prune-remote)
112 prune=1
113 prune_remote=1
114 ;;
115 --yes)
116 assume_yes=1
117 ;;
118 -h|--help)
119 usage
120 exit 0
121 ;;
122 *)
123 echo "unknown argument: $1" >&2
124 usage >&2
125 exit 2
126 ;;
127 esac
128 shift
129 done
130
131 cd "${repo_root}"
132
133 # --- Maintainer (Hunter) identity -------------------------------------------
134 # A branch is only ever a "safe delete" on author grounds if every unique
135 # commit on it is authored by the maintainer. Everyone else is contributor
136 # work that must be reviewed/merged/credited/preserved before deletion.
137 #
138 # We build the maintainer set from a small built-in list plus the canonical
139 # left-hand side of .mailmap (which already folds bots/Claude/Copilot into
140 # Hunter), plus any --maintainer overrides. We compare on email when present,
141 # otherwise on the lowercased name.
142 declare -a maintainer_emails=("hmbown@gmail.com" "hmbown.dev@gmail.com")
143 declare -a maintainer_names=("hunter bown" "hunter b")
144
145 if [[ -f .mailmap ]]; then
146 while IFS= read -r line; do
147 [[ -z "${line}" || "${line}" == \#* ]] && continue
148 # Canonical identity is the first "Name <email>" on each mailmap line.
149 if [[ "${line}" =~ ^([^<]+)\<([^>]+)\> ]]; then
150 cname="$(echo "${BASH_REMATCH[1]}" | sed -E 's/[[:space:]]+$//' | tr '[:upper:]' '[:lower:]')"
151 cemail="$(echo "${BASH_REMATCH[2]}" | tr '[:upper:]' '[:lower:]')"
152 [[ -n "${cname}" ]] && maintainer_names+=("${cname}")
153 [[ -n "${cemail}" ]] && maintainer_emails+=("${cemail}")
154 fi
155 done <.mailmap
156 fi
157
158 for m in "${extra_maintainers[@]+"${extra_maintainers[@]}"}"; do
159 if [[ "${m}" =~ \<([^>]+)\> ]]; then
160 maintainer_emails+=("$(echo "${BASH_REMATCH[1]}" | tr '[:upper:]' '[:lower:]')")
161 mname="$(echo "${m%%<*}" | sed -E 's/[[:space:]]+$//' | tr '[:upper:]' '[:lower:]')"
162 [[ -n "${mname}" ]] && maintainer_names+=("${mname}")
163 else
164 maintainer_names+=("$(echo "${m}" | tr '[:upper:]' '[:lower:]')")
165 fi
166 done
167
168 is_maintainer() {
169 # args: <author-name> <author-email> (both already lowercased)
170 local an="$1" ae="$2" e n
171 for e in "${maintainer_emails[@]}"; do
172 [[ -n "${e}" && "${ae}" == "${e}" ]] && return 0
173 done
174 for n in "${maintainer_names[@]}"; do
175 [[ -n "${n}" && "${an}" == "${n}" ]] && return 0
176 done
177 return 1
178 }
179
180 # --- Resolve main + release refs --------------------------------------------
181 looks_like_release_branch() {
182 [[ "$1" == codex/* || "$1" == work/* || "$1" == release/* ]]
183 }
184
185 current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")"
186
187 if [[ -z "${main_ref}" ]]; then
188 remote_main_ref="refs/remotes/${remote_name}/main"
189 if git rev-parse -q --verify "${remote_main_ref}" >/dev/null 2>&1; then
190 main_ref="${remote_main_ref}"
191 else
192 main_ref="main"
193 fi
194 fi
195
196 if [[ -z "${release_branch}" ]]; then
197 if [[ -n "${current_branch}" && "${current_branch}" != "HEAD" ]] && looks_like_release_branch "${current_branch}"; then
198 release_branch="${current_branch}"
199 else
200 # Highest codex/vX.Y.Z local branch by version sort.
201 release_branch="$(git for-each-ref --format='%(refname:short)' 'refs/heads/codex/v*' \
202 | sort -V | tail -n1 || true)"
203 fi
204 fi
205
206 if ! git rev-parse -q --verify "${main_ref}" >/dev/null 2>&1; then
207 echo "::error::main ref '${main_ref}' does not exist." >&2
208 exit 1
209 fi
210
211 main_sha="$(git rev-parse --short "${main_ref}")"
212
213 echo "== CodeWhale branch hygiene =="
214 echo "Current checkout : ${current_branch:-<detached>} ($(git rev-parse --short HEAD))"
215 echo "Main ref : ${main_ref} (${main_sha})"
216
217 inconsistent=0
218
219 if [[ -z "${release_branch}" ]]; then
220 echo "Release branch : <none found> (pass --release-branch to enable the state check)"
221 else
222 local_rel="refs/heads/${release_branch}"
223 remote_rel="refs/remotes/${remote_name}/${release_branch}"
224
225 if git rev-parse -q --verify "${local_rel}" >/dev/null 2>&1; then
226 local_rel_sha="$(git rev-parse --short "${local_rel}")"
227 else
228 local_rel_sha="<missing>"
229 fi
230 if git rev-parse -q --verify "${remote_rel}" >/dev/null 2>&1; then
231 remote_rel_sha="$(git rev-parse --short "${remote_rel}")"
232 else
233 remote_rel_sha="<missing>"
234 fi
235
236 echo "Release branch : ${release_branch}"
237 echo " local : ${local_rel_sha}"
238 echo " ${remote_name} : ${remote_rel_sha}"
239
240 # State verification: after an integration merge into the release branch,
241 # local and remote release tips should agree, and the working checkout
242 # should be on the release branch (not parked on a scratch/renovate name).
243 if [[ "${local_rel_sha}" != "<missing>" && "${remote_rel_sha}" != "<missing>" \
244 && "${local_rel_sha}" != "${remote_rel_sha}" ]]; then
245 if git merge-base --is-ancestor "${local_rel}" "${remote_rel}" 2>/dev/null; then
246 echo " ::warning:: local ${release_branch} is BEHIND ${remote_name} - fast-forward with:" \
247 "git fetch ${remote_name} && git branch -f ${release_branch} ${remote_rel}" >&2
248 elif git merge-base --is-ancestor "${remote_rel}" "${local_rel}" 2>/dev/null; then
249 echo " ::warning:: local ${release_branch} is AHEAD of ${remote_name} - push with:" \
250 "git push ${remote_name} ${release_branch}" >&2
251 else
252 echo " ::error:: local and remote ${release_branch} have DIVERGED." >&2
253 inconsistent=1
254 fi
255 fi
256
257 if [[ -n "${current_branch}" && "${current_branch}" != "HEAD" \
258 && "${current_branch}" != "${release_branch}" ]] \
259 && ! looks_like_release_branch "${current_branch}"; then
260 head_sha="$(git rev-parse HEAD)"
261 if git merge-base --is-ancestor "${head_sha}" "${main_ref}" 2>/dev/null \
262 || { [[ "${remote_rel_sha}" != "<missing>" ]] \
263 && git merge-base --is-ancestor "${head_sha}" "${remote_rel}" 2>/dev/null; }; then
264 echo " ::warning:: working checkout is parked on '${current_branch}', whose HEAD is" \
265 "already merged. Switch to the release branch: git switch ${release_branch}" >&2
266 fi
267 fi
268 fi
269
270 # Containment ref: a branch is "merged" if its tip is contained in main OR the
271 # release branch (prefer the remote release tip, then local, then just main).
272 declare -a contain_refs=("${main_ref}")
273 if [[ -n "${release_branch}" ]]; then
274 if git rev-parse -q --verify "refs/remotes/${remote_name}/${release_branch}" >/dev/null 2>&1; then
275 contain_refs+=("refs/remotes/${remote_name}/${release_branch}")
276 fi
277 if git rev-parse -q --verify "refs/heads/${release_branch}" >/dev/null 2>&1; then
278 contain_refs+=("refs/heads/${release_branch}")
279 fi
280 fi
281
282 is_contained() {
283 # arg: <commit-ish> - contained in any containment ref?
284 local tip="$1" ref
285 for ref in "${contain_refs[@]}"; do
286 if git merge-base --is-ancestor "${tip}" "${ref}" 2>/dev/null; then
287 return 0
288 fi
289 done
290 return 1
291 }
292
293 # unique_commits <branch-tip>: commits on the branch not in any containment
294 # ref. Uses the symmetric "not reachable from contain_refs" set.
295 declare -a not_args=()
296 for ref in "${contain_refs[@]}"; do
297 not_args+=("^${ref}")
298 done
299
300 # --- Classify branches -------------------------------------------------------
301 # Branches we never touch automatically.
302 protected_re='^(main|master|HEAD)$'
303
304 declare -a safe_local=()
305 declare -a safe_remote=()
306 declare -a keep_report=()
307 needs_human=0
308 kept_contributor=0
309
310 classify_branch() {
311 # args: <scope: local|remote> <short-name> <full-ref>
312 local scope="$1" name="$2" ref="$3"
313
314 # Skip protected and the active release branch / current checkout.
315 [[ "${name}" =~ ${protected_re} ]] && return 0
316 [[ -n "${release_branch}" && "${name}" == "${release_branch}" ]] && return 0
317 [[ "${scope}" == "local" && "${name}" == "${current_branch}" ]] && return 0
318
319 local tip
320 tip="$(git rev-parse "${ref}" 2>/dev/null || echo "")"
321 [[ -z "${tip}" ]] && return 0
322
323 if is_contained "${tip}"; then
324 if [[ "${scope}" == "local" ]]; then
325 safe_local+=("${name}")
326 else
327 safe_remote+=("${name}")
328 fi
329 return 0
330 fi
331
332 # Has unique commits; inspect authors for the contributor-preservation
333 # policy. Never auto-delete; always keep/review.
334 local unique non_maint=0
335 unique="$(git rev-list --count "${ref}" "${not_args[@]}" 2>/dev/null || echo 0)"
336 [[ "${unique}" -eq 0 ]] && return 0
337
338 # Distinct author "Name <email>" set on the unique commits.
339 local authors_raw
340 authors_raw="$(git log --format='%an|%ae' "${ref}" "${not_args[@]}" 2>/dev/null \
341 | sort -u || true)"
342
343 local display_authors=""
344 while IFS='|' read -r an ae; do
345 [[ -z "${an}${ae}" ]] && continue
346 local anl ael
347 anl="$(echo "${an}" | tr '[:upper:]' '[:lower:]')"
348 ael="$(echo "${ae}" | tr '[:upper:]' '[:lower:]')"
349 if ! is_maintainer "${anl}" "${ael}"; then
350 non_maint=1
351 fi
352 display_authors+="${display_authors:+, }${an}"
353 done <<<"${authors_raw}"
354
355 local reason
356 if [[ "${non_maint}" -eq 1 ]]; then
357 reason="KEEP - unique contributor work (not yet merged). Review/merge/credit before deleting."
358 kept_contributor=$((kept_contributor + 1))
359 else
360 reason="REVIEW - ${unique} unmerged maintainer commit(s); confirm intentionally abandoned before deleting."
361 needs_human=$((needs_human + 1))
362 fi
363 keep_report+=("[${scope}] ${name}: ${unique} unique commit(s); authors: ${display_authors:-unknown}; ${reason}")
364 }
365
366 while IFS= read -r name; do
367 [[ -z "${name}" ]] && continue
368 classify_branch local "${name}" "refs/heads/${name}"
369 done < <(git for-each-ref --format='%(refname:short)' refs/heads/)
370
371 while IFS= read -r name; do
372 [[ -z "${name}" ]] && continue
373 # name comes through as <remote>/<branch>; strip the remote prefix.
374 short="${name#"${remote_name}"/}"
375 [[ "${short}" == "HEAD" ]] && continue
376 classify_branch remote "${short}" "refs/remotes/${remote_name}/${short}"
377 done < <(git for-each-ref --format='%(refname:short)' "refs/remotes/${remote_name}/")
378
379 # --- Report ------------------------------------------------------------------
380 echo
381 echo "-- Safe to delete (tip already in main or the release branch) --"
382 if ((${#safe_local[@]} == 0 && ${#safe_remote[@]} == 0)); then
383 echo " (none)"
384 else
385 for b in "${safe_local[@]+"${safe_local[@]}"}"; do
386 echo " local : ${b} (git branch -D ${b})"
387 done
388 for b in "${safe_remote[@]+"${safe_remote[@]}"}"; do
389 echo " remote: ${remote_name}/${b} (git push ${remote_name} --delete ${b})"
390 done
391 fi
392
393 echo
394 echo "-- Keep / needs review (has unique commits) --"
395 if ((${#keep_report[@]} == 0)); then
396 echo " (none)"
397 else
398 for line in "${keep_report[@]}"; do
399 echo " ${line}"
400 done
401 fi
402
403 # --- Optional pruning --------------------------------------------------------
404 deleted=0
405 if ((prune == 1)); then
406 if ((${#safe_local[@]} == 0 && (prune_remote == 0 || ${#safe_remote[@]} == 0))); then
407 echo
408 echo "Nothing to prune."
409 else
410 if ((assume_yes == 0)); then
411 echo
412 printf "Delete the safe-delete branch(es) listed above? [y/N] "
413 read -r reply
414 if [[ ! "${reply}" =~ ^[Yy]$ ]]; then
415 echo "Aborted; no branches deleted."
416 prune=0
417 fi
418 fi
419 fi
420
421 if ((prune == 1)); then
422 for b in "${safe_local[@]+"${safe_local[@]}"}"; do
423 if git branch -D "${b}" >/dev/null 2>&1; then
424 echo "deleted local ${b}"
425 deleted=$((deleted + 1))
426 else
427 echo "::error::failed to delete local ${b}" >&2
428 inconsistent=1
429 fi
430 done
431 if ((prune_remote == 1)); then
432 for b in "${safe_remote[@]+"${safe_remote[@]}"}"; do
433 if git push "${remote_name}" --delete "${b}" >/dev/null 2>&1; then
434 echo "deleted remote ${remote_name}/${b}"
435 deleted=$((deleted + 1))
436 else
437 echo "::error::failed to delete remote ${remote_name}/${b}" >&2
438 inconsistent=1
439 fi
440 done
441 fi
442 fi
443 fi
444
445 # --- Summary -----------------------------------------------------------------
446 echo
447 echo "-- Summary --"
448 if ((prune == 1)); then
449 echo " deleted (safe) : ${deleted}"
450 else
451 total_safe=$(( ${#safe_local[@]} + ${#safe_remote[@]} ))
452 echo " safe to delete (dry-run) : ${total_safe} (re-run with --prune to delete)"
453 fi
454 echo " kept for contributor work : ${kept_contributor}"
455 echo " needs human decision : ${needs_human}"
456
457 if ((inconsistent == 1)); then
458 echo
459 echo "::error::branch state is INCONSISTENT - resolve the items above before releasing." >&2
460 exit 1
461 fi
462
463 exit 0
464
464 lines BASH