cleanup_backups piped `git branch --list` into `while read`, so the `deleted` counter incremented inside a pipeline subshell and never propagated. The closing "Deleted N backup branch(es)" always reported 0, however many were actually removed. Switch the inner loop to process substitution (matching the outer find_forked_repos loop) so the count survives.
409 lines
14 KiB
Bash
409 lines
14 KiB
Bash
#!/usr/bin/env bash
|
|
# dev-env: safe fork-onto-upstream rebase helper
|
|
#
|
|
# Walks every worktree under $DEV_ROOT that has an `upstream` remote and
|
|
# rebases your fork commits onto upstream, with backups and a
|
|
# force-with-lease push gated on confirmation. Reads paths from
|
|
# /etc/dev-env/config.sh; the rebase log lives under the user's XDG
|
|
# state dir.
|
|
#
|
|
# Workflow for a single rebase:
|
|
# 1. Safety checks (no uncommitted changes, upstream remote exists)
|
|
# 2. Create backup branch `backup/pre-rebase-YYYYMMDD-HHMMSS`
|
|
# 3. Show incoming + outgoing commits
|
|
# 4. Rebase and force-with-lease push (with confirmation)
|
|
# 5. On conflict, print resolution guide and preserve backup
|
|
|
|
set -e
|
|
|
|
# Load runtime config (sets DEV_ROOT, REPOS_DIR, SHARED_DIR, …)
|
|
if [[ -r /etc/dev-env/config.sh ]]; then
|
|
# shellcheck disable=SC1091
|
|
source /etc/dev-env/config.sh
|
|
else
|
|
echo "Error: /etc/dev-env/config.sh not found (is the dev-env module enabled?)" >&2
|
|
exit 1
|
|
fi
|
|
|
|
DEV_ROOT="${DEV_ROOT:-$HOME/dev}"
|
|
REPOS_DIR="${REPOS_DIR:-$DEV_ROOT/repos}"
|
|
SHARED_DIR="${SHARED_DIR:-$DEV_ROOT/shared}"
|
|
|
|
BACKUP_PREFIX="backup/pre-rebase"
|
|
LOG_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/dev-env/rebase.log"
|
|
mkdir -p "$(dirname "$LOG_FILE")"
|
|
|
|
#-------------------------------------------------------------------------------
|
|
# Colors
|
|
#-------------------------------------------------------------------------------
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
BLUE='\033[0;34m'
|
|
CYAN='\033[0;36m'
|
|
BOLD='\033[1m'
|
|
NC='\033[0m'
|
|
|
|
info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
|
success() { echo -e "${GREEN}[OK]${NC} $1"; }
|
|
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
|
error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
|
header() { echo -e "\n${BOLD}${CYAN}═══ $1 ═══${NC}\n"; }
|
|
|
|
log_action() {
|
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
|
|
}
|
|
|
|
confirm() {
|
|
local prompt="${1:-Continue?}"
|
|
read -r -p "$prompt (y/N) " -n 1 REPLY
|
|
echo
|
|
[[ $REPLY =~ ^[Yy]$ ]]
|
|
}
|
|
|
|
#-------------------------------------------------------------------------------
|
|
# Generic repo helpers
|
|
#-------------------------------------------------------------------------------
|
|
get_upstream_branch() {
|
|
local repo_path="$1"
|
|
(cd "$repo_path"
|
|
for branch in main master; do
|
|
if git rev-parse "upstream/$branch" &>/dev/null; then
|
|
echo "$branch"
|
|
return 0
|
|
fi
|
|
done
|
|
echo "main")
|
|
}
|
|
|
|
check_repo_clean() {
|
|
local repo_path="$1"
|
|
[[ -z "$(git -C "$repo_path" status --porcelain)" ]]
|
|
}
|
|
|
|
has_upstream() {
|
|
git -C "$1" remote get-url upstream &>/dev/null
|
|
}
|
|
|
|
create_backup() {
|
|
local repo_path="$1"
|
|
local branch
|
|
branch="$(git -C "$repo_path" branch --show-current)"
|
|
local backup_name="${BACKUP_PREFIX}-$(date +%Y%m%d-%H%M%S)"
|
|
git -C "$repo_path" branch -f "$backup_name" "$branch"
|
|
echo "$backup_name"
|
|
}
|
|
|
|
show_divergence() {
|
|
local repo_path="$1" upstream_branch="$2"
|
|
local behind ahead
|
|
behind=$(git -C "$repo_path" rev-list --count "HEAD..upstream/$upstream_branch" 2>/dev/null || echo 0)
|
|
ahead=$(git -C "$repo_path" rev-list --count "upstream/$upstream_branch..HEAD" 2>/dev/null || echo 0)
|
|
echo -e " ${CYAN}Behind upstream:${NC} $behind commits"
|
|
echo -e " ${GREEN}Ahead (your work):${NC} $ahead commits"
|
|
}
|
|
|
|
#-------------------------------------------------------------------------------
|
|
# Core rebase
|
|
#-------------------------------------------------------------------------------
|
|
rebase_single_repo() {
|
|
local repo_path="$1" repo_name="$2"
|
|
local upstream_branch="${3:-}"
|
|
local auto_mode="${4:-false}"
|
|
|
|
header "Rebasing: $repo_name"
|
|
|
|
if [[ ! -d "$repo_path/.git" ]] && [[ ! -f "$repo_path/.git" ]]; then
|
|
error "Not a git repository: $repo_path"
|
|
return 1
|
|
fi
|
|
|
|
if ! has_upstream "$repo_path"; then
|
|
warn "No upstream remote configured. Skipping."
|
|
return 0
|
|
fi
|
|
|
|
if ! check_repo_clean "$repo_path"; then
|
|
error "Uncommitted changes detected!"
|
|
echo " Stash or commit first: git stash / git commit"
|
|
return 1
|
|
fi
|
|
|
|
[[ -z "$upstream_branch" ]] && upstream_branch="$(get_upstream_branch "$repo_path")"
|
|
|
|
local current_branch
|
|
current_branch="$(git -C "$repo_path" branch --show-current)"
|
|
info "Current branch: $current_branch"
|
|
info "Upstream branch: upstream/$upstream_branch"
|
|
|
|
info "Fetching upstream..."
|
|
git -C "$repo_path" fetch upstream
|
|
|
|
show_divergence "$repo_path" "$upstream_branch"
|
|
|
|
local behind
|
|
behind=$(git -C "$repo_path" rev-list --count "HEAD..upstream/$upstream_branch" 2>/dev/null || echo 0)
|
|
if [[ "$behind" -eq 0 ]]; then
|
|
success "Already up to date with upstream!"
|
|
return 0
|
|
fi
|
|
|
|
echo ""
|
|
echo -e "${BOLD}New commits from upstream:${NC}"
|
|
git -C "$repo_path" log --oneline --graph "HEAD..upstream/$upstream_branch" | head -15
|
|
local total_upstream
|
|
total_upstream="$(git -C "$repo_path" rev-list --count "HEAD..upstream/$upstream_branch")"
|
|
(( total_upstream > 15 )) && echo " ... and $((total_upstream - 15)) more commits"
|
|
|
|
echo ""
|
|
echo -e "${BOLD}Your commits to replay:${NC}"
|
|
git -C "$repo_path" log --oneline "upstream/$upstream_branch..HEAD" | head -15
|
|
local total_yours
|
|
total_yours="$(git -C "$repo_path" rev-list --count "upstream/$upstream_branch..HEAD")"
|
|
(( total_yours > 15 )) && echo " ... and $((total_yours - 15)) more commits"
|
|
|
|
echo ""
|
|
if [[ "$auto_mode" != "true" ]] && ! confirm "Proceed with rebase?"; then
|
|
warn "Skipped."
|
|
return 0
|
|
fi
|
|
|
|
local backup_branch
|
|
backup_branch="$(create_backup "$repo_path")"
|
|
success "Backup created: $backup_branch"
|
|
|
|
info "Rebasing onto upstream/$upstream_branch..."
|
|
if git -C "$repo_path" rebase "upstream/$upstream_branch"; then
|
|
success "Rebase completed successfully!"
|
|
echo ""
|
|
if confirm "Push to origin with --force-with-lease?"; then
|
|
git -C "$repo_path" push origin "$current_branch" --force-with-lease
|
|
success "Pushed to origin!"
|
|
log_action "REBASE SUCCESS: $repo_name onto upstream/$upstream_branch"
|
|
else
|
|
warn "Not pushed. When ready: git push origin $current_branch --force-with-lease"
|
|
fi
|
|
echo ""
|
|
info "Backup branch '$backup_branch' preserved."
|
|
echo " Delete later: git branch -D $backup_branch"
|
|
return 0
|
|
else
|
|
error "Rebase encountered conflicts!"
|
|
cat <<EOF
|
|
|
|
${BOLD}${YELLOW}Conflict Resolution Guide:${NC}
|
|
1. Check conflicts: ${CYAN}git status${NC}
|
|
2. Resolve markers <<<<<<< ======= >>>>>>>
|
|
3. Stage: ${CYAN}git add <file>${NC}
|
|
4. Continue: ${CYAN}git rebase --continue${NC}
|
|
5. Skip empty: ${CYAN}git rebase --skip${NC}
|
|
6. Abort: ${CYAN}git rebase --abort${NC}
|
|
7. After success: ${CYAN}git push origin $current_branch --force-with-lease${NC}
|
|
|
|
Backup: ${GREEN}$backup_branch${NC}
|
|
EOF
|
|
log_action "REBASE CONFLICT: $repo_name - manual resolution required"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
#-------------------------------------------------------------------------------
|
|
# Discovery: walk actual worktrees under $DEV_ROOT instead of the old
|
|
# $PROJECTS_DIR layout that no longer exists.
|
|
#-------------------------------------------------------------------------------
|
|
find_forked_repos() {
|
|
# Print one "<path>:<display-name>" per rebase-eligible repo (has
|
|
# upstream remote). We look at every git dir (.git dir or .git file
|
|
# for worktrees) under $DEV_ROOT, excluding $REPOS_DIR bare repos.
|
|
find "$DEV_ROOT" -type d -name '.git' -prune 2>/dev/null | while read -r gitdir; do
|
|
local repo_path
|
|
repo_path="$(dirname "$gitdir")"
|
|
# Skip bare repos under REPOS_DIR
|
|
[[ "$repo_path" == "$REPOS_DIR"* ]] && continue
|
|
# Skip node_modules
|
|
[[ "$repo_path" == *"/node_modules/"* ]] && continue
|
|
if has_upstream "$repo_path"; then
|
|
local display
|
|
display="${repo_path#$DEV_ROOT/}"
|
|
echo "$repo_path:$display"
|
|
fi
|
|
done
|
|
# Also bare repos under REPOS_DIR (the .git file case)
|
|
find "$DEV_ROOT" -type f -name '.git' 2>/dev/null | while read -r gitfile; do
|
|
local repo_path
|
|
repo_path="$(dirname "$gitfile")"
|
|
[[ "$repo_path" == *"/node_modules/"* ]] && continue
|
|
if has_upstream "$repo_path"; then
|
|
local display
|
|
display="${repo_path#$DEV_ROOT/}"
|
|
echo "$repo_path:$display"
|
|
fi
|
|
done
|
|
}
|
|
|
|
#-------------------------------------------------------------------------------
|
|
# Batch modes
|
|
#-------------------------------------------------------------------------------
|
|
rebase_all() {
|
|
header "Rebasing ALL Forks with Upstreams"
|
|
warn "This walks $DEV_ROOT for every worktree with an 'upstream' remote."
|
|
echo ""
|
|
if ! confirm "Continue?"; then info "Aborted."; return 0; fi
|
|
|
|
local failed=()
|
|
while IFS=: read -r path name; do
|
|
if ! rebase_single_repo "$path" "$name"; then
|
|
failed+=("$name")
|
|
fi
|
|
done < <(find_forked_repos)
|
|
|
|
echo ""
|
|
header "Summary"
|
|
if (( ${#failed[@]} == 0 )); then
|
|
success "All repositories rebased successfully!"
|
|
else
|
|
error "Failed (${#failed[@]}):"
|
|
printf ' - %s\n' "${failed[@]}"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
show_all_status() {
|
|
header "Repository Rebase Status"
|
|
while IFS=: read -r path name; do
|
|
local upstream_branch behind ahead
|
|
upstream_branch="$(get_upstream_branch "$path")"
|
|
git -C "$path" fetch upstream --quiet 2>/dev/null || true
|
|
behind=$(git -C "$path" rev-list --count "HEAD..upstream/$upstream_branch" 2>/dev/null || echo ?)
|
|
ahead=$(git -C "$path" rev-list --count "upstream/$upstream_branch..HEAD" 2>/dev/null || echo ?)
|
|
printf " %-40s ↓%-3s ↑%-3s\n" "$name" "$behind" "$ahead"
|
|
done < <(find_forked_repos)
|
|
echo ""
|
|
echo -e "${CYAN}Legend:${NC} ↓=behind upstream ↑=ahead (your commits)"
|
|
}
|
|
|
|
view_log() {
|
|
header "Rebase Log"
|
|
if [[ -f "$LOG_FILE" ]]; then tail -50 "$LOG_FILE"
|
|
else info "No rebase log yet at $LOG_FILE"; fi
|
|
}
|
|
|
|
cleanup_backups() {
|
|
header "Cleanup Backup Branches"
|
|
echo "Options:"
|
|
echo " 1) Delete backups older than 7 days"
|
|
echo " 2) Delete backups older than 30 days"
|
|
echo " 3) Delete ALL backup branches"
|
|
echo " 4) Cancel"
|
|
read -r -p "Select option: " -n 1 REPLY; echo ""
|
|
|
|
local days=0
|
|
case $REPLY in
|
|
1) days=7 ;;
|
|
2) days=30 ;;
|
|
3) days=0 ;;
|
|
*) info "Cancelled."; return 0 ;;
|
|
esac
|
|
|
|
local cutoff=""
|
|
(( days > 0 )) && cutoff=$(date -d "$days days ago" +%Y%m%d 2>/dev/null || date -v-"${days}d" +%Y%m%d)
|
|
|
|
local deleted=0
|
|
while IFS=: read -r path _; do
|
|
cd "$path" || continue
|
|
while read -r branch; do
|
|
branch="$(echo "$branch" | tr -d ' *')"
|
|
local bdate
|
|
bdate="$(echo "$branch" | grep -oE '[0-9]{8}' | head -1)"
|
|
if [[ $days -eq 0 ]] \
|
|
|| { [[ -n "$bdate" ]] && [[ "$bdate" < "$cutoff" ]]; }; then
|
|
git branch -D "$branch" 2>/dev/null && deleted=$((deleted + 1)) || true
|
|
fi
|
|
done < <(git branch --list "backup/*" 2>/dev/null)
|
|
done < <(find_forked_repos)
|
|
success "Deleted $deleted backup branch(es)."
|
|
}
|
|
|
|
#-------------------------------------------------------------------------------
|
|
# Interactive / CLI
|
|
#-------------------------------------------------------------------------------
|
|
interactive_menu() {
|
|
while true; do
|
|
header "Rebase Helper"
|
|
echo " 1) Rebase a single repository (by path)"
|
|
echo " 2) Rebase ALL forks with upstream"
|
|
echo " s) Status of all repos"
|
|
echo " l) View rebase log"
|
|
echo " c) Clean up old backup branches"
|
|
echo " q) Quit"
|
|
echo ""
|
|
read -r -p "Select: " -n 1 REPLY; echo ""
|
|
case $REPLY in
|
|
1) read -r -p "Repo path: " p
|
|
[[ -d "$p/.git" || -f "$p/.git" ]] && rebase_single_repo "$p" "$(basename "$p")" || warn "Not a repo: $p" ;;
|
|
2) rebase_all ;;
|
|
s) show_all_status ;;
|
|
l) view_log ;;
|
|
c) cleanup_backups ;;
|
|
q) echo "Bye!"; exit 0 ;;
|
|
*) warn "Invalid option" ;;
|
|
esac
|
|
echo ""
|
|
read -r -p "Press Enter to continue..."
|
|
done
|
|
}
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
${BOLD}rebase${NC} — Safely rebase forks onto upstream
|
|
|
|
USAGE:
|
|
rebase Interactive mode
|
|
rebase single <path> Rebase a single repository
|
|
rebase all Rebase all forks with upstream remote
|
|
rebase status Show status of all forks
|
|
rebase log Show rebase log
|
|
rebase cleanup Clean up old backup branches
|
|
|
|
OPTIONS:
|
|
-b, --branch <name> Upstream branch to rebase onto
|
|
-y, --yes Auto-confirm prompts
|
|
-h, --help Show this help
|
|
|
|
Backups are branched automatically as backup/pre-rebase-YYYYMMDD-HHMMSS
|
|
and never deleted without explicit cleanup.
|
|
EOF
|
|
}
|
|
|
|
main() {
|
|
local command="" repo_path="" upstream_branch="" auto_yes=false
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
-h|--help) usage; exit 0 ;;
|
|
-b|--branch) upstream_branch="$2"; shift 2 ;;
|
|
-y|--yes) auto_yes=true; shift ;;
|
|
single|all|status|log|cleanup) command="$1"; shift ;;
|
|
*)
|
|
if [[ -z "$command" ]] && [[ -d "$1/.git" || -f "$1/.git" ]]; then
|
|
command="single"; repo_path="$1"
|
|
else
|
|
repo_path="$1"
|
|
fi
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
|
|
case $command in
|
|
"") interactive_menu ;;
|
|
single) [[ -z "$repo_path" ]] && { error "Need path"; exit 1; }
|
|
rebase_single_repo "$repo_path" "$(basename "$repo_path")" "$upstream_branch" "$auto_yes" ;;
|
|
all) rebase_all ;;
|
|
status) show_all_status ;;
|
|
log) view_log ;;
|
|
cleanup) cleanup_backups ;;
|
|
*) error "Unknown command: $command"; usage; exit 1 ;;
|
|
esac
|
|
}
|
|
|
|
main "$@"
|