Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions crates/tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 8 additions & 6 deletions docs/RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
27 changes: 27 additions & 0 deletions docs/RELEASE_RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 37 additions & 26 deletions scripts/release/branch-hygiene.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,6 +26,7 @@
#
# Usage:
# scripts/release/branch-hygiene.sh [--release-branch BRANCH]
# [--remote REMOTE]
# [--main-ref REF]
# [--maintainer "Name <email>"]...
# [--prune] [--prune-remote] [--yes]
Expand Down Expand Up @@ -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 <e>" Treat this author as the maintainer (Hunter).
May be repeated. Defaults are derived from
.mailmap plus a built-in list.
Expand All @@ -72,6 +76,7 @@ EOF
}

release_branch=""
remote_name="origin"
main_ref=""
prune=0
prune_remote=0
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -209,7 +220,7 @@ if [[ -z "${release_branch}" ]]; then
echo "Release branch : <none found> (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}")"
Expand All @@ -224,19 +235,19 @@ 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
# should be on the release branch (not parked on a scratch/renovate name).
if [[ "${local_rel_sha}" != "<missing>" && "${remote_rel_sha}" != "<missing>" \
&& "${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
Expand All @@ -260,16 +271,16 @@ 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}")
fi
fi

is_contained() {
# arg: <commit-ish> contained in any containment ref?
# arg: <commit-ish> - 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
Expand Down Expand Up @@ -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)"
Expand All @@ -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}")
Expand All @@ -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/<branch>; strip the remote prefix.
short="${name#origin/}"
# name comes through as <remote>/<branch>; 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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
33 changes: 28 additions & 5 deletions scripts/release/branch-hygiene.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down Expand Up @@ -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 <hmbown@gmail.com> Claude <noreply@anthropic.com>
Expand All @@ -70,9 +73,8 @@ commit() {
# commit <file> <content> <author-name> <author-email>
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"
Expand Down Expand Up @@ -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" \
Expand All @@ -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.
Expand Down