Repository: jesseduffield/lazygit (language: go)

Issue:
Support rebasing onto upstream of a branch
**Is your feature request related to a problem? Please describe.**
We need to provide an easy way to rebase onto a branch's upstream.

**Describe the solution you'd like**
I'm thinking we should put this option behind the upstream-specific menu that you see when you press 'u' on a branch.

**Describe alternatives you've considered**
We could instead add an option for the upstream when you press 'r' on a branch (or we could support both flows). I'm undecided whether this is worth it.

PR https://github.com/jesseduffield/lazygit/pull/2649 proposed making it so that when you use 'r' on the checked out branch it rebases onto the upstream. I rejected this on the basis that if we need a UX for rebasing onto any branch's upstream we may as well get that UX in place and avoid having the same keybinding to different things depending on whether the selected branch is checked out or not.

**Additional context**
Based on conversation in https://github.com/jesseduffield/lazygit/pull/2649



Discussion / hints:
Hey - sorry for resuming this issue 👋🏻

Considering how we implemented the feature of resetting the checked-out branch to the selected one upstream, I think @jesseduffield's solution would be the right one. We would offer a consistent experience to the users, and we would have a battle-tested pattern to follow for handling edge cases.

Shall we proceed this way? @mark2185, are you going to work on this?
If you have extra time and want to work on it, feel free to take it! :)

I'll look at other issues for things to do, no worries.
I agree that the proposal is a good one.

However, before working on it I would recommend to wait until #2992 is merged, you'll benefit from it for disabling the new entry in case the branch has no upstream. I hope to push this along a bit more on the weekend.
Mh, you're right, @stefanhaller - thanks for pointing that out! I didn't realize it was moving so fast.
@AzraelSec #2992 has been merged, so you can go ahead with this!
Thank you, @stefanhaller. @jesseduffield, feel free to assign this issue to me 💪🏻 

Fix patch:
diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go
index 0e14bd6cf57..daecc5e387c 100644
--- a/pkg/gui/controllers/branches_controller.go
+++ b/pkg/gui/controllers/branches_controller.go
@@ -109,7 +109,7 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty
 		},
 		{
 			Key:         opts.GetKey(opts.Config.Branches.SetUpstream),
-			Handler:     self.checkSelected(self.setUpstream),
+			Handler:     self.checkSelected(self.viewUpstreamOptions),
 			Description: self.c.Tr.ViewBranchUpstreamOptions,
 			Tooltip:     self.c.Tr.ViewBranchUpstreamOptionsTooltip,
 			OpensMenu:   true,
@@ -141,7 +141,7 @@ func (self *BranchesController) GetOnRenderToMain() func() error {
 	}
 }
 
