#!/bin/bash
# sync-modules.sh — clone missing / fast-forward existing hyper-fleeky modules
#
# Reads the GENERATED manifest at hyper-fleeky/modules.json (see
# scripts/gen-modules-manifest.js). Supersedes sync-submodules.sh, which
# hardcoded a module list from ~February and called `git submodule update
# --init` — broken for months because 12 gitlinks had no .gitmodules mapping.
#
# Modules marked tracking=submodule are left alone: their state is pinned in
# git, so `git submodule update` owns them, not this script.
#
# ./scripts/sync-modules.sh # clone missing, pull existing
# ./scripts/sync-modules.sh --dry-run # report only
# ./scripts/sync-modules.sh --status # show drift without changing anything
#
# NEVER force-pushes, NEVER resets, NEVER discards local commits. Anything
# that is not a clean fast-forward is reported and skipped — divergence is a
# decision for a human, not a sync script.
set -u
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
MANIFEST="$ROOT/hyper-fleeky/modules.json"
DRY=0; STATUS=0
for a in "$@"; do
case "$a" in
--dry-run) DRY=1 ;;
--status) STATUS=1 ;;
*) echo "unknown flag: $a" >&2; exit 2 ;;
esac
done
[ -f "$MANIFEST" ] || { echo "missing $MANIFEST — run: node scripts/gen-modules-manifest.js" >&2; exit 1; }
# name<TAB>path<TAB>repo<TAB>branch<TAB>tracking, manifest-only entries with a repo
rows=$(node -e '
const m = require(process.argv[1]).modules
for (const [n, x] of Object.entries(m)) {
if (x.tracking !== "manifest" || !x.repo) continue
console.log([n, x.path, x.repo, x.branch || "master"].join("\t"))
}
' "$MANIFEST")
cloned=0; pulled=0; skipped=0; diverged=0
while IFS=$'\t' read -r name rel repo branch; do
[ -n "$name" ] || continue
dir="$ROOT/$rel"
if [ ! -d "$dir/.git" ]; then
if [ "$STATUS" = 1 ]; then printf " %-24s MISSING (would clone)\n" "$name"; skipped=$((skipped+1)); continue; fi
if [ "$DRY" = 1 ]; then printf " %-24s would clone from %s\n" "$name" "$repo"; continue; fi
if git clone --quiet "$repo" "$dir" 2>/dev/null; then
printf " %-24s cloned\n" "$name"; cloned=$((cloned+1))
else
printf " %-24s CLONE FAILED from %s\n" "$name" "$repo"; skipped=$((skipped+1))
fi
continue
fi
# Existing checkout: only ever fast-forward, and only on its own branch.
cur=$(git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null)
remote_tip=$(git -C "$dir" ls-remote "$repo" "refs/heads/$branch" 2>/dev/null | cut -f1)
if [ -z "$remote_tip" ]; then printf " %-24s no %s on remote — skipped\n" "$name" "$branch"; skipped=$((skipped+1)); continue; fi
local_head=$(git -C "$dir" rev-parse HEAD)
if [ "$local_head" = "$remote_tip" ]; then
[ "$STATUS" = 1 ] && printf " %-24s up to date\n" "$name"
continue
fi
git -C "$dir" fetch --quiet "$repo" "$branch" 2>/dev/null || true
if git -C "$dir" merge-base --is-ancestor "$local_head" "$remote_tip" 2>/dev/null; then
if [ "$STATUS" = 1 ] || [ "$DRY" = 1 ]; then
printf " %-24s behind — would fast-forward\n" "$name"; continue
fi
if [ "$cur" != "$branch" ]; then
printf " %-24s on '%s', manifest says '%s' — skipped\n" "$name" "$cur" "$branch"; skipped=$((skipped+1)); continue
fi
git -C "$dir" merge --ff-only "$remote_tip" >/dev/null 2>&1 \
&& { printf " %-24s fast-forwarded\n" "$name"; pulled=$((pulled+1)); } \
|| { printf " %-24s FF FAILED (dirty tree?)\n" "$name"; skipped=$((skipped+1)); }
elif git -C "$dir" merge-base --is-ancestor "$remote_tip" "$local_head" 2>/dev/null; then
printf " %-24s AHEAD of remote by %s — unpushed, left alone\n" \
"$name" "$(git -C "$dir" rev-list --count "$remote_tip..$local_head")"
else
printf " %-24s *** DIVERGED — needs a human\n" "$name"; diverged=$((diverged+1))
fi
done <<< "$rows"
echo
echo "cloned=$cloned fast-forwarded=$pulled skipped=$skipped diverged=$diverged"
[ "$diverged" -gt 0 ] && exit 1
exit 0