fix(sync): never reset a submodule with local work (branch/dirty)

Phase-3 post-rebase reconcile ran 'git submodule update --init --recursive'
unconditionally, force-detaching every submodule to the parent's pinned gitlink
and discarding any feature branch, commits, or uncommitted edits inside it. The
Phase-1a init guard did not cover this path. New submodule_update_safe() advances
ONLY submodules in the pristine pinned state (clean, detached HEAD) and skips any
on a branch or with uncommitted changes, so in-progress submodule work survives a
parent sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 14:30:38 -07:00
parent 26aa5034f1
commit 9108f9419c

View File

@@ -145,6 +145,50 @@ resolve_submodule_collisions() {
[ "$moved" -eq 1 ] && return 0 || return 1
}
# Advance submodules to the parent's pinned gitlinks after a parent pull, WITHOUT
# ever clobbering a submodule that has local work. The pristine/pinned state of a
# submodule is a CLEAN, DETACHED HEAD; a developer actively working in a submodule
# checks out a BRANCH and/or has uncommitted edits. Those we skip entirely so an
# in-progress feature inside a submodule survives a parent sync.
#
# (Friction 2026-06-22: an unguarded `git submodule update --init --recursive`
# after the parent rebase repeatedly reset guru-rmm to its pinned commit — detaching
# HEAD and discarding a pushed-elsewhere feature branch + commits mid-build. The
# Phase-1a init guard at the top did not cover this post-rebase reconcile path.)
#
# Protected-submodule notices go to stderr; git's own output goes to stdout so the
# caller can capture it for failure reporting. Returns git's rc (0 if nothing to do).
submodule_update_safe() {
[ -f ".gitmodules" ] || return 0
local p safe=() prot=()
while read -r p; do
[ -n "$p" ] || continue
if [ -e "$p/.git" ]; then
# On a branch (not the pristine detached pin) -> someone is working here.
if git -C "$p" symbolic-ref -q HEAD >/dev/null 2>&1; then
prot+=("$p [branch $(git -C "$p" rev-parse --abbrev-ref HEAD 2>/dev/null)]")
continue
fi
# Uncommitted changes -> protect.
if [ -n "$(git -C "$p" status --porcelain 2>/dev/null)" ]; then
prot+=("$p [uncommitted changes]")
continue
fi
fi
# Clean detached HEAD, or not yet populated (fresh clone) -> safe to update.
safe+=("$p")
done < <(git config --file .gitmodules --get-regexp '^submodule\..*\.path$' 2>/dev/null | awk '{print $2}')
local item
for item in "${prot[@]}"; do
echo -e "${YELLOW}[INFO]${NC} sync: leaving submodule with local work untouched: ${item}" >&2
done
[ "${#safe[@]}" -eq 0 ] && return 0
git submodule update --init --recursive --quiet -- "${safe[@]}" 2>&1
return $?
}
# Machine + timestamp
if [ -n "$COMPUTERNAME" ]; then
MACHINE="$COMPUTERNAME"
@@ -522,14 +566,14 @@ if [ "$INCOMING_COUNT" -gt 0 ]; then
# raw "fatal: Unable to checkout" reads alarmingly right before a clean resolve.
# Only surface the raw output if the resolve+retry below also fails.
set +e
SUB_OUT=$(git submodule update --init --recursive --quiet 2>&1)
SUB_OUT=$(submodule_update_safe)
SUB_RC=$?
set -e
if [ "$SUB_RC" -ne 0 ]; then
# Move any untracked files colliding with the incoming commit aside, retry once.
if resolve_submodule_collisions; then
set +e
SUB_OUT=$(git submodule update --init --recursive --quiet 2>&1)
SUB_OUT=$(submodule_update_safe)
SUB_RC=$?
set -e
fi