#!/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 Commit message template (applied to every repo; omit to prompt per-repo) # -b 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 Separate message for the root bump commit (default: "bump: ") # -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/ 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/ 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 ` # 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 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 "$@"