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:
Sees dirty → auto-commits on top of the stale HEAD (correct in isolation)
Pushes → rejected (remote has moved)
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:
git stash push -u -m "git-guard-safety-stash-<timestamp>" — dirty changes are never lost
Call resync_with_origin() to safely fast-forward / push-if-ahead / refuse-if-truly-diverged, using the exact same rules as before
On successful resync: git stash pop and exec "$0" "$@" to re-run cleanly from the top
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.
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.
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 main2026-09-09 13:56:05 -07:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Problem
deploy/git-guard.sh's dirty-tree branch commits and attempts to push before ever comparingLOCAL/REMOTE/BASE— even thoughgit fetch originand those three variables are already computed earlier in the script. If the working tree is dirty and stale/diverged fromorigin/main, git-guard:Real incident that exposed this (2026-09-09)
A stray on-disk edit to
ai/ai.yaml(adding the sameflowagent-authmount line that PR #18 later added properly via Gitea) sat uncommitted on docker-2's checkout, which was already 4 commits behindorigin/main.stack-deploy.sh airan, git-guard auto-committed the dirty change, then the push was rejected (ahead 1, behind 4— a genuine divergence), aborting the deploy:Recovery required manually diffing the stray commit against
origin/main(confirmed byte-identical to PR #18's already-merged content) andgit reset --hard origin/mainbefore 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
resync_with_origin()function — behavior is byte-for-byte identical to before, just reusable.LOCAL != REMOTE(tree is dirty and stale/diverged), do not commit yet. Instead:git stash push -u -m "git-guard-safety-stash-<timestamp>"— dirty changes are never lostresync_with_origin()to safely fast-forward / push-if-ahead / refuse-if-truly-diverged, using the exact same rules as beforegit stash popandexec "$0" "$@"to re-run cleanly from the topgit stash list, manual conflict resolution,git stash drop)git stash popafter manual recoveryIf 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.shis invoked by everystack-deploy.shrun, 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).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:rootmode700, 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.