Skip to content

feat(pusher): add Pusher extension and wire it into the merge controller#151

Merged
behinddwalls merged 1 commit into
mainfrom
sergeyb/merge
May 8, 2026
Merged

feat(pusher): add Pusher extension and wire it into the merge controller#151
behinddwalls merged 1 commit into
mainfrom
sergeyb/merge

Conversation

@sbalabanov
Copy link
Copy Markdown
Contributor

Summary

  • New extension/pusher interface with all-or-nothing Push semantics, per-change outcomes (committed vs already-existed), and an ErrConflict sentinel for user-caused failures.
  • Git-backed implementation that fetches → resets → cherry-picks → pushes, serialized via mutex on the shared checkout. Detects redundant picks and rolls back genuinely empty commits so they don't reach the remote.
  • Concurrent-push contention is detected by re-checking the remote tip after a push failure (rather than parsing git error output, which varies across versions and rejection sources). The full cycle is retried up to Params.MaxPushAttempts (default 10).
  • Merge controller now calls Pusher.Push, transitions the batch to Succeeded/Failed, and nacks transient infra errors. Three outcome cases are inlined (success, ErrConflict → Failed, generic error → return) — retryability classification will live in separate infra.
  • example/server/orchestrator wires a git pusher from PUSHER_CHECKOUT_PATH / PUSHER_REMOTE / PUSHER_TARGET env vars.

Test plan

  • make test — 30 unit tests pass, including new pusher and rewritten merge controller suites
  • Real-git fixture tests for the pusher cover single/stacked URIs, already-existed, mixed-partial, conflict, recovery-after-conflict, reset-between-calls, retry-on-contention, and giveup-after-cap
  • Pre-receive race hook drives the retry loop end-to-end with real git mechanics (unsets GIT_QUARANTINE_PATH/GIT_OBJECT_DIRECTORY/GIT_ALTERNATE_OBJECT_DIRECTORIES to mutate refs from inside pre-receive)
  • make fmt, make gazelle, make tidy — tree clean

🤖 Generated with Claude Code

@sbalabanov sbalabanov requested review from a team and behinddwalls as code owners May 6, 2026 22:00
Comment thread extension/pusher/pusher.go
Comment thread extension/pusher/git/git_pusher.go
Comment thread extension/pusher/git/git_pusher.go Outdated
## Summary
Introduces the `extension/pusher` interface that lands a list of
`entity.Change` values onto a target branch with all-or-nothing
atomicity, and a git-backed implementation that operates against a
local checkout.

The merge controller now calls `Pusher.Push` for each batch, transitions
the batch to Succeeded on success or Failed on `pusher.ErrConflict`, and
nacks any other push error so the queue can retry.

## Pusher interface (extension/pusher)
- `Push([]Change) (Result, error)` with an explicit atomicity contract:
  on a non-nil error nothing has reached the remote.
- Per-change `ChangeOutcome` reports either `OutcomeStatusCommitted`
  with the produced commit SHAs in apply order (one Change can land as
  multiple commits, e.g. a stack), or `OutcomeStatusAlreadyExisted`
  with no commits when the change is already present on the target.
- `ErrConflict` sentinel marks user-caused apply failures so callers
  can route them to a non-retry path.

## git implementation (extension/pusher/git)
- Per-Push cycle: fetch -> reset --hard origin/<target> -> cherry-pick
  every URI's head SHA -> push HEAD to refs/heads/<target>.
- Cherry-pick uses `--allow-empty` and recovers from "previous
  cherry-pick is now empty" via `--skip`; genuinely empty resulting
  commits are rolled back. Both surface as `AlreadyExisted`.
- Empty-commit detection compares tree SHAs read via
  `git cat-file commit` rather than relying on `diff-tree --quiet`'s
  exit code 1, which has multiple meanings.
- A mutex serializes concurrent invocations against the shared
  checkout.
- Push is wrapped with `core/metrics` Begin/Complete so the operation
  emits the standard push.called / push.succeeded / push.failed /
  push.latency / push.latency_histogram with error-classification
  tags. Sub-event counters (push.empty_changes, push.reset_errors,
  push.cherry_pick_conflicts, push.git_push_errors,
  push.stale_base_retries, push.stale_base_giveup) live under the
  same op so dashboards filter on `push.*` to see the whole picture.

## Concurrent-push contention handling
- After a push failure the implementation refetches the remote and
  compares the current tip to the SHA captured at reset time. If it
  advanced, the failure is treated as contention and the full
  fetch/reset/cherry-pick/push cycle is retried. Other failures
  propagate immediately.
- Detection by ref-state comparison rather than git error message
  parsing — robust across git versions and rejection sources (NFF,
  hook reject, ref-store errors).
- Retries are capped at `Params.MaxPushAttempts` (default 10) to bound
  the worst case on a pathologically busy remote.

## Merge controller (orchestrator/controller/merge)
- Takes a `pusher.Pusher` dependency, loads each request in
  `batch.Contains` to collect changes in batch order, calls Push, and
  classifies the outcome inline with three explicit cases (success,
  conflict, generic error) — no helper, no error wrapping at this
  layer (retryability classification will be added by separate infra).
- Version arithmetic stays in the controller per the optimistic
  locking contract: newVersion is computed before UpdateState and
  assigned to the in-memory entity only on success.

## Tests
- Real-git fixture for the git Pusher: bare remote + checkout +
  author clone, with a `pre-receive` race hook that on its Nth call
  moves refs/heads/main to the Nth pre-staged SHA via update-ref
  (with GIT_QUARANTINE_PATH/GIT_OBJECT_DIRECTORY/
  GIT_ALTERNATE_OBJECT_DIRECTORIES unset to bypass git's pre-receive
  quarantine) and exits 1, driving the retry loop with real git
  mechanics. Covers single/stacked URIs, already-existed,
  mixed-partial, conflict, recovery-after-conflict,
  reset-between-calls, retry-on-contention, and giveup-after-cap.
- Merge controller tests rewritten with the new pusher mock and cover
  successful merge, multi-change ordering, conflict -> Failed,
  infra-error returns, terminal-batch idempotency, and store/publish
  failures.

## Wiring
- `example/server/orchestrator/main.go`: `newPusher()` reads
  `PUSHER_CHECKOUT_PATH` (required), `PUSHER_REMOTE` (default
  "origin"), `PUSHER_TARGET` (default "main").
- Makefile `mocks` target adds `./extension/pusher/...`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@behinddwalls behinddwalls added this pull request to the merge queue May 8, 2026
Merged via the queue into main with commit 110f04b May 8, 2026
13 checks passed
@github-actions github-actions Bot deleted the sergeyb/merge branch May 8, 2026 20:42
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.

3 participants