Skip to content

Commit 86b485c

Browse files
committed
feat(runway): git merger PROMOTE
## Summary ### Why? `PROMOTE` is the last strategy in the wire contract without an apply path. It is also the one that does not fit the shared machinery: the transforming strategies build new commits locally and push `HEAD:target`, while PROMOTE advances the target to a commit that already exists, unchanged. ### What? Adds the `promote` path, dispatched directly from `process` rather than through `applyTransforming`. **Fast-forward only.** After resetting to the remote tip, promote classifies the named commit three ways. Already the tip, or contained in it — idempotent success, no push. A strict descendant of the tip — a genuine fast-forward, pushed as `<sha>:refs/heads/<target>`. Anything else has diverged and is a terminal `ErrConflict`; PROMOTE never creates a commit to reconcile the two. Because it moves the ref to an existing commit, a change of any size arrives whole by construction — its ancestry comes with it, so PROMOTE needs none of the range machinery the picking strategies do. **Exclusivity.** `resolveAndValidate` rejects a PROMOTE that is not the entire request — one step, one change, one URI — as `ErrInvalidRequest`. Two reasons, both structural: a pre-existing commit cannot descend from commits an earlier transforming step just produced, and the push targets an exact SHA rather than the locally-built HEAD, so there is nothing for a preceding step to contribute. **Its own availability checks.** promote bypasses `tryApply`, so it performs the object-availability and staleness checks itself. Without them a commit the remote cannot supply makes every containment query fail with a plain error, which the consumer retries forever rather than reporting a request that can never succeed. **Contention.** The same bounded retry as the transforming path, but the loop re-runs the classification rather than the apply: if the push is rejected the tip may have moved, and the commit that was a fast-forward a moment ago may now be contained (success) or divergent (conflict). The push is a single atomic ref update, so PROMOTE needs no separate atomicity argument. A dry-run check performs the identical classification and returns without pushing, reporting no output. With this the merger implements every strategy in the contract; `isConcreteStrategy` now admits all four. ## Test Plan ✅ `bazel test //runway/extension/merger/git:go_default_test` — passes (61s) New cases: fast-forward promote, promote of a commit already contained in the tip, divergent promote rejected as a conflict, a multi-commit change promoted whole to the exact named commit, an unavailable commit reported as an invalid request rather than retried, both dry-run classifications, and the two composition rules (PROMOTE with a second step, PROMOTE with a second URI) rejected as invalid requests.
1 parent 5646e4c commit 86b485c

3 files changed

Lines changed: 243 additions & 17 deletions

File tree

runway/extension/merger/git/README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,10 @@ Fetching by SHA guarantees the merger applies exactly the commit a URI names —
4040
| `REBASE` | Cherry-picks every commit each change introduces onto the tip, in order. A commit already present on the target is skipped (no output), as is one that was empty to begin with. | one revision per newly-created commit |
4141
| `SQUASH_REBASE` | Applies each change like `REBASE`, then collapses the commits it produced into a single commit (squash unit = the change, not the step). | one revision per change, or none for a change already present |
4242
| `MERGE` | Creates a `--no-ff` merge commit per change, keeping the change's original commits reachable through second-parent history. A commit already contained in the tip is skipped. | the merge-commit revision(s) |
43+
| `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 |
4344
| `DEFAULT` | Resolved to the instance's configured default strategy before any step runs. | per the resolved strategy |
4445

45-
`PROMOTE` is defined by the wire contract but not yet applied here — a step naming it is rejected as an invalid request.
46+
`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.
4647

4748
## Importing an unrelated history
4849

@@ -58,11 +59,11 @@ Redelivery is safe: once imported, the source head is contained in the target, s
5859

5960
`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.
6061

61-
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.
62+
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.
6263

6364
## Failure classification
6465

65-
A merge conflict surfaces as `merger.ErrConflict`. An unusable request surfaces as `merger.ErrInvalidRequest`: an unsupported strategy or URI scheme, a malformed URI, a commit a reachable remote cannot supply, a change whose head has moved on, or a change sharing no history with the target under a picking strategy. Both are terminal — the controller publishes a `FAILED` result rather than retrying. Everything else (network/auth/push faults, and an unreachable remote) is returned as a plain error for the consumer to retry.
66+
A merge conflict surfaces as `merger.ErrConflict`. An unusable request surfaces as `merger.ErrInvalidRequest`: an unsupported strategy or URI scheme, a malformed URI, an invalid `PROMOTE` composition, a commit a reachable remote cannot supply, a change whose head has moved on, or a change sharing no history with the target under a picking strategy. Both are terminal — the controller publishes a `FAILED` result rather than retrying. Everything else (network/auth/push faults, and an unreachable remote) is returned as a plain error for the consumer to retry.
6667

6768
The distinction between the last two matters operationally: a commit that is missing while the remote answers is a property of the request, whereas a remote that will not answer is a property of the moment.
6869

runway/extension/merger/git/git_merger.go

Lines changed: 122 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,16 @@
2323
// single commit (squash unit = the step).
2424
// - MERGE: create a --no-ff merge commit per URI, preserving the
2525
// original commit hashes in second-parent history.
26+
// - PROMOTE: fast-forward the target to an already-existing commit with
27+
// no content transform. PROMOTE must be the entire request (one step, one
28+
// change, one URI) because a pre-existing commit cannot descend from
29+
// commits produced by an earlier transforming step.
2630
// - DEFAULT: resolved to the instance's configured DefaultStrategy
2731
// before any step runs.
2832
//
29-
// PROMOTE is not implemented yet; a step naming it is rejected as
30-
// merger.ErrInvalidRequest until its apply path lands.
31-
//
3233
// Atomicity: for a committing merge nothing reaches the remote until the final
33-
// push. A step that fails to apply aborts the in-progress git operation and
34+
// push (PROMOTE excepted, which is itself a single atomic fast-forward ref
35+
// update). A step that fails to apply aborts the in-progress git operation and
3436
// returns without pushing.
3537
//
3638
// Contention: if the push fails because the remote tip moved between reset and
@@ -116,7 +118,7 @@ type Params struct {
116118
// Target is the destination branch ref on the remote (e.g. "main").
117119
Target string
118120
// DefaultStrategy resolves a step whose strategy is DEFAULT. Must be a
119-
// concrete strategy (REBASE, SQUASH_REBASE, or MERGE).
121+
// concrete strategy (REBASE, SQUASH_REBASE, MERGE, or PROMOTE).
120122
DefaultStrategy mergestrategypb.Strategy
121123
// Runtime is the pinned Git runtime used for every invocation.
122124
Runtime GitRuntime
@@ -196,7 +198,7 @@ func NewMerger(params Params) (merger.Merger, error) {
196198
return nil, err
197199
}
198200
if !isConcreteStrategy(params.DefaultStrategy) {
199-
return nil, fmt.Errorf("default strategy must be concrete (REBASE, SQUASH_REBASE, or MERGE), got %v", params.DefaultStrategy)
201+
return nil, fmt.Errorf("default strategy must be concrete (REBASE, SQUASH_REBASE, MERGE, or PROMOTE), got %v", params.DefaultStrategy)
200202
}
201203
maxAttempts := params.MaxPushAttempts
202204
if maxAttempts <= 0 {
@@ -284,18 +286,25 @@ func (m *gitMerger) process(ctx context.Context, req *runwaymq.MergeRequest, com
284286
"commit", commit,
285287
)
286288

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

290-
// resolveAndValidate normalizes DEFAULT strategies to the configured default
291-
// and resolves every change URI, once, for the apply paths to work from. All failures here are terminal (merger.ErrInvalidRequest): retrying never
292-
// succeeds, so the controller publishes a FAILED result rather than nacking.
296+
// resolveAndValidate normalizes DEFAULT strategies to the configured default,
297+
// resolves every change URI once for the apply paths to work from, and enforces
298+
// the PROMOTE composition rule. All failures here are terminal
299+
// (merger.ErrInvalidRequest): retrying never succeeds, so the controller
300+
// publishes a FAILED result rather than nacking.
293301
func (m *gitMerger) resolveAndValidate(req *runwaymq.MergeRequest) ([]resolvedStep, error) {
294302
if len(req.GetSteps()) == 0 {
295303
return nil, fmt.Errorf("%w: request has no steps", merger.ErrInvalidRequest)
296304
}
297305

298306
resolved := make([]resolvedStep, 0, len(req.GetSteps()))
307+
promoteSeen := false
299308
for _, step := range req.GetSteps() {
300309
strategy := step.GetStrategy()
301310
if strategy == mergestrategypb.Strategy_DEFAULT {
@@ -304,6 +313,10 @@ func (m *gitMerger) resolveAndValidate(req *runwaymq.MergeRequest) ([]resolvedSt
304313
if !isConcreteStrategy(strategy) {
305314
return nil, fmt.Errorf("%w: unsupported strategy %v", merger.ErrInvalidRequest, step.GetStrategy())
306315
}
316+
if strategy == mergestrategypb.Strategy_PROMOTE {
317+
promoteSeen = true
318+
}
319+
307320
ch := step.GetChange()
308321
if ch == nil || len(ch.GetUris()) == 0 {
309322
return nil, fmt.Errorf("%w: step %q has no change URIs", merger.ErrInvalidRequest, step.GetStepId())
@@ -319,6 +332,17 @@ func (m *gitMerger) resolveAndValidate(req *runwaymq.MergeRequest) ([]resolvedSt
319332
resolved = append(resolved, resolvedStep{step: step, strategy: strategy, refs: refs})
320333
}
321334

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

@@ -569,6 +593,77 @@ func (m *gitMerger) applyMerge(ctx context.Context, rs resolvedStep) ([]*runwaym
569593
return outputs, nil
570594
}
571595

596+
// promote fast-forwards the target to an already-existing commit. It is only
597+
// reachable for a validated single-step/single-change/single-URI request.
598+
func (m *gitMerger) promote(ctx context.Context, req *runwaymq.MergeRequest, rs resolvedStep, commit bool) (*runwaymq.MergeResult, error) {
599+
// Validated to be one step with one change of one URI, so the request names
600+
// exactly one commit.
601+
ref := rs.refs[0]
602+
sha := ref.SHA
603+
604+
// PROMOTE does not go through tryApply, so it performs the same availability
605+
// and freshness checks itself. Without them a commit the remote cannot
606+
// supply turns every containment query into a plain error, which the
607+
// consumer retries forever instead of reporting. They sit outside the retry
608+
// loop because the commit under promotion is fixed for the whole request —
609+
// only the target tip moves between attempts.
610+
if err := m.ensureObjects(ctx, []changeRef{ref}); err != nil {
611+
return nil, err
612+
}
613+
if err := m.checkStale(ctx, []changeRef{ref}); err != nil {
614+
return nil, err
615+
}
616+
617+
var lastErr error
618+
for attempt := 1; attempt <= m.maxPushAttempts; attempt++ {
619+
if err := m.resetToRemote(ctx); err != nil {
620+
return nil, err
621+
}
622+
tip, err := m.headSHA(ctx)
623+
if err != nil {
624+
return nil, err
625+
}
626+
627+
// Idempotent: the commit is already the tip or contained in it.
628+
if sha == tip {
629+
return promoteResult(req, rs, sha, commit), nil
630+
}
631+
contained, err := m.isAncestor(ctx, sha, tip)
632+
if err != nil {
633+
return nil, err
634+
}
635+
if contained {
636+
return promoteResult(req, rs, sha, commit), nil
637+
}
638+
639+
// Only a true fast-forward is allowed; divergence is a terminal conflict.
640+
fastForward, err := m.isAncestor(ctx, tip, sha)
641+
if err != nil {
642+
return nil, err
643+
}
644+
if !fastForward {
645+
return nil, fmt.Errorf("%w: promote target %s is not a fast-forward of %s", merger.ErrConflict, sha, tip)
646+
}
647+
648+
if !commit {
649+
return promoteResult(req, rs, sha, commit), nil
650+
}
651+
652+
refspec := sha + ":refs/heads/" + m.target
653+
if _, err := m.run(ctx, nil, "push", m.remote, refspec); err != nil {
654+
// The target may have moved under us; re-fetch and re-classify.
655+
coremetrics.NamedCounter(m.metricsScope, "promote", "push_retries", 1)
656+
m.logger.Warnw("promote push failed, re-classifying",
657+
"attempt", attempt, "max_attempts", m.maxPushAttempts, "err", err)
658+
lastErr = err
659+
continue
660+
}
661+
return promoteResult(req, rs, sha, commit), nil
662+
}
663+
coremetrics.NamedCounter(m.metricsScope, "promote", "giveup", 1)
664+
return nil, fmt.Errorf("exceeded %d promote attempts due to remote contention: %w", m.maxPushAttempts, lastErr)
665+
}
666+
572667
// classifyMergeFailure decides what a failed `git merge` actually means, given
573668
// whether the index was left holding conflicted entries.
574669
//
@@ -932,13 +1027,13 @@ func passthroughEnv(extra []string) []string {
9321027
}
9331028

9341029
// isConcreteStrategy reports whether s names a concrete integration strategy
935-
// (i.e. not DEFAULT and not an unknown value). PROMOTE is not implemented yet
936-
// and is rejected as an invalid request until its apply path lands.
1030+
// (i.e. not DEFAULT and not an unknown value).
9371031
func isConcreteStrategy(s mergestrategypb.Strategy) bool {
9381032
switch s {
9391033
case mergestrategypb.Strategy_REBASE,
9401034
mergestrategypb.Strategy_SQUASH_REBASE,
941-
mergestrategypb.Strategy_MERGE:
1035+
mergestrategypb.Strategy_MERGE,
1036+
mergestrategypb.Strategy_PROMOTE:
9421037
return true
9431038
default:
9441039
return false
@@ -993,3 +1088,18 @@ func successResult(req *runwaymq.MergeRequest, steps []*runwaymq.StepResult) *ru
9931088
Steps: steps,
9941089
}
9951090
}
1091+
1092+
// promoteResult builds a SUCCEEDED MergeResult for a promote. A committing
1093+
// promote reports the promoted SHA as the step's single output; a dry-run check
1094+
// reports no output.
1095+
func promoteResult(req *runwaymq.MergeRequest, rs resolvedStep, sha string, commit bool) *runwaymq.MergeResult {
1096+
var outputs []*runwaymq.StepOutput
1097+
if commit {
1098+
outputs = []*runwaymq.StepOutput{{Id: sha}}
1099+
}
1100+
return &runwaymq.MergeResult{
1101+
Id: req.GetId(),
1102+
Outcome: runwaypb.Outcome_SUCCEEDED,
1103+
Steps: []*runwaymq.StepResult{{StepId: rs.step.GetStepId(), Outputs: outputs}},
1104+
}
1105+
}

runway/extension/merger/git/git_merger_test.go

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,85 @@ func TestMerge_Merge_AlreadyAncestor(t *testing.T) {
497497

498498
// --- PROMOTE ---
499499

500+
func TestMerge_Promote_MultiCommitChange(t *testing.T) {
501+
// PROMOTE moves the ref to the named commit, so a change of any size
502+
// arrives whole by construction — its ancestry comes along with it.
503+
f := setupGitFixture(t)
504+
head := f.pushMultiCommitPR(t, "feature/ff",
505+
commitSpec{"a.txt", "a\n", "add a"},
506+
commitSpec{"b.txt", "b\n", "add b"},
507+
commitSpec{"c.txt", "c\n", "add c"},
508+
)
509+
510+
m := f.newMerger(t, mergestrategypb.Strategy_REBASE)
511+
res, err := m.Merge(context.Background(), req("b", stepOf(mergestrategypb.Strategy_PROMOTE, "s1", uri(head))))
512+
require.NoError(t, err)
513+
assert.Equal(t, head, f.remoteHEAD(t), "promote fast-forwards to the exact named commit")
514+
assert.Equal(t, "a\n", f.remoteFile(t, "a.txt"))
515+
assert.Equal(t, "c\n", f.remoteFile(t, "c.txt"))
516+
require.Len(t, res.GetSteps(), 1)
517+
require.Len(t, res.GetSteps()[0].GetOutputs(), 1)
518+
assert.Equal(t, head, res.GetSteps()[0].GetOutputs()[0].GetId())
519+
}
520+
521+
func TestMerge_Promote_UnavailableCommitIsInvalidNotRetryable(t *testing.T) {
522+
// promote does not run through tryApply, so it needs its own availability
523+
// check; otherwise the containment queries fail with a plain error and the
524+
// consumer retries a request that can never succeed.
525+
f := setupGitFixture(t)
526+
m := f.newMerger(t, mergestrategypb.Strategy_REBASE)
527+
528+
_, err := m.Merge(context.Background(), req("b", stepOf(mergestrategypb.Strategy_PROMOTE, "s1", uri(fakeSHA))))
529+
require.Error(t, err)
530+
assert.True(t, errors.Is(err, merger.ErrInvalidRequest))
531+
assert.False(t, errors.Is(err, merger.ErrConflict))
532+
}
533+
534+
func TestMerge_Promote_FastForward(t *testing.T) {
535+
f := setupGitFixture(t)
536+
ffSHA := f.pushPRCommit(t, "feature/ff", "hello.txt", "hello\nearth\n", "ff")
537+
538+
m := f.newMerger(t, mergestrategypb.Strategy_REBASE)
539+
res, err := m.Merge(context.Background(), req("b", stepOf(mergestrategypb.Strategy_PROMOTE, "s1", uri(ffSHA))))
540+
require.NoError(t, err)
541+
assert.Equal(t, runwaypb.Outcome_SUCCEEDED, res.GetOutcome())
542+
require.Len(t, res.GetSteps(), 1)
543+
require.Len(t, res.GetSteps()[0].GetOutputs(), 1)
544+
assert.Equal(t, ffSHA, res.GetSteps()[0].GetOutputs()[0].GetId(), "promote reports the exact named SHA")
545+
assert.Equal(t, ffSHA, f.remoteHEAD(t))
546+
}
547+
548+
func TestMerge_Promote_AlreadyContained(t *testing.T) {
549+
f := setupGitFixture(t)
550+
seedSHA := f.remoteSHA(t, "main")
551+
advSHA := f.pushPRCommit(t, "feature/adv", "adv.txt", "adv\n", "adv")
552+
f.advanceMain(t, advSHA)
553+
mainBefore := f.remoteHEAD(t)
554+
555+
m := f.newMerger(t, mergestrategypb.Strategy_REBASE)
556+
res, err := m.Merge(context.Background(), req("b", stepOf(mergestrategypb.Strategy_PROMOTE, "s1", uri(seedSHA))))
557+
require.NoError(t, err)
558+
assert.Equal(t, runwaypb.Outcome_SUCCEEDED, res.GetOutcome())
559+
assert.Equal(t, mainBefore, f.remoteHEAD(t), "promoting an already-contained SHA does not move the tip")
560+
}
561+
562+
func TestMerge_Promote_Divergent(t *testing.T) {
563+
f := setupGitFixture(t)
564+
seedSHA := f.remoteSHA(t, "main")
565+
divSHA := f.pushPRCommitFrom(t, seedSHA, "feature/div", "div.txt", "div\n", "div")
566+
otherSHA := f.pushPRCommitFrom(t, seedSHA, "feature/other", "other.txt", "other\n", "other")
567+
f.advanceMain(t, otherSHA)
568+
mainBefore := f.remoteHEAD(t)
569+
570+
m := f.newMerger(t, mergestrategypb.Strategy_REBASE)
571+
_, err := m.Merge(context.Background(), req("b", stepOf(mergestrategypb.Strategy_PROMOTE, "s1", uri(divSHA))))
572+
require.Error(t, err)
573+
assert.True(t, errors.Is(err, merger.ErrConflict))
574+
assert.Equal(t, mainBefore, f.remoteHEAD(t))
575+
}
576+
577+
// --- DEFAULT ---
578+
500579
func TestMerge_Default_ResolvesToRebase(t *testing.T) {
501580
f := setupGitFixture(t)
502581
sha := f.pushPRCommit(t, "feature/a", "hello.txt", "hello\nearth\n", "tweak hello")
@@ -966,8 +1045,15 @@ func TestMerge_InvalidRequests(t *testing.T) {
9661045
req: req("b"),
9671046
},
9681047
{
969-
name: "unsupported strategy",
970-
req: req("b", stepOf(mergestrategypb.Strategy_PROMOTE, "s1", uri(fakeSHA))),
1048+
name: "promote with two steps",
1049+
req: req("b",
1050+
stepOf(mergestrategypb.Strategy_PROMOTE, "s1", uri(fakeSHA)),
1051+
stepOf(mergestrategypb.Strategy_PROMOTE, "s2", uri(fakeSHA)),
1052+
),
1053+
},
1054+
{
1055+
name: "promote step with two URIs",
1056+
req: req("b", stepOf(mergestrategypb.Strategy_PROMOTE, "s1", uri(fakeSHA), uri(fakeSHA))),
9711057
},
9721058
{
9731059
name: "malformed URI",
@@ -1027,6 +1113,35 @@ func TestCheckMergeability_Conflict(t *testing.T) {
10271113
assert.Equal(t, mainBefore, f.remoteHEAD(t))
10281114
}
10291115

1116+
func TestCheckMergeability_PromoteFastForward(t *testing.T) {
1117+
f := setupGitFixture(t)
1118+
ffSHA := f.pushPRCommit(t, "feature/ff", "hello.txt", "hello\nearth\n", "ff")
1119+
mainBefore := f.remoteHEAD(t)
1120+
1121+
m := f.newMerger(t, mergestrategypb.Strategy_REBASE)
1122+
res, err := m.CheckMergeability(context.Background(), req("b", stepOf(mergestrategypb.Strategy_PROMOTE, "s1", uri(ffSHA))))
1123+
require.NoError(t, err)
1124+
assert.Equal(t, runwaypb.Outcome_SUCCEEDED, res.GetOutcome())
1125+
require.Len(t, res.GetSteps(), 1)
1126+
assert.Empty(t, res.GetSteps()[0].GetOutputs())
1127+
assert.Equal(t, mainBefore, f.remoteHEAD(t))
1128+
}
1129+
1130+
func TestCheckMergeability_PromoteDivergent(t *testing.T) {
1131+
f := setupGitFixture(t)
1132+
seedSHA := f.remoteSHA(t, "main")
1133+
divSHA := f.pushPRCommitFrom(t, seedSHA, "feature/div", "div.txt", "div\n", "div")
1134+
otherSHA := f.pushPRCommitFrom(t, seedSHA, "feature/other", "other.txt", "other\n", "other")
1135+
f.advanceMain(t, otherSHA)
1136+
mainBefore := f.remoteHEAD(t)
1137+
1138+
m := f.newMerger(t, mergestrategypb.Strategy_REBASE)
1139+
_, err := m.CheckMergeability(context.Background(), req("b", stepOf(mergestrategypb.Strategy_PROMOTE, "s1", uri(divSHA))))
1140+
require.Error(t, err)
1141+
assert.True(t, errors.Is(err, merger.ErrConflict))
1142+
assert.Equal(t, mainBefore, f.remoteHEAD(t))
1143+
}
1144+
10301145
func TestPinnedGitVersion(t *testing.T) {
10311146
out := mustGitOutput(t, t.TempDir(), "--version")
10321147
assert.Equal(t, "git version "+pinnedGitVersion, strings.TrimSpace(string(out)))

0 commit comments

Comments
 (0)