From bd69cc85d5b9ab471c7281eb24701cc745d56db2 Mon Sep 17 00:00:00 2001 From: Bot Date: Wed, 9 Sep 2026 10:42:21 -0700 Subject: [PATCH] fix(git-guard): stash-before-commit when dirty tree is also stale vs origin Previously the dirty-tree branch committed unconditionally, then only discovered staleness/divergence when the push was rejected -- leaving a doomed auto-commit sitting on a stale base. Root-caused from a real incident: a stray on-disk edit to ai/ai.yaml sat on a checkout that was already 4 commits behind, git-guard auto-committed on top of it, then the push was rejected (ahead 1, behind 4), aborting the deploy. Fix: when DIRTY=1 and LOCAL != REMOTE, stash the dirty changes first, resync main with origin using the exact same behind/ahead/diverged rules as the clean-tree path (now shared via resync_with_origin()), then reapply the stash and re-run. Never commits on top of a stale base again. On any failure the stash is preserved and printed for manual recovery -- changes are never silently lost. --- deploy/git-guard.sh | 199 +++++++++++++++++++++++++++++--------------- 1 file changed, 133 insertions(+), 66 deletions(-) diff --git a/deploy/git-guard.sh b/deploy/git-guard.sh index ab0f622..4fed859 100644 --- a/deploy/git-guard.sh +++ b/deploy/git-guard.sh @@ -5,10 +5,18 @@ # Behavior: # - Clean + up to date -> pass silently # - Clean + behind (ff-only) -> auto `git pull --ff-only`, then pass -# - Ahead only (unpushed) -> interactive: offer to push; non-interactive: BLOCK -# - Dirty tracked changes -> offer to commit + push right now -# (auto in non-interactive/CI runs, after a +# - Clean + ahead only -> interactive: offer to push; non-interactive: BLOCK +# (unpushed) +# - Dirty + local in sync -> offer to commit + push right now +# with origin (auto in non-interactive/CI runs, after a # secret-pattern scan of the staged diff) +# - Dirty + local STALE/ -> NEVER commit on top of a stale base. Stash +# diverged vs origin the dirty changes first, resync main with +# origin using the same behind/ahead/diverged +# rules as the clean-tree case, then reapply +# the stash and re-run. On any failure the +# stash is preserved and printed for manual +# recovery. # - Diverged (local AND -> REFUSE. Never auto-resolves. Prints the # remote both moved) backup/stash/reset recovery steps and exits. # @@ -36,12 +44,130 @@ git status --porcelain | grep -q . && DIRTY=1 SECRET_PATTERN='(-----BEGIN [A-Z]+ PRIVATE KEY-----|AKIA[0-9A-Z]{16}|xox[baprs]-[0-9a-zA-Z-]+|password[[:space:]]*[:=][[:space:]]*[^$ ]|api[_-]?key[[:space:]]*[:=][[:space:]]*[^$ ])' +# resync_with_origin +# +# Handles the behind/ahead/diverged cases against a CLEAN working tree. +# Shared by both the "tree was already clean" path and the new +# "dirty tree turned out to be stale, so we stashed first" path, so the +# two paths can never drift out of sync with each other. +# +# Returns 0 if it's now safe to deploy, 1 if it could not safely resolve +# (guidance already printed to stdout in that case). +resync_with_origin() { + local local_sha="$1" remote_sha="$2" base_sha="$3" + + # ---- Case: fully in sync ---- + if [ "$local_sha" = "$remote_sha" ]; then + echo "==> In sync with origin/main ($local_sha). OK to deploy." + return 0 + fi + + # ---- Case: behind only (fast-forwardable) ---- + if [ "$local_sha" = "$base_sha" ]; then + echo "!! Local main is behind origin/main." + if [ "$INTERACTIVE" -eq 1 ]; then + read -rp "Fast-forward pull now? [y/N] " ans + else + ans="y" + echo "(non-interactive session — auto fast-forwarding)" + fi + if [[ "$ans" =~ ^[Yy]$ ]]; then + git pull --ff-only origin main + echo "==> Fast-forwarded to $(git rev-parse --short main). OK to deploy." + return 0 + else + echo "Aborting - pull manually, then retry." + return 1 + fi + fi + + # ---- Case: ahead only (local commits not yet pushed) ---- + if [ "$remote_sha" = "$base_sha" ]; then + echo "!! Local main is AHEAD of origin/main (unpushed commits):" + git log --oneline "origin/main..main" + echo + if [ "$INTERACTIVE" -eq 1 ]; then + read -rp "Push local commits to origin/main now? [y/N] " ans + else + ans="n" + echo "(non-interactive session — will NOT auto-push ahead commits; needs human review)" + fi + if [[ "$ans" =~ ^[Yy]$ ]]; then + git push origin main + echo "==> Pushed. OK to deploy." + return 0 + else + echo "Aborting. Review with: git log origin/main..main" + return 1 + fi + fi + + # ---- Case: true divergence (both ahead and behind) — NEVER auto-fix ---- + echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" + echo "!! DIVERGED: local main and origin/main have both moved independently." + echo "!!" + echo "!! Local-only commits:" + git log --oneline "$base_sha..main" | sed 's/^/!! /' + echo "!!" + echo "!! Remote-only commits:" + git log --oneline "$base_sha..origin/main" | sed 's/^/!! /' + echo "!!" + echo "!! This requires a human decision - git-guard will NOT auto-resolve this." + echo "!! Recommended recovery:" + echo "!! 1. tar backup: tar czf /volume1/docker/compose-files-backup-\$(date +%Y%m%d-%H%M%S).tar.gz -C /volume1/docker compose-files" + echo "!! 2. name the branch: git branch backup/pre-reset-\$(date +%Y%m%d)" + echo "!! 3. stash all state: git stash push -u -m 'pre-reset-snapshot'" + echo "!! 4. reset to origin: git reset --hard origin/main" + echo "!! 5. selectively restore needed files from the stash/backup branch" + echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" + return 1 +} + # ---- Case: dirty tracked changes ---- if [ "$DIRTY" -eq 1 ]; then echo "!! WORKING TREE DIRTY — uncommitted changes detected:" git status --short echo + # NEW: if local is ALSO stale/diverged from origin, committing right now + # would create a doomed commit on top of a base that's about to be + # rejected on push (this is exactly what caused a real incident: a stray + # on-disk edit sat on a checkout that was 4 commits behind, git-guard + # auto-committed anyway, then the push bounced). Stash first, resync + # safely using the same rules as the clean-tree path, then reapply. + if [ "$LOCAL" != "$REMOTE" ]; then + echo "!! Local main is ALSO stale/diverged from origin/main." + echo " Refusing to commit on top of a stale base — stashing the dirty" + echo " changes safely first, then resyncing with origin." + echo + + STASH_MSG="git-guard-safety-stash-$(date -u +%Y%m%dT%H%M%SZ)" + git stash push -u -m "$STASH_MSG" + echo "==> Stashed as: $STASH_MSG" + + if resync_with_origin "$LOCAL" "$REMOTE" "$BASE"; then + echo "==> Resync succeeded. Reapplying stashed changes..." + if git stash pop; then + echo "==> Stash reapplied cleanly. Re-checking sync state..." + exec "$0" "$@" + else + echo "ERROR: stash pop produced conflicts. Your changes are safe in the" + echo " stash but could not be auto-reapplied on the resynced base." + echo "Resolve manually:" + echo " cd $DIR" + echo " git status" + echo " git stash list # look for: $STASH_MSG" + echo " # resolve conflicts, then: git stash drop" + exit 1 + fi + else + echo "ERROR: could not safely resync with origin/main." + echo "Your uncommitted changes are preserved in the stash: $STASH_MSG" + echo "Resolve manually, then run: git stash pop" + exit 1 + fi + fi + if [ "$INTERACTIVE" -eq 1 ]; then read -rp "Commit and push these changes to origin/main now? [y/N] " ans else @@ -75,68 +201,9 @@ if [ "$DIRTY" -eq 1 ]; then fi fi -# ---- Case: fully in sync ---- -if [ "$LOCAL" = "$REMOTE" ]; then - echo "==> In sync with origin/main ($LOCAL). OK to deploy." +# ---- Clean tree: resync with origin using the shared logic above ---- +if resync_with_origin "$LOCAL" "$REMOTE" "$BASE"; then exit 0 +else + exit 1 fi - -# ---- Case: behind only (fast-forwardable) ---- -if [ "$LOCAL" = "$BASE" ]; then - echo "!! Local main is behind origin/main." - if [ "$INTERACTIVE" -eq 1 ]; then - read -rp "Fast-forward pull now? [y/N] " ans - else - ans="y" - echo "(non-interactive session — auto fast-forwarding)" - fi - if [[ "$ans" =~ ^[Yy]$ ]]; then - git pull --ff-only origin main - echo "==> Fast-forwarded to $(git rev-parse --short main). OK to deploy." - exit 0 - else - echo "Aborting deploy - pull manually, then retry." - exit 1 - fi -fi - -# ---- Case: ahead only (local commits not yet pushed) ---- -if [ "$REMOTE" = "$BASE" ]; then - echo "!! Local main is AHEAD of origin/main (unpushed commits):" - git log --oneline "origin/main..main" - echo - if [ "$INTERACTIVE" -eq 1 ]; then - read -rp "Push local commits to origin/main now? [y/N] " ans - else - ans="n" - echo "(non-interactive session — will NOT auto-push ahead commits; needs human review)" - fi - if [[ "$ans" =~ ^[Yy]$ ]]; then - git push origin main - echo "==> Pushed. OK to deploy." - exit 0 - else - echo "Aborting deploy. Review with: git log origin/main..main" - exit 1 - fi -fi - -# ---- Case: true divergence (both ahead and behind) — NEVER auto-fix ---- -echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" -echo "!! DIVERGED: local main and origin/main have both moved independently." -echo "!!" -echo "!! Local-only commits:" -git log --oneline "$BASE..main" | sed 's/^/!! /' -echo "!!" -echo "!! Remote-only commits:" -git log --oneline "$BASE..origin/main" | sed 's/^/!! /' -echo "!!" -echo "!! This requires a human decision - git-guard will NOT auto-resolve this." -echo "!! Recommended recovery:" -echo "!! 1. tar backup: tar czf /volume1/docker/compose-files-backup-\$(date +%Y%m%d-%H%M%S).tar.gz -C /volume1/docker compose-files" -echo "!! 2. name the branch: git branch backup/pre-reset-\$(date +%Y%m%d)" -echo "!! 3. stash all state: git stash push -u -m 'pre-reset-snapshot'" -echo "!! 4. reset to origin: git reset --hard origin/main" -echo "!! 5. selectively restore needed files from the stash/backup branch" -echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" -exit 1