From d8a4f1aee7e8f86d5c03dabb03f2c947c05760d9 Mon Sep 17 00:00:00 2001
From: Kent Rancourt <kent.rancourt@gmail.com>
Date: Wed, 11 Dec 2024 13:39:37 -0500
Subject: [PATCH] make git push detect non-fast-forwards

Signed-off-by: Kent Rancourt <kent.rancourt@gmail.com>
---
 internal/controller/git/errors.go      | 10 +++++++
 internal/controller/git/errors_test.go | 37 +++++++++++++++++++++++++-
 internal/controller/git/work_tree.go   | 11 +++++++-
 3 files changed, 56 insertions(+), 2 deletions(-)

diff --git a/internal/controller/git/errors.go b/internal/controller/git/errors.go
index 440280cf21..fea260bc73 100644
--- a/internal/controller/git/errors.go
+++ b/internal/controller/git/errors.go
@@ -12,3 +12,13 @@ var ErrMergeConflict = errors.New("merge conflict")
 func IsMergeConflict(err error) bool {
 	return errors.Is(err, ErrMergeConflict)
 }
+
+// ErrNonFastForward is returned when a push is rejected because it is not a
+// fast-forward or needs to be fetched first.
+var ErrNonFastForward = errors.New("non-fast-forward")
+
+// IsNonFastForward returns true if the error is a non-fast-forward or wraps one
+// and false otherwise.
+func IsNonFastForward(err error) bool {
+	return errors.Is(err, ErrNonFastForward)
+}
diff --git a/internal/controller/git/errors_test.go b/internal/controller/git/errors_test.go
index 6facdba44d..f92a720929 100644
--- a/internal/controller/git/errors_test.go
+++ b/internal/controller/git/errors_test.go
@@ -20,7 +20,7 @@ func TestIsMergeConflict(t *testing.T) {
 			expected: false,
 		},
 		{
-			name:     "not a a merge conflict",
+			name:     "not a merge conflict",
 			err:      errors.New("something went wrong"),
 			expected: false,
 		},
@@ -42,3 +42,38 @@ func TestIsMergeConflict(t *testing.T) {
 		})
 	}
 }
+
+func TestIsNonFastForward(t *testing.T) {
+	testCases := []struct {
+		name     string
+		err      error
+		expected bool
+	}{
+		{
+			name:     "nil error",
+			err:      nil,
+			expected: false,
+		},
+		{
+			name:     "not a non-fast-forward error",
+			err:      errors.New("something went wrong"),
+			expected: false,
+		},
+		{
+			name:     "a non-fast-forward error",
+			err:      ErrNonFastForward,
+			expected: true,
+		},
+		{
+			name:     "a wrapped fast forward error",
+			err:      fmt.Errorf("an error occurred: %w", ErrNonFastForward),
+			expected: true,
+		},
+	}
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			actual := IsNonFastForward(testCase.err)
+			require.Equal(t, testCase.expected, actual)
+		})
+	}
+}
diff --git a/internal/controller/git/work_tree.go b/internal/controller/git/work_tree.go
index 79f37e5952..214691b437 100644
--- a/internal/controller/git/work_tree.go
+++ b/internal/controller/git/work_tree.go
@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"regexp"
 	"strings"
 	"time"
 
@@ -516,6 +517,11 @@ type PushOptions struct {
 	PullRebase bool
 }
 
+// https://regex101.com/r/aNYjHP/1
+//
+// nolint: lll
+var nonFastForwardRegex = regexp.MustCompile(`(?m)^\s*!\s+\[(?:remote )?rejected].+\((?:non-fast-forward|fetch first|cannot lock ref.*)\)\s*$`)
+
 func (w *workTree) Push(opts *PushOptions) error {
 	if opts == nil {
 		opts = &PushOptions{}
@@ -551,7 +557,10 @@ func (w *workTree) Push(opts *PushOptions) error {
 	if opts.Force {
 		args = append(args, "--force")
 	}
-	if _, err := libExec.Exec(w.buildGitCommand(args...)); err != nil {
+	if res, err := libExec.Exec(w.buildGitCommand(args...)); err != nil {
+		if nonFastForwardRegex.MatchString(string(res)) {
+			return fmt.Errorf("error pushing branch: %w", ErrNonFastForward)
+		}
 		return fmt.Errorf("error pushing branch: %w", err)
 	}
 	return nil