From 9108f9419cbd30aea44a7c1f17dc9dea302be71b Mon Sep 17 00:00:00 2001 From: Howard Enos Date: Mon, 22 Jun 2026 14:30:38 -0700 Subject: [PATCH] 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) --- .claude/scripts/sync.sh | 48 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/.claude/scripts/sync.sh b/.claude/scripts/sync.sh index 7bef1506..f63779cc 100755 --- a/.claude/scripts/sync.sh +++ b/.claude/scripts/sync.sh @@ -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