diff --git a/.gitattributes b/.gitattributes index 6e639fdb8..06dd23920 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,6 +6,10 @@ crates/tui/src/prompts/*.md text eol=lf # Rustfmt writes LF; keep Rust sources stable across Windows/Linux/macOS. *.rs text eol=lf +# Branch hygiene release scripts are invoked directly by bash on Windows +# checkouts; CRLF turns `set -euo pipefail` into an invalid option. +scripts/release/branch-hygiene*.sh text eol=lf + # Keep repository attributes themselves stable on every platform. .gitattributes text eol=lf diff --git a/CHANGELOG.md b/CHANGELOG.md index 2271782ef..eff6024a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Release branch hygiene stays runnable from Windows checkouts (#3214).** + The branch-hygiene helper and its hermetic test are pinned to LF line endings, + so `bash scripts/release/branch-hygiene.test.sh` can verify the dry-run + cleanup report instead of failing before it reaches the branch checks. The + helper also accepts `--remote upstream` for fork checkouts whose canonical + release refs do not live on `origin`. + ## [0.8.63] - 2026-06-19 ### Added diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index fbd818d51..f4fd0632a 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Release branch hygiene stays runnable from Windows checkouts (#3214).** + The branch-hygiene helper and its hermetic test are pinned to LF line endings, + so `bash scripts/release/branch-hygiene.test.sh` can verify the dry-run + cleanup report instead of failing before it reaches the branch checks. The + helper also accepts `--remote upstream` for fork checkouts whose canonical + release refs do not live on `origin`. + ## [0.8.63] - 2026-06-19 ### Added diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 6d61f7266..99ef34f67 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -102,12 +102,14 @@ release anxiety: contributors cannot tell whether their work merged. ``` It prints: the current checkout branch, the local + remote release tips, - and `origin/main`; the branches that are **safe to delete** (tip already - contained in `origin/main` or the release branch); and a **keep / needs - review** list naming each branch, its unique commit count, the author(s), - and the keep reason. The summary line reports how many are safe-deletes, - how many were kept for contributor work, and how many need a human - decision. A diverged local/remote release tip exits non-zero. + and the main ref; the branches that are **safe to delete** (tip already + contained in the configured main ref or the release branch); and a + **keep / needs review** list naming each branch, its unique commit count, + the author(s), and the keep reason. The summary line reports how many are + safe-deletes, how many were kept for contributor work, and how many need a + human decision. A diverged local/remote release tip exits non-zero. Use + `--remote upstream` when the canonical release refs live on `upstream` + instead of `origin`. - [ ] If the working checkout is parked on a stale branch, switch to the release branch and fast-forward it: diff --git a/docs/RELEASE_RUNBOOK.md b/docs/RELEASE_RUNBOOK.md index ee89e4bbc..68563db3b 100644 --- a/docs/RELEASE_RUNBOOK.md +++ b/docs/RELEASE_RUNBOOK.md @@ -106,6 +106,33 @@ on npm and every `codewhale-*` crate at `X.Y.Z` on crates.io. For a rare npm packaging-only release, run with `--allow-npm-binary-mismatch` and keep the release notes explicit that no new Rust binary version shipped. +## Post-Merge Branch Hygiene + +After a release or scratch integration branch lands, run the branch hygiene +helper before pruning anything: + +```bash +./scripts/release/branch-hygiene.sh --release-branch codex/vX.Y.Z +``` + +The default mode is a dry run. It reports the current checkout branch, main ref, +local and remote release tips, safe local or remote branch deletes, branches +kept for contributor work, and branches that still need a human decision. Review +that report before running `--prune --yes`, and add `--prune-remote` only when +you have confirmed the remote branches are safe to delete. + +Use `--remote upstream` when you are working from a fork and the canonical +release refs live on the upstream remote instead of `origin`. + +Verify the helper itself after changing it: + +```bash +bash scripts/release/branch-hygiene.test.sh +``` + +Those scripts are pinned to LF line endings so the same command works from a +Windows checkout under Bash. + ## Rust Crates Release Crate publishing to crates.io is **manual** — there is no automated diff --git a/scripts/release/branch-hygiene.sh b/scripts/release/branch-hygiene.sh index de1cc0d47..ecfaa08df 100755 --- a/scripts/release/branch-hygiene.sh +++ b/scripts/release/branch-hygiene.sh @@ -14,10 +14,10 @@ # # What it reports: # 1. State check: current checkout branch, local + remote release branch -# tips, and origin/main, and whether they agree after an integration +# tips, and the configured main ref, and whether they agree after an integration # merge. # 2. Safe deletes: local and remote branches whose tip is already contained -# in origin/main or the release branch. +# in the main ref or the release branch. # 3. Keep/review: branches with unique commits, naming the branch, the # unique commit count, the contributor author(s), and the keep reason. # Non-Hunter contributor work is always a keep/review, never a safe @@ -26,6 +26,7 @@ # # Usage: # scripts/release/branch-hygiene.sh [--release-branch BRANCH] +# [--remote REMOTE] # [--main-ref REF] # [--maintainer "Name "]... # [--prune] [--prune-remote] [--yes] @@ -54,8 +55,11 @@ Options: --release-branch BRANCH Release branch to verify and prune against (default: current branch if it matches codex/* or work/*, else the highest codex/v* ref). + --remote REMOTE Remote whose release/scratch branches are checked + and pruned (default: origin). --main-ref REF The "everything merged here" ref - (default: origin/main, falling back to main). + (default: refs/remotes/REMOTE/main, falling back + to main). --maintainer "N " Treat this author as the maintainer (Hunter). May be repeated. Defaults are derived from .mailmap plus a built-in list. @@ -72,6 +76,7 @@ EOF } release_branch="" +remote_name="origin" main_ref="" prune=0 prune_remote=0 @@ -85,6 +90,11 @@ while (($# > 0)); do release_branch="$2" shift ;; + --remote) + [[ $# -ge 2 ]] || { usage >&2; exit 2; } + remote_name="$2" + shift + ;; --main-ref) [[ $# -ge 2 ]] || { usage >&2; exit 2; } main_ref="$2" @@ -175,8 +185,9 @@ looks_like_release_branch() { current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")" if [[ -z "${main_ref}" ]]; then - if git rev-parse -q --verify origin/main >/dev/null 2>&1; then - main_ref="origin/main" + remote_main_ref="refs/remotes/${remote_name}/main" + if git rev-parse -q --verify "${remote_main_ref}" >/dev/null 2>&1; then + main_ref="${remote_main_ref}" else main_ref="main" fi @@ -209,7 +220,7 @@ if [[ -z "${release_branch}" ]]; then echo "Release branch : (pass --release-branch to enable the state check)" else local_rel="refs/heads/${release_branch}" - remote_rel="refs/remotes/origin/${release_branch}" + remote_rel="refs/remotes/${remote_name}/${release_branch}" if git rev-parse -q --verify "${local_rel}" >/dev/null 2>&1; then local_rel_sha="$(git rev-parse --short "${local_rel}")" @@ -224,7 +235,7 @@ else echo "Release branch : ${release_branch}" echo " local : ${local_rel_sha}" - echo " origin : ${remote_rel_sha}" + echo " ${remote_name} : ${remote_rel_sha}" # State verification: after an integration merge into the release branch, # local and remote release tips should agree, and the working checkout @@ -232,11 +243,11 @@ else if [[ "${local_rel_sha}" != "" && "${remote_rel_sha}" != "" \ && "${local_rel_sha}" != "${remote_rel_sha}" ]]; then if git merge-base --is-ancestor "${local_rel}" "${remote_rel}" 2>/dev/null; then - echo " ::warning:: local ${release_branch} is BEHIND origin — fast-forward with:" \ - "git fetch origin && git branch -f ${release_branch} ${remote_rel}" >&2 + echo " ::warning:: local ${release_branch} is BEHIND ${remote_name} - fast-forward with:" \ + "git fetch ${remote_name} && git branch -f ${release_branch} ${remote_rel}" >&2 elif git merge-base --is-ancestor "${remote_rel}" "${local_rel}" 2>/dev/null; then - echo " ::warning:: local ${release_branch} is AHEAD of origin — push with:" \ - "git push origin ${release_branch}" >&2 + echo " ::warning:: local ${release_branch} is AHEAD of ${remote_name} - push with:" \ + "git push ${remote_name} ${release_branch}" >&2 else echo " ::error:: local and remote ${release_branch} have DIVERGED." >&2 inconsistent=1 @@ -260,8 +271,8 @@ fi # release branch (prefer the remote release tip, then local, then just main). declare -a contain_refs=("${main_ref}") if [[ -n "${release_branch}" ]]; then - if git rev-parse -q --verify "refs/remotes/origin/${release_branch}" >/dev/null 2>&1; then - contain_refs+=("refs/remotes/origin/${release_branch}") + if git rev-parse -q --verify "refs/remotes/${remote_name}/${release_branch}" >/dev/null 2>&1; then + contain_refs+=("refs/remotes/${remote_name}/${release_branch}") fi if git rev-parse -q --verify "refs/heads/${release_branch}" >/dev/null 2>&1; then contain_refs+=("refs/heads/${release_branch}") @@ -269,7 +280,7 @@ if [[ -n "${release_branch}" ]]; then fi is_contained() { - # arg: — contained in any containment ref? + # arg: - contained in any containment ref? local tip="$1" ref for ref in "${contain_refs[@]}"; do if git merge-base --is-ancestor "${tip}" "${ref}" 2>/dev/null; then @@ -318,7 +329,7 @@ classify_branch() { return 0 fi - # Has unique commits → inspect authors for the contributor-preservation + # Has unique commits; inspect authors for the contributor-preservation # policy. Never auto-delete; always keep/review. local unique authors non_maint=0 unique="$(git rev-list --count "${ref}" "${not_args[@]}" 2>/dev/null || echo 0)" @@ -343,10 +354,10 @@ classify_branch() { local reason if [[ "${non_maint}" -eq 1 ]]; then - reason="KEEP — unique contributor work (not yet merged). Review/merge/credit before deleting." + reason="KEEP - unique contributor work (not yet merged). Review/merge/credit before deleting." kept_contributor=$((kept_contributor + 1)) else - reason="REVIEW — ${unique} unmerged maintainer commit(s); confirm intentionally abandoned before deleting." + reason="REVIEW - ${unique} unmerged maintainer commit(s); confirm intentionally abandoned before deleting." needs_human=$((needs_human + 1)) fi keep_report+=("[${scope}] ${name}: ${unique} unique commit(s); authors: ${display_authors:-unknown}; ${reason}") @@ -359,11 +370,11 @@ done < <(git for-each-ref --format='%(refname:short)' refs/heads/) while IFS= read -r name; do [[ -z "${name}" ]] && continue - # name comes through as origin/; strip the remote prefix. - short="${name#origin/}" + # name comes through as /; strip the remote prefix. + short="${name#${remote_name}/}" [[ "${short}" == "HEAD" ]] && continue - classify_branch remote "${short}" "refs/remotes/origin/${short}" -done < <(git for-each-ref --format='%(refname:short)' refs/remotes/origin/) + classify_branch remote "${short}" "refs/remotes/${remote_name}/${short}" +done < <(git for-each-ref --format='%(refname:short)' "refs/remotes/${remote_name}/") # --- Report ------------------------------------------------------------------ echo @@ -375,7 +386,7 @@ else echo " local : ${b} (git branch -D ${b})" done for b in "${safe_remote[@]+"${safe_remote[@]}"}"; do - echo " remote: origin/${b} (git push origin --delete ${b})" + echo " remote: ${remote_name}/${b} (git push ${remote_name} --delete ${b})" done fi @@ -419,11 +430,11 @@ if ((prune == 1)); then done if ((prune_remote == 1)); then for b in "${safe_remote[@]+"${safe_remote[@]}"}"; do - if git push origin --delete "${b}" >/dev/null 2>&1; then - echo "deleted remote origin/${b}" + if git push "${remote_name}" --delete "${b}" >/dev/null 2>&1; then + echo "deleted remote ${remote_name}/${b}" deleted=$((deleted + 1)) else - echo "::error::failed to delete remote origin/${b}" >&2 + echo "::error::failed to delete remote ${remote_name}/${b}" >&2 inconsistent=1 fi done @@ -445,7 +456,7 @@ echo " needs human decision : ${needs_human}" if ((inconsistent == 1)); then echo - echo "::error::branch state is INCONSISTENT — resolve the items above before releasing." >&2 + echo "::error::branch state is INCONSISTENT - resolve the items above before releasing." >&2 exit 1 fi diff --git a/scripts/release/branch-hygiene.test.sh b/scripts/release/branch-hygiene.test.sh index 774989c5f..aa4171d12 100755 --- a/scripts/release/branch-hygiene.test.sh +++ b/scripts/release/branch-hygiene.test.sh @@ -9,6 +9,7 @@ # contributor work (never a safe delete), # * flags a branch with only unmerged maintainer commits as needs-review, # * detects a working checkout parked on an already-merged scratch branch, +# * honors --remote when the canonical release refs live outside origin, # * actually deletes only safe branches under --prune --yes and never the # contributor branch. # @@ -61,6 +62,8 @@ hygiene="${work}/scripts/release/branch-hygiene.sh" cd "${work}" export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git init -q -b main . +git config user.name "Hunter Bown" +git config user.email "hmbown@gmail.com" # Mirror the real repo's .mailmap canonicalization for Hunter. cat >.mailmap <<'EOF' Hunter Bown Claude @@ -70,9 +73,8 @@ commit() { # commit echo "$2" >"$1" git add -A - GIT_AUTHOR_NAME="$3" GIT_AUTHOR_EMAIL="$4" \ - GIT_COMMITTER_NAME="$3" GIT_COMMITTER_EMAIL="$4" \ - git commit -q -m "touch $1" + git -c user.name="$3" -c user.email="$4" \ + commit -q --author="$3 <$4>" -m "touch $1" } H_NAME="Hunter Bown"; H_EMAIL="hmbown@gmail.com" @@ -119,11 +121,11 @@ check "contributor branch is kept as contributor work" \ check "contributor branch names the contributor author" \ "Jane Contributor" <<<"${report}" check "contributor branch reason is KEEP" \ - "KEEP — unique contributor work" <<<"${report}" + "KEEP - unique contributor work" <<<"${report}" check "maintainer-only scratch is flagged for review" \ "[local] maintainer-scratch:" <<<"${report}" check "maintainer-only scratch reason is REVIEW" \ - "REVIEW —" <<<"${report}" + "REVIEW -" <<<"${report}" check "mailmap-folded bot commit is treated as maintainer (review, not keep)" \ "[local] bot-folded:" <<<"${report}" check "parked working checkout warning fires" \ @@ -148,6 +150,27 @@ check "contributor branch survives prune" "contributor-branch" <<<"${remaining}" check "maintainer-only scratch survives prune" "maintainer-scratch" <<<"${remaining}" refute "merged scratch branch is gone after prune" "merged-scratch" <<<"${remaining}" +# --- Custom remote name ------------------------------------------------------ +git switch -q main +git switch -q -c remote-main-tip +commit remote-main-only "remote" "${H_NAME}" "${H_EMAIL}" +git branch "upstream/main" main^1 +git update-ref "refs/remotes/upstream/main" "$(git rev-parse remote-main-tip)" +git update-ref "refs/remotes/upstream/codex/v0.8.61" "$(git rev-parse codex/v0.8.61)" +git update-ref "refs/remotes/upstream/merged-remote" "$(git rev-parse main)" +git update-ref "refs/remotes/upstream/remote-main-only" "$(git rev-parse remote-main-tip)" +upstream_report="$(bash "${hygiene}" --remote upstream --release-branch codex/v0.8.61 2>&1)" +check "custom remote release tip is reported" \ + "upstream" <<<"${upstream_report}" +check "custom remote default main ref is fully qualified" \ + "Main ref : refs/remotes/upstream/main" <<<"${upstream_report}" +check "custom remote safe-delete command uses the selected remote" \ + "remote: upstream/merged-remote" <<<"${upstream_report}" +check "custom remote main ref is not confused with a same-named local branch" \ + "remote: upstream/remote-main-only" <<<"${upstream_report}" +refute "custom remote report does not hard-code origin in safe deletes" \ + "remote: origin/merged-remote" <<<"${upstream_report}" + # --- State inconsistency: diverged local vs remote release branch ------------ git switch -q main # Simulate a remote release branch that has diverged from local.