1
0
mirror of git://f0xx.org/ac/ac-scripts synced 2026-07-29 03:57:56 +03:00
Files
ac-scripts/sync-push-repos.sh
2026-07-04 11:39:42 +02:00

690 lines
26 KiB
Bash
Executable File

#!/usr/bin/env bash
# sync-push-repos.sh — Recursive submodule commit, sync, and push for the ac monorepo.
#
# Run from the root of any git repo that contains submodules (typically `ac`).
# Finds dirty/ahead submodules, commits+pushes each one (leaf-first), then
# bubbles up SHA bumps through parent repos all the way to the root.
#
# USAGE
# ./sync-push-repos.sh [OPTIONS] [-- SUBMODULE_PATH...]
#
# OPTIONS
# -m <msg> Commit message template (applied to every repo; omit to prompt per-repo)
# -b <branch> Override the push branch for every repo (default: each repo's current branch)
# --next Shorthand for -b next
# --squash-to-next On feature/* branches: squash-merge your commits into next, then push next
# --auto-add Run `git add -A` in each submodule before committing
# --no-bump Skip SHA-bump commits in parent repos (just push submodules)
# --no-push Commit but do not push anything (local commits only)
# --dry-run Print every command without executing it
# --include-detached Include submodules on detached HEAD (deps/, third-party/* — off by default)
# --root-msg <msg> Separate message for the root bump commit (default: "bump: <repos>")
# -h|--help Show this help
#
# POSITIONAL (after --)
# Explicit list of submodule paths to process (default: all dirty/ahead submodules).
# Paths are relative to the repo root. Nesting is inferred automatically.
#
# EXAMPLES
# # Commit all dirty submodules with the same message, push, bump root
# ./sync-push-repos.sh -m "fix: update cluster scripts"
#
# # Process only specific repos
# ./sync-push-repos.sh -m "chore: sync" -- ac-scripts ac-deploy
#
# # Feature branch: squash everything into next and push
# ./sync-push-repos.sh -m "feat: new feature" --squash-to-next -- ac-mobile-android
#
# # Dry run — see exactly what would happen
# ./sync-push-repos.sh --dry-run -m "test"
#
# HOW IT WORKS
# 1. Collect all dirty/ahead submodules (recursive depth-first scan).
# 2. Pre-flight: git fetch --all --tags in each; detect behind-remote conflicts.
# 3. Push strategy per branch type:
# • On `next` directly: rebase on origin/next → commit → push origin/next
# • On feature/* or fix/*:
# default → push the side branch as-is
# --squash-to-next → squash-merge side branch into next → push origin/next
# 4. After all submodule pushes, bubble SHA bumps up to each parent repo
# (recursive: if a submodule is itself a submodule, its parent is bumped too).
# 5. Final root commit + push.
#
# CONFLICT HANDLING
# If fetch reveals that origin/<branch> is ahead of HEAD and a clean rebase/
# squash is not possible, the script reports the conflict and skips that repo
# (unless --force is given, which adds --force-with-lease to the push).
set -euo pipefail
PROG="$(basename "$0")"
VERSION="1.1.0"
# ── terminal colours (suppressed when not a tty) ─────────────────────────────
if [ -t 1 ]; then
RED='\033[0;31m' GRN='\033[0;32m' YEL='\033[1;33m' BLU='\033[0;34m'
CYN='\033[0;36m' DIM='\033[2m' BOLD='\033[1m' RST='\033[0m'
else
RED='' GRN='' YEL='' BLU='' CYN='' DIM='' BOLD='' RST=''
fi
# ── globals ──────────────────────────────────────────────────────────────────
DRY=0
AUTO_ADD=0
NO_BUMP=0
NO_PUSH=0
SQUASH_TO_NEXT=0
FORCE_PUSH=0
INCLUDE_DETACHED=0 # include repos on detached HEAD (e.g. deps/* pinned submodules)
COMMIT_MSG=""
ROOT_MSG=""
OVERRIDE_BRANCH=""
declare -a EXPLICIT_REPOS=()
declare -a PUSHED_SUBS=() # relative paths (from AC_ROOT) of successfully pushed repos
declare -a FAILED_SUBS=() # repos that had unresolvable conflicts
AC_ROOT=""
# ── logging helpers ──────────────────────────────────────────────────────────
ts() { date '+%H:%M:%S'; }
log() { printf "${BLU}[$(ts)]${RST} %s\n" "$*"; }
ok() { printf "${GRN}[OK]${RST} %s\n" "$*"; }
warn() { printf "${YEL}[WARN]${RST} %s\n" "$*" >&2; }
err() { printf "${RED}[ERR]${RST} %s\n" "$*" >&2; }
die() { err "$*"; exit 1; }
info() { printf " ${CYN}${RST} %s\n" "$*"; }
dim() { printf " ${DIM}%s${RST}\n" "$*"; }
# Print "ac [sha] / sub [sha] $" style breadcrumb
breadcrumb() {
local path="$1" # relative from AC_ROOT, empty = root
local sha
sha=$(short_sha "${AC_ROOT}${path:+/$path}")
local root_sha
root_sha=$(short_sha "$AC_ROOT")
local root_name
root_name=$(basename "$AC_ROOT")
if [ -z "$path" ]; then
printf "${BOLD}${CYN}%s${RST} ${DIM}[%s]${RST}" "$root_name" "$root_sha"
else
printf "${BOLD}${CYN}%s${RST} ${DIM}[%s]${RST} / ${BOLD}%s${RST} ${DIM}[%s]${RST}" \
"$root_name" "$root_sha" "$path" "$sha"
fi
}
# ── dry-run aware executor ───────────────────────────────────────────────────
run() {
if [ "$DRY" = "1" ]; then
printf " ${DIM}[DRY]${RST} %s\n" "$*"
return 0
fi
eval "$@"
}
# Execute a git command inside a directory; on failure: print error and return 1
git_in() {
local dir="$1"; shift
if [ "$DRY" = "1" ]; then
printf " ${DIM}[DRY] git -C '%s' %s${RST}\n" "$dir" "$*"
return 0
fi
git -C "$dir" "$@"
}
# ── git helpers ──────────────────────────────────────────────────────────────
short_sha() {
git -C "${1:-.}" rev-parse --short HEAD 2>/dev/null || echo "??????"
}
current_branch() {
git -C "${1:-.}" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "HEAD"
}
# Returns 0 if repo has uncommitted changes or commits not yet pushed to remote
is_dirty_or_ahead() {
local dir="$1"
# Staged or unstaged changes
if ! git -C "$dir" diff --quiet 2>/dev/null; then return 0; fi
if ! git -C "$dir" diff --cached --quiet 2>/dev/null; then return 0; fi
# Untracked files
if [ -n "$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null)" ]; then return 0; fi
# Local commits not on remote
local upstream
upstream=$(git -C "$dir" rev-parse --abbrev-ref '@{u}' 2>/dev/null) || return 0
local ahead
ahead=$(git -C "$dir" rev-list "${upstream}..HEAD" 2>/dev/null | wc -l | tr -d ' ')
[ "${ahead:-0}" -gt 0 ]
}
# How many commits is remote ahead of us?
remote_ahead_count() {
local dir="$1"
local branch
branch=$(current_branch "$dir")
local upstream="origin/${branch}"
git -C "$dir" rev-parse --verify "$upstream" >/dev/null 2>&1 || { echo 0; return; }
git -C "$dir" rev-list "HEAD..${upstream}" 2>/dev/null | wc -l | tr -d ' '
}
# How many local commits are not yet on remote?
local_ahead_count() {
local dir="$1"
local branch
branch=$(current_branch "$dir")
local upstream="origin/${branch}"
git -C "$dir" rev-parse --verify "$upstream" >/dev/null 2>&1 || { echo 0; return; }
git -C "$dir" rev-list "${upstream}..HEAD" 2>/dev/null | wc -l | tr -d ' '
}
# ── submodule scanning ───────────────────────────────────────────────────────
# Collect all dirty/ahead submodule paths relative to root (depth-first, leaves first).
# Output: one path per line, deepest nesting first so we can process bottom-up.
# By default skips repos on detached HEAD (pinned deps/); pass --include-detached to override.
collect_dirty_submodules() {
local root="$1"
local -a all=()
# `git submodule foreach --recursive` emits paths with displaypath
# which is already relative to the top-level work tree.
while IFS= read -r subpath; do
[ -z "$subpath" ] && continue
local subdir="${root}/${subpath}"
[ -d "$subdir/.git" ] || [ -f "$subdir/.git" ] || continue
# Skip detached HEAD repos unless explicitly requested.
# Detached = pinned dependency submodules (deps/, third-party/) not meant for direct commits.
if [ "$INCLUDE_DETACHED" = "0" ]; then
local br
br=$(git -C "$subdir" rev-parse --abbrev-ref HEAD 2>/dev/null)
[ "$br" = "HEAD" ] && continue
fi
if is_dirty_or_ahead "$subdir"; then
all+=("$subpath")
fi
done < <(
git -C "$root" submodule foreach --recursive --quiet \
'echo "$displaypath"' 2>/dev/null
)
# Sort: deeper paths (more '/') first so we process leaves before parents
printf '%s\n' "${all[@]+"${all[@]}"}" | awk '{ print gsub(/\//,"/")+1, $0 }' \
| sort -rn | awk '{print $2}'
}
# Given a submodule path (relative to AC_ROOT), find its parent repo dir.
# Returns the path of the parent directory (absolute).
parent_of() {
local relpath="$1"
local parent
parent=$(dirname "$relpath")
if [ "$parent" = "." ]; then
echo "$AC_ROOT"
else
echo "${AC_ROOT}/${parent}"
fi
}
# ── push strategy helpers ────────────────────────────────────────────────────
# Rebase local commits on top of origin/<branch> to avoid non-fast-forward pushes.
# Returns 1 if there are merge conflicts.
rebase_on_remote() {
local dir="$1"
local branch="$2"
local remote_ref="origin/${branch}"
if ! git -C "$dir" rev-parse --verify "$remote_ref" >/dev/null 2>&1; then
# Branch does not exist on remote yet — nothing to rebase onto
return 0
fi
local behind
behind=$(git -C "$dir" rev-list "HEAD..${remote_ref}" 2>/dev/null | wc -l | tr -d ' ')
[ "${behind:-0}" -eq 0 ] && return 0 # already up-to-date
info "rebasing ${branch} on ${remote_ref} (${behind} commit(s) behind)..."
if ! git_in "$dir" rebase "$remote_ref" 2>/dev/null; then
git_in "$dir" rebase --abort 2>/dev/null || true
return 1
fi
return 0
}
# Squash-merge the current feature branch into next, then push next.
# Called when --squash-to-next is active and the current branch is not `next`.
squash_branch_to_next() {
local dir="$1"
local feature_branch="$2"
local msg="$3"
info "squash-merging ${feature_branch} → next..."
# Save HEAD so we can return to feature branch later
local feature_sha
feature_sha=$(git -C "$dir" rev-parse HEAD)
# Ensure next is up-to-date
git_in "$dir" fetch origin next 2>/dev/null || true
# Create/reset a temp branch from origin/next
local tmp="tmp-squash-$(date +%s)"
git_in "$dir" checkout -b "$tmp" "origin/next" 2>/dev/null
# Squash merge — abort if there are conflicts
if ! git_in "$dir" merge --squash "$feature_branch" 2>/dev/null; then
warn "${dir}: squash merge conflict; aborting squash-to-next for this repo"
git_in "$dir" merge --abort 2>/dev/null || true
git_in "$dir" checkout "$feature_branch" 2>/dev/null
git_in "$dir" branch -D "$tmp" 2>/dev/null || true
return 1
fi
# Check if there are staged changes from the squash
local staged
staged=$(git -C "$dir" diff --cached --name-only 2>/dev/null | wc -l | tr -d ' ')
if [ "${staged:-0}" -eq 0 ]; then
info "no net change after squash — nothing to commit"
git_in "$dir" checkout "$feature_branch" 2>/dev/null
git_in "$dir" branch -D "$tmp" 2>/dev/null || true
return 0
fi
run "git -C \"$dir\" commit -s -m \"$msg\""
# Rename tmp → next locally
git_in "$dir" branch -M next 2>/dev/null
if [ "$NO_PUSH" = "1" ]; then
info "skipping push (--no-push)"
return 0
fi
local push_flags="origin next"
[ "$FORCE_PUSH" = "1" ] && push_flags="--force-with-lease origin next"
run "git -C \"$dir\" push $push_flags"
# Restore feature branch
git_in "$dir" checkout "$feature_branch" 2>/dev/null || true
return 0
}
# ── core: process one submodule ──────────────────────────────────────────────
process_repo() {
local relpath="$1" # relative to AC_ROOT
local dir="${AC_ROOT}/${relpath}"
local msg="$2"
local branch
branch=$(current_branch "$dir")
# Allow per-run branch override
[ -n "$OVERRIDE_BRANCH" ] && branch="$OVERRIDE_BRANCH"
local sha_before
sha_before=$(short_sha "$dir")
printf "\n$(breadcrumb "$relpath") \$ "
printf "${DIM}processing...${RST}\n"
# ── auto-add ─────────────────────────────────────────────────────────────
if [ "$AUTO_ADD" = "1" ]; then
run "git -C \"$dir\" add -A"
fi
# ── skip if nothing to do ─────────────────────────────────────────────────
local staged ahead
staged=$(git -C "$dir" diff --cached --name-only 2>/dev/null | wc -l | tr -d ' ')
ahead=$(git -C "$dir" rev-list "origin/${branch}..HEAD" 2>/dev/null | wc -l | tr -d ' ') || ahead=0
if [ "${staged:-0}" -eq 0 ] && [ "${ahead:-0}" -eq 0 ]; then
# Maybe untracked files only; skip commit but still check if we need to push
local untracked
untracked=$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null | wc -l | tr -d ' ')
if [ "${untracked:-0}" -eq 0 ]; then
info "no staged changes and not ahead of remote — skipping $relpath"
return 0
fi
info "untracked files present but not staged; use --auto-add to include them"
return 0
fi
# ── squash-to-next shortcut ───────────────────────────────────────────────
if [ "$SQUASH_TO_NEXT" = "1" ] && [ "$branch" != "next" ]; then
if squash_branch_to_next "$dir" "$branch" "$msg"; then
local sha_after
sha_after=$(short_sha "$dir")
ok "${relpath}: squashed ${branch} → next (${sha_before}${sha_after})"
PUSHED_SUBS+=("$relpath")
else
warn "${relpath}: squash-to-next failed — will push side branch instead"
# Fall through to normal push below
fi
return 0
fi
# ── commit staged changes (if any) ───────────────────────────────────────
if [ "${staged:-0}" -gt 0 ]; then
printf " $(breadcrumb "$relpath") \$ "
printf "${DIM}git commit -a -s -m \"$msg\"${RST}\n"
run "git -C \"$dir\" commit -s -m \"$msg\""
fi
# ── push ─────────────────────────────────────────────────────────────────
if [ "$NO_PUSH" = "1" ]; then
info "skipping push (--no-push)"
local sha_after
sha_after=$(short_sha "$dir")
ok "${relpath}: committed locally ${sha_before}${sha_after}"
PUSHED_SUBS+=("$relpath")
return 0
fi
# Rebase on remote to avoid non-ff rejection
if ! rebase_on_remote "$dir" "$branch"; then
warn "${relpath}: rebase conflict detected — skipping push"
FAILED_SUBS+=("$relpath")
return 1
fi
local push_flags
push_flags="origin ${branch}"
[ "$FORCE_PUSH" = "1" ] && push_flags="--force-with-lease ${push_flags}"
printf " $(breadcrumb "$relpath") \$ "
printf "${DIM}git push -v origin %s${RST}\n" "$branch"
if ! run "git -C \"$dir\" push $push_flags"; then
# One retry after pulling remote changes
warn "${relpath}: push rejected; pulling remote changes and retrying..."
if git_in "$dir" pull --rebase origin "$branch" 2>/dev/null; then
run "git -C \"$dir\" push $push_flags" || {
err "${relpath}: push failed after rebase — skipping"
FAILED_SUBS+=("$relpath")
return 1
}
else
git_in "$dir" rebase --abort 2>/dev/null || true
err "${relpath}: pull --rebase failed — resolve conflicts manually"
FAILED_SUBS+=("$relpath")
return 1
fi
fi
local sha_after
sha_after=$(short_sha "$dir")
ok "${relpath}: pushed ${sha_before}${sha_after} (origin/${branch})"
PUSHED_SUBS+=("$relpath")
}
# ── bubble SHA bumps up through parent repos ─────────────────────────────────
# After processing all leaf submodules, we need each parent to `git add <subpath>`
# so its recorded submodule SHA is updated. Then the parent itself may need
# pushing — we collect parents that now have staged submodule bumps and process
# them at the end.
declare -A PARENT_STAGED # parent_dir -> space-separated list of sub paths staged in it
stage_bump_in_parent() {
local sub_relpath="$1" # e.g. "ac-scripts" or "ac-platform-php/deps/foo"
local parent_dir
parent_dir=$(parent_of "$sub_relpath")
local sub_name
sub_name=$(basename "$sub_relpath")
local sub_full="${parent_dir}/${sub_name}"
# git add <submodule> inside the parent repo
info "$(breadcrumb "${parent_dir#$AC_ROOT/}") / git add ${sub_name}"
run "git -C \"$parent_dir\" add \"$sub_name\""
# Track which paths we staged in this parent
local existing="${PARENT_STAGED[$parent_dir]:-}"
PARENT_STAGED["$parent_dir"]="${existing:+$existing }${sub_name}"
# If parent is itself a submodule (has a super-project), recurse upward
local superproject
superproject=$(git -C "$parent_dir" rev-parse --show-superproject-working-tree 2>/dev/null) || true
if [ -n "$superproject" ]; then
local parent_relpath
parent_relpath=$(realpath --relative-to="$AC_ROOT" "$parent_dir")
stage_bump_in_parent "$parent_relpath"
fi
}
# ── commit bump in a parent repo ─────────────────────────────────────────────
commit_bump_in_parent() {
local parent_dir="$1"
local staged_paths="${PARENT_STAGED[$parent_dir]:-}"
[ -z "$staged_paths" ] && return 0
local staged_count
staged_count=$(git -C "$parent_dir" diff --cached --name-only 2>/dev/null | wc -l | tr -d ' ')
[ "${staged_count:-0}" -eq 0 ] && return 0
local parent_relpath
if [ "$parent_dir" = "$AC_ROOT" ]; then
parent_relpath="(root)"
else
parent_relpath=$(realpath --relative-to="$AC_ROOT" "$parent_dir")
fi
local bump_msg
if [ -n "$ROOT_MSG" ] && [ "$parent_dir" = "$AC_ROOT" ]; then
bump_msg="$ROOT_MSG"
else
# Auto-generate: "bump: repo1, repo2, ..."
local subs_display
subs_display=$(echo "$staged_paths" | tr ' ' '\n' | sort -u | paste -sd ', ')
bump_msg="bump ${subs_display}"
fi
local sha_before
sha_before=$(short_sha "$parent_dir")
printf "\n$(breadcrumb "${parent_relpath#(root)}") \$ "
printf "${DIM}git commit -s -m \"$bump_msg\"${RST}\n"
run "git -C \"$parent_dir\" commit -s -m \"$bump_msg\""
if [ "$NO_PUSH" = "1" ]; then
info "skipping push (--no-push)"
return 0
fi
local branch
branch=$(current_branch "$parent_dir")
local push_flags="origin ${branch}"
[ "$FORCE_PUSH" = "1" ] && push_flags="--force-with-lease ${push_flags}"
printf " $(breadcrumb "${parent_relpath#(root)}") \$ "
printf "${DIM}git push -v origin %s${RST}\n" "$branch"
if ! run "git -C \"$parent_dir\" push $push_flags"; then
warn "${parent_relpath}: root push failed — trying rebase..."
git_in "$parent_dir" pull --rebase origin "$branch" 2>/dev/null && \
run "git -C \"$parent_dir\" push $push_flags" || \
err "${parent_relpath}: push failed — commit is local only"
fi
local sha_after
sha_after=$(short_sha "$parent_dir")
ok "${parent_relpath}: ${sha_before}${sha_after} pushed (origin/${branch})"
}
# ── pre-flight: fetch and report conflicts ───────────────────────────────────
preflight_repos() {
local -a repos=("$@")
local -a behind_repos=()
log "Pre-flight: fetching remote state for ${#repos[@]} repo(s)..."
for relpath in "${repos[@]}"; do
local dir="${AC_ROOT}/${relpath}"
printf " ${DIM}fetch %s ...${RST}" "$relpath"
if [ "$DRY" = "0" ]; then
git -C "$dir" fetch --all --tags --quiet 2>/dev/null || true
fi
local behind
behind=$(remote_ahead_count "$dir")
if [ "${behind:-0}" -gt 0 ]; then
printf " ${YEL}remote is %d ahead${RST}\n" "$behind"
behind_repos+=("${relpath} (remote +${behind})")
else
printf " ${GRN}ok${RST}\n"
fi
done
# Also fetch root
printf " ${DIM}fetch root (${AC_ROOT}) ...${RST}"
git -C "$AC_ROOT" fetch --all --tags --quiet 2>/dev/null || true
printf " ${GRN}ok${RST}\n"
if [ "${#behind_repos[@]}" -gt 0 ]; then
warn "The following repos have remote commits ahead of local HEAD:"
for r in "${behind_repos[@]}"; do
warn "$r"
done
warn "The script will attempt an automatic rebase before pushing."
if [ -t 0 ] && [ "$DRY" = "0" ]; then
printf "\n${YEL}Continue? [y/N]${RST} "
read -r ans
[[ "${ans:-}" =~ ^[Yy] ]] || { log "Aborted by user."; exit 1; }
fi
fi
}
# ── argument parsing ──────────────────────────────────────────────────────────
parse_args() {
local parse_explicit=0
while [ $# -gt 0 ]; do
case "$1" in
-m) shift; COMMIT_MSG="$1" ;;
--message) shift; COMMIT_MSG="$1" ;;
-b|--branch) shift; OVERRIDE_BRANCH="$1" ;;
--next) OVERRIDE_BRANCH="next" ;;
--squash-to-next) SQUASH_TO_NEXT=1 ;;
--auto-add|-A) AUTO_ADD=1 ;;
--no-bump) NO_BUMP=1 ;;
--no-push) NO_PUSH=1 ;;
--dry-run) DRY=1 ;;
--force) FORCE_PUSH=1 ;;
--include-detached) INCLUDE_DETACHED=1 ;;
--root-msg) shift; ROOT_MSG="$1" ;;
--) parse_explicit=1 ;;
-h|--help)
sed -n '2,55p' "$0" | sed 's/^# \?//'
exit 0
;;
*)
if [ "$parse_explicit" = "1" ]; then
EXPLICIT_REPOS+=("$1")
else
warn "Unknown option: $1 (ignored)"
fi
;;
esac
shift
done
}
# ── usage ─────────────────────────────────────────────────────────────────────
show_banner() {
printf "\n${BOLD}sync-push-repos.sh v${VERSION}${RST} — recursive submodule sync\n"
printf "Root: ${BLU}%s${RST} ${DIM}[%s]${RST} branch: ${CYN}%s${RST}\n\n" \
"$(basename "$AC_ROOT")" "$(short_sha "$AC_ROOT")" "$(current_branch "$AC_ROOT")"
[ "$DRY" = "1" ] && printf " ${YEL}*** DRY RUN — no changes will be made ***${RST}\n\n"
}
# ── main ──────────────────────────────────────────────────────────────────────
main() {
parse_args "$@"
# Resolve repo root
AC_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) \
|| die "Not inside a git repository."
cd "$AC_ROOT"
show_banner
# ── determine work set ─────────────────────────────────────────────────────
declare -a repos=()
if [ "${#EXPLICIT_REPOS[@]}" -gt 0 ]; then
repos=("${EXPLICIT_REPOS[@]}")
log "Explicit repos: ${repos[*]}"
else
log "Scanning for dirty/ahead submodules..."
while IFS= read -r r; do
[ -n "$r" ] && repos+=("$r")
done < <(collect_dirty_submodules "$AC_ROOT")
if [ "${#repos[@]}" -eq 0 ]; then
ok "All submodules are clean — nothing to sync."
exit 0
fi
log "Dirty/ahead submodules (${#repos[@]}):"
for r in "${repos[@]}"; do
printf " ${DIM}• %s${RST} [%s] branch: %s\n" \
"$r" "$(short_sha "${AC_ROOT}/${r}")" "$(current_branch "${AC_ROOT}/${r}")"
done
echo
fi
# ── pre-flight fetch ───────────────────────────────────────────────────────
preflight_repos "${repos[@]}"
# ── prompt for commit message if not provided ──────────────────────────────
if [ -z "$COMMIT_MSG" ]; then
if [ -t 0 ]; then
printf "\n${BOLD}Commit message${RST} ${DIM}(applied to all repos; Ctrl-C to abort)${RST}: "
read -r COMMIT_MSG
[ -n "$COMMIT_MSG" ] || die "Empty commit message — aborted."
else
die "Commit message required: use -m 'message'"
fi
fi
# ── process each submodule (leaves first, deepest nesting first) ───────────
echo
log "Processing ${#repos[@]} repo(s)..."
for relpath in "${repos[@]}"; do
process_repo "$relpath" "$COMMIT_MSG" || true # collect failures, don't abort
done
# ── bump parent SHA refs ───────────────────────────────────────────────────
if [ "$NO_BUMP" = "0" ] && [ "${#PUSHED_SUBS[@]}" -gt 0 ]; then
echo
log "Bubbling SHA bumps up to parent repos..."
for relpath in "${PUSHED_SUBS[@]}"; do
stage_bump_in_parent "$relpath"
done
# Commit bumps in each affected parent (root last)
# Sort parents: deepest first (most slashes in path), root last
declare -a parents_sorted=()
for parent_dir in "${!PARENT_STAGED[@]}"; do
if [ "$parent_dir" != "$AC_ROOT" ]; then
parents_sorted+=("$parent_dir")
fi
done
# Add root at the end
parents_sorted+=("$AC_ROOT")
for parent_dir in "${parents_sorted[@]}"; do
commit_bump_in_parent "$parent_dir"
done
fi
# ── summary ────────────────────────────────────────────────────────────────
echo
printf "${BOLD}═══ Summary ═══${RST}\n"
if [ "${#PUSHED_SUBS[@]}" -gt 0 ]; then
ok "Pushed (${#PUSHED_SUBS[@]}): ${PUSHED_SUBS[*]}"
fi
if [ "${#FAILED_SUBS[@]}" -gt 0 ]; then
err "Failed (${#FAILED_SUBS[@]}): ${FAILED_SUBS[*]}"
err "Resolve conflicts manually in the repos above, then re-run."
exit 1
fi
}
main "$@"