From e3cab9777c46a13dedd9dd51fbb7989efd3deb1d Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Mon, 3 Aug 2026 12:26:51 +0100 Subject: [PATCH] add restore upstream branch menu item Restore an upstream branch that was deleted on the remote by pushing the local branch back to its configured upstream, removing the '(upstream gone)' state. --- pkg/gui/controllers/branches_controller.go | 40 ++++++++++++++ pkg/i18n/english.go | 6 +++ pkg/integration/components/git.go | 8 +++ .../tests/branch/restore_upstream_branch.go | 52 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 5 files changed, 107 insertions(+) create mode 100644 pkg/integration/tests/branch/restore_upstream_branch.go diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index 4b31de366..e6babc435 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -332,6 +332,14 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc Keys: menuKey('s'), } + restoreUpstreamItem := &types.MenuItem{ + LabelColumns: []string{self.c.Tr.RestoreUpstreamBranch}, + OnPress: func() error { + return self.pushBranchToUpstream(selectedBranch) + }, + Keys: menuKey('p'), + } + upstreamResetOptions := utils.ResolvePlaceholderString( self.c.Tr.ViewUpstreamResetOptions, map[string]string{"upstream": upstream}, @@ -388,11 +396,22 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc upstreamRebaseItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.UpstreamNotSetError} } + // We can only restore an upstream that still has a tracking configuration + // but whose remote branch has been deleted (i.e. it shows "upstream gone"). + if !selectedBranch.UpstreamGone { + disabledReason := self.c.Tr.UpstreamNotSetError + if selectedBranch.IsTrackingRemote() { + disabledReason = self.c.Tr.UpstreamNotGoneError + } + restoreUpstreamItem.DisabledReason = &types.DisabledReason{Text: disabledReason} + } + options := []*types.MenuItem{ viewDivergenceItem, viewDivergenceFromBaseBranchItem, unsetUpstreamItem, setUpstreamItem, + restoreUpstreamItem, upstreamResetItem, upstreamRebaseItem, } @@ -754,6 +773,27 @@ func (self *BranchesController) fastForward(branch *models.Branch) error { }) } +// pushBranchToUpstream pushes the given branch to its configured upstream, +// recreating a remote branch that was deleted (e.g. on GitHub) so the branch +// no longer shows as "upstream gone". +func (self *BranchesController) pushBranchToUpstream(branch *models.Branch) error { + return self.c.WithInlineStatus(branch, types.ItemOperationPushing, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error { + self.c.LogAction(self.c.Tr.Actions.RestoreUpstreamBranch) + err := self.c.Git().Sync.Push( + task, + git_commands.PushOpts{ + CurrentBranch: branch.Name, + UpstreamRemote: branch.UpstreamRemote, + UpstreamBranch: branch.UpstreamBranch, + }) + if err != nil { + return err + } + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS}}) + return nil + }) +} + func (self *BranchesController) createTag(branch *models.Branch) error { return self.c.Helpers().Tags.OpenCreateTagPrompt(branch.FullRefName(), func() {}) } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index fa51d85ea..eb49cdd7d 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -575,6 +575,7 @@ type TranslationSet struct { SetAsUpstreamTooltip string SetUpstream string UnsetUpstream string + RestoreUpstreamBranch string ViewDivergenceFromUpstream string ViewDivergenceFromBaseBranch string CouldNotDetermineBaseBranch string @@ -646,6 +647,7 @@ type TranslationSet struct { ViewBranchUpstreamOptions string ViewBranchUpstreamOptionsTooltip string UpstreamNotSetError string + UpstreamNotGoneError string UpstreamsNotSetError string NewGitFlowBranchPrompt string RenameBranchWarning string @@ -1024,6 +1026,7 @@ type Actions struct { RenameBranch string CreateBranch string FastForwardBranch string + RestoreUpstreamBranch string AutoForwardBranches string CherryPick string CheckoutFile string @@ -1740,6 +1743,7 @@ func EnglishTranslationSet() *TranslationSet { SetAsUpstreamTooltip: "Set the selected remote branch as the upstream of the checked-out branch.", SetUpstream: "Set upstream of selected branch", UnsetUpstream: "Unset upstream of selected branch", + RestoreUpstreamBranch: "Restore upstream branch", ViewDivergenceFromUpstream: "View divergence from upstream", ViewDivergenceFromBaseBranch: "View divergence from base branch ({{.baseBranch}})", CouldNotDetermineBaseBranch: "Couldn't determine base branch", @@ -1807,6 +1811,7 @@ func EnglishTranslationSet() *TranslationSet { ViewBranchUpstreamOptions: "View upstream options", ViewBranchUpstreamOptionsTooltip: "View options relating to the branch's upstream e.g. setting/unsetting the upstream and resetting to the upstream.", UpstreamNotSetError: "The selected branch has no upstream (or the upstream is not stored locally)", + UpstreamNotGoneError: "The selected branch's upstream still exists", UpstreamsNotSetError: "Some of the selected branches have no upstream (or the upstream is not stored locally)", Upstream: "Upstream", NewBranchNamePrompt: "Enter new branch name for branch", @@ -2241,6 +2246,7 @@ func EnglishTranslationSet() *TranslationSet { MixedReset: "Mixed reset", HardReset: "Hard reset", FastForwardBranch: "Fast forward branch", + RestoreUpstreamBranch: "Restore upstream branch", AutoForwardBranches: "Auto-forward branches", Undo: "Undo", Redo: "Redo", diff --git a/pkg/integration/components/git.go b/pkg/integration/components/git.go index 1b07e5cf8..913c9fae4 100644 --- a/pkg/integration/components/git.go +++ b/pkg/integration/components/git.go @@ -27,6 +27,14 @@ func (self *Git) RemoteTagDeleted(ref string, tagName string) *Git { }) } +// AssertRemoteBranchExists asserts that the given branch still exists on the +// given remote, i.e. it has been pushed. +func (self *Git) AssertRemoteBranchExists(ref string, branchName string) *Git { + return self.expect([]string{"git", "ls-remote", ref, fmt.Sprintf("refs/heads/%s", branchName)}, func(s string) (bool, string) { + return len(s) > 0, fmt.Sprintf("Expected branch %s to still exist on %s", branchName, ref) + }) +} + func (self *Git) assert(cmdArgs []string, expected string) *Git { self.expect(cmdArgs, func(output string) (bool, string) { return output == expected, fmt.Sprintf("Expected current branch name to be '%s', but got '%s'", expected, output) diff --git a/pkg/integration/tests/branch/restore_upstream_branch.go b/pkg/integration/tests/branch/restore_upstream_branch.go new file mode 100644 index 000000000..422c1fb87 --- /dev/null +++ b/pkg/integration/tests/branch/restore_upstream_branch.go @@ -0,0 +1,52 @@ +package branch + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var RestoreUpstreamBranch = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Restore an upstream branch that was deleted on the remote", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell. + CloneIntoRemote("origin"). + EmptyCommit("base commit"). + NewBranch("feature"). + EmptyCommit("on feature"). + PushBranchAndSetUpstream("origin", "feature"). + Checkout("master"). + RunCommand([]string{"git", "-C", "../origin", "branch", "-D", "feature"}). + RunCommand([]string{"git", "fetch", "origin", "--prune"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("master").IsSelected(), + Contains("feature").Contains("upstream gone"), + ) + + t.Views().Branches(). + NavigateToLine(Contains("feature")). + Press(keys.Branches.SetUpstream). + Tap(func() { + t.ExpectPopup(). + Menu(). + Title(Equals("Upstream options")). + Select(Contains("Restore upstream branch")). + Confirm() + }) + + // the "upstream gone" message is gone and the remote branch is recreated + t.Views().Branches(). + Lines( + Contains("master"), + Contains("feature").DoesNotContain("upstream gone"), + ) + + t.Git().AssertRemoteBranchExists("origin", "feature") + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 1c1896ddc..57177e0a0 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -81,6 +81,7 @@ var tests = []*components.IntegrationTest{ branch.ResetToDuplicateNamedUpstream, branch.ResetToUpstream, branch.RestoreDeletedBranch, + branch.RestoreUpstreamBranch, branch.SelectCommitsOfCurrentBranch, branch.SetUpstream, branch.ShowDivergenceFromBaseBranch,