Skip to content

Commit e3972ff

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 e85c9ca commit e3972ff

3 files changed

Lines changed: 239 additions & 16 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 the step like `REBASE`, then collapses the commits it produced into a single commit (squash unit = the step, not the change). | one revision, or none when the step is entirely 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: 118 additions & 11 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
@@ -191,7 +193,7 @@ func NewMerger(params Params) (merger.Merger, error) {
191193
return nil, err
192194
}
193195
if !isConcreteStrategy(params.DefaultStrategy) {
194-
return nil, fmt.Errorf("default strategy must be concrete (REBASE, SQUASH_REBASE, or MERGE), got %v", params.DefaultStrategy)
196+
return nil, fmt.Errorf("default strategy must be concrete (REBASE, SQUASH_REBASE, MERGE, or PROMOTE), got %v", params.DefaultStrategy)
195197
}
196198
maxAttempts := params.MaxPushAttempts
197199
if maxAttempts <= 0 {
@@ -279,18 +281,24 @@ func (m *gitMerger) process(ctx context.Context, req *runwaymq.MergeRequest, com
279281
"commit", commit,
280282
)
281283

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

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

293300
resolved := make([]resolvedStep, 0, len(req.GetSteps()))
301+
promoteSeen := false
294302
for _, step := range req.GetSteps() {
295303
strategy := step.GetStrategy()
296304
if strategy == mergestrategypb.Strategy_DEFAULT {
@@ -299,6 +307,10 @@ func (m *gitMerger) resolveAndValidate(req *runwaymq.MergeRequest) ([]resolvedSt
299307
if !isConcreteStrategy(strategy) {
300308
return nil, fmt.Errorf("%w: unsupported strategy %v", merger.ErrInvalidRequest, step.GetStrategy())
301309
}
310+
if strategy == mergestrategypb.Strategy_PROMOTE {
311+
promoteSeen = true
312+
}
313+
302314
ch := step.GetChange()
303315
if ch == nil || len(ch.GetUris()) == 0 {
304316
return nil, fmt.Errorf("%w: step %q has no change URIs", merger.ErrInvalidRequest, step.GetStepId())
@@ -311,6 +323,17 @@ func (m *gitMerger) resolveAndValidate(req *runwaymq.MergeRequest) ([]resolvedSt
311323
resolved = append(resolved, resolvedStep{step: step, strategy: strategy})
312324
}
313325

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

@@ -544,6 +567,75 @@ func (m *gitMerger) applyMerge(ctx context.Context, step *runwaymq.MergeStep) ([
544567
return outputs, nil
545568
}
546569

570+
// promote fast-forwards the target to an already-existing commit. It is only
571+
// reachable for a validated single-step/single-change/single-URI request.
572+
func (m *gitMerger) promote(ctx context.Context, req *runwaymq.MergeRequest, rs resolvedStep, commit bool) (*runwaymq.MergeResult, error) {
573+
ref, err := resolveChange(rs.step.GetChange().GetUris()[0])
574+
if err != nil {
575+
return nil, err
576+
}
577+
sha := ref.SHA
578+
579+
var lastErr error
580+
for attempt := 1; attempt <= m.maxPushAttempts; attempt++ {
581+
if err := m.resetToRemote(ctx); err != nil {
582+
return nil, err
583+
}
584+
// PROMOTE does not go through tryApply, so it performs the same
585+
// availability and freshness checks itself. Without them a commit the
586+
// remote cannot supply turns every containment query into a plain
587+
// error, which the consumer retries forever instead of reporting.
588+
if err := m.ensureObjects(ctx, []changeRef{ref}); err != nil {
589+
return nil, err
590+
}
591+
if err := m.checkStale(ctx, []changeRef{ref}); err != nil {
592+
return nil, err
593+
}
594+
tip, err := m.headSHA(ctx)
595+
if err != nil {
596+
return nil, err
597+
}
598+
599+
// Idempotent: the commit is already the tip or contained in it.
600+
if sha == tip {
601+
return promoteResult(req, rs, sha, commit), nil
602+
}
603+
contained, err := m.isAncestor(ctx, sha, tip)
604+
if err != nil {
605+
return nil, err
606+
}
607+
if contained {
608+
return promoteResult(req, rs, sha, commit), nil
609+
}
610+
611+
// Only a true fast-forward is allowed; divergence is a terminal conflict.
612+
fastForward, err := m.isAncestor(ctx, tip, sha)
613+
if err != nil {
614+
return nil, err
615+
}
616+
if !fastForward {
617+
return nil, fmt.Errorf("%w: promote target %s is not a fast-forward of %s", merger.ErrConflict, sha, tip)
618+
}
619+
620+
if !commit {
621+
return promoteResult(req, rs, sha, commit), nil
622+
}
623+
624+
refspec := sha + ":refs/heads/" + m.target
625+
if _, err := m.run(ctx, nil, "push", m.remote, refspec); err != nil {
626+
// The target may have moved under us; re-fetch and re-classify.
627+
coremetrics.NamedCounter(m.metricsScope, "promote", "push_retries", 1)
628+
m.logger.Warnw("promote push failed, re-classifying",
629+
"attempt", attempt, "max_attempts", m.maxPushAttempts, "err", err)
630+
lastErr = err
631+
continue
632+
}
633+
return promoteResult(req, rs, sha, commit), nil
634+
}
635+
coremetrics.NamedCounter(m.metricsScope, "promote", "giveup", 1)
636+
return nil, fmt.Errorf("exceeded %d promote attempts due to remote contention: %w", m.maxPushAttempts, lastErr)
637+
}
638+
547639
// classifyMergeFailure decides what a failed `git merge` actually means, given
548640
// whether the index was left holding conflicted entries.
549641
//
@@ -930,13 +1022,13 @@ func passthroughEnv(extra []string) []string {
9301022
}
9311023

9321024
// isConcreteStrategy reports whether s names a concrete integration strategy
933-
// (i.e. not DEFAULT and not an unknown value). PROMOTE is not implemented yet
934-
// and is rejected as an invalid request until its apply path lands.
1025+
// (i.e. not DEFAULT and not an unknown value).
9351026
func isConcreteStrategy(s mergestrategypb.Strategy) bool {
9361027
switch s {
9371028
case mergestrategypb.Strategy_REBASE,
9381029
mergestrategypb.Strategy_SQUASH_REBASE,
939-
mergestrategypb.Strategy_MERGE:
1030+
mergestrategypb.Strategy_MERGE,
1031+
mergestrategypb.Strategy_PROMOTE:
9401032
return true
9411033
default:
9421034
return false
@@ -997,3 +1089,18 @@ func successResult(req *runwaymq.MergeRequest, steps []*runwaymq.StepResult) *ru
9971089
Steps: steps,
9981090
}
9991091
}
1092+
1093+
// promoteResult builds a SUCCEEDED MergeResult for a promote. A committing
1094+
// promote reports the promoted SHA as the step's single output; a dry-run check
1095+
// reports no output.
1096+
func promoteResult(req *runwaymq.MergeRequest, rs resolvedStep, sha string, commit bool) *runwaymq.MergeResult {
1097+
var outputs []*runwaymq.StepOutput
1098+
if commit {
1099+
outputs = []*runwaymq.StepOutput{{Id: sha}}
1100+
}
1101+
return &runwaymq.MergeResult{
1102+
Id: req.GetId(),
1103+
Outcome: runwaypb.Outcome_SUCCEEDED,
1104+
Steps: []*runwaymq.StepResult{{StepId: rs.step.GetStepId(), Outputs: outputs}},
1105+
}
1106+
}

runway/extension/merger/git/git_merger_test.go

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

494494
// --- PROMOTE ---
495495

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

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

0 commit comments

Comments
 (0)