From 6952914463274e1513937071975b6ce1ae553907 Mon Sep 17 00:00:00 2001 From: Nightt <87569709+nightt5879@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:53:34 +0800 Subject: [PATCH 1/4] fix(release): harden branch hygiene checks Add a --remote option so post-merge branch hygiene can inspect upstream release refs from fork checkouts while keeping origin as the default. Pin the branch-hygiene shell helper and test to LF line endings, make the hermetic test independent of global git identity, and document the post-merge dry-run/prune workflow in the release runbook and checklist. Fixes #3214 Validation: - cargo fmt --all -- --check - bash scripts/release/branch-hygiene.test.sh - bash scripts/release/branch-hygiene.sh --remote upstream --release-branch hunter/0.8.62-glm-subagents --main-ref upstream/main - cargo test --workspace --all-features --locked (local run reached codewhale_tui; session_manager temp-dir tests fail because this machine has C:\\.git, and the pandoc/sidebar failures passed when rerun in isolation) --- .gitattributes | 4 ++ CHANGELOG.md | 9 ++++ docs/RELEASE_CHECKLIST.md | 14 +++--- docs/RELEASE_RUNBOOK.md | 27 ++++++++++++ scripts/release/branch-hygiene.sh | 61 +++++++++++++++----------- scripts/release/branch-hygiene.test.sh | 23 +++++++--- 6 files changed, 101 insertions(+), 37 deletions(-) diff --git a/.gitattributes b/.gitattributes index 6e639fdb86..06dd23920b 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 bd5ec0d6c5..74179dffb5 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.62] - 2026-06-17 ### Changed diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 6d61f7266f..99ef34f672 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 ee89e4bbc0..68563db3b5 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 de1cc0d475..cecd1275c5 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,10 @@ 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: 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 +75,7 @@ EOF } release_branch="" +remote_name="origin" main_ref="" prune=0 prune_remote=0 @@ -85,6 +89,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 +184,8 @@ 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" + if git rev-parse -q --verify "${remote_name}/main" >/dev/null 2>&1; then + main_ref="${remote_name}/main" else main_ref="main" fi @@ -209,7 +218,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 +233,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 +241,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 +269,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 +278,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 +327,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 +352,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 +368,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 +384,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 +428,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 +454,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 774989c5f7..20155251b2 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,17 @@ 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 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)" +upstream_report="$(bash "${hygiene}" --remote upstream --release-branch codex/v0.8.61 --main-ref main 2>&1)" +check "custom remote release tip is reported" \ + "upstream" <<<"${upstream_report}" +check "custom remote safe-delete command uses the selected remote" \ + "remote: upstream/merged-remote" <<<"${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. From bafb9b377db9c93ed815fd650f2ea6f3c800cd7e Mon Sep 17 00:00:00 2001 From: Nightt <87569709+nightt5879@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:57:14 +0800 Subject: [PATCH 2/4] chore(tui): sync changelog slice Update the TUI embedded changelog after the #3214 release hygiene entry so the Version drift CI job matches the root changelog slice. Validation: - cargo fmt --all -- --check - git diff --check --- crates/tui/CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index a116be8440..def97a98d3 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.62] - 2026-06-17 ### Changed From 34a3e227f98999be751539b0ab6ac020f743adfd Mon Sep 17 00:00:00 2001 From: Nightt <87569709+nightt5879@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:00:24 +0800 Subject: [PATCH 3/4] fix(release): qualify branch hygiene remote main Use refs/remotes//main when deriving the default main ref for branch hygiene so a local branch named /main cannot shadow the intended remote-tracking branch. This addresses Gemini Code Assist feedback on PR #3348. Validation: - bash scripts/release/branch-hygiene.test.sh - bash scripts/release/branch-hygiene.sh --remote upstream --release-branch hunter/0.8.62-glm-subagents - cargo fmt --all -- --check --- scripts/release/branch-hygiene.sh | 8 +++++--- scripts/release/branch-hygiene.test.sh | 12 +++++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/scripts/release/branch-hygiene.sh b/scripts/release/branch-hygiene.sh index cecd1275c5..ecfaa08df2 100755 --- a/scripts/release/branch-hygiene.sh +++ b/scripts/release/branch-hygiene.sh @@ -58,7 +58,8 @@ Options: --remote REMOTE Remote whose release/scratch branches are checked and pruned (default: origin). --main-ref REF The "everything merged here" ref - (default: REMOTE/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. @@ -184,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 "${remote_name}/main" >/dev/null 2>&1; then - main_ref="${remote_name}/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 diff --git a/scripts/release/branch-hygiene.test.sh b/scripts/release/branch-hygiene.test.sh index 20155251b2..aa4171d123 100755 --- a/scripts/release/branch-hygiene.test.sh +++ b/scripts/release/branch-hygiene.test.sh @@ -151,13 +151,23 @@ check "maintainer-only scratch survives prune" "maintainer-scratch" <<<"${remain 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)" -upstream_report="$(bash "${hygiene}" --remote upstream --release-branch codex/v0.8.61 --main-ref main 2>&1)" +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}" From 7772391fada97a158ddefc749fb1acb0649b3f26 Mon Sep 17 00:00:00 2001 From: Nightt <87569709+nightt5879@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:05:56 +0800 Subject: [PATCH 4/4] fix(config): restore huggingface env precedence Make the TUI Hugging Face active-key detection check HUGGINGFACE_API_KEY before HF_TOKEN, matching the shared provider registry and fixing the provider registry drift CI job. Validation: - python scripts/check-provider-registry.py - cargo test -p codewhale-tui --bin codewhale-tui huggingface_ -- --test-threads=1 - cargo fmt --all -- --check - git diff --check --- crates/tui/src/config.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 179f8a8e1e..7e47cc2d42 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -5526,7 +5526,9 @@ pub fn active_provider_has_config_api_key(config: &Config) -> bool { return crate::oauth::auth_file_path().exists(); } if matches!(provider, ApiProvider::Huggingface) - && std::env::var("HF_TOKEN").is_ok_and(|k| !k.trim().is_empty()) + && std::env::var("HUGGINGFACE_API_KEY") + .or_else(|_| std::env::var("HF_TOKEN")) + .is_ok_and(|k| !k.trim().is_empty()) { return true; }