Skip to content

Instantly share code, notes, and snippets.

@methylDragon
Last active December 11, 2025 05:20
Show Gist options
  • Select an option

  • Save methylDragon/c53ab1ace72ec47995d6b62355149d0b to your computer and use it in GitHub Desktop.

Select an option

Save methylDragon/c53ab1ace72ec47995d6b62355149d0b to your computer and use it in GitHub Desktop.
Git utilities

Git Stack Utilities

A collection of scripts to wrangle branches, especially in a stacked-diff context in repos where the main branch keeps updating.

Requirements: Git 2.38+ (relies on rebase --update-refs).

Setup

Add to your shell rc file (.zshrc / .bashrc):

source git_bash_functions.sh

Usage

Function Description
git_rebase_prefix <prefix> [base] Batch Update. Rebases all stacks matching prefix onto base (default: main). Preserves topology; skips commits already squashed upstream.
git_evolve Rescue Orphans. Run immediately after git commit --amend to rebase child branches onto the new HEAD automatically.
git_push_prefix <prefix> [opts] Batch Push. Pushes all branches matching prefix. Passes extra args (e.g., --force-with-lease) to git.
git_prune_remote_prefix <prefix> Remote Cleanup. Deletes remote branches that are fully merged or squash-merged into main.
git_prune_local_branches Local Cleanup. Deletes local branches whose remote tracking branches are gone.
# ==============================================================================
# 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
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment