Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 80 additions & 3 deletions cli/bash/commands/basectl/subcommands/gh.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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"
}
Expand Down
152 changes: 151 additions & 1 deletion cli/bash/commands/basectl/tests/gh.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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

Expand Down
21 changes: 14 additions & 7 deletions docs/github-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading