Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions runway/extension/merger/git/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,20 @@ The URI carries only the head commit SHA — no base and no commit range. A chan
| `REBASE` | Cherry-picks each URI's head commit onto the tip, in order. A pick already present on the target is rebased out (reported, no output); an empty pick is rolled back. | one revision per newly-created commit |
| `SQUASH_REBASE` | Applies the step like `REBASE`, then collapses the resulting commits into a single commit (squash unit = the step). | one revision, or none when the step is entirely already-present |
| `MERGE` | Creates a `--no-ff` merge commit per URI, preserving the original commit hashes in second-parent history. A commit already contained in the tip is skipped. | the merge-commit revision(s) |
| `PROMOTE` | Fast-forwards the target to an already-existing commit — no content transform, no new revision. Must be the entire request (one step, one change, one URI). | the exact named revision |
| `DEFAULT` | Resolved to the instance's configured default strategy before any step runs. | per the resolved strategy |

`PROMOTE` is defined by the wire contract but not yet applied here — a step naming it is rejected as an invalid request.
`PROMOTE` is exclusive because a pre-existing commit cannot descend from commits an earlier transforming step produced, and it advances the ref to an exact SHA rather than to the locally-built HEAD. Mixing it with any other step is rejected as an invalid request.

## Committing, dry-run, atomicity, contention

`Merge` commits and reports outputs; `CheckMergeability` runs the identical apply but never pushes, then resets the checkout to discard the local commits and reports empty outputs. A multi-step check commits its intermediate steps locally so it sees the same conflict surface a real merge would.

For a committing merge 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 fails because the remote tip moved between reset and push, the whole reset/apply/push cycle is retried up to a bounded number of attempts; detection re-fetches the tip and compares it to the SHA the cycle was based on.
For a committing merge nothing reaches the remote until the final push (a `PROMOTE` is itself a single atomic fast-forward ref update). A step that fails to apply aborts its in-progress git operation and returns without pushing. If the push fails because the remote tip moved between reset and push, the whole reset/apply/push cycle is retried up to a bounded number of attempts; detection re-fetches the tip and compares it to the SHA the cycle was based on.

## Failure classification

A merge conflict surfaces as `merger.ErrConflict`; an unusable request (unsupported strategy, malformed URI) surfaces as `merger.ErrInvalidRequest`. Both are terminal — the controller publishes a `FAILED` result rather than retrying. Everything else (fetch/network/auth/push faults) is returned as a plain error for the consumer to retry.
A merge conflict surfaces as `merger.ErrConflict`; an unusable request (unknown strategy, malformed URI, invalid `PROMOTE` composition) surfaces as `merger.ErrInvalidRequest`. Both are terminal — the controller publishes a `FAILED` result rather than retrying. Everything else (fetch/network/auth/push faults) is returned as a plain error for the consumer to retry.

## Runtime and identity