-func (self *BranchesController) setUpstream(selectedBranch *models.Branch) error {
+func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branch) error {
 	viewDivergenceItem := &types.MenuItem{
 		LabelColumns: []string{self.c.Tr.ViewDivergenceFromUpstream},
 		OnPress: func() error {
@@ -220,6 +220,15 @@ func (self *BranchesController) setUpstream(selectedBranch *models.Branch) error
 		map[string]string{"upstream": upstream},
 	)
 
+	upstreamRebaseOptions := utils.ResolvePlaceholderString(
+		self.c.Tr.ViewUpstreamRebaseOptions,
+		map[string]string{"upstream": upstream},
+	)
+	upstreamRebaseTooltip := utils.ResolvePlaceholderString(
+		self.c.Tr.ViewUpstreamRebaseOptionsTooltip,
+		map[string]string{"upstream": upstream},
+	)
+
 	upstreamResetItem := &types.MenuItem{
 		LabelColumns: []string{upstreamResetOptions},
 		OpensMenu:    true,
@@ -234,10 +243,24 @@ func (self *BranchesController) setUpstream(selectedBranch *models.Branch) error
 		Key:     'g',
 	}
 
+	upstreamRebaseItem := &types.MenuItem{
+		LabelColumns: []string{upstreamRebaseOptions},
+		OpensMenu:    true,
+		OnPress: func() error {
+			if err := self.c.Helpers().MergeAndRebase.RebaseOntoRef(selectedBranch.ShortUpstreamRefName()); err != nil {
+				return self.c.Error(err)
+			}
+			return nil
+		},
+		Tooltip: upstreamRebaseTooltip,
+		Key:     'r',
+	}
+
 	if !selectedBranch.RemoteBranchStoredLocally() {
 		viewDivergenceItem.DisabledReason = self.c.Tr.UpstreamNotSetError
 		unsetUpstreamItem.DisabledReason = self.c.Tr.UpstreamNotSetError
 		upstreamResetItem.DisabledReason = self.c.Tr.UpstreamNotSetError
+		upstreamRebaseItem.DisabledReason = self.c.Tr.UpstreamNotSetError
 	}
 
 	options := []*types.MenuItem{
@@ -245,6 +268,7 @@ func (self *BranchesController) setUpstream(selectedBranch *models.Branch) error
 		unsetUpstreamItem,
 		setUpstreamItem,
 		upstreamResetItem,
+		upstreamRebaseItem,
 	}
 
 	return self.c.Menu(types.CreateMenuOptions{
diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go
index 6fc4cec7806..b442af6f6ef 100644
--- a/pkg/i18n/english.go
+++ b/pkg/i18n/english.go
@@ -356,6 +356,8 @@ type TranslationSet struct {
 	DivergenceSectionHeaderRemote       string
 	ViewUpstreamResetOptions            string
 	ViewUpstreamResetOptionsTooltip     string
+	ViewUpstreamRebaseOptions           string
+	ViewUpstreamRebaseOptionsTooltip    string
 	UpstreamGenericName                 string
 	SetUpstreamTitle                    string
 	SetUpstreamMessage                  string
@@ -1152,6 +1154,8 @@ func EnglishTranslationSet() TranslationSet {
 		DivergenceSectionHeaderRemote:       "Remote",
 		ViewUpstreamResetOptions:            "Reset checked-out branch onto {{.upstream}}",
 		ViewUpstreamResetOptionsTooltip:     "View options for resetting the checked-out branch onto {{upstream}}. Note: this will not reset the selected branch onto the upstream, it will reset the checked-out branch onto the upstream",
+		ViewUpstreamRebaseOptions:           "Rebase checked-out branch onto {{.upstream}}",
+		ViewUpstreamRebaseOptionsTooltip:    "View options for rebasing the checked-out branch onto {{upstream}}. Note: this will not rebase the selected branch onto the upstream, it will rebased the checked-out branch onto the upstream",
 		UpstreamGenericName:                 "upstream of selected branch",
 		SetUpstreamTitle:                    "Set upstream branch",
 		SetUpstreamMessage:                  "Are you sure you want to set the upstream branch of '{{.checkedOut}}' to '{{.selected}}'",


Test patch:
diff --git a/pkg/integration/tests/branch/rebase_to_upstream.go b/pkg/integration/tests/branch/rebase_to_upstream.go
new file mode 100644
index 00000000000..1fc51350d0d
--- /dev/null
+++ b/pkg/integration/tests/branch/rebase_to_upstream.go
@@ -0,0 +1,82 @@
+package branch
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var RebaseToUpstream = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "Rebase the current branch to the selected branch upstream",
+	ExtraCmdArgs: []string{},
+	Skip:         false,
+	SetupConfig:  func(config *config.AppConfig) {},
+	SetupRepo: func(shell *Shell) {
+		shell.
+			CloneIntoRemote("origin").
+			EmptyCommit("ensure-master").
+			EmptyCommit("to-be-added"). // <- this will only exist remotely
+			PushBranch("origin", "master").
+			HardReset("HEAD~1").
+			NewBranchFrom("base-branch", "master").
+			EmptyCommit("base-branch-commit").
+			NewBranch("target").
+			EmptyCommit("target-commit")
+	},
+	Run: func(t *TestDriver, keys config.KeybindingConfig) {
+		t.Views().Commits().Lines(
+			Contains("target-commit"),
+			Contains("base-branch-commit"),
+			Contains("ensure-master"),
+		)
+
+		t.Views().Branches().
+			Focus().
+			Lines(
+				Contains("target").IsSelected(),
+				Contains("base-branch"),
+				Contains("master"),
+			).
+			SelectNextItem().
+			Lines(
+				Contains("target"),
+				Contains("base-branch").IsSelected(),
+				Contains("master"),
+			).
+			Press(keys.Branches.SetUpstream).
+			Tap(func() {
+				t.ExpectPopup().Menu().
+					Title(Equals("Upstream options")).
+					Select(Contains("Rebase checked-out branch onto upstream of selected branch")).
+					Tooltip(Contains("Disabled: The selected branch has no upstream (or the upstream is not stored locally)")).
+					Confirm()
+				t.ExpectPopup().Alert().
+					Title(Equals("Error")).
+					Content(Equals("The selected branch has no upstream (or the upstream is not stored locally)")).
+					Confirm()
+			}).
+			SelectNextItem().
+			Lines(
+				Contains("target"),
+				Contains("base-branch"),
+				Contains("master").IsSelected(),
+			).
+			Press(keys.Branches.SetUpstream).
+			Tap(func() {
+				t.ExpectPopup().Menu().
+					Title(Equals("Upstream options")).
+					Select(Contains("Rebase checked-out branch onto origin/master...")).
+					Confirm()
+				t.ExpectPopup().Menu().
+					Title(Equals("Rebase 'target' onto 'origin/master'")).
+					Select(Contains("Simple rebase")).
+					Confirm()
+			})
+
+		t.Views().Commits().Lines(
+			Contains("target-commit"),
+			Contains("base-branch-commit"),
+			Contains("to-be-added"),
+			Contains("ensure-master"),
+		)
+	},
+})
diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go
index f4ec26ec23d..535bf55fead 100644
--- a/pkg/integration/tests/test_list.go
+++ b/pkg/integration/tests/test_list.go
@@ -47,6 +47,7 @@ var tests = []*components.IntegrationTest{
 	branch.RebaseCancelOnConflict,
 	branch.RebaseDoesNotAutosquash,
 	branch.RebaseFromMarkedBase,
+	branch.RebaseToUpstream,
 	branch.Rename,
 	branch.Reset,
 	branch.ResetToUpstream,