Repository: jesseduffield/lazygit (language: go)

Issue:
Order remote branches by date of last commit
**Is your feature request related to a problem? Please describe.**
In our company we have way too many branches remotely. Basically everybody creates new ones for every PR, and most of the time the branches don't get deleted afterwards.
That makes it very hard to check out a remote branch with lazygit, as I have to search for it. Need to know the exact PR number, ...


**Describe the solution you'd like**
I would like that the remote branches are ordered by the date of their last commit, and not alphabetically (similar to the local branches (although they are sorted by last checkout, not by last commit).
Or at least I would like to have the option to enable such a sorting.

**Describe alternatives you've considered**
I know the best solution would be that my team enforces a delete-branch-after-merge policy, but I would still appreciate this feature. Not sure if everybody can enforce it, and that still leaves all he stall branches that don't get merged.

**Additional context**




Discussion / hints:
Sounds a lot like my company haha. This sounds like a sensible change to me. I wonder if it's expensive to grab the last commit date for each remote branch, but I suspect it's not.
A simple solution is to replace the git command in:

https://github.com/jesseduffield/lazygit/blob/f4011643dda14c6da522dcd172b66b2d2cadb02b/pkg/commands/loaders/remotes.go#L34

with

`"git for-each-ref --sort=-authordate refs/remotes --format=\"%(refname:lstrip=2)\""`

Fix patch:
diff --git a/docs/keybindings/Keybindings_en.md b/docs/keybindings/Keybindings_en.md
index 5c7d2f1f97d..e4f6fd8be03 100644
--- a/docs/keybindings/Keybindings_en.md
+++ b/docs/keybindings/Keybindings_en.md
@@ -258,6 +258,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
   <kbd>r</kbd>: Rebase checked-out branch onto this branch
   <kbd>d</kbd>: Delete remote tag
   <kbd>u</kbd>: Set as upstream of checked-out branch
+  <kbd>s</kbd>: Sort order
   <kbd>g</kbd>: View reset options
   <kbd>w</kbd>: View worktree options
   <kbd>&lt;enter&gt;</kbd>: View commits
diff --git a/docs/keybindings/Keybindings_ja.md b/docs/keybindings/Keybindings_ja.md
index 2acbd3777cb..16e2d8a4dfc 100644
--- a/docs/keybindings/Keybindings_ja.md
+++ b/docs/keybindings/Keybindings_ja.md
@@ -323,6 +323,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
   <kbd>r</kbd>: Rebase checked-out branch onto this branch
   <kbd>d</kbd>: Delete remote tag
   <kbd>u</kbd>: Set as upstream of checked-out branch
+  <kbd>s</kbd>: 並び替え
   <kbd>g</kbd>: View reset options
   <kbd>w</kbd>: View worktree options
   <kbd>&lt;enter&gt;</kbd>: コミットを閲覧
diff --git a/docs/keybindings/Keybindings_ko.md b/docs/keybindings/Keybindings_ko.md
index 68e3a25ed5a..35dd0ebc9bd 100644
--- a/docs/keybindings/Keybindings_ko.md
+++ b/docs/keybindings/Keybindings_ko.md
@@ -241,6 +241,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
   <kbd>r</kbd>: 체크아웃된 브랜치를 이 브랜치에 리베이스
   <kbd>d</kbd>: Delete remote tag
   <kbd>u</kbd>: Set as upstream of checked-out branch
+  <kbd>s</kbd>: Sort order
   <kbd>g</kbd>: View reset options
   <kbd>w</kbd>: View worktree options
   <kbd>&lt;enter&gt;</kbd>: 커밋 보기
diff --git a/docs/keybindings/Keybindings_nl.md b/docs/keybindings/Keybindings_nl.md
index a53ad13a875..c5fe3f3b3d1 100644
--- a/docs/keybindings/Keybindings_nl.md
+++ b/docs/keybindings/Keybindings_nl.md
@@ -236,6 +236,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
   <kbd>r</kbd>: Rebase branch
   <kbd>d</kbd>: Delete remote tag
   <kbd>u</kbd>: Stel in als upstream van uitgecheckte branch
+  <kbd>s</kbd>: Sort order
   <kbd>g</kbd>: Bekijk reset opties
   <kbd>w</kbd>: View worktree options
   <kbd>&lt;enter&gt;</kbd>: Bekijk commits
diff --git a/docs/keybindings/Keybindings_pl.md b/docs/keybindings/Keybindings_pl.md
index 9ffffade50a..558e3b50f4d 100644
--- a/docs/keybindings/Keybindings_pl.md
+++ b/docs/keybindings/Keybindings_pl.md
@@ -235,6 +235,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
   <kbd>r</kbd>: Zmiana bazy gałęzi
   <kbd>d</kbd>: Delete remote tag
   <kbd>u</kbd>: Set as upstream of checked-out branch
+  <kbd>s</kbd>: Sort order
   <kbd>g</kbd>: Wyświetl opcje resetu
   <kbd>w</kbd>: View worktree options
   <kbd>&lt;enter&gt;</kbd>: View commits
diff --git a/docs/keybindings/Keybindings_ru.md b/docs/keybindings/Keybindings_ru.md
index 3296c8c1615..22600aea788 100644
--- a/docs/keybindings/Keybindings_ru.md
+++ b/docs/keybindings/Keybindings_ru.md
@@ -296,6 +296,7 @@ _Связки клавиш_
   <kbd>r</kbd>: Перебазировать переключённую ветку на эту ветку
   <kbd>d</kbd>: Delete remote tag
   <kbd>u</kbd>: Установить как upstream-ветку переключённую ветку
+  <kbd>s</kbd>: Порядок сортировки
   <kbd>g</kbd>: Просмотреть параметры сброса
   <kbd>w</kbd>: View worktree options
   <kbd>&lt;enter&gt;</kbd>: Просмотреть коммиты
diff --git a/docs/keybindings/Keybindings_zh-CN.md b/docs/keybindings/Keybindings_zh-CN.md
index 3f9d060ca45..354ef952a36 100644
--- a/docs/keybindings/Keybindings_zh-CN.md
+++ b/docs/keybindings/Keybindings_zh-CN.md
@@ -337,6 +337,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
   <kbd>r</kbd>: 将已检出的分支变基到该分支
   <kbd>d</kbd>: Delete remote tag
   <kbd>u</kbd>: 设置为检出分支的上游
+  <kbd>s</kbd>: Sort order
   <kbd>g</kbd>: 查看重置选项
   <kbd>w</kbd>: View worktree options
   <kbd>&lt;enter&gt;</kbd>: 查看提交
diff --git a/docs/keybindings/Keybindings_zh-TW.md b/docs/keybindings/Keybindings_zh-TW.md
index 78fd756872f..6e3094afd9c 100644
--- a/docs/keybindings/Keybindings_zh-TW.md
+++ b/docs/keybindings/Keybindings_zh-TW.md
@@ -347,6 +347,7 @@ _說明：`<c-b>` 表示 Ctrl+B、`<a-b>` 表示 Alt+B，`B`表示 Shift+B_
   <kbd>r</kbd>: 將已檢出的分支變基至此分支
   <kbd>d</kbd>: Delete remote tag
   <kbd>u</kbd>: 將此分支設為當前分支之上游
+  <kbd>s</kbd>: Sort order
   <kbd>g</kbd>: 檢視重設選項
   <kbd>w</kbd>: View worktree options
   <kbd>&lt;enter&gt;</kbd>: 檢視提交
diff --git a/pkg/commands/git_commands/remote_loader.go b/pkg/commands/git_commands/remote_loader.go
index 6551ecb25cf..9d48374c52a 100644
--- a/pkg/commands/git_commands/remote_loader.go
+++ b/pkg/commands/git_commands/remote_loader.go
@@ -1,6 +1,7 @@
 package git_commands
 
 import (
+	"fmt"
 	"strings"
 	"sync"
 
@@ -83,14 +84,23 @@ func (self *RemoteLoader) GetRemotes() ([]*models.Remote, error) {
 func (self *RemoteLoader) getRemoteBranchesByRemoteName() (map[string][]*models.RemoteBranch, error) {
 	remoteBranchesByRemoteName := make(map[string][]*models.RemoteBranch)
 
-	cmdArgs := NewGitCmd("branch").Arg("-r").ToArgv()
-	err := self.cmd.New(cmdArgs).DontLog().RunAndProcessLines(func(line string) (bool, error) {
-		// excluding lines like 'origin/HEAD -> origin/master' (there will be a separate
-		// line for 'origin/master')
-		if strings.Contains(line, "->") {
-			return false, nil
-		}
+	var sortOrder string
+	switch strings.ToLower(self.AppState.RemoteBranchSortOrder) {
+	case "alphabetical":
+		sortOrder = "refname"
+	case "date":
+		sortOrder = "-committerdate"
+	default:
+		sortOrder = "refname"
+	}
+
+	cmdArgs := NewGitCmd("for-each-ref").
+		Arg(fmt.Sprintf("--sort=%s", sortOrder)).
+		Arg("--format=%(refname:short)").
+		Arg("refs/remotes").
+		ToArgv()
 
+	err := self.cmd.New(cmdArgs).DontLog().RunAndProcessLines(func(line string) (bool, error) {
 		line = strings.TrimSpace(line)
 
 		split := strings.SplitN(line, "/", 2)
diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go
index 54d41b695e2..927f9f30973 100644
--- a/pkg/config/app_config.go
+++ b/pkg/config/app_config.go
@@ -323,14 +323,16 @@ type AppState struct {
 	HideCommandLog             bool
 	IgnoreWhitespaceInDiffView bool
 	DiffContextSize            int
+	RemoteBranchSortOrder      string
 }
 
 func getDefaultAppState() *AppState {
 	return &AppState{
-		LastUpdateCheck:     0,
-		RecentRepos:         []string{},
-		StartupPopupVersion: 0,
-		DiffContextSize:     3,
+		LastUpdateCheck:       0,
+		RecentRepos:           []string{},
+		StartupPopupVersion:   0,
+		DiffContextSize:       3,
+		RemoteBranchSortOrder: "alphabetical",
 	}
 }
 
diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go
index 3d772e778d4..749b4182240 100644
--- a/pkg/config/user_config.go
+++ b/pkg/config/user_config.go
@@ -389,6 +389,7 @@ type KeybindingBranchesConfig struct {
 	PushTag                string `yaml:"pushTag"`
 	SetUpstream            string `yaml:"setUpstream"`
 	FetchRemote            string `yaml:"fetchRemote"`
+	SortOrder              string `yaml:"sortOrder"`
 }
 
 type KeybindingWorktreesConfig struct {
@@ -781,6 +782,7 @@ func GetDefaultConfig() *UserConfig {
 				PushTag:                "P",
 				SetUpstream:            "u",
 				FetchRemote:            "f",
+				SortOrder:              "s",
 			},
 			Worktrees: KeybindingWorktreesConfig{
 				ViewWorktreeOptions: "w",
diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go
index a6de0d39fe8..b4617c8a033 100644
--- a/pkg/gui/controllers/helpers/refs_helper.go
+++ b/pkg/gui/controllers/helpers/refs_helper.go
@@ -119,6 +119,37 @@ func (self *RefsHelper) ResetToRef(ref string, strength string, envVars []string
 	return nil
 }
 
+func (self *RefsHelper) CreateSortOrderMenu(onSelected func(sortOrder string) error) error {
+	type sortOrderWithKey struct {
+		key       types.Key
+		label     string
+		sortKey   string
+		sortOrder string
+	}
+	sortKeys := []sortOrderWithKey{
+		{label: self.c.Tr.SortAlphabetical, sortKey: "refname", sortOrder: "alphabetical", key: 'a'},
+		{label: self.c.Tr.SortByDate, sortKey: "-committerdate", sortOrder: "date", key: 'd'},
+	}
+
+	menuItems := lo.Map(sortKeys, func(row sortOrderWithKey, _ int) *types.MenuItem {
+		return &types.MenuItem{
+			LabelColumns: []string{
+				row.label,
+				style.FgYellow.Sprintf("--sort=%s", row.sortKey),
+			},
+			OnPress: func() error {
+				return onSelected(row.sortOrder)
+			},
+			Key: row.key,
+		}
+	})
+
+	return self.c.Menu(types.CreateMenuOptions{
+		Title: self.c.Tr.SortOrder,
+		Items: menuItems,
+	})
+}
+
 func (self *RefsHelper) CreateGitResetMenu(ref string) error {
 	type strengthWithKey struct {
 		strength string
diff --git a/pkg/gui/controllers/remote_branches_controller.go b/pkg/gui/controllers/remote_branches_controller.go
index ffb55d5cad2..6d224474631 100644
--- a/pkg/gui/controllers/remote_branches_controller.go
+++ b/pkg/gui/controllers/remote_branches_controller.go
@@ -58,6 +58,12 @@ func (self *RemoteBranchesController) GetKeybindings(opts types.KeybindingsOpts)
 			Handler:     self.checkSelected(self.setAsUpstream),
 			Description: self.c.Tr.SetAsUpstream,
 		},
+		{
+			Key:         opts.GetKey(opts.Config.Branches.SortOrder),
+			Handler:     self.createSortMenu,
+			Description: self.c.Tr.SortOrder,
+			OpensMenu:   true,
+		},
 		{
 			Key:         opts.GetKey(opts.Config.Commits.ViewResetOptions),
 			Handler:     self.checkSelected(self.createResetMenu),
@@ -121,6 +127,18 @@ func (self *RemoteBranchesController) rebase(selectedBranch *models.RemoteBranch
 	return self.c.Helpers().MergeAndRebase.RebaseOntoRef(selectedBranch.FullName())
 }
 
+func (self *RemoteBranchesController) createSortMenu() error {
+	return self.c.Helpers().Refs.CreateSortOrderMenu(func(sortOrder string) error {
+		if self.c.GetAppState().RemoteBranchSortOrder != sortOrder {
+			self.c.GetAppState().RemoteBranchSortOrder = sortOrder
+			self.c.SaveAppStateAndLogError()
+			self.c.Contexts().RemoteBranches.SetSelectedLineIdx(0)
+			return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.REMOTES}})
+		}
+		return nil
+	})
+}
+
 func (self *RemoteBranchesController) createResetMenu(selectedBranch *models.RemoteBranch) error {
 	return self.c.Helpers().Refs.CreateGitResetMenu(selectedBranch.FullName())
 }
diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go
index 32392cee7a4..260976d649e 100644
--- a/pkg/i18n/english.go
+++ b/pkg/i18n/english.go
@@ -555,6 +555,9 @@ type TranslationSet struct {
 	LogMenuTitle                        string
 	ToggleShowGitGraphAll               string
 	ShowGitGraph                        string
+	SortOrder                           string
+	SortAlphabetical                    string
+	SortByDate                          string
 	SortCommits                         string
 	CantChangeContextSizeError          string
 	OpenCommitInBrowser                 string
@@ -1364,6 +1367,9 @@ func EnglishTranslationSet() TranslationSet {
 		LogMenuTitle:                        "Commit Log Options",
 		ToggleShowGitGraphAll:               "Toggle show whole git graph (pass the `--all` flag to `git log`)",
 		ShowGitGraph:                        "Show git graph",
+		SortOrder:                           "Sort order",
+		SortAlphabetical:                    "Alphabetical",
+		SortByDate:                          "Date",
 		SortCommits:                         "Commit sort order",
 		CantChangeContextSizeError:          "Cannot change context while in patch building mode because we were too lazy to support it when releasing the feature. If you really want it, please let us know!",
 		OpenCommitInBrowser:                 "Open commit in browser",
diff --git a/pkg/i18n/japanese.go b/pkg/i18n/japanese.go
index 03e4ff6f48d..089c95ed680 100644
--- a/pkg/i18n/japanese.go
+++ b/pkg/i18n/japanese.go
@@ -461,8 +461,11 @@ func japaneseTranslationSet() TranslationSet {
 		OpenLogMenu:  "ログメニューを開く",
 		LogMenuTitle: "コミットログオプション",
 		// ToggleShowGitGraphAll:               "Toggle show whole git graph (pass the `--all` flag to `git log`)",
-		ShowGitGraph: "コミットグラフの表示",
-		SortCommits:  "コミットの表示順",
+		ShowGitGraph:     "コミットグラフの表示",
+		SortOrder:        "並び替え",
+		SortAlphabetical: "アルファベット順",
+		SortByDate:       "日付順",
+		SortCommits:      "コミットの表示順",
 		// CantChangeContextSizeError:          "Cannot change context while in patch building mode because we were too lazy to support it when releasing the feature. If you really want it, please let us know!",
 		OpenCommitInBrowser: "ブラウザでコミットを開く",
 		// LcViewBisectOptions:                 "View bisect options",
diff --git a/pkg/i18n/russian.go b/pkg/i18n/russian.go
index 3332f72cdb2..e4b67a77ecf 100644
--- a/pkg/i18n/russian.go
+++ b/pkg/i18n/russian.go
@@ -525,6 +525,9 @@ func RussianTranslationSet() TranslationSet {
 		LogMenuTitle:                        "Параметры журнала коммитов",
 		ToggleShowGitGraphAll:               "Переключить отображение всего git графа (передать флаг --all в git log )",
 		ShowGitGraph:                        "Показать git граф",
+		SortOrder:                           "Порядок сортировки",
+		SortAlphabetical:                    "По алфавиту",
+		SortByDate:                          "По дате",
 		SortCommits:                         "Упорядочить коммиты",
 		CantChangeContextSizeError:          "Невозможно изменить контекст в режиме создания патча, потому что мы были слишком ленивы, чтобы поддерживать его при выпуске функции. Если вы действительно этого хотите, пожалуйста, дайте нам знать!",
 		OpenCommitInBrowser:                 "Открыть коммит в браузере",
diff --git a/pkg/integration/components/shell.go b/pkg/integration/components/shell.go
index 33b82fc58d5..5a3bbaa29a8 100644
--- a/pkg/integration/components/shell.go
+++ b/pkg/integration/components/shell.go
@@ -31,7 +31,11 @@ func NewShell(dir string, fail func(string)) *Shell {
 }
 
 func (self *Shell) RunCommand(args []string) *Shell {
-	output, err := self.runCommandWithOutput(args)
+	return self.RunCommandWithEnv(args, []string{})
+}
+
+func (self *Shell) RunCommandWithEnv(args []string, env []string) *Shell {
+	output, err := self.runCommandWithOutputAndEnv(args, env)
 	if err != nil {
 		self.fail(fmt.Sprintf("error running command: %v\n%s", args, output))
 	}
@@ -49,8 +53,12 @@ func (self *Shell) RunCommandExpectError(args []string) *Shell {
 }
 
 func (self *Shell) runCommandWithOutput(args []string) (string, error) {
+	return self.runCommandWithOutputAndEnv(args, []string{})
+}
+
+func (self *Shell) runCommandWithOutputAndEnv(args []string, env []string) (string, error) {
 	cmd := exec.Command(args[0], args[1:]...)
-	cmd.Env = os.Environ()
+	cmd.Env = append(os.Environ(), env...)
 	cmd.Dir = self.dir
 
 	output, err := cmd.CombinedOutput()
@@ -164,6 +172,14 @@ func (self *Shell) EmptyCommitDaysAgo(message string, daysAgo int) *Shell {
 	return self.RunCommand([]string{"git", "commit", "--allow-empty", "--date", fmt.Sprintf("%d days ago", daysAgo), "-m", message})
 }
 
+func (self *Shell) EmptyCommitWithDate(message string, date string) *Shell {
+	env := []string{
+		"GIT_AUTHOR_DATE=" + date,
+		"GIT_COMMITTER_DATE=" + date,
+	}
+	return self.RunCommandWithEnv([]string{"git", "commit", "--allow-empty", "-m", message}, env)
+}
+
 func (self *Shell) Revert(ref string) *Shell {
 	return self.RunCommand([]string{"git", "revert", ref})
 }
diff --git a/schema/config.json b/schema/config.json
index 186ca25760b..54f6d69612b 100644
--- a/schema/config.json
+++ b/schema/config.json
@@ -1014,6 +1014,10 @@
             "fetchRemote": {
               "type": "string",
               "default": "f"
+            },
+            "sortOrder": {
+              "type": "string",
+              "default": "s"
             }
           },
           "additionalProperties": false,


Test patch:
diff --git a/pkg/integration/tests/branch/sort_remote_branches.go b/pkg/integration/tests/branch/sort_remote_branches.go
new file mode 100644
index 00000000000..35e2f700a07
--- /dev/null
+++ b/pkg/integration/tests/branch/sort_remote_branches.go
@@ -0,0 +1,55 @@
+package branch
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var SortRemoteBranches = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "Sort remote branches alphabetically or by date",
+	ExtraCmdArgs: []string{},
+	Skip:         false,
+	SetupConfig:  func(config *config.AppConfig) {},
+	SetupRepo: func(shell *Shell) {
+		shell.NewBranch("first")
+		shell.EmptyCommitWithDate("commit", "2023-04-07 10:00:00")
+		shell.NewBranch("second")
+		shell.EmptyCommitWithDate("commit", "2023-04-07 12:00:00")
+		shell.NewBranch("third")
+		shell.EmptyCommitWithDate("commit", "2023-04-07 11:00:00")
+		shell.CloneIntoRemote("origin")
+	},
+	Run: func(t *TestDriver, keys config.KeybindingConfig) {
+		t.Views().Remotes().
+			Focus().
+			Lines(
+				Contains("origin").IsSelected(),
+			).
+			PressEnter()
+
+		// sorted alphabetically by default
+		t.Views().RemoteBranches().
+			IsFocused().
+			Lines(
+				Contains("first").IsSelected(),
+				Contains("second"),
+				Contains("third"),
+			).
+			SelectNextItem() // to test that the selection jumps back to the first when sorting
+
+		t.Views().RemoteBranches().
+			Press(keys.Branches.SortOrder)
+
+		t.ExpectPopup().Menu().Title(Equals("Sort order")).
+			Select(Contains("-committerdate")).
+			Confirm()
+
+		t.Views().RemoteBranches().
+			IsFocused().
+			Lines(
+				Contains("second").IsSelected(),
+				Contains("third"),
+				Contains("first"),
+			)
+	},
+})
diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go
index 0aa61b463bf..9246aa82a80 100644
--- a/pkg/integration/tests/test_list.go
+++ b/pkg/integration/tests/test_list.go
@@ -54,6 +54,7 @@ var tests = []*components.IntegrationTest{
 	branch.ResetToUpstream,
 	branch.SetUpstream,
 	branch.ShowDivergenceFromUpstream,
+	branch.SortRemoteBranches,
 	branch.Suggestions,
 	branch.UnsetUpstream,
 	cherry_pick.CherryPick,