mirror of
git://f0xx.org/ac/ac-scripts
synced 2026-07-29 04:18:19 +03:00
add sync-push-repos.sh: recursive submodule commit/push/bump tool
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
689
sync-push-repos.sh
Executable file
689
sync-push-repos.sh
Executable file
@@ -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 <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 "$@"
|
||||
Reference in New Issue
Block a user