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.
This commit is contained in:
Samuel Onoja 2026-08-03 12:26:51 +01:00
parent a133dc0dad
commit e3cab9777c
No known key found for this signature in database
5 changed files with 107 additions and 0 deletions

View file

@ -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() {})
}

View file

@ -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",

View file

@ -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)

View file

@ -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")
},
})

View file

@ -81,6 +81,7 @@ var tests = []*components.IntegrationTest{
branch.ResetToDuplicateNamedUpstream,
branch.ResetToUpstream,
branch.RestoreDeletedBranch,
branch.RestoreUpstreamBranch,
branch.SelectCommitsOfCurrentBranch,
branch.SetUpstream,
branch.ShowDivergenceFromBaseBranch,