fix(git-guard): stash-before-commit when dirty tree is also stale vs origin #19

Merged
Bot merged 2 commits from fix-git-guard-stash-before-commit into main 2026-09-09 13:56:05 -07:00
Owner

Problem

deploy/git-guard.sh's dirty-tree branch commits and attempts to push before ever comparing LOCAL/REMOTE/BASE — even though git fetch origin and those three variables are already computed earlier in the script. If the working tree is dirty and stale/diverged from origin/main, git-guard:

  1. Sees dirty → auto-commits on top of the stale HEAD (correct in isolation)
  2. Pushes → rejected (remote has moved)
  3. Only then discovers the divergence, after already creating a doomed commit

Real incident that exposed this (2026-09-09)

A stray on-disk edit to ai/ai.yaml (adding the same flowagent-auth mount line that PR #18 later added properly via Gitea) sat uncommitted on docker-2's checkout, which was already 4 commits behind origin/main. stack-deploy.sh ai ran, git-guard auto-committed the dirty change, then the push was rejected (ahead 1, behind 4 — a genuine divergence), aborting the deploy:

!! WORKING TREE DIRTY — uncommitted changes detected:
M ai/ai.yaml
(non-interactive session — auto-committing and pushing)
[main a402b6d] chore(auto): git-guard autofix...
! [rejected] main -> main (non-fast-forward)
ERROR: push failed (likely diverged from origin). Aborting deploy.

Recovery required manually diffing the stray commit against origin/main (confirmed byte-identical to PR #18's already-merged content) and git reset --hard origin/main before re-running the deploy.

Note: this is a distinct bug from #17 (which fixed a phantom "verify" failure from secrets/ being misdetected as a stack). #17 never touched this dirty+stale interaction.

Fix

  • Factored the existing behind/ahead/true-divergence handling out of the "clean tree" tail into a shared resync_with_origin() function — behavior is byte-for-byte identical to before, just reusable.
  • In the dirty-tree branch, added a check: if LOCAL != REMOTE (tree is dirty and stale/diverged), do not commit yet. Instead:
    1. git stash push -u -m "git-guard-safety-stash-<timestamp>" — dirty changes are never lost
    2. Call resync_with_origin() to safely fast-forward / push-if-ahead / refuse-if-truly-diverged, using the exact same rules as before
    3. On successful resync: git stash pop and exec "$0" "$@" to re-run cleanly from the top
    4. On stash-pop conflict: bail with exit 1, stash preserved, exact recovery commands printed (git stash list, manual conflict resolution, git stash drop)
    5. On resync failure (e.g. true divergence): bail with exit 1, stash preserved, message directs to git stash pop after manual recovery

If the tree is dirty but LOCAL == REMOTE (the common case — local is current, just has an uncommitted edit), behavior is completely unchanged: same commit + push + secret-scan flow as before.

Risk / blast radius

  • deploy/git-guard.sh is invoked by every stack-deploy.sh run, so this touches all deploys — but the changed code path only activates in the specific dirty+stale combination that previously caused a bad auto-commit. The far more common dirty-but-current and clean-but-stale paths are unchanged (literally the same code, just extracted into a function).
  • No new external dependencies, no secret handling changes, no change to the secret-pattern scan.
  • Worth a dry run / manual staging validation of the new stash path before the next real dirty+stale scenario, since it's a genuinely new code path.

Known follow-up (not in scope here)

We still don't know how the stray on-disk edit got onto docker-2's checkout in the first place — the directory is already root:root mode 700, so it required either a manual root SSH session or an unidentified process writing directly to the deploy-only mirror, which violates the "Gitea is the only source of truth, checkout is deploy-only" rule. This PR makes git-guard safe against that scenario recurring, but doesn't prevent the underlying policy violation. Tracked separately in the FlowAgent progress note.

## Problem `deploy/git-guard.sh`'s dirty-tree branch commits and attempts to push **before** ever comparing `LOCAL`/`REMOTE`/`BASE` — even though `git fetch origin` and those three variables are already computed earlier in the script. If the working tree is dirty *and* stale/diverged from `origin/main`, git-guard: 1. Sees dirty → auto-commits on top of the stale HEAD (correct in isolation) 2. Pushes → **rejected** (remote has moved) 3. Only then discovers the divergence, after already creating a doomed commit ### Real incident that exposed this (2026-09-09) A stray on-disk edit to `ai/ai.yaml` (adding the same `flowagent-auth` mount line that PR #18 later added properly via Gitea) sat uncommitted on docker-2's checkout, which was already 4 commits behind `origin/main`. `stack-deploy.sh ai` ran, git-guard auto-committed the dirty change, then the push was rejected (`ahead 1, behind 4` — a genuine divergence), aborting the deploy: ``` !! WORKING TREE DIRTY — uncommitted changes detected: M ai/ai.yaml (non-interactive session — auto-committing and pushing) [main a402b6d] chore(auto): git-guard autofix... ! [rejected] main -> main (non-fast-forward) ERROR: push failed (likely diverged from origin). Aborting deploy. ``` Recovery required manually diffing the stray commit against `origin/main` (confirmed byte-identical to PR #18's already-merged content) and `git reset --hard origin/main` before re-running the deploy. **Note:** this is a distinct bug from #17 (which fixed a phantom "verify" failure from `secrets/` being misdetected as a stack). #17 never touched this dirty+stale interaction. ## Fix - Factored the existing behind/ahead/true-divergence handling out of the "clean tree" tail into a shared `resync_with_origin()` function — behavior is byte-for-byte identical to before, just reusable. - In the dirty-tree branch, added a check: if `LOCAL != REMOTE` (tree is dirty **and** stale/diverged), do **not** commit yet. Instead: 1. `git stash push -u -m "git-guard-safety-stash-<timestamp>"` — dirty changes are never lost 2. Call `resync_with_origin()` to safely fast-forward / push-if-ahead / refuse-if-truly-diverged, using the exact same rules as before 3. On successful resync: `git stash pop` and `exec "$0" "$@"` to re-run cleanly from the top 4. On stash-pop conflict: bail with exit 1, stash preserved, exact recovery commands printed (`git stash list`, manual conflict resolution, `git stash drop`) 5. On resync failure (e.g. true divergence): bail with exit 1, stash preserved, message directs to `git stash pop` after manual recovery If the tree is dirty but `LOCAL == REMOTE` (the common case — local is current, just has an uncommitted edit), behavior is **completely unchanged**: same commit + push + secret-scan flow as before. ## Risk / blast radius - `deploy/git-guard.sh` is invoked by every `stack-deploy.sh` run, so this touches all deploys — but the changed code path only activates in the specific dirty+stale combination that previously caused a bad auto-commit. The far more common dirty-but-current and clean-but-stale paths are unchanged (literally the same code, just extracted into a function). - No new external dependencies, no secret handling changes, no change to the secret-pattern scan. - Worth a dry run / manual staging validation of the new stash path before the next real dirty+stale scenario, since it's a genuinely new code path. ## Known follow-up (not in scope here) We still don't know *how* the stray on-disk edit got onto docker-2's checkout in the first place — the directory is already `root:root` mode `700`, so it required either a manual root SSH session or an unidentified process writing directly to the deploy-only mirror, which violates the "Gitea is the only source of truth, checkout is deploy-only" rule. This PR makes git-guard *safe* against that scenario recurring, but doesn't prevent the underlying policy violation. Tracked separately in the FlowAgent progress note.
Bot added 1 commit 2026-09-09 10:42:49 -07:00
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.
Bot added 1 commit 2026-09-09 13:47:16 -07:00
Adds the unmerged-conflict guard flagged in PR review: before the dirty-tree
flow can run, check `git ls-files -u` and refuse to auto-commit if unresolved
merge conflict markers are present (e.g. left behind by a prior run's failed
`git stash pop`). Without this, a re-run's `git add -A` would silently stage
literal <<<<<<< / ======= / >>>>>>> markers into a real commit and push them
to origin/main.

Also expands every failure-path message (stash-push failure, stash-pop
conflict vs. untracked-file-collision, resync failure, true divergence,
manual-abort cases) to include concrete, copy-pasteable remediation command
sequences with multiple options (resolve-in-place vs. abandon-and-reapply vs.
drop-if-unneeded), rather than a single generic hint. The true-divergence
message now also tells the operator to check `git stash list` first, so a
stash created by the new dirty+stale path isn't confused with a second
manually-created one.

No change to the common dirty-but-current or clean-but-stale behavior.
Bot merged commit d72a8ebd04 into main 2026-09-09 13:56:05 -07:00
Bot deleted branch fix-git-guard-stash-before-commit 2026-09-09 13:56:05 -07:00
Sign in to join this conversation.