Expand Down
120 changes: 109 additions & 11 deletions runway/extension/merger/git/git_merger.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,16 @@
// single commit (squash unit = the step).
// - MERGE: create a --no-ff merge commit per URI, preserving the
// original commit hashes in second-parent history.
// - PROMOTE: fast-forward the target to an already-existing commit with
// no content transform. PROMOTE must be the entire request (one step, one
// change, one URI) because a pre-existing commit cannot descend from
// commits produced by an earlier transforming step.
// - DEFAULT: resolved to the instance's configured DefaultStrategy
// before any step runs.
//
// PROMOTE is not implemented yet; a step naming it is rejected as
// merger.ErrInvalidRequest until its apply path lands.
//
// Atomicity: for a committing merge nothing reaches the remote until the final
// push. A step that fails to apply aborts the in-progress git operation and
// push (PROMOTE excepted, which is itself a single atomic fast-forward ref
// update). A step that fails to apply aborts the in-progress git operation and
// returns without pushing.
//
// Contention: if the push fails because the remote tip moved between reset and
Expand Down Expand Up @@ -106,7 +108,7 @@ type Params struct {
// Target is the destination branch ref on the remote (e.g. "main").
Target string
// DefaultStrategy resolves a step whose strategy is DEFAULT. Must be a
// concrete strategy (REBASE, SQUASH_REBASE, or MERGE).
// concrete strategy (REBASE, SQUASH_REBASE, MERGE, or PROMOTE).
DefaultStrategy mergestrategypb.Strategy
// Runtime is the pinned Git runtime used for every invocation.
Runtime GitRuntime
Expand Down Expand Up @@ -160,7 +162,7 @@ func NewMerger(params Params) (merger.Merger, error) {
return nil, err
}
if !isConcreteStrategy(params.DefaultStrategy) {
return nil, fmt.Errorf("default strategy must be concrete (REBASE, SQUASH_REBASE, or MERGE), got %v", params.DefaultStrategy)
return nil, fmt.Errorf("default strategy must be concrete (REBASE, SQUASH_REBASE, MERGE, or PROMOTE), got %v", params.DefaultStrategy)
}
maxAttempts := params.MaxPushAttempts
if maxAttempts <= 0 {
Expand Down Expand Up @@ -244,18 +246,24 @@ func (m *gitMerger) process(ctx context.Context, req *runwaymq.MergeRequest, com
"commit", commit,
)

// PROMOTE is exclusive: validated to be the entire request (one step).
if steps[0].strategy == mergestrategypb.Strategy_PROMOTE {
return m.promote(ctx, req, steps[0], commit)
}
return m.applyTransforming(ctx, req, steps, commit)
}

// resolveAndValidate normalizes DEFAULT strategies to the configured default
// and validates every change URI parses. All failures here are terminal (merger.ErrInvalidRequest): retrying never
// resolveAndValidate normalizes DEFAULT strategies to the configured default,
// validates every change URI parses, and enforces the PROMOTE composition rule.
// All failures here are terminal (merger.ErrInvalidRequest): retrying never
// succeeds, so the controller publishes a FAILED result rather than nacking.
func (m *gitMerger) resolveAndValidate(req *runwaymq.MergeRequest) ([]resolvedStep, error) {
if len(req.GetSteps()) == 0 {
return nil, fmt.Errorf("%w: request has no steps", merger.ErrInvalidRequest)
}

resolved := make([]resolvedStep, 0, len(req.GetSteps()))
promoteSeen := false
for _, step := range req.GetSteps() {
strategy := step.GetStrategy()
if strategy == mergestrategypb.Strategy_DEFAULT {
Expand All @@ -264,6 +272,10 @@ func (m *gitMerger) resolveAndValidate(req *runwaymq.MergeRequest) ([]resolvedSt
if !isConcreteStrategy(strategy) {
return nil, fmt.Errorf("%w: unsupported strategy %v", merger.ErrInvalidRequest, step.GetStrategy())
}
if strategy == mergestrategypb.Strategy_PROMOTE {
promoteSeen = true
}

ch := step.GetChange()
if ch == nil || len(ch.GetUris()) == 0 {
return nil, fmt.Errorf("%w: step %q has no change URIs", merger.ErrInvalidRequest, step.GetStepId())
Expand All @@ -276,6 +288,17 @@ func (m *gitMerger) resolveAndValidate(req *runwaymq.MergeRequest) ([]resolvedSt
resolved = append(resolved, resolvedStep{step: step, strategy: strategy})
}

// PROMOTE must be the entire request: one step, one change, one URI. A
// pre-existing commit cannot descend from commits an earlier transforming
// step produced, and PROMOTE pushes <sha>:target directly rather than
// HEAD:target, so it cannot compose with any other step.
if promoteSeen {
if len(resolved) != 1 ||
len(resolved[0].step.GetChange().GetUris()) != 1 {
return nil, fmt.Errorf("%w: PROMOTE must be the entire request (one step, one change, one URI)", merger.ErrInvalidRequest)
}
}

return resolved, nil
}

Expand Down Expand Up @@ -489,6 +512,66 @@ func (m *gitMerger) applyMerge(ctx context.Context, step *runwaymq.MergeStep) ([
return outputs, nil
}

// promote fast-forwards the target to an already-existing commit. It is only
// reachable for a validated single-step/single-change/single-URI request.
func (m *gitMerger) promote(ctx context.Context, req *runwaymq.MergeRequest, rs resolvedStep, commit bool) (*runwaymq.MergeResult, error) {
uri := rs.step.GetChange().GetUris()[0]
cid, err := entitygithub.ParseChangeID(uri)
if err != nil {
return nil, fmt.Errorf("%w: invalid change URI %q: %v", merger.ErrInvalidRequest, uri, err)
}
sha := cid.HeadCommitSHA

var lastErr error
for attempt := 1; attempt <= m.maxPushAttempts; attempt++ {
if err := m.resetToRemote(ctx); err != nil {
return nil, err
}
tip, err := m.headSHA(ctx)
if err != nil {
return nil, err
}

// Idempotent: the commit is already the tip or contained in it.
if sha == tip {
return promoteResult(req, rs, sha, commit), nil
}
contained, err := m.isAncestor(ctx, sha, tip)
if err != nil {
return nil, err
}
if contained {
return promoteResult(req, rs, sha, commit), nil
}

// Only a true fast-forward is allowed; divergence is a terminal conflict.
fastForward, err := m.isAncestor(ctx, tip, sha)
if err != nil {
return nil, err
}
if !fastForward {
return nil, fmt.Errorf("%w: promote target %s is not a fast-forward of %s", merger.ErrConflict, sha, tip)
}

if !commit {
return promoteResult(req, rs, sha, commit), nil
}

refspec := sha + ":refs/heads/" + m.target
if _, err := m.run(ctx, nil, "push", m.remote, refspec); err != nil {
// The target may have moved under us; re-fetch and re-classify.
coremetrics.NamedCounter(m.metricsScope, "promote", "push_retries", 1)
m.logger.Warnw("promote push failed, re-classifying",
"attempt", attempt, "max_attempts", m.maxPushAttempts, "err", err)
lastErr = err
continue
}
return promoteResult(req, rs, sha, commit), nil
}
coremetrics.NamedCounter(m.metricsScope, "promote", "giveup", 1)
return nil, fmt.Errorf("exceeded %d promote attempts due to remote contention: %w", m.maxPushAttempts, lastErr)
}

// pickStepChanges cherry-picks the head SHA of every URI of every change in the
// step, in order, returning the new commit SHAs (empty for picks that were
// no-ops because the content is already on the target).
Expand Down Expand Up @@ -714,13 +797,13 @@ func newGitCommand(ctx context.Context, runtime GitRuntime, dir string, args ...
}

// isConcreteStrategy reports whether s names a concrete integration strategy
// (i.e. not DEFAULT and not an unknown value). PROMOTE is not implemented yet
// and is rejected as an invalid request until its apply path lands.
// (i.e. not DEFAULT and not an unknown value).
func isConcreteStrategy(s mergestrategypb.Strategy) bool {
switch s {
case mergestrategypb.Strategy_REBASE,
mergestrategypb.Strategy_SQUASH_REBASE,
mergestrategypb.Strategy_MERGE:
mergestrategypb.Strategy_MERGE,
mergestrategypb.Strategy_PROMOTE:
return true
default:
return false
Expand Down Expand Up @@ -778,3 +861,18 @@ func successResult(req *runwaymq.MergeRequest, steps []*runwaymq.StepResult) *ru
Steps: steps,
}
}

// promoteResult builds a SUCCEEDED MergeResult for a promote. A committing
// promote reports the promoted SHA as the step's single output; a dry-run check
// reports no output.
func promoteResult(req *runwaymq.MergeRequest, rs resolvedStep, sha string, commit bool) *runwaymq.MergeResult {
var outputs []*runwaymq.StepOutput
if commit {
outputs = []*runwaymq.StepOutput{{Id: sha}}
}
return &runwaymq.MergeResult{
Id: req.GetId(),
Outcome: runwaypb.Outcome_SUCCEEDED,
Steps: []*runwaymq.StepResult{{StepId: rs.step.GetStepId(), Outputs: outputs}},
}
}
85 changes: 83 additions & 2 deletions runway/extension/merger/git/git_merger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,51 @@ func TestMerge_Merge_AlreadyAncestor(t *testing.T) {

// --- PROMOTE ---

func TestMerge_Promote_FastForward(t *testing.T) {
f := setupGitFixture(t)
ffSHA := f.pushPRCommit(t, "feature/ff", "hello.txt", "hello\nearth\n", "ff")

m := f.newMerger(t, mergestrategypb.Strategy_REBASE)
res, err := m.Merge(context.Background(), req("b", stepOf(mergestrategypb.Strategy_PROMOTE, "s1", uri(ffSHA))))
require.NoError(t, err)
assert.Equal(t, runwaypb.Outcome_SUCCEEDED, res.GetOutcome())
require.Len(t, res.GetSteps(), 1)
require.Len(t, res.GetSteps()[0].GetOutputs(), 1)
assert.Equal(t, ffSHA, res.GetSteps()[0].GetOutputs()[0].GetId(), "promote reports the exact named SHA")
assert.Equal(t, ffSHA, f.remoteHEAD(t))
}

func TestMerge_Promote_AlreadyContained(t *testing.T) {
f := setupGitFixture(t)
seedSHA := f.remoteSHA(t, "main")
advSHA := f.pushPRCommit(t, "feature/adv", "adv.txt", "adv\n", "adv")
f.advanceMain(t, advSHA)
mainBefore := f.remoteHEAD(t)

m := f.newMerger(t, mergestrategypb.Strategy_REBASE)
res, err := m.Merge(context.Background(), req("b", stepOf(mergestrategypb.Strategy_PROMOTE, "s1", uri(seedSHA))))
require.NoError(t, err)
assert.Equal(t, runwaypb.Outcome_SUCCEEDED, res.GetOutcome())
assert.Equal(t, mainBefore, f.remoteHEAD(t), "promoting an already-contained SHA does not move the tip")
}

func TestMerge_Promote_Divergent(t *testing.T) {
f := setupGitFixture(t)
seedSHA := f.remoteSHA(t, "main")
divSHA := f.pushPRCommitFrom(t, seedSHA, "feature/div", "div.txt", "div\n", "div")
otherSHA := f.pushPRCommitFrom(t, seedSHA, "feature/other", "other.txt", "other\n", "other")
f.advanceMain(t, otherSHA)
mainBefore := f.remoteHEAD(t)

m := f.newMerger(t, mergestrategypb.Strategy_REBASE)
_, err := m.Merge(context.Background(), req("b", stepOf(mergestrategypb.Strategy_PROMOTE, "s1", uri(divSHA))))
require.Error(t, err)
assert.True(t, errors.Is(err, merger.ErrConflict))
assert.Equal(t, mainBefore, f.remoteHEAD(t))
}

// --- DEFAULT ---

func TestMerge_Default_ResolvesToRebase(t *testing.T) {
f := setupGitFixture(t)
sha := f.pushPRCommit(t, "feature/a", "hello.txt", "hello\nearth\n", "tweak hello")
Expand All @@ -448,8 +493,15 @@ func TestMerge_InvalidRequests(t *testing.T) {
req: req("b"),
},
{
name: "unsupported strategy",
req: req("b", stepOf(mergestrategypb.Strategy_PROMOTE, "s1", uri(fakeSHA))),
name: "promote with two steps",
req: req("b",
stepOf(mergestrategypb.Strategy_PROMOTE, "s1", uri(fakeSHA)),
stepOf(mergestrategypb.Strategy_PROMOTE, "s2", uri(fakeSHA)),
),
},
{
name: "promote step with two URIs",
req: req("b", stepOf(mergestrategypb.Strategy_PROMOTE, "s1", uri(fakeSHA), uri(fakeSHA))),
},
{
name: "malformed URI",
Expand Down Expand Up @@ -509,6 +561,35 @@ func TestCheckMergeability_Conflict(t *testing.T) {
assert.Equal(t, mainBefore, f.remoteHEAD(t))
}

func TestCheckMergeability_PromoteFastForward(t *testing.T) {
f := setupGitFixture(t)
ffSHA := f.pushPRCommit(t, "feature/ff", "hello.txt", "hello\nearth\n", "ff")
mainBefore := f.remoteHEAD(t)

m := f.newMerger(t, mergestrategypb.Strategy_REBASE)
res, err := m.CheckMergeability(context.Background(), req("b", stepOf(mergestrategypb.Strategy_PROMOTE, "s1", uri(ffSHA))))
require.NoError(t, err)
assert.Equal(t, runwaypb.Outcome_SUCCEEDED, res.GetOutcome())
require.Len(t, res.GetSteps(), 1)
assert.Empty(t, res.GetSteps()[0].GetOutputs())
assert.Equal(t, mainBefore, f.remoteHEAD(t))
}

func TestCheckMergeability_PromoteDivergent(t *testing.T) {
f := setupGitFixture(t)
seedSHA := f.remoteSHA(t, "main")
divSHA := f.pushPRCommitFrom(t, seedSHA, "feature/div", "div.txt", "div\n", "div")
otherSHA := f.pushPRCommitFrom(t, seedSHA, "feature/other", "other.txt", "other\n", "other")
f.advanceMain(t, otherSHA)
mainBefore := f.remoteHEAD(t)

m := f.newMerger(t, mergestrategypb.Strategy_REBASE)
_, err := m.CheckMergeability(context.Background(), req("b", stepOf(mergestrategypb.Strategy_PROMOTE, "s1", uri(divSHA))))
require.Error(t, err)
assert.True(t, errors.Is(err, merger.ErrConflict))
assert.Equal(t, mainBefore, f.remoteHEAD(t))
}

func TestPinnedGitVersion(t *testing.T) {
out := mustGitOutput(t, t.TempDir(), "--version")
assert.Equal(t, "git version "+pinnedGitVersion, strings.TrimSpace(string(out)))
Expand Down