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,