Skip to content

feat(runway): git-backed merger with REBASE - #460

Open
behinddwalls wants to merge 1 commit into
preetam/runway-dlq-reconcilerfrom
preetam/runway-git-merger-rebase
Open

feat(runway): git-backed merger with REBASE#460
behinddwalls wants to merge 1 commit into
preetam/runway-dlq-reconcilerfrom
preetam/runway-git-merger-rebase

Conversation

@behinddwalls

@behinddwalls behinddwalls commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Why?

Runway's merger extension has had exactly one implementation — noop, which always succeeds. Nothing actually merges anything. This lands the first real backend: a Merger driven by the git CLI against a local checkout.

It ships REBASE only. The strategy-specific apply paths are small and independent, but the machinery underneath them — the pinned git runtime, the reset/apply/push cycle, contention retry, dry-run discard, conflict classification — is shared and is the bulk of what needs review. Landing it with one strategy keeps that review separable from the per-strategy mechanics that follow in this stack.

What?

Adds runway/extension/merger/git.

Model. A request is an ordered list of steps; each step names a change (github-family URIs, each ending in a full head commit SHA) and a strategy. Steps apply in order on top of the target tip — earlier steps are the in-flight base, the last is the candidate. Each step yields one StepResult whose outputs are the revisions it produced, in application order. Because the URI carries only the head SHA, a change is its head commit; multi-commit fidelity is not expressible in the current contract.

REBASE cherry-picks each URI's head commit onto the tip. A pick whose content is already on the target is reported with no output rather than failing, which is what makes redelivery idempotent; an empty pick is rolled back.

Atomicity and contention. Nothing reaches the remote until the final push. A step that fails to apply aborts its in-progress git operation and returns without pushing. If the push is rejected because the remote tip moved between reset and push, the whole reset/apply/push cycle retries up to a bounded number of attempts, re-fetching the tip to confirm that is what happened.

Dry run. CheckMergeability runs the identical apply path but never pushes, then resets the checkout and reports empty outputs. Intermediate steps are still committed locally so a multi-step check sees the same conflict surface a real merge would.

Runtime hygiene. Every invocation uses an explicitly pinned git runtime (executable, exec-path, template dir) and a scrubbed environment — no system or global config, no interactive prompts. That leaves no ambient identity, so the committer name and email are injected per-invocation.

Conflicts surface as merger.ErrConflict and unusable requests as merger.ErrInvalidRequest; both are terminal, everything else is a plain error the consumer retries. isConcreteStrategy currently admits only REBASE, so a step naming SQUASH_REBASE, MERGE, or PROMOTE is rejected as an invalid request until its apply path lands.

The merger is not wired into the server yet — that is the last change in this stack. Until then the server still builds the noop factory, so this adds no production behavior.

Test Plan

bazel test //runway/... — 6/6 targets pass, including the new //runway/extension/merger/git suite (20s)

The suite drives a real git binary against throwaway local repos: single and stacked URIs, already-landed changes, multi-step requests, conflicts, checkout recovery after a prior conflict, contention retry when the remote tip moves mid-cycle, give-up after max attempts, DEFAULT resolution, invalid requests, and the dry-run paths. It also asserts the pinned runtime is used and that the command environment is not inherited.

Stack

  1. chore(submitqueue): remove dead pusher extension #457
  2. feat(runway): classify invalid merge requests as terminal #458
  3. feat(runway): dlq reconciler for merge topics #459
  4. @ feat(runway): git-backed merger with REBASE #460
  5. feat(runway): git merger SQUASH_REBASE and MERGE #461
  6. feat(runway): git merger PROMOTE #462
  7. feat(runway): wire the git merger into the server #463

## Summary

### Why?

Runway's `merger` extension has had exactly one implementation — `noop`, which always succeeds. Nothing actually merges anything. This lands the first real backend: a `Merger` driven by the `git` CLI against a local checkout.

It ships REBASE only. The strategy-specific apply paths are small and independent, but the machinery underneath them — the pinned git runtime, the reset/apply/push cycle, contention retry, dry-run discard, conflict classification — is shared and is the bulk of what needs review. Landing it with one strategy keeps that review separable from the per-strategy mechanics that follow in this stack.

### What?

Adds `runway/extension/merger/git`.

**Model.** A request is an ordered list of steps; each step names a change (github-family URIs, each ending in a full head commit SHA) and a strategy. Steps apply in order on top of the target tip — earlier steps are the in-flight base, the last is the candidate. Each step yields one `StepResult` whose outputs are the revisions it produced, in application order. Because the URI carries only the head SHA, a change *is* its head commit; multi-commit fidelity is not expressible in the current contract.

**REBASE** cherry-picks each URI's head commit onto the tip. A pick whose content is already on the target is reported with no output rather than failing, which is what makes redelivery idempotent; an empty pick is rolled back.

**Atomicity and contention.** Nothing reaches the remote until the final push. A step that fails to apply aborts its in-progress git operation and returns without pushing. If the push is rejected because the remote tip moved between reset and push, the whole reset/apply/push cycle retries up to a bounded number of attempts, re-fetching the tip to confirm that is what happened.

**Dry run.** `CheckMergeability` runs the identical apply path but never pushes, then resets the checkout and reports empty outputs. Intermediate steps are still committed locally so a multi-step check sees the same conflict surface a real merge would.

**Runtime hygiene.** Every invocation uses an explicitly pinned git runtime (executable, exec-path, template dir) and a scrubbed environment — no system or global config, no interactive prompts. That leaves no ambient identity, so the committer name and email are injected per-invocation.

Conflicts surface as `merger.ErrConflict` and unusable requests as `merger.ErrInvalidRequest`; both are terminal, everything else is a plain error the consumer retries. `isConcreteStrategy` currently admits only REBASE, so a step naming SQUASH_REBASE, MERGE, or PROMOTE is rejected as an invalid request until its apply path lands.

The merger is not wired into the server yet — that is the last change in this stack. Until then the server still builds the noop factory, so this adds no production behavior.

## Test Plan

✅ `bazel test //runway/...` — 6/6 targets pass, including the new `//runway/extension/merger/git` suite (20s)

The suite drives a real git binary against throwaway local repos: single and stacked URIs, already-landed changes, multi-step requests, conflicts, checkout recovery after a prior conflict, contention retry when the remote tip moves mid-cycle, give-up after max attempts, DEFAULT resolution, invalid requests, and the dry-run paths. It also asserts the pinned runtime is used and that the command environment is not inherited.
if err != nil {
return nil, fmt.Errorf("%w: invalid change URI %q: %v", merger.ErrInvalidRequest, uri, err)
}
sha, ok, err := m.cherryPickSHA(ctx, cid.HeadCommitSHA)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

say a PR has mutliple commits when we cherry-pick just the head? does it have all the changes or do we need to go down the history to cherry pick all commits and then put on the head/main?

// no-ops because the content is already on the target).
func (m *gitMerger) pickStepChanges(ctx context.Context, step *runwaymq.MergeStep) ([]string, error) {
var picked []string
for _, uri := range step.GetChange().GetUris() {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

have we fetched these commits? before cherry-picking? how do we know if we have them in local

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant