From 59b9d7d22276c316e002b6c780dc8cb276888f86 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Mon, 3 Aug 2026 11:16:31 +0100 Subject: [PATCH 01/10] add deleted branch model --- pkg/commands/models/branch.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/pkg/commands/models/branch.go b/pkg/commands/models/branch.go index 4dc48a88d8d..e02d85389e2 100644 --- a/pkg/commands/models/branch.go +++ b/pkg/commands/models/branch.go @@ -5,6 +5,39 @@ import ( "sync/atomic" ) +// DeletedBranch is a branch that was detected as no longer existing locally but +// whose commit history can still be recovered (e.g. from the reflog). +type DeletedBranch struct { + Name string + DisplayName string + // the commit hash the branch pointed at when it was last seen + CommitHash string + // indicator of when the branch was last checked out e.g. '2d', '3m' + Recency string + // unix timestamp of when the branch was last committed to; used for sorting + UnixTimestamp int64 +} + +func (b *DeletedBranch) FullRefName() string { + return "refs/heads/" + b.Name +} + +func (b *DeletedBranch) ID() string { + return b.RefName() +} + +func (b *DeletedBranch) RefName() string { + return b.Name +} + +func (b *DeletedBranch) URN() string { + return "deleted-branch-" + b.ID() +} + +func (b *DeletedBranch) Description() string { + return b.DisplayName +} + // Branch : A git branch // duplicating this for now type Branch struct { From 93599a389c3e9481526fc1498319f6b280edfc2a Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Mon, 3 Aug 2026 11:16:40 +0100 Subject: [PATCH 02/10] detect and restore deleted branches from reflog --- pkg/commands/git_commands/branch.go | 42 ++++- pkg/commands/git_commands/branch_loader.go | 147 +++++++++++++++ .../git_commands/deleted_branch_test.go | 172 ++++++++++++++++++ 3 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 pkg/commands/git_commands/deleted_branch_test.go diff --git a/pkg/commands/git_commands/branch.go b/pkg/commands/git_commands/branch.go index a55278b5b0a..9282a49e910 100644 --- a/pkg/commands/git_commands/branch.go +++ b/pkg/commands/git_commands/branch.go @@ -132,7 +132,47 @@ func (self *BranchCommands) PreviousRef() (string, error) { return strings.TrimSpace(output), nil } -// LocalDelete delete branch locally +// RestoreBranch recreates a deleted local branch at the given commit hash and, +// if exactly one remote-tracking branch with the same name still exists, +// re-attaches it as the upstream. Returns the upstream ref name that was +// re-attached (or "" if none was). +func (self *BranchCommands) RestoreBranch(name string, commitHash string) (string, error) { + cmdArgs := NewGitCmd("branch"). + Arg(name, commitHash). + ToArgv() + + if err := self.cmd.New(cmdArgs).Run(); err != nil { + return "", err + } + + upstream := "" + remoteRefs, err := self.cmd.New( + NewGitCmd("for-each-ref"). + Arg("--format=%(refname:short)"). + Arg("refs/remotes"). + ToArgv(), + ).DontLog().RunWithOutput() + if err != nil { + return "", err + } + + matchingRefs := lo.Filter(strings.Split(strings.TrimSpace(remoteRefs), "\n"), func(ref string, _ int) bool { + return "refs/remotes/"+ref == "refs/remotes/"+name || strings.HasSuffix(ref, "/"+name) + }) + if len(matchingRefs) == 1 { + matchingRef := strings.TrimSpace(matchingRefs[0]) + parts := strings.SplitN(matchingRef, "/", 2) + if len(parts) == 2 { + if err := self.SetUpstream(parts[0], parts[1], name); err == nil { + upstream = matchingRef + } + } + } + + return upstream, nil +} + +// LocalDelete delete local branch func (self *BranchCommands) LocalDelete(branches []string, force bool) error { cmdArgs := NewGitCmd("branch"). ArgIfElse(force, "-D", "-d"). diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go index b41b0564ff7..f42b66e2c54 100644 --- a/pkg/commands/git_commands/branch_loader.go +++ b/pkg/commands/git_commands/branch_loader.go @@ -490,6 +490,153 @@ func parseDifference(track string, regexStr string) string { return "0" } +// reflogEntry is a single parsed line of `git log -g` output (the reflog of +// HEAD). Entries are fed in the order git produces them, i.e. newest first. +type reflogEntry struct { + hash string // the commit HEAD pointed at when this reflog action occurred + subject string + timestamp int64 // commit timestamp of the `hash` commit + from string // set on "checkout: moving from X to Y" lines to the source branch X; "" otherwise + to string // set to the destination branch Y on checkout lines; "" otherwise +} + +// GetDeletedBranches returns branches that were deleted locally but can still +// be restored. It infers them by walking HEAD's reflog: any branch that was +// checked out (appears in a "checkout: moving from X to Y" line) but is no +// longer a local branch is a candidate, and its last-known commit +// (reconstructed from the reflog) is the commit it pointed at when it was +// deleted. +func (self *BranchLoader) GetDeletedBranches() ([]*models.DeletedBranch, error) { + currentBranches, err := self.getCurrentBranchNames() + if err != nil { + return nil, err + } + + rawReflog, err := self.cmd.New( + NewGitCmd("log"). + Config("log.showSignature=false"). + Arg("-g"). + Arg("--format=+%H%x00%ct%x00%gs"). + ToArgv(), + ).DontLog().RunWithOutput() + if err != nil { + return nil, err + } + + entries := parseReflogEntries(rawReflog) + return obtainDeletedBranches(entries, currentBranches), nil +} + +// getCurrentBranchNames returns the short names of all local branches. +func (self *BranchLoader) getCurrentBranchNames() ([]string, error) { + output, err := self.cmd.New( + NewGitCmd("for-each-ref"). + Arg("--format=%(refname:short)"). + Arg("refs/heads"). + ToArgv(), + ).DontLog().RunWithOutput() + if err != nil { + return nil, err + } + return strings.Split(strings.TrimSpace(output), "\n"), nil +} + +// parseReflogEntries parses the raw output of +// `git log -g --format=+%H%x00%ct%x00%gs`. The output is newest-first; we +// preserve that order. +func parseReflogEntries(rawReflog string) []*reflogEntry { + entries := make([]*reflogEntry, 0) + for _, line := range strings.Split(rawReflog, "\n") { + line = strings.TrimPrefix(line, "+") + if line == "" { + continue + } + parts := strings.SplitN(line, "\x00", 3) + if len(parts) != 3 { + continue + } + timestamp, _ := strconv.ParseInt(parts[1], 10, 64) + from, to := parseReflogCheckoutSubject(parts[2]) + entries = append(entries, &reflogEntry{ + hash: parts[0], + timestamp: timestamp, + from: from, + to: to, + }) + } + return entries +} + +var reflogCheckoutRegex = regexp.MustCompile(`checkout: moving from ([\S]+) to ([\S]+)`) + +// parseReflogCheckoutSubject extracts the branch moved from and the branch +// moved to from a "checkout: moving from X to Y" reflog subject. Returns "", "" +// for non-checkout subjects. +func parseReflogCheckoutSubject(subject string) (string, string) { + match := reflogCheckoutRegex.FindStringSubmatch(subject) + if len(match) != 3 { + return "", "" + } + return match[1], match[2] +} + +// obtainDeletedBranches reconstructs deleted branches from a newest-first +// reflog of HEAD. Returns branches that appear in the reflog as being checked +// out but are no longer local branches, together with the commit they pointed +// at when last seen. The result is ordered by recency (most recently committed +// to first). +func obtainDeletedBranches(entries []*reflogEntry, currentBranchNames []string) []*models.DeletedBranch { + currentBranches := set.NewFromSlice(currentBranchNames) + + // currentBranch is the branch HEAD was on leading up to the current entry. + currentBranch := "" + branchTip := make(map[string]string) + branchTimestamp := make(map[string]int64) + + for i := len(entries) - 1; i >= 0; i-- { + entry := entries[i] + + if entry.from != "" && entry.to != "" { + currentBranch = entry.to + continue + } + + if currentBranch != "" && currentBranch != "HEAD" { + branchTip[currentBranch] = entry.hash + branchTimestamp[currentBranch] = entry.timestamp + } + } + + deleted := make([]*models.DeletedBranch, 0, len(branchTip)) + for name, tip := range branchTip { + if name == "HEAD" || currentBranches.Includes(name) { + continue + } + deleted = append(deleted, &models.DeletedBranch{ + Name: name, + CommitHash: tip, + Recency: utils.UnixToTimeAgo(branchTimestamp[name]), + DisplayName: name, + UnixTimestamp: branchTimestamp[name], + }) + } + if len(deleted) == 0 { + return nil + } + + slices.SortFunc(deleted, func(a, b *models.DeletedBranch) int { + if a.UnixTimestamp == b.UnixTimestamp { + return 0 + } + if a.UnixTimestamp > b.UnixTimestamp { + return -1 + } + return 1 + }) + + return deleted +} + // TODO: only look at the new reflog commits, and otherwise store the recencies in // int form against the branch to recalculate the time ago func (self *BranchLoader) obtainReflogBranches(reflogCommits []*models.Commit) []*models.Branch { diff --git a/pkg/commands/git_commands/deleted_branch_test.go b/pkg/commands/git_commands/deleted_branch_test.go new file mode 100644 index 00000000000..2b3b5d2072f --- /dev/null +++ b/pkg/commands/git_commands/deleted_branch_test.go @@ -0,0 +1,172 @@ +package git_commands + +import ( + "testing" + + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/stretchr/testify/assert" +) + +func TestObtainDeletedBranches(t *testing.T) { + type scenario struct { + testName string + entries []*reflogEntry + currentBranchNames []string + expected []*models.DeletedBranch + } + + scenarios := []scenario{ + { + testName: "recover deleted branch that was committed to", + // newest-first reflog like `git log -g --format=+%H%x00%ct%x00%gs` + entries: []*reflogEntry{ + {hash: "a", timestamp: 300, from: "feature", to: "main"}, + {hash: "b", timestamp: 200}, + {hash: "c", timestamp: 100, from: "main", to: "feature"}, + {hash: "d", timestamp: 50}, + }, + currentBranchNames: []string{"main"}, + expected: []*models.DeletedBranch{ + {Name: "feature", CommitHash: "b", DisplayName: "feature", Recency: "56y", UnixTimestamp: 200}, + }, + }, + { + testName: "deleted branch left in favor of another branch, both tracked", + // newest-first reflog + entries: []*reflogEntry{ + {hash: "a", timestamp: 300, from: "feat/a", to: "main"}, // newest: leave feat/a + {hash: "b", timestamp: 250}, // commit on feat/a + {hash: "a", timestamp: 200, from: "feat/b", to: "feat/a"}, + {hash: "c", timestamp: 150}, // commit on feat/b + {hash: "a", timestamp: 100, from: "main", to: "feat/b"}, // oldest: create feat/b + }, + currentBranchNames: []string{"main"}, + expected: []*models.DeletedBranch{ + {Name: "feat/a", CommitHash: "b", DisplayName: "feat/a", Recency: "56y", UnixTimestamp: 250}, + {Name: "feat/b", CommitHash: "c", DisplayName: "feat/b", Recency: "56y", UnixTimestamp: 150}, + }, + }, + { + testName: "existing branches are excluded", + entries: []*reflogEntry{ + {hash: "a", timestamp: 300, from: "main", to: "other"}, + {hash: "b", timestamp: 200}, + {hash: "c", timestamp: 100, from: "other", to: "main"}, + }, + currentBranchNames: []string{"main", "other"}, + expected: nil, + }, + { + testName: "no checkout entries means nothing recoverable", + entries: []*reflogEntry{ + {hash: "a", timestamp: 300}, + {hash: "b", timestamp: 200}, + }, + currentBranchNames: []string{"main"}, + expected: nil, + }, + { + testName: "HEAD is not treated as a deleted branch", + entries: []*reflogEntry{ + {hash: "a", timestamp: 300, from: "main", to: "HEAD"}, + {hash: "b", timestamp: 200}, + }, + currentBranchNames: []string{"main"}, + expected: nil, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + result := obtainDeletedBranches(s.entries, s.currentBranchNames) + assert.Equal(t, s.expected, result) + }) + } +} + +func TestParseReflogCheckoutSubject(t *testing.T) { + type scenario struct { + testName string + subject string + expected []string + } + + scenarios := []scenario{ + { + testName: "normal checkout", + subject: "checkout: moving from feature to main", + expected: []string{"feature", "main"}, + }, + { + testName: "not a checkout", + subject: "commit: message", + expected: []string{"", ""}, + }, + { + testName: "checkout from detached head", + subject: "checkout: moving from HEAD to main", + expected: []string{"HEAD", "main"}, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + from, to := parseReflogCheckoutSubject(s.subject) + assert.Equal(t, s.expected[0], from) + assert.Equal(t, s.expected[1], to) + }) + } +} + +func TestBranchRestoreBranch(t *testing.T) { + type scenario struct { + testName string + runner *oscommands.FakeCmdObjRunner + expectedErr bool + expectedUpstream string + } + + scenarios := []scenario{ + { + testName: "restore branch with no surviving remote branch", + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"branch", "feature", "abc123"}, "", nil). + ExpectGitArgs([]string{"for-each-ref", "--format=%(refname:short)", "refs/remotes"}, "", nil), + expectedErr: false, + expectedUpstream: "", + }, + { + testName: "restore branch and reattach upstream when remote branch survives", + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"branch", "feature", "abc123"}, "", nil). + ExpectGitArgs([]string{"for-each-ref", "--format=%(refname:short)", "refs/remotes"}, "origin/feature\n", nil). + ExpectGitArgs([]string{"branch", "--set-upstream-to=origin/feature", "feature"}, "", nil), + expectedErr: false, + expectedUpstream: "origin/feature", + }, + { + testName: "restore branch when multiple remotes share the name does not set upstream", + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"branch", "feature", "abc123"}, "", nil). + ExpectGitArgs([]string{"for-each-ref", "--format=%(refname:short)", "refs/remotes"}, "origin/feature\nfork/feature\n", nil), + expectedErr: false, + expectedUpstream: "", + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + instance := buildBranchCommands(commonDeps{runner: s.runner}) + + upstream, err := instance.RestoreBranch("feature", "abc123") + if s.expectedErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, s.expectedUpstream, upstream) + s.runner.CheckForMissingCalls() + }) + } +} From 773530012a1eab0ae21a40faa430840693e0ead7 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Mon, 3 Aug 2026 11:17:09 +0100 Subject: [PATCH 03/10] add restore branch menu to branches panel --- pkg/gui/controllers/branches_controller.go | 49 ++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index cfc46b50360..e60fa8f0e38 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -124,6 +124,13 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty OpensMenu: true, DisplayOnScreen: true, }, + { + Keys: opts.GetKeys(opts.Config.Branches.RestoreBranch), + Handler: self.restoreDeletedBranch, + Description: self.c.Tr.RestoreBranch, + Tooltip: self.c.Tr.RestoreBranchTooltip, + OpensMenu: true, + }, { Keys: opts.GetKeys(opts.Config.Branches.RebaseBranch), Handler: opts.Guards.OutsideFilterMode(self.withItem(self.rebase)), @@ -580,6 +587,48 @@ func (self *BranchesController) localAndRemoteDelete(branches []*models.Branch) return self.c.Helpers().BranchesHelper.ConfirmLocalAndRemoteDelete(branches) } +func (self *BranchesController) restoreDeletedBranch() error { + deletedBranches, err := self.c.Git().Loaders.BranchLoader.GetDeletedBranches() + if err != nil { + return err + } + + if len(deletedBranches) == 0 { + self.c.Toast(self.c.Tr.NoDeletedBranches) + return nil + } + + items := lo.Map(deletedBranches, func(branch *models.DeletedBranch, _ int) *types.MenuItem { + return &types.MenuItem{ + LabelColumns: []string{ + branch.Name, + branch.Recency, + }, + OnPress: func() error { + upstream, err := self.c.Git().Branch.RestoreBranch(branch.Name, branch.CommitHash) + if err != nil { + return err + } + self.c.LogAction(self.c.Tr.Actions.RestoreBranch) + if upstream != "" { + self.c.Toast(fmt.Sprintf("%s %s (%s)", self.c.Tr.RestoredBranch, branch.Name, self.c.Tr.RestoredBranchUpstream)) + } else { + self.c.Toast(fmt.Sprintf("%s %s", self.c.Tr.RestoredBranch, branch.Name)) + } + self.c.Refresh(types.RefreshOptions{ + Scope: []types.RefreshableView{types.BRANCHES}, + }) + return nil + }, + } + }) + + return self.c.Menu(types.CreateMenuOptions{ + Title: self.c.Tr.RestoreBranchTitle, + Items: items, + }) +} + func (self *BranchesController) delete(branches []*models.Branch) error { checkedOutBranch := self.c.Helpers().Refs.GetCheckedOutRef() isBranchCheckedOut := lo.SomeBy(branches, func(branch *models.Branch) bool { From 72e7a806bcc8480f1c886ad2e1fddbbf9a2c7fde Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Mon, 3 Aug 2026 11:17:14 +0100 Subject: [PATCH 04/10] add restore branch keybinding and translations --- pkg/config/user_config.go | 2 ++ pkg/i18n/english.go | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 9738186d93a..b00a7eab8b8 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -610,6 +610,7 @@ type KeybindingBranchesConfig struct { FetchRemote Keybinding `yaml:"fetchRemote"` AddForkRemote Keybinding `yaml:"addForkRemote"` SortOrder Keybinding `yaml:"sortOrder"` + RestoreBranch Keybinding `yaml:"restoreBranch"` } type KeybindingCommitsConfig struct { @@ -1125,6 +1126,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { FetchRemote: Keybinding{"f"}, AddForkRemote: Keybinding{"F"}, SortOrder: Keybinding{"s"}, + RestoreBranch: Keybinding{"R"}, }, Commits: KeybindingCommitsConfig{ SquashDown: Keybinding{"s"}, diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index deb55d6e915..9d23b037524 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -127,6 +127,12 @@ type TranslationSet struct { DeleteRemoteBranchesPrompt string DeleteLocalAndRemoteBranchPrompt string DeleteLocalAndRemoteBranchesPrompt string + RestoreBranch string + RestoreBranchTooltip string + RestoreBranchTitle string + NoDeletedBranches string + RestoredBranch string + RestoredBranchUpstream string ForceDeleteBranchTitle string ForceDeleteBranchMessage string ForceDeleteBranchesMessage string @@ -1010,6 +1016,7 @@ type Actions struct { CheckoutBranch string CheckoutBranchOrCommit string ForceCheckoutBranch string + RestoreBranch string DeleteLocalBranch string Merge string SquashMerge string @@ -1276,6 +1283,12 @@ func EnglishTranslationSet() *TranslationSet { DeleteRemoteBranchesPrompt: "Are you sure you want to delete the remote branches of the selected branches from their respective remotes?", DeleteLocalAndRemoteBranchPrompt: "Are you sure you want to delete both '{{.localBranchName}}' from your machine, and '{{.remoteBranchName}}' from '{{.remoteName}}'?", DeleteLocalAndRemoteBranchesPrompt: "Are you sure you want to delete both the selected branches from your machine, and their remote branches from their respective remotes?", + RestoreBranch: "Restore deleted branch", + RestoreBranchTooltip: "Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists.", + RestoreBranchTitle: "Deleted branches", + NoDeletedBranches: "No deleted branches were found in the reflog", + RestoredBranch: "Restored branch", + RestoredBranchUpstream: "re-attached upstream", ForceDeleteBranchTitle: "Force delete branch", ForceDeleteBranchMessage: "'{{.selectedBranchName}}' is not fully merged. Are you sure you want to delete it?", ForceDeleteBranchesMessage: "Some of the selected branches are not fully merged. Are you sure you want to delete them?", @@ -2122,6 +2135,7 @@ func EnglishTranslationSet() *TranslationSet { CheckoutBranch: "Checkout branch", ForceCheckoutBranch: "Force checkout branch", CheckoutBranchOrCommit: "Checkout branch or commit", + RestoreBranch: "Restore deleted branch", DeleteLocalBranch: "Delete local branch", Merge: "Merge", SquashMerge: "Squash merge", From c27183139970cb9ce632ecba08ca597c9f33ca54 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Mon, 3 Aug 2026 11:17:21 +0100 Subject: [PATCH 05/10] regenerate docs for restore branch --- docs-master/Config.md | 1 + docs-master/keybindings/Keybindings_en.md | 1 + docs-master/keybindings/Keybindings_ja.md | 1 + docs-master/keybindings/Keybindings_ko.md | 1 + docs-master/keybindings/Keybindings_nl.md | 1 + docs-master/keybindings/Keybindings_pl.md | 1 + docs-master/keybindings/Keybindings_pt.md | 1 + docs-master/keybindings/Keybindings_ru.md | 1 + docs-master/keybindings/Keybindings_zh-CN.md | 1 + docs-master/keybindings/Keybindings_zh-TW.md | 1 + schema-master/config.json | 14 ++++++++++++++ 11 files changed, 24 insertions(+) diff --git a/docs-master/Config.md b/docs-master/Config.md index 857a4e35980..9affede663f 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -777,6 +777,7 @@ keybinding: fetchRemote: f addForkRemote: F sortOrder: s + restoreBranch: R commits: squashDown: s renameCommit: r diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index 3ec731bf292..76c37f2378d 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -187,6 +187,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | Checkout previous branch | | | `` F `` | Force checkout | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. | | `` d `` | Delete | View delete options for local/remote branch. | +| `` R `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | | `` r `` | Rebase | Rebase the checked-out branch onto the selected branch. | | `` M `` | Merge | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` f `` | Fast-forward | Fast-forward selected branch from its upstream. | diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index 6a3d9b5c1d6..d5368a19921 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -385,6 +385,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | 直前のブランチにチェックアウト | | | `` F `` | 強制チェックアウト | 選択したブランチを強制的にチェックアウトします。これにより、選択したブランチをチェックアウトする前にワーキングディレクトリ内のすべてのローカル変更が破棄されます。 | | `` d `` | 削除 | ローカル/リモートブランチの削除オプションを表示します。 | +| `` R `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | | `` r `` | リベース | チェックアウトしたブランチを選択したブランチ上にリベースします。 | | `` M `` | マージ | 選択した項目を現在のブランチにマージするためのオプションを表示します(通常のマージ、スカッシュマージ) | | `` f `` | ブランチを最新化(fast-forward) | 選択したブランチを対応するアップストリームの最新状態に追いつかせます(fast-forward)。 | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index a0e5d84dc95..e153f4be8fc 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -221,6 +221,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | Checkout previous branch | | | `` F `` | 강제 체크아웃 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. | | `` d `` | 삭제 | View delete options for local/remote branch. | +| `` R `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | | `` r `` | 체크아웃된 브랜치를 이 브랜치에 리베이스 | Rebase the checked-out branch onto the selected branch. | | `` M `` | 현재 브랜치에 병합 | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` f `` | Fast-forward this branch from its upstream | Fast-forward selected branch from its upstream. | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index cf9934b40d0..3a4e25524e3 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -111,6 +111,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | Vorige branch uitchecken | | | `` F `` | Forceer checkout | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. | | `` d `` | Verwijderen | View delete options for local/remote branch. | +| `` R `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | | `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. | | `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` f `` | Fast-forward deze branch vanaf zijn upstream | Fast-forward selected branch from its upstream. | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index ba46f7c463c..c65a4e91ddc 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -173,6 +173,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | Przełącz na poprzednią gałąź | | | `` F `` | Wymuś przełączenie | Wymuś przełączenie wybranej gałęzi. To spowoduje odrzucenie wszystkich lokalnych zmian w drzewie roboczym przed przełączeniem na wybraną gałąź. | | `` d `` | Usuń | Wyświetl opcje usuwania lokalnej/odległej gałęzi. | +| `` R `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | | `` r `` | Przebazuj | Przebazuj przełączoną gałąź na wybraną gałąź. | | `` M `` | Scal | Scal wybraną gałąź z aktualnie sprawdzoną gałęzią. | | `` f `` | Szybkie przewijanie | Szybkie przewijanie wybranej gałęzi z jej źródła. | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index 3071613bef3..a55c282d096 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -103,6 +103,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | Checkout da branch anterior | | | `` F `` | Forçar checagem | Forçar checagem da branch selecionada. Isso irá descartar todas as mudanças no seu diretório de trabalho antes cheque a branch selecionada | | `` d `` | Apagar | Ver opções de exclusão para a branch local/remoto. | +| `` R `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | | `` r `` | Refazer | Refazer a branch checada na branch selecionada | | `` M `` | Mesclar | Ver opções para mesclar o item selecionado no branch atual (mesclar regularmente, mesclar squash) | | `` f `` | Avanço rápido | Encaminhamento rápido de branch selecionada a partir do upstream. | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 3d03f9ca686..cf5f4cbb4b6 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -221,6 +221,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | Checkout previous branch | | | `` F `` | Принудительное переключение | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. | | `` d `` | Delete | View delete options for local/remote branch. | +| `` R `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | | `` r `` | Перебазировать переключённую ветку на эту ветку | Rebase the checked-out branch onto the selected branch. | | `` M `` | Слияние с текущей переключённой веткой | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` f `` | Перемотать эту ветку вперёд из её upstream-ветки | Fast-forward selected branch from its upstream. | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index 9819cb982bf..7db42b8b513 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -236,6 +236,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | 签出上一个分支 | | | `` F `` | 强制检出 | 强制检出所选分支。这将在检出所选分支之前放弃工作目录中的所有本地更改。 | | `` d `` | 删除 | 查看本地/远程分支的删除选项 | +| `` R `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | | `` r `` | 变基 | 将检出的分支变基到所选的分支上。 | | `` M `` | 合并到当前检出的分支 | 查看将选中项合并到当前分支的选项(正常合并,压缩合并) | | `` f `` | 从上游快进此分支 | 将当前分支直接移动到远程追踪分支的最新提交 | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index 1706a627e92..6a02b10ac1c 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -296,6 +296,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | Checkout previous branch | | | `` F `` | 強制檢出 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. | | `` d `` | 刪除 | View delete options for local/remote branch. | +| `` R `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | | `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. | | `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` f `` | 從上游快進此分支 | 從遠端快進所選的分支 | diff --git a/schema-master/config.json b/schema-master/config.json index 45a2b9efe4b..bef26b67433 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -1258,6 +1258,20 @@ } ], "default": "s" + }, + "restoreBranch": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "R" } }, "additionalProperties": false, From b04d4b4ed1347ecb5a7c5eb9ef91b5d7b6457e1b Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Mon, 3 Aug 2026 11:41:12 +0100 Subject: [PATCH 06/10] fix restore branch keybinding conflict and i18n --- pkg/config/user_config.go | 2 +- pkg/gui/controllers/branches_controller.go | 8 ++++---- pkg/i18n/english.go | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index b00a7eab8b8..eeda84784c1 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -1126,7 +1126,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { FetchRemote: Keybinding{"f"}, AddForkRemote: Keybinding{"F"}, SortOrder: Keybinding{"s"}, - RestoreBranch: Keybinding{"R"}, + RestoreBranch: Keybinding{""}, }, Commits: KeybindingCommitsConfig{ SquashDown: Keybinding{"s"}, diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index e60fa8f0e38..4b31de36614 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -605,16 +605,16 @@ func (self *BranchesController) restoreDeletedBranch() error { branch.Recency, }, OnPress: func() error { + self.c.LogAction(self.c.Tr.Actions.RestoreBranch) upstream, err := self.c.Git().Branch.RestoreBranch(branch.Name, branch.CommitHash) if err != nil { return err } - self.c.LogAction(self.c.Tr.Actions.RestoreBranch) + toast := utils.ResolvePlaceholderString(self.c.Tr.RestoredBranch, map[string]string{"branchName": branch.Name}) if upstream != "" { - self.c.Toast(fmt.Sprintf("%s %s (%s)", self.c.Tr.RestoredBranch, branch.Name, self.c.Tr.RestoredBranchUpstream)) - } else { - self.c.Toast(fmt.Sprintf("%s %s", self.c.Tr.RestoredBranch, branch.Name)) + toast = fmt.Sprintf("%s (%s)", toast, self.c.Tr.RestoredBranchUpstream) } + self.c.Toast(toast) self.c.Refresh(types.RefreshOptions{ Scope: []types.RefreshableView{types.BRANCHES}, }) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 9d23b037524..fa51d85eac9 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -1287,7 +1287,7 @@ func EnglishTranslationSet() *TranslationSet { RestoreBranchTooltip: "Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists.", RestoreBranchTitle: "Deleted branches", NoDeletedBranches: "No deleted branches were found in the reflog", - RestoredBranch: "Restored branch", + RestoredBranch: "Restored branch '{{.branchName}}'", RestoredBranchUpstream: "re-attached upstream", ForceDeleteBranchTitle: "Force delete branch", ForceDeleteBranchMessage: "'{{.selectedBranchName}}' is not fully merged. Are you sure you want to delete it?", From a5741831b5d724a454b4808bbeddb19885f42d1e Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Mon, 3 Aug 2026 11:41:22 +0100 Subject: [PATCH 07/10] regenerate docs for keybinding change --- docs-master/Config.md | 2 +- docs-master/keybindings/Keybindings_en.md | 2 +- docs-master/keybindings/Keybindings_ja.md | 2 +- docs-master/keybindings/Keybindings_ko.md | 2 +- docs-master/keybindings/Keybindings_nl.md | 2 +- docs-master/keybindings/Keybindings_pl.md | 2 +- docs-master/keybindings/Keybindings_pt.md | 2 +- docs-master/keybindings/Keybindings_ru.md | 2 +- docs-master/keybindings/Keybindings_zh-CN.md | 2 +- docs-master/keybindings/Keybindings_zh-TW.md | 2 +- schema-master/config.json | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs-master/Config.md b/docs-master/Config.md index 9affede663f..459b06cd308 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -777,7 +777,7 @@ keybinding: fetchRemote: f addForkRemote: F sortOrder: s - restoreBranch: R + restoreBranch: commits: squashDown: s renameCommit: r diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index 76c37f2378d..4d889320726 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -187,7 +187,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | Checkout previous branch | | | `` F `` | Force checkout | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. | | `` d `` | Delete | View delete options for local/remote branch. | -| `` R `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | +| `` `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | | `` r `` | Rebase | Rebase the checked-out branch onto the selected branch. | | `` M `` | Merge | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` f `` | Fast-forward | Fast-forward selected branch from its upstream. | diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index d5368a19921..1641553f2b2 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -385,7 +385,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | 直前のブランチにチェックアウト | | | `` F `` | 強制チェックアウト | 選択したブランチを強制的にチェックアウトします。これにより、選択したブランチをチェックアウトする前にワーキングディレクトリ内のすべてのローカル変更が破棄されます。 | | `` d `` | 削除 | ローカル/リモートブランチの削除オプションを表示します。 | -| `` R `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | +| `` `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | | `` r `` | リベース | チェックアウトしたブランチを選択したブランチ上にリベースします。 | | `` M `` | マージ | 選択した項目を現在のブランチにマージするためのオプションを表示します(通常のマージ、スカッシュマージ) | | `` f `` | ブランチを最新化(fast-forward) | 選択したブランチを対応するアップストリームの最新状態に追いつかせます(fast-forward)。 | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index e153f4be8fc..bd54ae3cae0 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -221,7 +221,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | Checkout previous branch | | | `` F `` | 강제 체크아웃 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. | | `` d `` | 삭제 | View delete options for local/remote branch. | -| `` R `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | +| `` `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | | `` r `` | 체크아웃된 브랜치를 이 브랜치에 리베이스 | Rebase the checked-out branch onto the selected branch. | | `` M `` | 현재 브랜치에 병합 | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` f `` | Fast-forward this branch from its upstream | Fast-forward selected branch from its upstream. | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index 3a4e25524e3..f0933dc52b6 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -111,7 +111,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | Vorige branch uitchecken | | | `` F `` | Forceer checkout | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. | | `` d `` | Verwijderen | View delete options for local/remote branch. | -| `` R `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | +| `` `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | | `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. | | `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` f `` | Fast-forward deze branch vanaf zijn upstream | Fast-forward selected branch from its upstream. | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index c65a4e91ddc..78c08f9eaf2 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -173,7 +173,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | Przełącz na poprzednią gałąź | | | `` F `` | Wymuś przełączenie | Wymuś przełączenie wybranej gałęzi. To spowoduje odrzucenie wszystkich lokalnych zmian w drzewie roboczym przed przełączeniem na wybraną gałąź. | | `` d `` | Usuń | Wyświetl opcje usuwania lokalnej/odległej gałęzi. | -| `` R `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | +| `` `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | | `` r `` | Przebazuj | Przebazuj przełączoną gałąź na wybraną gałąź. | | `` M `` | Scal | Scal wybraną gałąź z aktualnie sprawdzoną gałęzią. | | `` f `` | Szybkie przewijanie | Szybkie przewijanie wybranej gałęzi z jej źródła. | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index a55c282d096..85d5dd21ec9 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -103,7 +103,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | Checkout da branch anterior | | | `` F `` | Forçar checagem | Forçar checagem da branch selecionada. Isso irá descartar todas as mudanças no seu diretório de trabalho antes cheque a branch selecionada | | `` d `` | Apagar | Ver opções de exclusão para a branch local/remoto. | -| `` R `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | +| `` `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | | `` r `` | Refazer | Refazer a branch checada na branch selecionada | | `` M `` | Mesclar | Ver opções para mesclar o item selecionado no branch atual (mesclar regularmente, mesclar squash) | | `` f `` | Avanço rápido | Encaminhamento rápido de branch selecionada a partir do upstream. | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index cf5f4cbb4b6..dfe2e02653d 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -221,7 +221,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | Checkout previous branch | | | `` F `` | Принудительное переключение | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. | | `` d `` | Delete | View delete options for local/remote branch. | -| `` R `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | +| `` `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | | `` r `` | Перебазировать переключённую ветку на эту ветку | Rebase the checked-out branch onto the selected branch. | | `` M `` | Слияние с текущей переключённой веткой | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` f `` | Перемотать эту ветку вперёд из её upstream-ветки | Fast-forward selected branch from its upstream. | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index 7db42b8b513..220b6dd1e66 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -236,7 +236,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | 签出上一个分支 | | | `` F `` | 强制检出 | 强制检出所选分支。这将在检出所选分支之前放弃工作目录中的所有本地更改。 | | `` d `` | 删除 | 查看本地/远程分支的删除选项 | -| `` R `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | +| `` `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | | `` r `` | 变基 | 将检出的分支变基到所选的分支上。 | | `` M `` | 合并到当前检出的分支 | 查看将选中项合并到当前分支的选项(正常合并,压缩合并) | | `` f `` | 从上游快进此分支 | 将当前分支直接移动到远程追踪分支的最新提交 | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index 6a02b10ac1c..ae641fa2a2e 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -296,7 +296,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` - `` | Checkout previous branch | | | `` F `` | 強制檢出 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. | | `` d `` | 刪除 | View delete options for local/remote branch. | -| `` R `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | +| `` `` | Restore deleted branch | Restore a locally deleted branch from the reflog. The branch's upstream will be re-attached if a matching remote-tracking branch still exists. | | `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. | | `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` f `` | 從上游快進此分支 | 從遠端快進所選的分支 | diff --git a/schema-master/config.json b/schema-master/config.json index bef26b67433..31e51c6efa1 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -1271,7 +1271,7 @@ "type": "array" } ], - "default": "R" + "default": "\u003cctrl+r\u003e" } }, "additionalProperties": false, From 7da8a04187f47ba1ff3e6589c37a38e11e6abeff Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Mon, 3 Aug 2026 11:50:54 +0100 Subject: [PATCH 08/10] harden deleted branch detection against reflog noise --- pkg/commands/git_commands/branch_loader.go | 93 +++++++-- .../git_commands/deleted_branch_test.go | 195 +++++++++++++++--- 2 files changed, 248 insertions(+), 40 deletions(-) diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go index f42b66e2c54..532167acf6c 100644 --- a/pkg/commands/git_commands/branch_loader.go +++ b/pkg/commands/git_commands/branch_loader.go @@ -494,8 +494,7 @@ func parseDifference(track string, regexStr string) string { // HEAD). Entries are fed in the order git produces them, i.e. newest first. type reflogEntry struct { hash string // the commit HEAD pointed at when this reflog action occurred - subject string - timestamp int64 // commit timestamp of the `hash` commit + timestamp int64 // commit timestamp of the `hash` commit from string // set on "checkout: moving from X to Y" lines to the source branch X; "" otherwise to string // set to the destination branch Y on checkout lines; "" otherwise } @@ -507,7 +506,7 @@ type reflogEntry struct { // (reconstructed from the reflog) is the commit it pointed at when it was // deleted. func (self *BranchLoader) GetDeletedBranches() ([]*models.DeletedBranch, error) { - currentBranches, err := self.getCurrentBranchNames() + existingRefs, err := self.getExistingRefNames() if err != nil { return nil, err } @@ -524,21 +523,40 @@ func (self *BranchLoader) GetDeletedBranches() ([]*models.DeletedBranch, error) } entries := parseReflogEntries(rawReflog) - return obtainDeletedBranches(entries, currentBranches), nil + return obtainDeletedBranches(entries, existingRefs, self.isValidRefFormat), nil } -// getCurrentBranchNames returns the short names of all local branches. -func (self *BranchLoader) getCurrentBranchNames() ([]string, error) { +// isValidRefFormat reports whether git accepts `name` as a valid ref name, +// deferring the ref-name grammar (e.g. rejecting "HEAD~1", "main@{0}", trailing +// dots) to `git check-ref-format`. We pass --allow-onelevel because reflog +// checkout names are bare branch names (e.g. "master"), which are single-level +// refs. Note that git's rules only cover shape: things like tags, remote-tracking +// branches and abbreviated SHAs are all valid refs to git, so the caller still +// has to filter those out separately. +func (self *BranchLoader) isValidRefFormat(name string) bool { + return self.cmd.New( + NewGitCmd("check-ref-format"). + Arg("--allow-onelevel"). + Arg(name). + ToArgv(), + ).DontLog().Run() == nil +} + +// getExistingRefNames returns the short names of all refs (local branches, +// remote-tracking branches and tags) plus HEAD itself. A name present here is +// known not to be a deleted local branch, so it is excluded from recovery +// candidates. +func (self *BranchLoader) getExistingRefNames() ([]string, error) { output, err := self.cmd.New( NewGitCmd("for-each-ref"). Arg("--format=%(refname:short)"). - Arg("refs/heads"). + Arg("refs/heads", "refs/remotes", "refs/tags"). ToArgv(), ).DontLog().RunWithOutput() if err != nil { return nil, err } - return strings.Split(strings.TrimSpace(output), "\n"), nil + return append(strings.Split(strings.TrimSpace(output), "\n"), "HEAD"), nil } // parseReflogEntries parses the raw output of @@ -585,8 +603,8 @@ func parseReflogCheckoutSubject(subject string) (string, string) { // out but are no longer local branches, together with the commit they pointed // at when last seen. The result is ordered by recency (most recently committed // to first). -func obtainDeletedBranches(entries []*reflogEntry, currentBranchNames []string) []*models.DeletedBranch { - currentBranches := set.NewFromSlice(currentBranchNames) +func obtainDeletedBranches(entries []*reflogEntry, existingRefs []string, isValidRefFormat func(string) bool) []*models.DeletedBranch { + existing := set.NewFromSlice(existingRefs) // currentBranch is the branch HEAD was on leading up to the current entry. currentBranch := "" @@ -597,11 +615,21 @@ func obtainDeletedBranches(entries []*reflogEntry, currentBranchNames []string) entry := entries[i] if entry.from != "" && entry.to != "" { + // The hash of a checkout entry is the tip of the branch being + // moved to. Seeding it here means branches created via + // `git checkout -b` (and never committed to) are still + // recoverable. We never touch the source branch: its tip was + // recorded by the older entries that preceded this checkout, and + // overwriting it with the destination's tip would be wrong. + if isBranchName(entry.to, isValidRefFormat) { + branchTip[entry.to] = entry.hash + branchTimestamp[entry.to] = entry.timestamp + } currentBranch = entry.to continue } - if currentBranch != "" && currentBranch != "HEAD" { + if currentBranch != "" && isBranchName(currentBranch, isValidRefFormat) { branchTip[currentBranch] = entry.hash branchTimestamp[currentBranch] = entry.timestamp } @@ -609,7 +637,7 @@ func obtainDeletedBranches(entries []*reflogEntry, currentBranchNames []string) deleted := make([]*models.DeletedBranch, 0, len(branchTip)) for name, tip := range branchTip { - if name == "HEAD" || currentBranches.Includes(name) { + if existing.Includes(name) { continue } deleted = append(deleted, &models.DeletedBranch{ @@ -637,6 +665,47 @@ func obtainDeletedBranches(entries []*reflogEntry, currentBranchNames []string) return deleted } +// isBranchName returns true if the given string could be a local branch name +// (as opposed to a commit hash, a tag, a remote-tracking ref, or HEAD). This +// filters out reflog noise like "checkout: moving from HEAD to abc1234" or +// tag/remote checkouts, which would otherwise show up as phantom deleted +// branches. The ref-name grammar itself is validated by isValidRefFormat +// (which defers to `git check-ref-format`). +func isBranchName(name string, isValidRefFormat func(string) bool) bool { + if name == "" || name == "HEAD" { + return false + } + if !isValidRefFormat(name) { + return false + } + // A name containing a slash is only treated as a branch if the part before + // the first slash is not a well-known remote marker (git writes + // "origin/feature" or "tags/v1.0" for remote/tag checkouts, and branch + // names can legitimately contain slashes, e.g. "feature/foo"). + if strings.ContainsRune(name, '/') { + remote, _, _ := strings.Cut(name, "/") + return !lo.Contains([]string{"origin", "upstream", "fork", "tags", "remotes"}, remote) + } + return !looksLikeSha(name) +} + +// looksLikeSha returns true if the string looks like a commit hash: all hex +// characters and at least as long as git's minimum abbreviation. This covers +// both abbreviated and full-length hashes that git writes for detached-head +// checkouts. A branch name that happens to be all-hex would be missed, but +// that's an acceptable trade-off since such names are vanishingly rare. +func looksLikeSha(name string) bool { + if len(name) < 7 { + return false + } + for _, c := range name { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + return true +} + // TODO: only look at the new reflog commits, and otherwise store the recencies in // int form against the branch to recalculate the time ago func (self *BranchLoader) obtainReflogBranches(reflogCommits []*models.Commit) []*models.Branch { diff --git a/pkg/commands/git_commands/deleted_branch_test.go b/pkg/commands/git_commands/deleted_branch_test.go index 2b3b5d2072f..3c62ea63b84 100644 --- a/pkg/commands/git_commands/deleted_branch_test.go +++ b/pkg/commands/git_commands/deleted_branch_test.go @@ -1,6 +1,8 @@ package git_commands import ( + "errors" + "strings" "testing" "github.com/jesseduffield/lazygit/pkg/commands/models" @@ -10,10 +12,10 @@ import ( func TestObtainDeletedBranches(t *testing.T) { type scenario struct { - testName string - entries []*reflogEntry - currentBranchNames []string - expected []*models.DeletedBranch + testName string + entries []*reflogEntry + existingRefs []string + expected []*models.DeletedBranch } scenarios := []scenario{ @@ -26,7 +28,7 @@ func TestObtainDeletedBranches(t *testing.T) { {hash: "c", timestamp: 100, from: "main", to: "feature"}, {hash: "d", timestamp: 50}, }, - currentBranchNames: []string{"main"}, + existingRefs: []string{"main", "HEAD"}, expected: []*models.DeletedBranch{ {Name: "feature", CommitHash: "b", DisplayName: "feature", Recency: "56y", UnixTimestamp: 200}, }, @@ -36,35 +38,35 @@ func TestObtainDeletedBranches(t *testing.T) { // newest-first reflog entries: []*reflogEntry{ {hash: "a", timestamp: 300, from: "feat/a", to: "main"}, // newest: leave feat/a - {hash: "b", timestamp: 250}, // commit on feat/a + {hash: "b", timestamp: 250}, // commit on feat/a {hash: "a", timestamp: 200, from: "feat/b", to: "feat/a"}, - {hash: "c", timestamp: 150}, // commit on feat/b + {hash: "c", timestamp: 150}, // commit on feat/b {hash: "a", timestamp: 100, from: "main", to: "feat/b"}, // oldest: create feat/b }, - currentBranchNames: []string{"main"}, + existingRefs: []string{"main", "HEAD"}, expected: []*models.DeletedBranch{ {Name: "feat/a", CommitHash: "b", DisplayName: "feat/a", Recency: "56y", UnixTimestamp: 250}, {Name: "feat/b", CommitHash: "c", DisplayName: "feat/b", Recency: "56y", UnixTimestamp: 150}, }, }, { - testName: "existing branches are excluded", - entries: []*reflogEntry{ + testName: "existing branches are excluded", + entries: []*reflogEntry{ {hash: "a", timestamp: 300, from: "main", to: "other"}, {hash: "b", timestamp: 200}, {hash: "c", timestamp: 100, from: "other", to: "main"}, }, - currentBranchNames: []string{"main", "other"}, - expected: nil, + existingRefs: []string{"main", "other", "HEAD"}, + expected: nil, }, { - testName: "no checkout entries means nothing recoverable", - entries: []*reflogEntry{ + testName: "no checkout entries means nothing recoverable", + entries: []*reflogEntry{ {hash: "a", timestamp: 300}, {hash: "b", timestamp: 200}, }, - currentBranchNames: []string{"main"}, - expected: nil, + existingRefs: []string{"main", "HEAD"}, + expected: nil, }, { testName: "HEAD is not treated as a deleted branch", @@ -72,24 +74,161 @@ func TestObtainDeletedBranches(t *testing.T) { {hash: "a", timestamp: 300, from: "main", to: "HEAD"}, {hash: "b", timestamp: 200}, }, - currentBranchNames: []string{"main"}, - expected: nil, + existingRefs: []string{"main", "HEAD"}, + expected: nil, + }, + { + testName: "branch created via checkout with no commits is recoverable", + // newest-first reflog: create feature, then immediately leave it + entries: []*reflogEntry{ + {hash: "a", timestamp: 300, from: "feature", to: "main"}, // leave feature + {hash: "b", timestamp: 200, from: "main", to: "feature"}, // create feature + }, + existingRefs: []string{"main", "HEAD"}, + expected: []*models.DeletedBranch{ + {Name: "feature", CommitHash: "b", DisplayName: "feature", Recency: "56y", UnixTimestamp: 200}, + }, + }, + { + testName: "detached head checkout is not treated as a deleted branch", + entries: []*reflogEntry{ + {hash: "a", timestamp: 300, from: "abc1234567890abcdefabcdefabcdefabcdefabcdef", to: "main"}, + {hash: "b", timestamp: 200, from: "main", to: "abc1234567890abcdefabcdefabcdefabcdefabcdef"}, + }, + existingRefs: []string{"main", "HEAD"}, + expected: nil, + }, + { + testName: "existing remote-tracking ref is excluded from candidates", + entries: []*reflogEntry{ + {hash: "a", timestamp: 300, from: "origin/feature", to: "main"}, + {hash: "b", timestamp: 200, from: "main", to: "origin/feature"}, + }, + existingRefs: []string{"main", "origin/feature", "HEAD"}, + expected: nil, + }, + { + testName: "existing tag checkout is not treated as a deleted branch", + entries: []*reflogEntry{ + {hash: "a", timestamp: 300, from: "v1.0.0", to: "main"}, + {hash: "b", timestamp: 200, from: "main", to: "v1.0.0"}, + }, + existingRefs: []string{"main", "v1.0.0", "HEAD"}, + expected: nil, + }, + { + testName: "checkout to a commit expression is not treated as a deleted branch", + entries: []*reflogEntry{ + {hash: "a", timestamp: 300, from: "HEAD~1", to: "main"}, + {hash: "b", timestamp: 200, from: "main", to: "HEAD~1"}, + }, + existingRefs: []string{"main", "HEAD"}, + expected: nil, + }, + { + testName: "checkout to a sha-256 hash is not treated as a deleted branch", + entries: []*reflogEntry{ + {hash: "a", timestamp: 300, from: "8f0f1f2f3f4f5f6f7f8f9fafbfcfdfefff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff", to: "main"}, + {hash: "b", timestamp: 200, from: "main", to: "8f0f1f2f3f4f5f6f7f8f9fafbfcfdfefff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"}, + }, + existingRefs: []string{"main", "HEAD"}, + expected: nil, }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { - result := obtainDeletedBranches(s.entries, s.currentBranchNames) + result := obtainDeletedBranches(s.entries, s.existingRefs, isValidRefFormatStub) assert.Equal(t, s.expected, result) }) } } +// isValidRefFormatStub is a stand-in for `git check-ref-format` used to keep +// the pure tests independent of the subprocess. It applies the same ref-name +// grammar rules git enforces. +func isValidRefFormatStub(name string) bool { + if name == "" { + return false + } + if strings.HasPrefix(name, "-") { + return false + } + if strings.HasSuffix(name, ".") || strings.HasSuffix(name, "/") { + return false + } + if strings.Contains(name, "..") || strings.Contains(name, "@{") { + return false + } + for _, c := range name { + if c <= ' ' || strings.ContainsRune("~^:?*[\\", c) { + return false + } + } + return true +} + +func TestIsValidRefFormat(t *testing.T) { + type scenario struct { + testName string + name string + isValid bool + } + + scenarios := []scenario{ + { + testName: "valid branch name", + name: "feature/foo", + isValid: true, + }, + { + // a sha-looking string is still a valid ref to git; filtering it is + // done separately by looksLikeSha + testName: "sha-like is a valid ref to git", + name: "8f0f1f2f3f4f", + isValid: true, + }, + { + testName: "rejects commit expression", + name: "HEAD~1", + }, + { + testName: "rejects trailing slash", + name: "feature/", + }, + { + testName: "rejects double dot", + name: "feature..other", + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + err := errors.New("invalid ref") + if s.isValid { + err = nil + } + runner := oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"check-ref-format", "--allow-onelevel", s.name}, "", err) + + gitCommon := buildGitCommon(commonDeps{runner: runner}) + loader := &BranchLoader{ + Common: gitCommon.Common, + GitCommon: gitCommon, + cmd: gitCommon.cmd, + } + + assert.Equal(t, s.isValid, loader.isValidRefFormat(s.name)) + runner.CheckForMissingCalls() + }) + } +} + func TestParseReflogCheckoutSubject(t *testing.T) { type scenario struct { - testName string - subject string - expected []string + testName string + subject string + expected []string } scenarios := []scenario{ @@ -121,9 +260,9 @@ func TestParseReflogCheckoutSubject(t *testing.T) { func TestBranchRestoreBranch(t *testing.T) { type scenario struct { - testName string - runner *oscommands.FakeCmdObjRunner - expectedErr bool + testName string + runner *oscommands.FakeCmdObjRunner + expectedErr bool expectedUpstream string } @@ -133,7 +272,7 @@ func TestBranchRestoreBranch(t *testing.T) { runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"branch", "feature", "abc123"}, "", nil). ExpectGitArgs([]string{"for-each-ref", "--format=%(refname:short)", "refs/remotes"}, "", nil), - expectedErr: false, + expectedErr: false, expectedUpstream: "", }, { @@ -142,7 +281,7 @@ func TestBranchRestoreBranch(t *testing.T) { ExpectGitArgs([]string{"branch", "feature", "abc123"}, "", nil). ExpectGitArgs([]string{"for-each-ref", "--format=%(refname:short)", "refs/remotes"}, "origin/feature\n", nil). ExpectGitArgs([]string{"branch", "--set-upstream-to=origin/feature", "feature"}, "", nil), - expectedErr: false, + expectedErr: false, expectedUpstream: "origin/feature", }, { @@ -150,7 +289,7 @@ func TestBranchRestoreBranch(t *testing.T) { runner: oscommands.NewFakeRunner(t). ExpectGitArgs([]string{"branch", "feature", "abc123"}, "", nil). ExpectGitArgs([]string{"for-each-ref", "--format=%(refname:short)", "refs/remotes"}, "origin/feature\nfork/feature\n", nil), - expectedErr: false, + expectedErr: false, expectedUpstream: "", }, } From a133dc0dad24b67dcb1a1ff88faec2e664fa3fee Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Mon, 3 Aug 2026 12:05:25 +0100 Subject: [PATCH 09/10] add integration test for restoring a deleted branch --- .../tests/branch/restore_deleted_branch.go | 51 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 2 files changed, 52 insertions(+) create mode 100644 pkg/integration/tests/branch/restore_deleted_branch.go diff --git a/pkg/integration/tests/branch/restore_deleted_branch.go b/pkg/integration/tests/branch/restore_deleted_branch.go new file mode 100644 index 00000000000..cdc73764da0 --- /dev/null +++ b/pkg/integration/tests/branch/restore_deleted_branch.go @@ -0,0 +1,51 @@ +package branch + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var RestoreDeletedBranch = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Restore a deleted local branch from the reflog", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell. + EmptyCommit("base commit"). + NewBranch("feature"). + EmptyCommit("on feature"). + Checkout("master"). + EmptyCommit("on master"). + RunCommand([]string{"git", "branch", "-D", "feature"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("master").IsSelected(), + ) + + t.Views().Branches(). + Press(keys.Branches.RestoreBranch). + Tap(func() { + t.ExpectPopup(). + Menu(). + Title(Equals("Deleted branches")). + ContainsLines( + Contains("feature"), + ). + Select(Contains("feature")). + Confirm() + }). + Tap(func() { + t.ExpectToast(Contains("Restored branch 'feature'")) + }) + + t.Views().Branches(). + Lines( + Contains("master").IsSelected(), + Contains("feature"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index a6b5aeafdf0..1c1896ddc59 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -80,6 +80,7 @@ var tests = []*components.IntegrationTest{ branch.ResetToDuplicateNamedTag, branch.ResetToDuplicateNamedUpstream, branch.ResetToUpstream, + branch.RestoreDeletedBranch, branch.SelectCommitsOfCurrentBranch, branch.SetUpstream, branch.ShowDivergenceFromBaseBranch, From e3cab9777c46a13dedd9dd51fbb7989efd3deb1d Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Mon, 3 Aug 2026 12:26:51 +0100 Subject: [PATCH 10/10] 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 4b31de36614..e6babc435b7 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 fa51d85eac9..eb49cdd7dc6 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 1b07e5cf802..913c9fae4d8 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 00000000000..422c1fb878e --- /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 1c1896ddc59..57177e0a009 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,