This repository has been archived on 2026-06-22. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
lnbits-sensei/modules/dev-env/scripts/rebase.sh
Padreug cd95974e48 fix(dev-env): count deleted backups in the parent shell, not a subshell
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.
2026-06-20 09:51:31 +02:00

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 "$@"