|
# ============================================================================== |
|
# GIT STACK UTILITIES |
|
# |
|
# High-performance tools for managing "stacked diffs". |
|
# |
|
# OPTIMIZATIONS (Verified Safe): |
|
# - Uses `git merge-base --independent` for O(1) tip detection. |
|
# - Uses `git rebase <upstream> <branch>` to skip redundant checkouts. |
|
# - Uses `git branch --merged` for fast summary generation. |
|
# |
|
# Dependencies: git >= 2.38 (requires --update-refs) |
|
# ============================================================================== |
|
|
|
# ------------------------------------------------------------------------------ |
|
# PRIVATE HELPERS |
|
# ------------------------------------------------------------------------------ |
|
|
|
_git_check_version() { |
|
local v |
|
v=$(git --version | awk '{print $3}') |
|
if [[ "$(printf '%s\n' "2.38" "$v" | sort -V | head -n1)" != "2.38" ]]; then |
|
echo "β Error: Git 2.38+ required (detected $v)." |
|
return 1 |
|
fi |
|
} |
|
|
|
_git_is_ancestor() { |
|
git merge-base --is-ancestor "$1" "$2" |
|
} |
|
|
|
# Checks if a branch is content-equivalent to upstream (Patch-ID match). |
|
_git_is_obsolete() { |
|
! git cherry "$2" "$1" | grep -q "^+" |
|
} |
|
|
|
# Safe update of the target branch. |
|
# |
|
# Checks if branch exists -> Checks if upstream exists -> Pulls or Warns. |
|
_git_update_target() { |
|
local target="$1" |
|
|
|
if ! git show-ref --verify --quiet "refs/heads/$target"; then |
|
echo "β Error: Target branch '$target' does not exist locally." |
|
return 1 |
|
fi |
|
|
|
# Switch to target (if not already there) |
|
local current |
|
current=$(git branch --show-current) |
|
if [[ "$current" != "$target" ]]; then |
|
if ! git checkout "$target" 2>/dev/null; then |
|
echo "β Error: Could not checkout '$target'." |
|
return 1 |
|
fi |
|
fi |
|
|
|
# Check if upstream exists |
|
local upstream |
|
upstream=$(git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>/dev/null) |
|
|
|
if [[ -n "$upstream" ]]; then |
|
echo "π Pulling updates from $upstream..." |
|
if ! git pull --rebase; then |
|
echo "β Error: Could not pull updates. Aborting." |
|
return 1 |
|
fi |
|
else |
|
echo "β οΈ '$target' is local-only (no upstream). Using current state." |
|
fi |
|
} |
|
|
|
# Optimized Tip Detection. |
|
# |
|
# Uses `git merge-base --independent` to filter the list in O(1) Git operations. |
|
_git_find_tips() { |
|
local branches=("${@}") |
|
[[ ${#branches[@]} -eq 0 ]] && return 0 |
|
|
|
# Get hashes of commits that are "independent" (not reachable from others in the list) |
|
local tip_hashes |
|
tip_hashes=$(git merge-base --independent "${branches[@]}") |
|
|
|
local unique_tips=() |
|
for branch in "${branches[@]}"; do |
|
local hash |
|
hash=$(git rev-parse "$branch") |
|
if [[ "$tip_hashes" == *"$hash"* ]]; then |
|
unique_tips+=("$branch") |
|
fi |
|
done |
|
|
|
# Return unique sorted list |
|
printf "%s\n" "${unique_tips[@]}" | sort -u |
|
} |
|
|
|
# Find the optimal "Cut Point" commit for the purposes of rebasing. |
|
# |
|
# Walks backwards from the Tip. The first ancestor we encounter that is |
|
# "obsolete" (in target) is our cut point. |
|
_git_find_cut_point() { |
|
local tip="$1" |
|
local target="$2" |
|
|
|
# Get list of commits in Tip that are NOT in Target (linearized) |
|
# We limit the lookback to prevent scanning the entire history of the repo if divergent. |
|
local commits |
|
commits=$(git rev-list --max-count=100 "$target..$tip") |
|
|
|
for commit in $commits; do |
|
# We are walking backwards (Newest -> Oldest). |
|
# |
|
# The MOMENT we hit a commit that IS obsolete/merged, that is our cut point. |
|
# Everything after it is unique work. |
|
if _git_is_obsolete "$commit" "$target"; then |
|
echo "$commit" |
|
return 0 |
|
fi |
|
done |
|
} |
|
|
|
# Generates a visual tree string for the stack. |
|
# Format: |
|
# TipBranch |
|
# ββ ChildBranch |
|
# ββ ChildBranch |
|
# |
|
# Args: |
|
# 1: Tip Branch |
|
# 2: Prefix (Optional filter) |
|
# 3: Target (Optional filter) |
|
# 4: FilterMerged (true/false) |
|
# 5: AllowedRefs (Optional: Space-separated whitelist of branches to include) |
|
_git_format_stack_tree() { |
|
local tip="$1" |
|
local prefix="$2" |
|
local target="$3" |
|
local filter_merged_in_target="$4" # "true" or "false" |
|
local allowed_refs="$5" # Space-separated list of allowed branches |
|
|
|
local tree="$tip" |
|
local stack_refs |
|
|
|
# Optimization: Use prefix in git command if available |
|
if [[ -n "$prefix" ]]; then |
|
stack_refs=$(git branch --format='%(refname:short)' --list "${prefix}*" --merged "$tip") |
|
else |
|
stack_refs=$(git branch --format='%(refname:short)' --merged "$tip") |
|
fi |
|
|
|
local target_refs="" |
|
if [[ "$filter_merged_in_target" == "true" ]] && [[ -n "$target" ]]; then |
|
target_refs=$(git branch --format='%(refname:short)' --list "${prefix}*" --merged "$target") |
|
fi |
|
|
|
# Accumulate children |
|
local children=() |
|
for ref in $stack_refs; do |
|
[[ "$ref" == "$tip" ]] && continue |
|
|
|
# Filter: Allowed Refs (Whitelist) |
|
if [[ -n "$allowed_refs" ]]; then |
|
if [[ ! " $allowed_refs " =~ " $ref " ]]; then continue; fi |
|
fi |
|
|
|
# Filter: Already merged in target |
|
if [[ "$filter_merged_in_target" == "true" ]] && [[ "$target_refs" == *"$ref"* ]]; then |
|
continue |
|
fi |
|
children+=("$ref") |
|
done |
|
|
|
# Sort children for consistency |
|
# Note: logic prevents sorting if empty to avoid syntax errors/empty elements |
|
if [ ${#children[@]} -gt 0 ]; then |
|
IFS=$'\n' children=($(sort <<<"${children[*]}")) |
|
unset IFS |
|
fi |
|
|
|
# Format the tree |
|
local count=${#children[@]} |
|
for ((i = 0; i < count; i++)); do |
|
local child="${children[$i]}" |
|
|
|
# Check if this is the last child in the list |
|
if ((i == count - 1)); then |
|
tree+=$'\n ββ '"$child" |
|
else |
|
tree+=$'\n ββ '"$child" |
|
fi |
|
done |
|
|
|
echo "$tree" |
|
} |
|
|
|
# ------------------------------------------------------------------------------ |
|
# PUBLIC FUNCTIONS |
|
# ------------------------------------------------------------------------------ |
|
|
|
# ------------------------------------------------------------------------------ |
|
# git_rebase_prefix <prefix> [target_branch] |
|
# |
|
# Batch updates stacks. Handles squash-merged upstreams automatically. |
|
# For any branches found to already be included in upstream, prompts to optionally delete |
|
# their local branches. |
|
# |
|
# This function identifies "tip" branches (branches that are not ancestors of any other |
|
# matching branch) and rebases them using `git rebase --update-refs`. |
|
# |
|
# Key Features: |
|
# - Preserves Topology: If you have a stack A -> B -> C, rebasing C will automatically |
|
# update A and B to the correct new commits, keeping the stack intact. |
|
# - Atomic Failure: If a conflict occurs anywhere in the stack (e.g., in A), the rebase |
|
# for the entire stack (A, B, and C) is aborted and reverted to the original state. |
|
# - Summary: Records successes and failures per stack and prints a summary at the end. |
|
# |
|
# Usage: |
|
# rebase_prefix <prefix> [target_branch] |
|
# rebase_prefix -h | --help |
|
# ------------------------------------------------------------------------------ |
|
git_rebase_prefix() { |
|
_git_check_version || return 1 |
|
|
|
local prefix="$1" |
|
local target="${2:-main}" |
|
local start_branch |
|
start_branch=$(git rev-parse --abbrev-ref HEAD) |
|
|
|
[[ -z "$prefix" ]] && { echo "β Error: Missing <prefix>."; return 1; } |
|
|
|
if ! _git_update_target "$target"; then |
|
git checkout "$start_branch" 2>/dev/null |
|
return 1 |
|
fi |
|
|
|
echo "π Scanning 'refs/heads/${prefix}*'..." |
|
local all_branches=($(git for-each-ref --format='%(refname:short)' "refs/heads/${prefix}*")) |
|
all_branches=(${all_branches[@]/$target}) |
|
|
|
if [[ ${#all_branches[@]} -eq 0 ]]; then |
|
echo " No matching branches found." |
|
git checkout "$start_branch" 2>/dev/null |
|
return 0 |
|
fi |
|
|
|
local unique_tips=($(_git_find_tips "${all_branches[@]}")) |
|
echo " Found ${#unique_tips[@]} stack tips." |
|
|
|
local success_log=() |
|
local skipped_log=() |
|
local failed_log=() |
|
|
|
# Tracking lists for cleanup logic |
|
local skipped_branches_flat=() |
|
local kept_branches_flat=() |
|
|
|
for branch in "${unique_tips[@]}"; do |
|
echo -e "\n----------------------------------------" |
|
echo "### Processing Stack: $branch ###" |
|
|
|
# Identify all branches in this current stack |
|
local stack_refs |
|
stack_refs=$(git branch --format='%(refname:short)' --list "${prefix}*" --merged "$branch") |
|
|
|
# --- Case 1: Skipped (Fully Merged) --- |
|
if _git_is_obsolete "$branch" "$target"; then |
|
echo "π€ Fully merged. Skipping." |
|
# For skipped stacks, we show ALL branches in the stack (so user knows what to delete) |
|
skipped_log+=("$(_git_format_stack_tree "$branch" "$prefix" "$target" "false")") |
|
|
|
# Collect these branches as candidates for deletion |
|
for ref in $stack_refs; do |
|
skipped_branches_flat+=("$ref") |
|
done |
|
continue |
|
fi |
|
|
|
# If not skipped, we are attempting to keep these branches (either updated or failed) |
|
for ref in $stack_refs; do |
|
kept_branches_flat+=("$ref") |
|
done |
|
|
|
# --- Case 2: Rebase --- |
|
local cut_point |
|
cut_point=$(_git_find_cut_point "$branch" "$target") |
|
|
|
local rebase_ok=false |
|
if [[ -n "$cut_point" ]]; then |
|
echo "β‘ Found obsolete ancestor: ${cut_point:0:7}" |
|
echo " Dropping it; grafting stack onto $target..." |
|
if git rebase --update-refs --onto "$target" "$cut_point" "$branch"; then |
|
rebase_ok=true |
|
fi |
|
else |
|
echo " Standard rebase onto $target..." |
|
if git rebase --update-refs "$target" "$branch"; then |
|
rebase_ok=true |
|
fi |
|
fi |
|
|
|
# --- Case 3: Result Logging --- |
|
if [[ "$rebase_ok" == true ]]; then |
|
# For updated stacks, we hide branches that are ALREADY in target (redundant info) |
|
success_log+=("$(_git_format_stack_tree "$branch" "$prefix" "$target" "true")") |
|
else |
|
echo "β Conflict. Aborting." |
|
git rebase --abort 2>/dev/null |
|
# For failed stacks, show full context |
|
failed_log+=("$(_git_format_stack_tree "$branch" "$prefix" "$target" "false")") |
|
fi |
|
done |
|
|
|
# Summary Output |
|
echo -e "\n========================================" |
|
echo "BATCH SUMMARY" |
|
echo "========================================" |
|
|
|
if [[ ${#success_log[@]} -gt 0 ]]; then |
|
printf "β
Updated Stacks:\n" |
|
for entry in "${success_log[@]}"; do |
|
echo " - $entry" |
|
done | sed 's/^/ /' # Indent for cleaner look |
|
fi |
|
|
|
if [[ ${#skipped_log[@]} -gt 0 ]]; then |
|
printf "\nπ€ Skipped (Fully Merged):\n" |
|
for entry in "${skipped_log[@]}"; do |
|
echo " - $entry" |
|
done | sed 's/^/ /' |
|
fi |
|
|
|
if [[ ${#failed_log[@]} -gt 0 ]]; then |
|
printf "\nβ οΈ Failed (Manual Fix Needed):\n" |
|
for entry in "${failed_log[@]}"; do |
|
echo " - $entry" |
|
done | sed 's/^/ /' |
|
fi |
|
|
|
# --- Cleanup Prompt --- |
|
if [[ ${#skipped_branches_flat[@]} -gt 0 ]]; then |
|
local branches_to_delete=() |
|
local kept_str=" ${kept_branches_flat[*]} " |
|
|
|
# Only delete branches that are NOT also part of a kept/failed stack |
|
# (This handles shared base branches correctly) |
|
for cand in "${skipped_branches_flat[@]}"; do |
|
if [[ "$kept_str" != *" $cand "* ]]; then |
|
branches_to_delete+=("$cand") |
|
fi |
|
done |
|
|
|
if [[ ${#branches_to_delete[@]} -gt 0 ]]; then |
|
# Deduplicate list |
|
local unique_to_delete=($(printf "%s\n" "${branches_to_delete[@]}" | sort -u)) |
|
|
|
echo "" |
|
echo -n "β Delete these ${#unique_to_delete[@]} fully merged local branches? [y/N] " |
|
read -r reply |
|
if [[ "$reply" =~ ^[Yy]$ ]]; then |
|
echo "π₯ Deleting branches..." |
|
# Use -D to force delete since we already confirmed they are obsolete/merged via script logic |
|
git branch -D "${unique_to_delete[@]}" |
|
fi |
|
fi |
|
fi |
|
|
|
git checkout "$start_branch" 2>/dev/null |
|
[[ ${#failed_log[@]} -gt 0 ]] && return 1 || return 0 |
|
} |
|
|
|
# ------------------------------------------------------------------------------ |
|
# git_evolve |
|
# |
|
# Usage: |
|
# git_evolve |
|
# git_evolve <old_base_commit_sha> |
|
# |
|
# Rescues orphaned children after a parent amend/rebase. |
|
# Automatically detects displaced stacks and rebases them with --update-refs. |
|
# ------------------------------------------------------------------------------ |
|
git_evolve() { |
|
_git_check_version || return 1 |
|
|
|
local new_hash old_hash current_branch reply |
|
local orphans=() |
|
|
|
# Snapshotting |
|
# |
|
# We must map every branch to its hash BEFORE we start rebasing anything. |
|
# |
|
# This allows us to calculate topological distance on the "Original Graph" |
|
# later, even after we have started moving parts of the tree. |
|
declare -A initial_ref_map |
|
|
|
new_hash=$(git rev-parse HEAD) |
|
current_branch=$(git branch --show-current) |
|
|
|
if [ -n "$1" ]; then |
|
old_hash=$(git rev-parse --verify "$1") |
|
else |
|
if ! old_hash=$(git rev-parse --verify HEAD@{1} 2>/dev/null); then |
|
echo "β Error: Could not find previous HEAD in reflog." |
|
echo "Usage: git_evolve <OLD_HASH>" |
|
return 1 |
|
fi |
|
echo "βΉοΈ No hash provided. Auto-detected previous HEAD: ${old_hash:0:7}" |
|
fi |
|
|
|
if [ "$old_hash" == "$new_hash" ]; then |
|
echo "β
HEAD is identical to the target hash. Nothing to evolve." |
|
return 0 |
|
fi |
|
|
|
echo "π Scanning for stacks displaced by move from ${old_hash:0:7} to ${new_hash:0:7}..." |
|
|
|
# Find branches currently pointing to the OLD history |
|
local candidates |
|
candidates=$(git branch --format='%(refname:short)' --contains "$old_hash") |
|
|
|
for branch in $candidates; do |
|
[[ "$branch" == "$current_branch" ]] && continue |
|
if _git_is_ancestor "$new_hash" "$branch"; then continue; fi |
|
|
|
orphans+=("$branch") |
|
initial_ref_map["$branch"]=$(git rev-parse "$branch") |
|
done |
|
|
|
if [ ${#orphans[@]} -eq 0 ]; then |
|
echo "β
No displaced branches found." |
|
return 0 |
|
fi |
|
|
|
# Filter for Tips only (let --update-refs handle the bodies) |
|
local unique_tips=($(_git_find_tips "${orphans[@]}")) |
|
|
|
echo "β‘ Found ${#unique_tips[@]} stack tip(s) (covering ${#orphans[@]} branches):" |
|
for tip in "${unique_tips[@]}"; do |
|
local tree_view |
|
tree_view=$(_git_format_stack_tree "$tip" "" "" "false" "${orphans[*]}") |
|
echo "$tree_view" | sed '1s/^/ - /; 2,$s/^/ /' |
|
done |
|
echo "" |
|
|
|
echo -n "β Rebase these stacks onto ${new_hash:0:7} using --update-refs? (y/n) " |
|
read -r reply |
|
echo "" |
|
|
|
local failed_log=() |
|
local success_count=0 |
|
|
|
if [[ "$reply" =~ ^[Yy]$ ]]; then |
|
for tip in "${unique_tips[@]}"; do |
|
echo "π Reconnecting stack '$tip'..." |
|
|
|
# Dynamic Topology Linking |
|
# |
|
# If Stack A and B share a base (e.g., 'feature-x'), and we rebase Stack A first, |
|
# 'feature-x' moves to a new hash. When we process Stack B, we must detect this movement |
|
# and graft Stack B onto the NEW 'feature-x' to avoid duplicating commits. |
|
|
|
local sync_branch="" |
|
local sync_old_hash="" |
|
local sync_new_hash="" |
|
local best_dist=999999 |
|
|
|
for candidate in "${orphans[@]}"; do |
|
[[ "$candidate" == "$tip" ]] && continue |
|
|
|
# 1. Check Ancestry using SNAPSHOT hashes. |
|
# We must use the old topology to establish relationship, as the candidate |
|
# might have already moved to the new topology. |
|
local candidate_initial_hash="${initial_ref_map[$candidate]}" |
|
|
|
if _git_is_ancestor "$candidate_initial_hash" "$tip"; then |
|
|
|
# 2. Check for Movement. |
|
# Has this ancestor been rebased by a previous iteration of this loop? |
|
local candidate_curr_hash |
|
candidate_curr_hash=$(git rev-parse "$candidate") |
|
|
|
if [[ "$candidate_curr_hash" != "$candidate_initial_hash" ]]; then |
|
# 3. Calculate Distance using INITIAL hashes. |
|
# We must measure "how close" the ancestor is on the ORIGINAL graph. |
|
# Comparing Old-Hash vs New-Hash yields invalid distances. |
|
local dist |
|
dist=$(git rev-list --count "$candidate_initial_hash..$tip") |
|
|
|
if ((dist < best_dist)); then |
|
best_dist=$dist |
|
sync_branch="$candidate" |
|
sync_old_hash="$candidate_initial_hash" |
|
sync_new_hash="$candidate_curr_hash" |
|
fi |
|
fi |
|
fi |
|
done |
|
|
|
# Execute Rebase |
|
if [[ -n "$sync_branch" ]]; then |
|
echo " β¨ Detected shared history! Linking onto updated '$sync_branch'..." |
|
# Rebase Range: (Old_Sync_Hash .. Tip] -> Onto New_Sync_Hash |
|
if git rebase --update-refs --onto "$sync_new_hash" "$sync_old_hash" "$tip"; then |
|
echo " β
Success." |
|
((success_count++)) |
|
else |
|
echo " π₯ Conflict. Aborting..." |
|
git rebase --abort 2>/dev/null |
|
failed_log+=("$(_git_format_stack_tree "$tip" "" "" "false" "${orphans[*]}")") |
|
fi |
|
else |
|
# Standard Rebase: (Old_Base .. Tip] -> Onto New_Base |
|
if git rebase --update-refs --onto "$new_hash" "$old_hash" "$tip"; then |
|
echo " β
Success." |
|
((success_count++)) |
|
else |
|
echo " π₯ Conflict. Aborting..." |
|
git rebase --abort 2>/dev/null |
|
failed_log+=("$(_git_format_stack_tree "$tip" "" "" "false" "${orphans[*]}")") |
|
fi |
|
fi |
|
done |
|
|
|
echo -e "\n========================================" |
|
if [[ ${#failed_log[@]} -eq 0 ]]; then |
|
echo "β¨ All Done! ($success_count stacks evolved)" |
|
git checkout "$new_hash" 2>/dev/null || git checkout - |
|
return 0 |
|
else |
|
echo "β οΈ SUMMARY: $success_count succeeded, ${#failed_log[@]} failed." |
|
echo " The repository has been reset to clean state (per stack)." |
|
echo " The following stacks require manual intervention:" |
|
for entry in "${failed_log[@]}"; do |
|
echo " - $entry" |
|
done | sed 's/^/ /' |
|
git checkout "$new_hash" 2>/dev/null || git checkout - |
|
return 1 |
|
fi |
|
else |
|
echo "β Operation cancelled." |
|
fi |
|
} |
|
|
|
# ------------------------------------------------------------------------------ |
|
# git_push_prefix <prefix> [options] |
|
# |
|
# Usage: |
|
# git_push_prefix "feature/login-" |
|
# git_push_prefix "feature/login-" --force-with-lease |
|
# |
|
# Atomically pushes branches matching the prefix to origin. |
|
# Skips branches where local HEAD == origin HEAD. |
|
# ------------------------------------------------------------------------------ |
|
git_push_prefix() { |
|
local prefix="$1" |
|
shift |
|
local push_opts=("$@") |
|
|
|
[[ -z "$prefix" ]] && { echo "β Error: Missing <prefix>."; return 1; } |
|
|
|
echo "π Scanning 'refs/heads/${prefix}*'..." |
|
|
|
local branches_to_push=() |
|
local up_to_date_count=0 |
|
|
|
# Iterate over local branches with their hash |
|
# Format: branch_name commit_hash |
|
while read -r branch local_hash; do |
|
# Resolve the hash of the remote tracking branch (from local cache) |
|
# We suppress errors because the remote branch might not exist yet (new branch). |
|
local remote_hash |
|
remote_hash=$(git rev-parse --verify "refs/remotes/origin/$branch" 2>/dev/null) |
|
|
|
# Push if remote is missing OR if hashes differ |
|
if [[ -z "$remote_hash" ]]; then |
|
branches_to_push+=("$branch") # New branch |
|
elif [[ "$local_hash" != "$remote_hash" ]]; then |
|
branches_to_push+=("$branch") # Has updates (or needs force push) |
|
else |
|
((up_to_date_count++)) |
|
fi |
|
done < <(git for-each-ref --format='%(refname:short) %(objectname)' "refs/heads/${prefix}*") |
|
|
|
if [[ ${#branches_to_push[@]} -eq 0 ]]; then |
|
if [[ $up_to_date_count -eq 0 ]]; then |
|
echo " No matching branches found." |
|
else |
|
echo "β
All matched branches ($up_to_date_count) are already up-to-date with origin." |
|
fi |
|
return 0 |
|
fi |
|
|
|
echo "π¦ Found ${#branches_to_push[@]} branches to push (Skipped $up_to_date_count up-to-date):" |
|
printf " - %s\n" "${branches_to_push[@]}" |
|
|
|
echo -e "\nπ Pushing to origin (Options: ${push_opts[*]:-(none)})..." |
|
|
|
if git push origin "${branches_to_push[@]}" "${push_opts[@]}"; then |
|
echo -e "\nβ
Batch push complete." |
|
else |
|
echo -e "\nβ Push failed. Check remote permissions or try --force-with-lease." |
|
return 1 |
|
fi |
|
} |
|
|
|
# ------------------------------------------------------------------------------ |
|
# git_prune_local_branches [options] |
|
# |
|
# Usage: |
|
# git_prune_local_branches |
|
# git_prune_local_branches --dry-run |
|
# |
|
# Prunes local branches whose tracking branch is gone from the remote. |
|
# ------------------------------------------------------------------------------ |
|
git_prune_local_branches() { |
|
local dry_run=false |
|
if [[ "$1" == "-n" ]] || [[ "$1" == "--dry-run" ]]; then |
|
echo "Running git_prune_local_branches in dry-run mode..." |
|
dry_run=true |
|
fi |
|
|
|
echo "π Fetching origin --prune..." |
|
git fetch -p |
|
|
|
# Safe parsing: 'git branch -vv' puts a '*' in column 1 if it's the current branch. |
|
# We check for that to ensure we get the branch name (column 2) in that case. |
|
local branches |
|
branches=$(git branch -vv | grep ': gone]' | awk '{if ($1 == "*") print $2; else print $1}') |
|
|
|
if [[ -z "$branches" ]]; then |
|
echo "β
No orphaned branches found." |
|
return 0 |
|
fi |
|
|
|
if [[ "$dry_run" == "true" ]]; then |
|
echo "π¦ [Dry Run] The following branches would be deleted:" |
|
echo "$branches" | sed 's/^/ - /' |
|
return 0 |
|
fi |
|
|
|
echo "ποΈ Pruning branches..." |
|
echo "$branches" | xargs git branch -D |
|
} |
|
|
|
# ------------------------------------------------------------------------------ |
|
# git_prune_remote_prefix <prefix> [target_branch] [options] |
|
# |
|
# Examples: |
|
# git_prune_remote_prefix "feature/old-work-" |
|
# git_prune_remote_prefix "feature/" main --dry-run |
|
# |
|
# Prunes REMOTE branches matching <prefix> that are fully merged/obsolete |
|
# in the target branch (default: main). |
|
# ------------------------------------------------------------------------------ |
|
git_prune_remote_prefix() { |
|
local prefix="$1" |
|
shift |
|
local target="main" |
|
local dry_run=false |
|
|
|
# Argument parsing |
|
while [[ $# -gt 0 ]]; do |
|
case "$1" in |
|
-n | --dry-run) |
|
echo "Running git_prune_remote_prefix in dry-run mode..." |
|
dry_run=true |
|
;; |
|
*) target="$1" ;; |
|
esac |
|
shift |
|
done |
|
|
|
[[ -z "$prefix" ]] && { echo "β Error: Missing <prefix>."; return 1; } |
|
|
|
echo "π Fetching origin..." |
|
git fetch origin |
|
|
|
# Verify remote target exists |
|
if ! git rev-parse --verify "origin/$target" >/dev/null 2>&1; then |
|
echo "β Error: Remote target 'origin/$target' not found." |
|
return 1 |
|
fi |
|
|
|
echo "π Scanning 'origin/${prefix}*' for obsolete branches..." |
|
|
|
# Use for-each-ref for safe parsing |
|
local remote_branches=($(git for-each-ref --format='%(refname:short)' "refs/remotes/origin/${prefix}*")) |
|
|
|
if [[ ${#remote_branches[@]} -eq 0 ]]; then |
|
echo " No matching remote branches found." |
|
return 0 |
|
fi |
|
|
|
local to_delete=() |
|
|
|
for branch in "${remote_branches[@]}"; do |
|
# Skip the target itself or HEAD |
|
[[ "$branch" == "origin/HEAD" ]] && continue |
|
[[ "$branch" == "origin/$target" ]] && continue |
|
|
|
# Reuse the logic: Checks for exact ancestry OR patch-ID match (squash merge) |
|
if _git_is_obsolete "$branch" "origin/$target"; then |
|
# Strip 'origin/' prefix for the push command |
|
local clean_name="${branch#origin/}" |
|
to_delete+=("$clean_name") |
|
fi |
|
done |
|
|
|
if [[ ${#to_delete[@]} -eq 0 ]]; then |
|
echo "β
No obsolete remote branches found." |
|
return 0 |
|
fi |
|
|
|
echo "ποΈ Found ${#to_delete[@]} obsolete remote branches:" |
|
printf " - %s\n" "${to_delete[@]}" |
|
|
|
if [[ "$dry_run" == "true" ]]; then |
|
echo -e "\nπ¦ [Dry Run] No changes made." |
|
return 0 |
|
fi |
|
|
|
echo -e "\nπ₯ Deleting from origin..." |
|
# Atomic delete |
|
if git push origin --delete "${to_delete[@]}"; then |
|
echo "β
Remote cleanup complete." |
|
else |
|
echo "β Error during deletion." |
|
return 1 |
|
fi |
|
} |