Skip to content

docs(git-ops): add push rejection and merge-overwrite recovery protocol - #287

Open
Joi Ito (Joi) wants to merge 1 commit into
microsoft:mainfrom
Joi:docs/git-ops-push-rejection-recovery
Open

docs(git-ops): add push rejection and merge-overwrite recovery protocol#287
Joi Ito (Joi) wants to merge 1 commit into
microsoft:mainfrom
Joi:docs/git-ops-push-rejection-recovery

Conversation

@Joi

Copy link
Copy Markdown

Problem

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 two lines:

git pull --rebase            # Update from remote
git push -u origin <branch>  # Push with tracking

With no protocol, a rejected push had two bad outcomes: hand the raw ! [rejected] ... (non-fast-forward) back to the caller as a hard failure, or reach for --force and overwrite remote work.

Reported in microsoft/amplifier#288.

Approach

Adds a Push Rejection Recovery section, structured so 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/<branch>. The local origin/<branch> 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 actual recovery, with the stash conditional on a dirty tree and git stash pop conditional on having stashed.

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; and the distinct "local changes would be overwritten" working-tree case, which is a working-tree problem rather than a history problem.

The design decision worth reviewing: divergence stops

A diverged branch does not auto-recover. That is deliberate, and it is the part I would most like a second opinion on.

Two different situations produce an identical commit graph:

  1. Someone else pushed while you worked — rebasing is correct.
  2. You amended, squashed, or rebased commits that had already been pushed — the remote's commits are older copies of your own, and git pull --rebase replays your rewritten versions on top of them and publishes both, duplicating history.

The graph cannot tell these apart. I tried two mechanical classifiers and both were unsound: matching commit subjects are a hint rather than proof, since an amend can change the subject and two people can independently write the same one. The information that actually distinguishes the cases — whether you rewrote those commits — is the caller's knowledge, not the agent's.

So the protocol shows the divergence with git log --oneline --left-right HEAD...origin/<branch> and stops. If the caller confirms the remote commits are older copies of work they rewrote, the resolution is git push --force-with-lease origin <branch> — named explicitly, on a branch they own, never on main/master, never on the agent's own initiative.

Over-blocking is cheap here. An agent guessing wrong publishes duplicate history to a shared branch.

Consistency notes

  • Force-push guidance lives in exactly one place (the divergence paragraph) so there is no second rule to contradict it. An earlier draft had a flat "never force-push" in the ALWAYS list alongside the --force-with-lease escape hatch — a real internal contradiction in executable guidance.
  • Remote and branch are named explicitly throughout, including in the force path. 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.
  • Git Safety Protocol gains "rebase or force-push a diverged branch" under NEVER-without-explicit-request, plus "classify a rejected push before recovering it" and "stop before pushing if restoring a stash conflicted" under ALWAYS.
  • Frontmatter Authoritative on: now lists push rejection so callers route these failures here.

Scope: which copies changed

git-ops.md exists in five places. The four thin variants 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, never on main/master.

Path Change
agents/git-ops.md Full section
bundles/anchors/agents/git-ops.md Compressed rule
bundles/anchors-amp-dev/agents/git-ops.md Compressed rule
experiments/behavioral-anchor/agents/git-ops.md Compressed rule
experiments/behavioral-anchor-amplifier-dev/agents/git-ops.md Compressed rule

The two under experiments/ are included because they are installable runtime surfaces, not inert samples — their READMEs document amplifier bundle add 'git+https://github.com/microsoft/amplifier-foundation@main#subdirectory=experiments/...' and their bundle entrypoints reference <bundle>:git-ops. Happy to drop them from this PR if you would rather keep experiments frozen.

How to verify

This is prompt/documentation content, so there is nothing to run — existing tests cover frontmatter and metadata shape, not runbook semantics. Review is the verification. The specific claims to check are:

  • git rev-list --left-right --count HEAD...origin/<branch> after git fetch origin correctly reports <local-only> <remote-only>.
  • git stash pop retains the stash on conflict (so the "nothing is lost yet" claim holds).
  • git push --force-with-lease refuses when the remote moved after your fetch.

This went through five rounds of independent review; the divergence-stops design is where it landed after two unsound classifier attempts were rejected. If you would prefer the protocol attempt classification anyway, say so and I will rework it — but I could not find a sound test.

`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 <branch>`.

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/<branch>`. The local
  `origin/<branch>` 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 `<bundle>:git-ops`.

Closes microsoft/amplifier#288

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant