From e34cdbe9e35496e4bc6e006777c89ed25f3938bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Bauer?= Date: Mon, 31 Aug 2026 19:57:12 +0200 Subject: [PATCH] Add opt-in retry-with-backoff for rejected pushes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When many independent ImageUpdateAutomation objects push to the same branch of one GitOps repo, a rejected push (another writer already advanced the branch) previously just failed the reconciliation, with the next attempt only happening on the next scheduled reconcile or via controller-runtime's exponential-backoff requeue. Under heavy write contention this turns a sub-minute git operation into a delay of tens of minutes. Add commitAndPushWithRetry, which on a push rejected specifically due to a conflict (source.IsPushConflict) fetches and hard-resets onto the new remote tip (SourceManager.RefreshToRemote, no full re-clone), re-applies policies against the refreshed tree, and retries the commit and push, up to 5 attempts with exponential backoff (2s/4s/8s/16s). Any other error, or exhaustion of all attempts, is returned unchanged. The retry loop is bounded by min(.spec.interval / 2, 2 minutes) so contention on one branch cannot block a reconcile worker indefinitely. Gated behind the new GitPushRetryOnConflict feature gate, disabled by default. Depends on FetchAndReset and ErrPushRejected from a companion fluxcd/pkg change; go.mod currently points at that change's fork branch pending a tagged release. Assisted-by: Claude Sonnet 5/claude-sonnet-5 Signed-off-by: André Bauer --- CHANGELOG.md | 14 + go.mod | 2 + go.sum | 4 +- .../imageupdateautomation_controller.go | 2 +- internal/controller/push_retry.go | 128 +++++++++ internal/controller/push_retry_test.go | 251 ++++++++++++++++++ internal/features/features.go | 9 + internal/source/source.go | 20 ++ internal/source/source_test.go | 120 +++++++++ 9 files changed, 547 insertions(+), 3 deletions(-) create mode 100644 internal/controller/push_retry.go create mode 100644 internal/controller/push_retry_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aa596df..18a1139b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## Unreleased + +This release adds automatic retry for `ImageUpdateAutomation` pushes rejected +because another writer already advanced the same push branch (a lost +non-fast-forward race). On a rejected push, the controller now fetches and +hard-resets to the new remote tip, re-applies policies, and retries the +commit and push, up to 5 attempts with exponential backoff (2s/4s/8s/16s), +instead of waiting for the next scheduled reconciliation. This is controlled +by the new `GitPushRetryOnConflict` feature gate, disabled by default. + +Improvements: +- Retry pushes rejected due to a lost push race instead of waiting for the + next reconciliation, behind the opt-in `GitPushRetryOnConflict` feature gate + ## 1.2.4 **Release date:** 2026-08-07 diff --git a/go.mod b/go.mod index db88f2db..2b2b87a6 100644 --- a/go.mod +++ b/go.mod @@ -202,3 +202,5 @@ require ( sigs.k8s.io/structured-merge-diff/v6 v6.4.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) + +replace github.com/fluxcd/pkg/git => github.com/monotek/fluxcd-pkg/git v0.0.0-20260831175242-c1457b1dca05 diff --git a/go.sum b/go.sum index 2055d0ee..40e2bf04 100644 --- a/go.sum +++ b/go.sum @@ -136,8 +136,6 @@ github.com/fluxcd/pkg/auth v0.54.0 h1:EiNUhksFwUULmrctsTcfjtwszmzVgpEPuP2LcgfwIb github.com/fluxcd/pkg/auth v0.54.0/go.mod h1:bf+0mQNaxgMLvdR3S15qz/3t0GoLTipl1xDQBguNsJI= github.com/fluxcd/pkg/cache v0.14.0 h1:wEwJA8NhYj+nH9P6ifcsglDZARWlcbxbmwngGOzfU4c= github.com/fluxcd/pkg/cache v0.14.0/go.mod h1:KwzU2gyVQ83YOHJsbBeveJ0HsXmLrH0I668zX19d/+s= -github.com/fluxcd/pkg/git v0.52.0 h1:dgsliHdaLADUcDO4pI0pc11N4dZ21NfDdhNcgRNuAkM= -github.com/fluxcd/pkg/git v0.52.0/go.mod h1:mOvFDxoiuz+Mm4Ux1wKeTTckvBgZFvbTK8lNxmVHzKs= github.com/fluxcd/pkg/gittestserver v0.29.0 h1:2j03zKVL6iVn6oiUuecG/O/3Q1pULWM9JrF/HSjkpnc= github.com/fluxcd/pkg/gittestserver v0.29.0/go.mod h1:O8151jV0ppBZTb9IUXMjxh6hZpkiuLq8JQHDBPOkZFw= github.com/fluxcd/pkg/runtime v0.110.0 h1:ziGAuoQ3OVSEqmMXS6doZWi2LcF7exEKPe69dun5RNg= @@ -302,6 +300,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/monotek/fluxcd-pkg/git v0.0.0-20260831175242-c1457b1dca05 h1:v/aprrWOMkIb0IUrckkrXC8+9nTBXDLLcR49ON32zUA= +github.com/monotek/fluxcd-pkg/git v0.0.0-20260831175242-c1457b1dca05/go.mod h1:mOvFDxoiuz+Mm4Ux1wKeTTckvBgZFvbTK8lNxmVHzKs= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= diff --git a/internal/controller/imageupdateautomation_controller.go b/internal/controller/imageupdateautomation_controller.go index 8c20fe1e..cd1769f5 100644 --- a/internal/controller/imageupdateautomation_controller.go +++ b/internal/controller/imageupdateautomation_controller.go @@ -491,7 +491,7 @@ func (r *ImageUpdateAutomationReconciler) reconcile(ctx context.Context, sp *pat pushCfg = append(pushCfg, source.WithPushConfigOptions(obj.Spec.GitSpec.Push.Options)) } - pushResult, err = sm.CommitAndPush(ctx, obj, policyResult, pushCfg...) + pushResult, err = r.commitAndPushWithRetry(ctx, sm, obj, policies, policyResult, pushCfg) if err != nil { // Check if error is due to removed template field usage. // Set Stalled condition and return nil error to prevent requeue, allowing user to fix template. diff --git a/internal/controller/push_retry.go b/internal/controller/push_retry.go new file mode 100644 index 00000000..7c76a1c7 --- /dev/null +++ b/internal/controller/push_retry.go @@ -0,0 +1,128 @@ +/* +Copyright 2026 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "context" + "fmt" + "time" + + ctrl "sigs.k8s.io/controller-runtime" + + reflectorv1 "github.com/fluxcd/image-reflector-controller/api/v1" + + imagev1 "github.com/fluxcd/image-automation-controller/api/v1" + "github.com/fluxcd/image-automation-controller/internal/features" + "github.com/fluxcd/image-automation-controller/internal/policy" + "github.com/fluxcd/image-automation-controller/internal/source" + "github.com/fluxcd/image-automation-controller/internal/update" +) + +// These are vars, not consts, so tests can shrink them for determinism and +// speed without changing the retry mechanics itself. +var ( + // pushRetryMaxAttempts mirrors Staffbase's gitops-github-action + // retry_with_backoff(5, 2, push_to_gitops_repo) pattern: 5 attempts, + // base delay 2s, doubling each retry. + pushRetryMaxAttempts = 5 + // pushRetryBaseDelay is the initial backoff, doubled after each retry: + // 2s, 4s, 8s, 16s. + pushRetryBaseDelay = 2 * time.Second + // maxPushRetryBudget bounds how long a single reconcile() call may block + // retrying a push, independent of the object's configured interval, so + // heavy contention on one branch cannot starve other ImageUpdateAutomation + // objects sharing the same reconcile worker pool. + maxPushRetryBudget = 2 * time.Minute +) + +// pushRetryTestHook, when set, is called immediately before each attempt's +// CommitAndPush. It exists only so tests can deterministically land a +// competing commit inside the race window, instead of relying on wall-clock +// timing against a background pusher. Always nil in production. +var pushRetryTestHook func(attempt int) + +// commitAndPushWithRetry wraps SourceManager.CommitAndPush with a +// fetch+reset+reapply retry loop, engaged only when the push is rejected +// because another writer already advanced the same branch +// (source.IsPushConflict). Any other error, or exhaustion of +// pushRetryMaxAttempts, is returned unchanged for the caller's existing +// error handling. +// +// On retry, only the cheap SourceManager.RefreshToRemote (fetch + hard +// reset) is used to catch the working directory up to the new remote tip; +// SourceManager.CheckoutSource's full clone is deliberately not repeated. +func (r *ImageUpdateAutomationReconciler) commitAndPushWithRetry( + ctx context.Context, + sm *source.SourceManager, + obj *imagev1.ImageUpdateAutomation, + policies []reflectorv1.ImagePolicy, + policyResult update.Result, + pushCfg []source.PushConfig, +) (*source.PushResult, error) { + if !r.features[features.GitPushRetryOnConflict] { + return sm.CommitAndPush(ctx, obj, policyResult, pushCfg...) + } + + retryBudget := obj.GetRequeueAfter() / 2 + if retryBudget > maxPushRetryBudget { + retryBudget = maxPushRetryBudget + } + retryCtx, cancel := context.WithTimeout(ctx, retryBudget) + defer cancel() + + log := ctrl.LoggerFrom(ctx) + + for attempt := 1; attempt <= pushRetryMaxAttempts; attempt++ { + if pushRetryTestHook != nil { + pushRetryTestHook(attempt) + } + pushResult, err := sm.CommitAndPush(retryCtx, obj, policyResult, pushCfg...) + if err == nil { + if attempt > 1 { + log.Info("push succeeded after retry", "attempts", attempt) + } + return pushResult, nil + } + if !source.IsPushConflict(err) || attempt == pushRetryMaxAttempts { + return nil, err + } + + delay := pushRetryBaseDelay * time.Duration(uint64(1)<=", pushRetryBaseDelay)) + g.Expect(elapsed).To(BeNumerically("<", 2*pushRetryBaseDelay)) +} + +func TestCommitAndPushWithRetry_ExhaustsAttempts(t *testing.T) { + g, _, sm, obj, policies, repoURL := newPushRetryFixture(t, time.Hour) + ctx := context.TODO() + + origMaxAttempts, origBaseDelay := pushRetryMaxAttempts, pushRetryBaseDelay + pushRetryMaxAttempts = 3 + pushRetryBaseDelay = 10 * time.Millisecond + t.Cleanup(func() { pushRetryMaxAttempts, pushRetryBaseDelay = origMaxAttempts, origBaseDelay }) + + result, err := policy.ApplyPolicies(ctx, sm.WorkDirectory(), obj, policies) + g.Expect(err).ToNot(HaveOccurred()) + + competitorRepo, competitorDir, err := testutil.Clone(ctx, repoURL, "main", originRemote) + g.Expect(err).ToNot(HaveOccurred()) + defer os.RemoveAll(competitorDir) + remote, err := competitorRepo.Remote(originRemote) + g.Expect(err).ToNot(HaveOccurred()) + + // A competitor wins every single attempt: the test hook fires + // synchronously right before each attempt's CommitAndPush, so a fresh + // competing commit deterministically lands inside the race window + // instead of racing wall-clock timing against a background pusher. + pushRetryTestHook = func(attempt int) { + name := filepath.Join(competitorDir, "competitor-"+rand.String(5)+".txt") + g.Expect(os.WriteFile(name, []byte("competing change"), 0o644)).To(Succeed()) + testutil.CommitWorkDir(g, competitorRepo, "main", "competing change") + g.Expect(remote.PushContext(ctx, &extgogit.PushOptions{RemoteName: originRemote})).To(Succeed()) + } + t.Cleanup(func() { pushRetryTestHook = nil }) + + r := &ImageUpdateAutomationReconciler{features: map[string]bool{features.GitPushRetryOnConflict: true}} + _, err = r.commitAndPushWithRetry(ctx, sm, obj, policies, result, nil) + + g.Expect(err).To(HaveOccurred()) + g.Expect(source.IsPushConflict(err)).To(BeTrue()) +} + +func TestCommitAndPushWithRetry_NonConflictErrorNotRetried(t *testing.T) { + g, gitServer, sm, obj, policies, _ := newPushRetryFixture(t, time.Hour) + ctx := context.TODO() + + result, err := policy.ApplyPolicies(ctx, sm.WorkDirectory(), obj, policies) + g.Expect(err).ToNot(HaveOccurred()) + + // Break connectivity so the push fails for a reason other than a + // conflict (connection refused, not "someone else pushed first"). + gitServer.StopHTTP() + + r := &ImageUpdateAutomationReconciler{features: map[string]bool{features.GitPushRetryOnConflict: true}} + start := time.Now() + _, err = r.commitAndPushWithRetry(ctx, sm, obj, policies, result, nil) + elapsed := time.Since(start) + + g.Expect(err).To(HaveOccurred()) + g.Expect(source.IsPushConflict(err)).To(BeFalse()) + // No backoff/refresh should have been attempted for a non-conflict error. + g.Expect(elapsed).To(BeNumerically("<", pushRetryBaseDelay)) +} + +func TestCommitAndPushWithRetry_BudgetExceeded(t *testing.T) { + // A very short interval collapses the retry budget below the first + // backoff delay, so the loop must bail out via its own context timeout + // rather than sleeping the full pushRetryBaseDelay. + g, _, sm, obj, policies, repoURL := newPushRetryFixture(t, 1*time.Second) + ctx := context.TODO() + + result, err := policy.ApplyPolicies(ctx, sm.WorkDirectory(), obj, policies) + g.Expect(err).ToNot(HaveOccurred()) + + pushCompetingCommit(g, repoURL, "main", "competitor-1.txt") + + r := &ImageUpdateAutomationReconciler{features: map[string]bool{features.GitPushRetryOnConflict: true}} + start := time.Now() + _, err = r.commitAndPushWithRetry(ctx, sm, obj, policies, result, nil) + elapsed := time.Since(start) + + g.Expect(err).To(HaveOccurred()) + g.Expect(elapsed).To(BeNumerically("<", pushRetryBaseDelay)) +} + +func TestCommitAndPushWithRetry_FeatureGateDisabled(t *testing.T) { + g, _, sm, obj, policies, repoURL := newPushRetryFixture(t, time.Hour) + ctx := context.TODO() + + result, err := policy.ApplyPolicies(ctx, sm.WorkDirectory(), obj, policies) + g.Expect(err).ToNot(HaveOccurred()) + + pushCompetingCommit(g, repoURL, "main", "competitor-1.txt") + + r := &ImageUpdateAutomationReconciler{features: map[string]bool{features.GitPushRetryOnConflict: false}} + _, err = r.commitAndPushWithRetry(ctx, sm, obj, policies, result, nil) + g.Expect(err).To(HaveOccurred()) + g.Expect(source.IsPushConflict(err)).To(BeTrue()) +} diff --git a/internal/features/features.go b/internal/features/features.go index 7a30cc11..c4981027 100644 --- a/internal/features/features.go +++ b/internal/features/features.go @@ -37,6 +37,11 @@ const ( // GitSparseCheckout enables the use of sparse checkout when pulling source from // Git repositories. GitSparseCheckout = "GitSparseCheckout" + // GitPushRetryOnConflict enables retrying a rejected push (fetch, hard + // reset, re-apply policies, recommit, push again) instead of failing + // the reconciliation immediately when another writer has already + // advanced the push branch. + GitPushRetryOnConflict = "GitPushRetryOnConflict" // CacheSecretsAndConfigMaps controls whether Secrets and ConfigMaps should // be cached. // @@ -62,6 +67,10 @@ var features = map[string]bool{ // opt-in from v0.42 GitSparseCheckout: false, + // GitPushRetryOnConflict + // opt-in from v1.2.5 + GitPushRetryOnConflict: false, + // CacheSecretsAndConfigMaps // opt-in from v0.29 CacheSecretsAndConfigMaps: false, diff --git a/internal/source/source.go b/internal/source/source.go index d2f58cf1..47ae37d8 100644 --- a/internal/source/source.go +++ b/internal/source/source.go @@ -378,6 +378,26 @@ func (sm SourceManager) CommitAndPush(ctx context.Context, obj *imagev1.ImageUpd return NewPushResult(sm.srcCfg.pushBranch, rev, commitMsg, prOpts...) } +// IsPushConflict reports whether err indicates that CommitAndPush's push was +// rejected because another writer already advanced the remote branch past +// what the local working directory knows about. This is recoverable by +// calling RefreshToRemote, re-applying policies against the refreshed +// working tree, and retrying, rather than failing the reconciliation. +func IsPushConflict(err error) bool { + return errors.Is(err, git.ErrPushRejected) +} + +// RefreshToRemote fetches the current state of the push branch from the +// remote and hard-resets the local working directory onto it, discarding +// any local commits or changes made since the last successful push. It is +// used to recover from a push rejected due to a conflict (see +// IsPushConflict) without a full re-clone via CheckoutSource. +func (sm SourceManager) RefreshToRemote(ctx context.Context) error { + gitOpCtx, cancel := context.WithTimeout(ctx, sm.srcCfg.timeout.Duration) + defer cancel() + return sm.gitClient.FetchAndReset(gitOpCtx, sm.srcCfg.pushBranch) +} + // templateMsg renders a msg template, returning the message or an error. func templateMsg(messageTemplate string, templateValues *TemplateData) (string, error) { if messageTemplate == "" { diff --git a/internal/source/source_test.go b/internal/source/source_test.go index e3c2caab..a6e0d7b3 100644 --- a/internal/source/source_test.go +++ b/internal/source/source_test.go @@ -914,6 +914,126 @@ Testing: value } } +// TestSourceManager_RefreshToRemote_RecoversFromPushConflict simulates a lost +// push race: another writer pushes to the same branch between CheckoutSource +// and CommitAndPush, so the first CommitAndPush is rejected. It verifies +// IsPushConflict identifies the error, RefreshToRemote (fetch + hard reset) +// catches the working directory up to the new remote tip without a full +// re-clone, and a recomputed CommitAndPush then succeeds, landing on top of +// the competing commit rather than overwriting it. +func TestSourceManager_RefreshToRemote_RecoversFromPushConflict(t *testing.T) { + g := NewWithT(t) + ctx := context.TODO() + + gitServer := testutil.SetUpGitTestServer(g) + t.Cleanup(func() { + g.Expect(os.RemoveAll(gitServer.Root())).ToNot(HaveOccurred()) + gitServer.StopHTTP() + }) + + testNS := "test-ns" + workDir := t.TempDir() + + imgPolicy := &reflectorv1.ImagePolicy{} + imgPolicy.Name = "policy1" + imgPolicy.Namespace = testNS + imgPolicy.Status = reflectorv1.ImagePolicyStatus{ + LatestRef: testutil.ImageToRef("helloworld:1.0.1"), + } + policyKey := client.ObjectKeyFromObject(imgPolicy) + + fixture := "testdata/appconfig" + g.Expect(copy.Copy(fixture, workDir)).ToNot(HaveOccurred()) + g.Expect(testutil.ReplaceMarker(filepath.Join(workDir, "deploy.yaml"), policyKey)) + + branch := "main" + repoPath := "/config-" + rand.String(5) + ".git" + testutil.InitGitRepo(g, gitServer, workDir, branch, repoPath) + cloneLocalRepoURL := gitServer.HTTPAddressWithCredentials() + repoPath + + gitRepo := &sourcev1.GitRepository{} + gitRepo.Name = "test-repo" + gitRepo.Namespace = testNS + gitRepo.Spec = sourcev1.GitRepositorySpec{ + URL: cloneLocalRepoURL, + Reference: &sourcev1.GitRepositoryRef{Branch: branch}, + } + + updateAuto := &imagev1.ImageUpdateAutomation{} + updateAuto.Name = "test-update" + updateAuto.Namespace = testNS + updateAuto.Spec = imagev1.ImageUpdateAutomationSpec{ + SourceRef: imagev1.CrossNamespaceSourceReference{ + Kind: sourcev1.GitRepositoryKind, + Name: gitRepo.Name, + }, + Update: &imagev1.UpdateStrategy{ + Strategy: imagev1.UpdateStrategySetters, + }, + GitSpec: &imagev1.GitSpec{ + Push: &imagev1.PushSpec{Branch: branch}, + }, + } + + kClient := fakeclient.NewClientBuilder().WithScheme(scheme.Scheme). + WithObjects(imgPolicy, gitRepo, updateAuto).Build() + + sm, err := NewSourceManager(ctx, kClient, updateAuto) + g.Expect(err).ToNot(HaveOccurred()) + defer func() { + g.Expect(sm.Cleanup()).ToNot(HaveOccurred()) + }() + + _, err = sm.CheckoutSource(ctx) + g.Expect(err).ToNot(HaveOccurred()) + + policies := []reflectorv1.ImagePolicy{*imgPolicy} + result, err := policy.ApplyPolicies(ctx, sm.workingDir, updateAuto, policies) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result.FileChanges).ToNot(BeEmpty()) + + // A competing writer pushes directly to the remote, out-of-band, after + // our checkout but before our push - the same race window IUAs hit + // against a shared branch. + competitorRepo, competitorDir, err := testutil.Clone(ctx, cloneLocalRepoURL, branch, originRemote) + g.Expect(err).ToNot(HaveOccurred()) + defer os.RemoveAll(competitorDir) + g.Expect(os.WriteFile(filepath.Join(competitorDir, "competitor.txt"), []byte("competing change"), 0o644)).To(Succeed()) + competitorCommit := testutil.CommitWorkDir(g, competitorRepo, branch, "competing change") + remote, err := competitorRepo.Remote(originRemote) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(remote.PushContext(ctx, &extgogit.PushOptions{RemoteName: originRemote})).To(Succeed()) + + // Our push loses the race. + _, err = sm.CommitAndPush(ctx, updateAuto, result) + g.Expect(err).To(HaveOccurred()) + g.Expect(IsPushConflict(err)).To(BeTrue()) + + // Recover: fetch + hard reset onto the new remote tip, without a full + // re-clone, then recompute the change against the refreshed tree. + g.Expect(sm.RefreshToRemote(ctx)).To(Succeed()) + result, err = policy.ApplyPolicies(ctx, sm.workingDir, updateAuto, policies) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(result.FileChanges).ToNot(BeEmpty()) + + pushResult, err := sm.CommitAndPush(ctx, updateAuto, result) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(pushResult).ToNot(BeNil()) + + // The recovered commit must land on top of the competing commit, proving + // the reset+reapply rebased onto the new tip rather than overwriting it. + localRepo, cloneDir, err := testutil.Clone(ctx, cloneLocalRepoURL, branch, originRemote) + g.Expect(err).ToNot(HaveOccurred()) + defer os.RemoveAll(cloneDir) + head, err := localRepo.Head() + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(head.Hash().String()).To(Equal(pushResult.Commit().Hash.String())) + recoveredCommit, err := localRepo.CommitObject(head.Hash()) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(recoveredCommit.NumParents()).To(Equal(1)) + g.Expect(recoveredCommit.ParentHashes[0].String()).To(Equal(competitorCommit.String())) +} + // Test_pushBranchUpdateScenarios tests the push operation for different states // of the remote repository. func Test_pushBranchUpdateScenarios(t *testing.T) {