From 912cc54ec9c99c0329288751efc5df8af95e96a6 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Sat, 4 Jul 2026 11:39:42 +0200 Subject: [PATCH] add sync-push-repos.sh: recursive submodule commit/push/bump tool Co-authored-by: Cursor --- sync-push-repos.sh | 689 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 689 insertions(+) create mode 100755 sync-push-repos.sh diff --git a/sync-push-repos.sh b/sync-push-repos.sh new file mode 100755 index 0000000..3c260c8 --- /dev/null +++ b/sync-push-repos.sh @@ -0,0 +1,689 @@ +#!/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 "$@"