Skip to content

Stack 1/3: fix(spr): detect maintainer edits to PR head branches before force-push#516

Open
jucor wants to merge 2 commits into
ejoffe:masterfrom
jucor:maintainer-edit-detection
Open

Stack 1/3: fix(spr): detect maintainer edits to PR head branches before force-push#516
jucor wants to merge 2 commits into
ejoffe:masterfrom
jucor:maintainer-edit-detection

Conversation

@jucor

@jucor jucor commented Jun 10, 2026

Copy link
Copy Markdown

Stack 1/3 — maintainer-edit-protection sub-stack:

  1. Stack 1/3 (this PR) — detect divergence, refuse with a per-PR resolution guide.
  2. Stack 2/3 (#517) — auto-fold the maintainer's commits into the matching local commit (merge policy).
  3. Stack 3/3 (#518) — preserve maintainer authorship via Co-authored-by: trailers + post a PR comment after each fold.

Each PR is independently reviewable; the later ones build on this one and only make sense after it lands.

Problem

spr update silently force-pushes the local stack over any commits a maintainer added to a PR head branch (e.g. via GitHub's "Allow edits by maintainers"). commitUpdated is a symmetric SHA inequality with no awareness of which side is ahead; the push is unconditional --force. Local always wins, maintainer work disappears silently.

Fix (Stage 1)

Detect divergence pre-push, refuse with a per-PR resolution guide. Stage 2 (auto-fold) is a separate follow-up.

Walk back from each remote head looking for the local commit's commit-id: trailer:

  • found at tip, SHA differs from local → amend-in-place, indistinguishable from a user amend, proceed.
  • found N>0 commits back → those N commits are likely the maintainer's, flag DivergenceForeignCommits.
  • different commit-id: trailer first → DivergenceCidMismatch.
  • depth (20) exhausted → DivergenceCidNotFound.

Stateless; uses an explicit per-ref fetch so a restrictive remote.origin.fetch can't disable detection.

Policy

New onRemoteDivergence config on RepoConfig:

  • ask (default) — interactive prompt on TTY; on non-TTY, refuse with copy-pasteable resolution commands.
  • drop — silently force-push local (pre-fix behavior, explicit opt-in).
  • merge — reserved for Stack 2/3; currently falls back to ask.

Push switches from --force to --force-with-lease, closing the race between detection and push.

Tests

  • Layer 1 (pure, git/divergence_test.go): 13 algorithm tests + 9 trailer-extraction sub-cases.
  • Layer 2 (I/O, git/divergence_io_test.go): 5 tests of ReadRemoteHeads driven against mockgit.
  • Policy (spr/divergence_test.go): 12 tests of applyDivergencePolicy and the TTY/prompter paths.
  • Integration: 19 existing spr_test.go tests updated with ExpectDivergenceCheckClean.

Known limitation

Maintainer changes to a PR's base branch (via the GitHub UI) are silently reset by UpdatePullRequest. Same class of bug, much rarer in practice — readme calls it out, separate follow-up.


A note on size

+1247/-7. Roughly half is tests (593 LOC), a third is the algorithm (271) and policy/UI (215), the rest is mockgit helpers (103), wiring (35), config (19), docs (13).

Suggested review order: git/divergence.go::DetectDivergence and spr/divergence.go::applyDivergencePolicy capture the whole behaviour. Tests organised one-layer-per-file; skim by file.


Full transparency: I made heavy use of Claude Code (Opus 4.7, high effort) for this PR, as I do for my own work these days.

jucor added 2 commits June 10, 2026 10:18
goreleaser v2.16.0 (2026-05-24) turned the deprecated `brews:` key in
.goreleaser.yml into a hard error. With `version: latest`, every push
since fails CI's `goreleaser check` step regardless of the code change.

v2.15.4 (2026-04-21) is the latest release that still accepts `brews:`
with only a warning and also understands the current
`archives.format_overrides.formats` syntax. Pinning to it restores green
CI without touching .goreleaser.yml.

## Migration path (for whoever unpins)

The goreleaser deprecation page recommends migrating `brews:` →
`homebrew_casks:`. For simple cases this is a key rename, plus a
`hooks.post.install` block to strip the macOS quarantine xattr (because
the binaries aren't signed/notarized).

End-user impact:

- macOS: transparent. The binary still lands at
  `/opt/homebrew/bin/git-spr` (via cask `binary` stanza) — same PATH,
  same usage. The install command may need `--cask` explicitly in the
  readme.
- Linux: regression. Casks are macOS-only; Linux brew users currently
  installing via `brew install ejoffe/tap/spr` would have to switch to
  another channel (binary release, apt, Nix, source).
- Tap repo: `Formula/spr.rb` becomes `Casks/spr.rb`. The
  `ejoffe/homebrew-tap` repo would need a transition plan.

Alternative: drop `brews:` entirely and remove the brew install path
from the readme. Simpler but breaks the channel outright.

Either way it's a deliberate distribution decision, not a mechanical CI
fix, which is why this PR pins rather than migrates.

Unpin once the `brews:` block is migrated or removed.
…sh (Stage 1)

Before this change `spr update` silently force-pushed the local stack over
any commits that had been added to PR head branches on the remote — for
example, commits a maintainer pushed via GitHub's "Allow edits by
maintainers" toggle. The check in `syncCommitStackToGitHub::commitUpdated`
was a symmetric SHA inequality with no awareness of which side was ahead,
and the push was an unconditional `--force`. Whichever side had the newer
SHA, local always won; the maintainer's work disappeared without warning.

This change introduces Stage 1 of the fix: detect divergence pre-push,
refuse with a per-PR resolution guide that spells out the manual
fold-into-local-commit recipe. Stage 2 (automatic fold) will follow as a
separate PR.

Detection algorithm (stateless, lives in `git.DetectDivergence`):

  For each local commit X with commit-id <cid>:
    branch = "<prefix>/<target>/<cid>"
    if remote tip == local SHA: skip
    walk back up to MaxDivergenceWalkDepth commits from remote tip,
    looking for a commit carrying our commit-id trailer:
      - found at the tip: amend-in-place; can't be distinguished from
        a local user amend without per-branch state. Proceed.
      - found N>0 commits back: those N commits are likely the
        maintainer's contribution. Flag as DivergenceForeignCommits.
      - hit a commit with a *different* commit-id trailer first:
        flag DivergenceCidMismatch (cherry-pick or hand-edit).
      - exhausted depth without a match: flag DivergenceCidNotFound
        (deep rewrite).

Detection runs against the raw local stack so it sees every commit that
might have a corresponding remote head ref, not just the head-of-PR
subset that alignLocalCommits returns later. It also uses an explicit
per-branch fetch refspec so a restrictive `remote.origin.fetch` can't
silently disable detection.

Policy is config-driven via the new `onRemoteDivergence` field on
RepoConfig:

  "ask"   (default) — interactive prompt on TTY; on non-TTY, refuse with
                      the resolution guide. The guide prints
                      copy-pasteable git commands that preserve the
                      commit-id trailer.
  "drop"            — silently force-push local (the pre-fix behavior;
                      kept as an explicit opt-in for users who genuinely
                      don't want maintainer edits incorporated).
  "merge"           — reserved for Stage 2 auto-fold. Until that lands,
                      falls back to "ask" with a printed notice.

The push itself is switched from `git push --force` to
`git push --force-with-lease`, which catches the same-detection-window
race where the maintainer pushes between our fetch and our push.
Justifies itself outside the maintainer-edit story too (concurrent spr
invocations, manual pushes between runs).

Test layering:

  Layer 1 (pure):  git/divergence_test.go covers DetectDivergence over
                   every branch (no-divergence, foreign-commits-on-top,
                   mid-stack side branch, multiple PRs at once, cid
                   mismatch, depth cap, amend-in-place, custom prefix).
                   13 tests + 9 commit-id-extraction sub-cases.

  Layer 2 (I/O):   git/divergence_io_test.go drives ReadRemoteHeads
                   through mockgit, verifying the batched explicit-refspec
                   fetch, the per-ref `git log -z` walk, the parse, and
                   the missing-ref/default-depth/non-default-remote
                   variants. 5 tests.

  Policy:          spr/divergence_test.go exercises applyDivergencePolicy
                   for each value of OnRemoteDivergence, with an injected
                   prompter for the ask path and TTY-detection routed
                   through the bytes.Buffer-as-non-TTY trick.
                   12 tests.

  Integration:     19 existing tests in spr_test.go updated with the new
                   ExpectDivergenceCheckClean expectation (no-op-by-shape
                   divergence detection that matches the local stack
                   exactly — confirms detection runs and stays silent
                   when there's nothing for it to flag).

The readme gains an "onRemoteDivergence" config row and a
"Maintainer edits to PR head branches" subsection explaining the model
and the deferred base-change limitation (separate follow-up).

Known limitation (called out in the readme): a maintainer changing the
*base* branch of a PR via the GitHub UI is silently reset by spr's
UpdatePullRequest call. Same class of bug as the head-branch wipe but
rarer in practice; addressed in a separate change.
@jucor jucor marked this pull request as ready for review June 10, 2026 09:44
@jucor jucor changed the title fix(spr): detect maintainer edits to PR head branches before force-push (Stage 1) Stack 1/3: fix(spr): detect maintainer edits to PR head branches before force-push Jun 10, 2026
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