From 862e566e4c8deb7602cfcd84a7773fe974b7b947 Mon Sep 17 00:00:00 2001 From: Joi Ito Date: Wed, 29 Jul 2026 08:23:30 +0600 Subject: [PATCH] docs(git-ops): add push rejection and merge-overwrite recovery protocol `agents/git-ops.md` had no guidance for the most common way a git operation fails mid-task. Grepping the agent for reject, non-fast-forward, fetch-first, stash, recover, diverge, or force-with-lease returned zero matches, and Remote Operations offered only `git pull --rebase` and `git push -u origin `. With no protocol, a rejected push had two bad outcomes: hand the raw `! [rejected] ... (non-fast-forward)` back to the caller as a failure, or reach for `--force` and overwrite remote work. Adds a Push Rejection Recovery section, deliberately structured so that no recovery command appears before the check that decides whether it is safe: - Step 1 fetches, THEN counts with `git rev-list --left-right --count HEAD...origin/`. The local `origin/` ref is stale until the fetch, so counting first reads the wrong data. - Step 2 is a case table. Remote-only ahead goes to recovery. Both sides non-zero stops. - Step 3 holds the recovery, with the stash conditional on a dirty tree and `git stash pop` conditional on having stashed. Divergence stops rather than auto-recovering, and that is the deliberate design choice in this change. Two different situations produce an identical commit graph: someone else pushed while you worked (rebase is correct), or you amended/squashed/rebased already-pushed commits (rebase replays your rewritten versions over their originals and publishes both). The graph cannot tell them apart -- matching subjects are a hint, not proof, since an amend can change the subject and two people can write the same one. The distinguishing information is whether *you* rewrote those commits, which is the caller's knowledge, not the agent's. So the protocol shows the divergence with `git log --oneline --left-right` and stops. An agent guessing wrong here publishes duplicate history to a shared branch, and over-blocking is cheap. Remote and branch are named explicitly throughout, including in the `--force-with-lease` escape hatch: a rejected `git push -u` never established upstream tracking, so a bare `git pull --rebase` has nothing to rebase onto and `git status -sb` has no ahead/behind to report. Also covered: stop and report on rebase conflict rather than guessing (and never `git rebase --skip`, which silently discards a commit); stop BEFORE pushing if `git stash pop` conflicts, since the rebase can succeed while the caller's pre-existing uncommitted work collides with what was just integrated; and the distinct "local changes would be overwritten" working-tree case. Force-push guidance lives in exactly one place -- the divergence paragraph -- so there is no second rule to contradict it. The Git Safety Protocol lists get "rebase or force-push a diverged branch" under NEVER-without-explicit-request, and "classify a rejected push before recovering it" plus "stop before pushing if restoring a stash conflicted" under ALWAYS. Frontmatter Authoritative-on now lists push rejection so callers route these failures here. The four thin agent copies get one compressed rule rather than the full section, matching their numbered-rule format, but carrying every load-bearing element: fetch before count, stop on divergence, conditional stash pop, stop-on-conflict, no force-push on the agent's own initiative, and never on main/master. This includes the two under experiments/, which are installable runtime surfaces -- their READMEs document `amplifier bundle add ...` and their bundle entrypoints reference `:git-ops`. Closes microsoft/amplifier#288 Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/git-ops.md | 78 ++++++++++++++++++- bundles/anchors-amp-dev/agents/git-ops.md | 3 +- bundles/anchors/agents/git-ops.md | 3 +- .../agents/git-ops.md | 3 +- .../behavioral-anchor/agents/git-ops.md | 3 +- 5 files changed, 85 insertions(+), 5 deletions(-) diff --git a/agents/git-ops.md b/agents/git-ops.md index bbba1616..04d08e3e 100644 --- a/agents/git-ops.md +++ b/agents/git-ops.md @@ -6,7 +6,7 @@ meta: Use PROACTIVELY when: creating commits, opening or managing PRs, branch operations, conflict resolution, GitHub Issues/Releases/Actions interactions, repo discovery, or any git/gh CLI task. - **Authoritative on:** commits, conventional commits, co-author attribution, PRs, branches, merge, rebase, conflicts, GitHub Issues, GitHub Releases, GitHub Actions, gh CLI, repo discovery + **Authoritative on:** commits, conventional commits, co-author attribution, PRs, branches, merge, rebase, conflicts, push rejection, non-fast-forward recovery, GitHub Issues, GitHub Releases, GitHub Actions, gh CLI, repo discovery Context: Agent completed a multi-file implementation task. @@ -114,12 +114,15 @@ Good callers will provide semantic context in their delegation message. Use ever - Skip hooks (--no-verify) - Force push to main/master - Amend commits you didn't create +- Rebase or force-push a branch that has diverged from its remote **ALWAYS do these:** - Check status before committing - Verify branch before pushing - Check authorship before amending - Quote paths with spaces +- Classify a rejected push before recovering it (see Push Rejection Recovery) +- Stop before pushing if restoring a stash conflicted ## Common Git Commands @@ -151,6 +154,79 @@ git pull --rebase # Update from remote git push -u origin # Push with tracking ``` +### Push Rejection Recovery + +`! [rejected] ... (non-fast-forward)` and `Updates were rejected because the remote contains +work that you do not have locally` are recoverable. But the correct recovery depends on *why* +the push was rejected, and the wrong one publishes broken history. Classify before you run +anything. Do not hand the raw error back to the caller as a failure, and do not reach for +`--force`. + +Name the remote and branch explicitly throughout. A rejected `git push -u` never established +upstream tracking, so a bare `git pull --rebase` has nothing to rebase onto and `git status -sb` +has no ahead/behind to report. + +**Step 1 -- fetch, then count.** `origin/` is stale until you fetch, so counting first +reads the wrong data. + +```bash +git fetch origin +git rev-list --left-right --count HEAD...origin/ # +``` + +**Step 2 -- act on the counts.** + +| local-only | remote-only | Meaning | Action | +|------------|-------------|---------|--------| +| 0 | > 0 | Remote moved ahead; you have nothing of your own to preserve | Recover (Step 3) | +| > 0 | > 0 | The branch has diverged | **Stop and report** | +| > 0 | 0 | Not a non-fast-forward rejection | Re-read the actual error | + +**Why divergence always stops.** Two very different situations produce an identical commit +graph. Either someone else pushed while you worked -- in which case rebasing is correct -- or +you amended, squashed, or rebased commits that had already been pushed, in which case the +remote's commits are older copies of your own and `git pull --rebase` will replay your rewritten +versions on top of them and publish both, duplicating history. + +You cannot tell these apart from the graph. Matching commit subjects are a hint, not proof: an +amend can change the subject, and two people can write the same subject. The information that +actually distinguishes them -- whether *you* rewrote those commits -- is the caller's knowledge, +not yours. So show them and stop: + +```bash +git log --oneline --left-right HEAD...origin/ +``` + +If the caller confirms the remote commits are older copies of work they rewrote, the resolution +is `git push --force-with-lease origin ` -- named explicitly, on a branch they own, never +on `main`/`master`, and never on your own initiative. `--force-with-lease` is preferred over +`--force` because it refuses if the remote moved again since your fetch. + +**Step 3 -- recover.** Only for the remote-moved-ahead case. + +```bash +git stash push -u # ONLY if the working tree is dirty +git pull --rebase origin # STOP and report if this conflicts +git stash pop # ONLY if you stashed. STOP if this conflicts +git push -u origin +``` + +**If the rebase conflicts:** stop and report. Name the conflicting paths and what each side +changed, and let the caller decide. Do not guess a resolution. Do not run `git rebase --skip` to +get past it -- that silently discards a commit. + +**If `git stash pop` conflicts:** stop before pushing. The rebase succeeded, but the caller's +uncommitted work now collides with the remote changes you just integrated. `pop` keeps the stash +on conflict, so nothing is lost yet -- confirm with `git stash list`, report the conflicting +paths, and let the caller resolve. Pushing here would publish the rebase while leaving their tree +unresolved, and resolving it yourself with `git checkout --ours/--theirs` risks discarding work +you were never asked to touch. + +**"Your local changes would be overwritten by merge/checkout":** the working tree is in the way, +not the history. Commit or stash the named files, then retry. Never clear this with +`git checkout -- ` or `git reset --hard` unless the caller explicitly asked to discard +those changes. + ## Common GitHub CLI Commands ### Pull Requests diff --git a/bundles/anchors-amp-dev/agents/git-ops.md b/bundles/anchors-amp-dev/agents/git-ops.md index 0a6e7bda..d76305af 100644 --- a/bundles/anchors-amp-dev/agents/git-ops.md +++ b/bundles/anchors-amp-dev/agents/git-ops.md @@ -30,7 +30,8 @@ You handle all git and GitHub CLI operations. 1. Always check `git status` and `git diff` before committing. 2. Write conventional commit messages (`feat:`, `fix:`, `refactor:`, `docs:`). 3. Never force-push to main. -4. End every commit message with: +4. If a push is rejected as non-fast-forward: `git fetch origin` FIRST (the `origin/` ref is stale until you do), then count with `git rev-list --left-right --count HEAD...origin/`. If ONLY the remote is ahead, recover in this order: stash if (and only if) the tree is dirty (`git stash push -u`), `git pull --rebase origin `, `git stash pop` if you stashed, then retry the push -- stopping and reporting if either the rebase or the pop conflicts. If BOTH sides have commits the branch has diverged: stop and report with `git log --oneline --left-right HEAD...origin/`. Do not rebase and do not force-push on your own initiative -- you cannot tell from the graph whether the remote commits are someone else's work or older copies of commits you rewrote, and only the caller knows. If they confirm it is their own rewritten history, the fix is `git push --force-with-lease origin ` on a branch they own, never on main/master. +5. End every commit message with: ``` Generated with Amplifier diff --git a/bundles/anchors/agents/git-ops.md b/bundles/anchors/agents/git-ops.md index 71daa25b..e8fc1b03 100644 --- a/bundles/anchors/agents/git-ops.md +++ b/bundles/anchors/agents/git-ops.md @@ -24,7 +24,8 @@ You handle all git and GitHub CLI operations. 1. Always check `git status` and `git diff` before committing. 2. Write conventional commit messages (`feat:`, `fix:`, `refactor:`, `docs:`). 3. Never force-push to main. -4. End every commit message with: +4. If a push is rejected as non-fast-forward: `git fetch origin` FIRST (the `origin/` ref is stale until you do), then count with `git rev-list --left-right --count HEAD...origin/`. If ONLY the remote is ahead, recover in this order: stash if (and only if) the tree is dirty (`git stash push -u`), `git pull --rebase origin `, `git stash pop` if you stashed, then retry the push -- stopping and reporting if either the rebase or the pop conflicts. If BOTH sides have commits the branch has diverged: stop and report with `git log --oneline --left-right HEAD...origin/`. Do not rebase and do not force-push on your own initiative -- you cannot tell from the graph whether the remote commits are someone else's work or older copies of commits you rewrote, and only the caller knows. If they confirm it is their own rewritten history, the fix is `git push --force-with-lease origin ` on a branch they own, never on main/master. +5. End every commit message with: ``` Generated with Amplifier diff --git a/experiments/behavioral-anchor-amplifier-dev/agents/git-ops.md b/experiments/behavioral-anchor-amplifier-dev/agents/git-ops.md index 0a6e7bda..d76305af 100644 --- a/experiments/behavioral-anchor-amplifier-dev/agents/git-ops.md +++ b/experiments/behavioral-anchor-amplifier-dev/agents/git-ops.md @@ -30,7 +30,8 @@ You handle all git and GitHub CLI operations. 1. Always check `git status` and `git diff` before committing. 2. Write conventional commit messages (`feat:`, `fix:`, `refactor:`, `docs:`). 3. Never force-push to main. -4. End every commit message with: +4. If a push is rejected as non-fast-forward: `git fetch origin` FIRST (the `origin/` ref is stale until you do), then count with `git rev-list --left-right --count HEAD...origin/`. If ONLY the remote is ahead, recover in this order: stash if (and only if) the tree is dirty (`git stash push -u`), `git pull --rebase origin `, `git stash pop` if you stashed, then retry the push -- stopping and reporting if either the rebase or the pop conflicts. If BOTH sides have commits the branch has diverged: stop and report with `git log --oneline --left-right HEAD...origin/`. Do not rebase and do not force-push on your own initiative -- you cannot tell from the graph whether the remote commits are someone else's work or older copies of commits you rewrote, and only the caller knows. If they confirm it is their own rewritten history, the fix is `git push --force-with-lease origin ` on a branch they own, never on main/master. +5. End every commit message with: ``` Generated with Amplifier diff --git a/experiments/behavioral-anchor/agents/git-ops.md b/experiments/behavioral-anchor/agents/git-ops.md index 71daa25b..e8fc1b03 100644 --- a/experiments/behavioral-anchor/agents/git-ops.md +++ b/experiments/behavioral-anchor/agents/git-ops.md @@ -24,7 +24,8 @@ You handle all git and GitHub CLI operations. 1. Always check `git status` and `git diff` before committing. 2. Write conventional commit messages (`feat:`, `fix:`, `refactor:`, `docs:`). 3. Never force-push to main. -4. End every commit message with: +4. If a push is rejected as non-fast-forward: `git fetch origin` FIRST (the `origin/` ref is stale until you do), then count with `git rev-list --left-right --count HEAD...origin/`. If ONLY the remote is ahead, recover in this order: stash if (and only if) the tree is dirty (`git stash push -u`), `git pull --rebase origin `, `git stash pop` if you stashed, then retry the push -- stopping and reporting if either the rebase or the pop conflicts. If BOTH sides have commits the branch has diverged: stop and report with `git log --oneline --left-right HEAD...origin/`. Do not rebase and do not force-push on your own initiative -- you cannot tell from the graph whether the remote commits are someone else's work or older copies of commits you rewrote, and only the caller knows. If they confirm it is their own rewritten history, the fix is `git push --force-with-lease origin ` on a branch they own, never on main/master. +5. End every commit message with: ``` Generated with Amplifier