From 0f90ed6beb5e417cd19892adc228f9749c0652bc Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah Date: Mon, 1 Jun 2026 15:46:59 -0700 Subject: [PATCH] Delete safe remote branches during prune --- CHANGELOG.md | 3 + cli/bash/commands/basectl/subcommands/gh.sh | 83 ++++++++++- cli/bash/commands/basectl/tests/gh.bats | 152 +++++++++++++++++++- docs/github-workflow.md | 21 ++- 4 files changed, 248 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59a2780..fc6fa0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and Base versions are tracked in the repo-root `VERSION` file. - Added `basectl repo init/check/configure` to create, validate, and configure a standard Base-managed repository baseline. +- Added GitHub remote branch cleanup to `basectl gh branch prune --remote` so + safe merged branches can be deleted from GitHub before stale `origin/*` refs + are pruned locally. - Added `basectl gh worktree prune` for dry-run-by-default cleanup of stale, merged Git worktrees from PR trains. - Added `basectl logs` to list, print, open, and tail recent Base CLI runtime diff --git a/cli/bash/commands/basectl/subcommands/gh.sh b/cli/bash/commands/basectl/subcommands/gh.sh index f31903d..c6c78f2 100644 --- a/cli/bash/commands/basectl/subcommands/gh.sh +++ b/cli/bash/commands/basectl/subcommands/gh.sh @@ -469,6 +469,22 @@ base_gh_branch_delete() { fi } +base_gh_list_remote_branches() { + local output ref + + output="$(git ls-remote --heads origin)" || return 1 + while read -r _sha ref; do + [[ "$ref" == refs/heads/* ]] || continue + printf '%s\n' "${ref#refs/heads/}" + done <<< "$output" +} + +base_gh_branch_delete_remote() { + local branch="$1" + + git push origin --delete "$branch" >/dev/null 2>&1 +} + base_gh_branch_prune_local() { local dry_run="$1" local default_branch="$2" @@ -530,7 +546,67 @@ base_gh_branch_prune_local() { return "$failed" } -base_gh_branch_prune_remote() { +base_gh_branch_prune_github_branches() { + local dry_run="$1" + local default_branch="$2" + local branch current_branch worktree_path remote_branches + local deleted=0 skipped_worktree=0 skipped_unmerged=0 failed=0 candidates=0 found=0 + + printf 'GitHub branches\n' + if ! base_gh_prune_github_ready; then + printf 'SKIP GitHub branch cleanup requires authenticated gh. Run `gh auth login -h github.com` and retry.\n' + printf 'Summary: 0 %s, 0 skipped worktree, 0 skipped unmerged, 0 failed.\n' \ + "$([[ "$dry_run" -eq 1 ]] && printf 'would delete remotely' || printf 'deleted remotely')" + return 0 + fi + + current_branch="$(git branch --show-current)" + remote_branches="$(base_gh_list_remote_branches)" || { + base_gh_error "Unable to list remote branches from origin." + return 1 + } + while read -r branch; do + [[ -n "$branch" ]] || continue + found=1 + [[ "$branch" == "$default_branch" || "$branch" == "$current_branch" ]] && continue + + if ! base_gh_branch_github_merged "$branch"; then + skipped_unmerged=$((skipped_unmerged + 1)) + continue + fi + candidates=$((candidates + 1)) + + worktree_path="$(base_gh_worktree_path_for_branch "$branch" || true)" + if [[ -n "$worktree_path" ]]; then + printf 'SKIP origin/%s attached to worktree %s\n' "$branch" "$worktree_path" + skipped_worktree=$((skipped_worktree + 1)) + continue + fi + + if ((dry_run)); then + printf '[DRY-RUN] DELETE-REMOTE origin/%s merged GitHub PR\n' "$branch" + deleted=$((deleted + 1)) + elif base_gh_branch_delete_remote "$branch"; then + printf 'DELETE-REMOTE origin/%s\n' "$branch" + deleted=$((deleted + 1)) + else + printf 'FAIL origin/%s git push origin --delete failed\n' "$branch" + failed=$((failed + 1)) + fi + done <<< "$remote_branches" + + if ((found == 0)); then + printf 'No GitHub remote branches found.\n' + elif ((candidates == 0)); then + printf 'No merged GitHub remote branches found.\n' + fi + printf 'Summary: %s %s, %s skipped worktree, %s skipped unmerged, %s failed.\n' \ + "$deleted" "$([[ "$dry_run" -eq 1 ]] && printf 'would delete remotely' || printf 'deleted remotely')" \ + "$skipped_worktree" "$skipped_unmerged" "$failed" + return "$failed" +} + +base_gh_branch_prune_remote_tracking_refs() { local dry_run="$1" local output line ref found=0 @@ -567,7 +643,7 @@ base_gh_branch_prune_remote() { if ((found == 0)); then printf 'No stale remote-tracking refs found.\n' fi - printf 'Note: --remote prunes stale origin/* tracking refs; it does not delete GitHub branches.\n' + printf 'Note: remote-tracking ref cleanup prunes stale local origin/* refs after GitHub branch cleanup.\n' } base_gh_branch_prune() { @@ -604,7 +680,8 @@ base_gh_branch_prune() { base_gh_branch_prune_local "$dry_run" "$default_branch" || status=$? if ((remote)); then - base_gh_branch_prune_remote "$dry_run" || status=$? + base_gh_branch_prune_github_branches "$dry_run" "$default_branch" || status=$? + base_gh_branch_prune_remote_tracking_refs "$dry_run" || status=$? fi return "$status" } diff --git a/cli/bash/commands/basectl/tests/gh.bats b/cli/bash/commands/basectl/tests/gh.bats index 708970e..3ca0361 100644 --- a/cli/bash/commands/basectl/tests/gh.bats +++ b/cli/bash/commands/basectl/tests/gh.bats @@ -272,9 +272,20 @@ EOF create_tracked_repo_with_upstream "$repo" "$remote" "README.md" "hello" git -C "$repo" update-ref refs/remotes/origin/stale-branch HEAD + cat > "$TEST_MOCKBIN/gh" <<'EOF' +#!/usr/bin/env bash +if [[ "$*" == "auth status -h github.com" ]]; then + exit 1 +fi +printf 'unexpected gh args: %s\n' "$*" >&2 +exit 99 +EOF + chmod +x "$TEST_MOCKBIN/gh" + run env \ HOME="$TEST_HOME" \ BASE_HOME="$BASE_REPO_ROOT" \ + PATH="$TEST_MOCKBIN:$PATH" \ bash -c ' cd "$1" source "$BASE_HOME/base_init.sh" @@ -284,14 +295,153 @@ EOF [ "$status" -eq 0 ] [[ "$output" == *"Local branches"* ]] + [[ "$output" == *"GitHub branches"* ]] + [[ "$output" == *"SKIP GitHub branch cleanup requires authenticated gh."* ]] [[ "$output" == *"Remote tracking refs"* ]] [[ "$output" == *"PRUNE origin/stale-branch"* ]] - [[ "$output" == *"Note: --remote prunes stale origin/* tracking refs; it does not delete GitHub branches."* ]] + [[ "$output" == *"Note: remote-tracking ref cleanup prunes stale local origin/* refs after GitHub branch cleanup."* ]] [[ "$output" != *"Pruning origin"* ]] [[ "$output" != *"URL:"* ]] ! git -C "$repo" show-ref --verify --quiet refs/remotes/origin/stale-branch } +@test "basectl gh branch prune --remote previews safe GitHub branch deletion" { + local repo remote + + repo="$TEST_TMPDIR/repo" + remote="$TEST_TMPDIR/remote.git" + create_tracked_repo_with_upstream "$repo" "$remote" "README.md" "hello" + git -C "$repo" switch -c squash-remote >/dev/null + printf 'topic\n' > "$repo/topic.txt" + commit_all "$repo" "Topic commit" + git -C "$repo" push -u origin squash-remote >/dev/null 2>&1 + git -C "$repo" switch master >/dev/null + git -C "$repo" branch -D squash-remote >/dev/null 2>&1 + + cat > "$TEST_MOCKBIN/gh" <<'EOF' +#!/usr/bin/env bash +if [[ "$*" == "auth status -h github.com" ]]; then + exit 0 +fi +if [[ "$*" == "pr list --head squash-remote --state merged --json number --jq length" ]]; then + printf '1\n' + exit 0 +fi +printf 'unexpected gh args: %s\n' "$*" >&2 +exit 1 +EOF + chmod +x "$TEST_MOCKBIN/gh" + + run env \ + HOME="$TEST_HOME" \ + BASE_HOME="$BASE_REPO_ROOT" \ + PATH="$TEST_MOCKBIN:$PATH" \ + bash -c ' + cd "$1" + source "$BASE_HOME/base_init.sh" + source "$BASE_HOME/cli/bash/commands/basectl/subcommands/gh.sh" + base_gh_subcommand_main branch prune --remote + ' bash "$repo" + + [ "$status" -eq 0 ] + [[ "$output" == *"[DRY-RUN] Branch prune preview for default branch master."* ]] + [[ "$output" == *"GitHub branches"* ]] + [[ "$output" == *"[DRY-RUN] DELETE-REMOTE origin/squash-remote merged GitHub PR"* ]] + [[ "$output" == *"Summary: 1 would delete remotely, 0 skipped worktree, 0 skipped unmerged, 0 failed."* ]] + git -C "$repo" ls-remote --exit-code --heads origin squash-remote >/dev/null +} + +@test "basectl gh branch prune --remote --yes deletes safe GitHub branches" { + local repo remote + + repo="$TEST_TMPDIR/repo" + remote="$TEST_TMPDIR/remote.git" + create_tracked_repo_with_upstream "$repo" "$remote" "README.md" "hello" + git -C "$repo" switch -c squash-remote >/dev/null + printf 'topic\n' > "$repo/topic.txt" + commit_all "$repo" "Topic commit" + git -C "$repo" push -u origin squash-remote >/dev/null 2>&1 + git -C "$repo" switch master >/dev/null + git -C "$repo" branch -D squash-remote >/dev/null 2>&1 + + cat > "$TEST_MOCKBIN/gh" <<'EOF' +#!/usr/bin/env bash +if [[ "$*" == "auth status -h github.com" ]]; then + exit 0 +fi +if [[ "$*" == "pr list --head squash-remote --state merged --json number --jq length" ]]; then + printf '1\n' + exit 0 +fi +printf 'unexpected gh args: %s\n' "$*" >&2 +exit 1 +EOF + chmod +x "$TEST_MOCKBIN/gh" + + run env \ + HOME="$TEST_HOME" \ + BASE_HOME="$BASE_REPO_ROOT" \ + PATH="$TEST_MOCKBIN:$PATH" \ + bash -c ' + cd "$1" + source "$BASE_HOME/base_init.sh" + source "$BASE_HOME/cli/bash/commands/basectl/subcommands/gh.sh" + base_gh_subcommand_main branch prune --remote --yes + ' bash "$repo" + + [ "$status" -eq 0 ] + [[ "$output" == *"GitHub branches"* ]] + [[ "$output" == *"DELETE-REMOTE origin/squash-remote"* ]] + [[ "$output" == *"Summary: 1 deleted remotely, 0 skipped worktree, 0 skipped unmerged, 0 failed."* ]] + ! git -C "$repo" ls-remote --exit-code --heads origin squash-remote >/dev/null +} + +@test "basectl gh branch prune --remote skips branches attached to worktrees" { + local repo remote worktree + + repo="$TEST_TMPDIR/repo" + remote="$TEST_TMPDIR/remote.git" + worktree="$TEST_TMPDIR/squash-worktree" + create_tracked_repo_with_upstream "$repo" "$remote" "README.md" "hello" + git -C "$repo" switch -c squash-work >/dev/null + printf 'topic\n' > "$repo/topic.txt" + commit_all "$repo" "Topic commit" + git -C "$repo" push -u origin squash-work >/dev/null 2>&1 + git -C "$repo" switch master >/dev/null + git -C "$repo" worktree add "$worktree" squash-work >/dev/null 2>&1 + + cat > "$TEST_MOCKBIN/gh" <<'EOF' +#!/usr/bin/env bash +if [[ "$*" == "auth status -h github.com" ]]; then + exit 0 +fi +if [[ "$*" == "pr list --head squash-work --state merged --json number --jq length" ]]; then + printf '1\n' + exit 0 +fi +printf 'unexpected gh args: %s\n' "$*" >&2 +exit 1 +EOF + chmod +x "$TEST_MOCKBIN/gh" + + run env \ + HOME="$TEST_HOME" \ + BASE_HOME="$BASE_REPO_ROOT" \ + PATH="$TEST_MOCKBIN:$PATH" \ + bash -c ' + cd "$1" + source "$BASE_HOME/base_init.sh" + source "$BASE_HOME/cli/bash/commands/basectl/subcommands/gh.sh" + base_gh_subcommand_main branch prune --remote --yes + ' bash "$repo" + + [ "$status" -eq 0 ] + [[ "$output" == *"SKIP squash-work attached to worktree "*"/squash-worktree"* ]] + [[ "$output" == *"SKIP origin/squash-work attached to worktree "*"/squash-worktree"* ]] + [[ "$output" == *"Summary: 0 deleted remotely, 1 skipped worktree, 0 skipped unmerged, 0 failed."* ]] + git -C "$repo" ls-remote --exit-code --heads origin squash-work >/dev/null +} + @test "basectl gh branch prune deletes squash-merged branches confirmed by GitHub" { local repo diff --git a/docs/github-workflow.md b/docs/github-workflow.md index 8471474..f520696 100644 --- a/docs/github-workflow.md +++ b/docs/github-workflow.md @@ -123,13 +123,20 @@ basectl gh worktree prune --yes ``` The command is dry-run by default. It reports merged local branches as delete -candidates, reports branches attached to worktrees as skipped, and keeps remote -cleanup scoped to stale `origin/*` tracking refs. Because Base usually uses -squash merges, pruning checks GitHub PR state when available and falls back to -Git ancestry when offline. Worktree pruning is also dry-run by default; it -removes only clean, non-current worktrees whose branches are confirmed merged -into the default branch or through a merged GitHub PR, then deletes the now-free -local branch when safe. +candidates, reports branches attached to worktrees as skipped, and treats +`--remote` as GitHub remote branch cleanup plus stale `origin/*` tracking-ref +cleanup. Because Base usually uses squash merges, pruning checks GitHub PR state +when available and falls back to Git ancestry when offline. + +Remote branch pruning is deliberately conservative. Base deletes a GitHub branch +only when GitHub confirms a merged pull request for that exact branch name. It +does not delete the default branch, the current branch, or a branch attached to a +local worktree. After safe GitHub branches are deleted, Base prunes stale local +`origin/*` refs. + +Worktree pruning is also dry-run by default. It removes only clean, non-current +worktrees whose branches are confirmed merged into the default branch or through +a merged GitHub PR, then deletes the now-free local branch when safe. ## Pull Requests