From 3577808a146a69cc8eb307342a30f1750f8bfa84 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 17 Apr 2025 19:44:34 +0200 Subject: [PATCH 1/4] Remove double Refresh fetchAux already calls Refresh (and with a more targeted scope too), no need to call it again here. --- pkg/gui/controllers/files_controller.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 1240e3cf7..44f65752d 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1189,10 +1189,7 @@ func (self *FilesController) onClickMain(opts gocui.ViewMouseBindingOpts) error func (self *FilesController) fetch() error { return self.c.WithWaitingStatus(self.c.Tr.FetchingStatus, func(task gocui.Task) error { - if err := self.fetchAux(task); err != nil { - return err - } - return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + return self.fetchAux(task) }) } From 2174762315ac52f497337d19421c10f979f07eb7 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 17 Apr 2025 19:46:41 +0200 Subject: [PATCH 2/4] Inline fetchAux into call site It's only called from this one place, and the call is a one-liner, so it makes more sense to inline the code there. --- pkg/gui/controllers/files_controller.go | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 44f65752d..2746afd32 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1189,23 +1189,19 @@ func (self *FilesController) onClickMain(opts gocui.ViewMouseBindingOpts) error func (self *FilesController) fetch() error { return self.c.WithWaitingStatus(self.c.Tr.FetchingStatus, func(task gocui.Task) error { - return self.fetchAux(task) + self.c.LogAction("Fetch") + err := self.c.Git().Sync.Fetch(task) + + if err != nil && strings.Contains(err.Error(), "exit status 128") { + return errors.New(self.c.Tr.PassUnameWrong) + } + + _ = self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS}, Mode: types.ASYNC}) + + return err }) } -func (self *FilesController) fetchAux(task gocui.Task) (err error) { - self.c.LogAction("Fetch") - err = self.c.Git().Sync.Fetch(task) - - if err != nil && strings.Contains(err.Error(), "exit status 128") { - return errors.New(self.c.Tr.PassUnameWrong) - } - - _ = self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS}, Mode: types.ASYNC}) - - return err -} - // Couldn't think of a better term than 'normalised'. Alas. // The idea is that when you select a range of nodes, you will often have both // a node and its parent node selected. If we are trying to discard changes to the From eaaa937315c37fe64dab2f1327f2f35ae4613bc9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 18 Apr 2025 14:02:11 +0200 Subject: [PATCH 3/4] Add config for auto-forwarding branches after fetching --- docs/Config.md | 4 ++ pkg/commands/git_commands/branch.go | 8 +++ pkg/commands/oscommands/cmd_obj.go | 9 ++++ pkg/config/user_config.go | 4 ++ pkg/config/user_config_validation.go | 4 ++ pkg/gui/background.go | 6 ++- pkg/gui/controllers/files_controller.go | 6 ++- .../controllers/helpers/branches_helper.go | 32 +++++++++++ pkg/i18n/english.go | 2 + ..._and_auto_forward_branches_all_branches.go | 54 +++++++++++++++++++ .../fetch_and_auto_forward_branches_none.go | 54 +++++++++++++++++++ ...uto_forward_branches_only_main_branches.go | 54 +++++++++++++++++++ pkg/integration/tests/test_list.go | 3 ++ schema/config.json | 10 ++++ 14 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 pkg/integration/tests/sync/fetch_and_auto_forward_branches_all_branches.go create mode 100644 pkg/integration/tests/sync/fetch_and_auto_forward_branches_none.go create mode 100644 pkg/integration/tests/sync/fetch_and_auto_forward_branches_only_main_branches.go diff --git a/docs/Config.md b/docs/Config.md index 9eb146d92..26af074ad 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -337,6 +337,10 @@ git: # If true, periodically refresh files and submodules autoRefresh: true + # If not "none", lazygit will automatically forward branches to their upstream after fetching. Applies to branches that are not the currently checked out branch, and only to those that are strictly behind their upstream (as opposed to diverged). + # Possible values: 'none' | 'onlyMainBranches' | 'allBranches' + autoForwardBranches: onlyMainBranches + # If true, pass the --all arg to git fetch fetchAll: true diff --git a/pkg/commands/git_commands/branch.go b/pkg/commands/git_commands/branch.go index 85408f1d9..72473bc14 100644 --- a/pkg/commands/git_commands/branch.go +++ b/pkg/commands/git_commands/branch.go @@ -285,3 +285,11 @@ func (self *BranchCommands) IsBranchMerged(branch *models.Branch, mainBranches * return stdout == "", nil } + +func (self *BranchCommands) UpdateBranchRefs(updateCommands string) error { + cmdArgs := NewGitCmd("update-ref"). + Arg("--stdin"). + ToArgv() + + return self.cmd.New(cmdArgs).SetStdin(updateCommands).Run() +} diff --git a/pkg/commands/oscommands/cmd_obj.go b/pkg/commands/oscommands/cmd_obj.go index 06b07cd84..1355403c0 100644 --- a/pkg/commands/oscommands/cmd_obj.go +++ b/pkg/commands/oscommands/cmd_obj.go @@ -21,6 +21,9 @@ type ICmdObj interface { // outputs args vector e.g. ["git", "commit", "-m", "my message"] Args() []string + // Set a string to be used as stdin for the command. + SetStdin(input string) ICmdObj + AddEnvVars(...string) ICmdObj GetEnvVars() []string @@ -131,6 +134,12 @@ func (self *CmdObj) Args() []string { return self.cmd.Args } +func (self *CmdObj) SetStdin(input string) ICmdObj { + self.cmd.Stdin = strings.NewReader(input) + + return self +} + func (self *CmdObj) AddEnvVars(vars ...string) ICmdObj { self.cmd.Env = append(self.cmd.Env, vars...) diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 49e8e13c8..5e6b150b5 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -244,6 +244,9 @@ type GitConfig struct { AutoFetch bool `yaml:"autoFetch"` // If true, periodically refresh files and submodules AutoRefresh bool `yaml:"autoRefresh"` + // If not "none", lazygit will automatically forward branches to their upstream after fetching. Applies to branches that are not the currently checked out branch, and only to those that are strictly behind their upstream (as opposed to diverged). + // Possible values: 'none' | 'onlyMainBranches' | 'allBranches' + AutoForwardBranches string `yaml:"autoForwardBranches" jsonschema:"enum=none,enum=onlyMainBranches,enum=allBranches"` // If true, pass the --all arg to git fetch FetchAll bool `yaml:"fetchAll"` // If true, lazygit will automatically stage files that used to have merge @@ -822,6 +825,7 @@ func GetDefaultConfig() *UserConfig { MainBranches: []string{"master", "main"}, AutoFetch: true, AutoRefresh: true, + AutoForwardBranches: "onlyMainBranches", FetchAll: true, AutoStageResolvedConflicts: true, BranchLogCmd: "git log --graph --color=always --abbrev-commit --decorate --date=relative --pretty=medium {{branchName}} --", diff --git a/pkg/config/user_config_validation.go b/pkg/config/user_config_validation.go index 2be2f04e9..0cbb0884e 100644 --- a/pkg/config/user_config_validation.go +++ b/pkg/config/user_config_validation.go @@ -19,6 +19,10 @@ func (config *UserConfig) Validate() error { []string{"none", "onlyArrow", "arrowAndNumber"}); err != nil { return err } + if err := validateEnum("git.autoForwardBranches", config.Git.AutoForwardBranches, + []string{"none", "onlyMainBranches", "allBranches"}); err != nil { + return err + } if err := validateKeybindings(config.Keybinding); err != nil { return err } diff --git a/pkg/gui/background.go b/pkg/gui/background.go index 8142c9af9..80b73b006 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -125,7 +125,11 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan stru func (self *BackgroundRoutineMgr) backgroundFetch() (err error) { err = self.gui.git.Sync.FetchBackground() - _ = self.gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS}, Mode: types.ASYNC}) + _ = self.gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS}, Mode: types.SYNC}) + + if err == nil { + err = self.gui.helpers.BranchesHelper.AutoForwardBranches() + } return err } diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 2746afd32..6df28a523 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1196,7 +1196,11 @@ func (self *FilesController) fetch() error { return errors.New(self.c.Tr.PassUnameWrong) } - _ = self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS}, Mode: types.ASYNC}) + _ = self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS}, Mode: types.SYNC}) + + if err == nil { + err = self.c.Helpers().BranchesHelper.AutoForwardBranches() + } return err }) diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 30c5dbee7..115c6070a 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -2,6 +2,7 @@ package helpers import ( "errors" + "fmt" "strings" "github.com/jesseduffield/gocui" @@ -263,3 +264,34 @@ func (self *BranchesHelper) deleteRemoteBranches(remoteBranches []*models.Remote } return nil } + +func (self *BranchesHelper) AutoForwardBranches() error { + if self.c.UserConfig().Git.AutoForwardBranches == "none" { + return nil + } + + allBranches := self.c.UserConfig().Git.AutoForwardBranches == "allBranches" + branches := self.c.Model().Branches + updateCommands := "" + // The first branch is the currently checked out branch; skip it + for _, branch := range branches[1:] { + if branch.RemoteBranchStoredLocally() && (allBranches || lo.Contains(self.c.UserConfig().Git.MainBranches, branch.Name)) { + isStrictlyBehind := branch.IsBehindForPull() && !branch.IsAheadForPull() + if isStrictlyBehind { + updateCommands += fmt.Sprintf("update %s %s %s\n", branch.FullRefName(), branch.FullUpstreamRefName(), branch.CommitHash) + } + } + } + + if updateCommands == "" { + return nil + } + + self.c.LogAction(self.c.Tr.Actions.AutoForwardBranches) + self.c.LogCommand(strings.TrimRight(updateCommands, "\n"), false) + err := self.c.Git().Branch.UpdateBranchRefs(updateCommands) + + _ = self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}, Mode: types.SYNC}) + + return err +} diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 53c376293..85903dd96 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -931,6 +931,7 @@ type Actions struct { RenameBranch string CreateBranch string FastForwardBranch string + AutoForwardBranches string CherryPick string CheckoutFile string DiscardOldFileChange string @@ -2059,6 +2060,7 @@ func EnglishTranslationSet() *TranslationSet { MixedReset: "Mixed reset", HardReset: "Hard reset", FastForwardBranch: "Fast forward branch", + AutoForwardBranches: "Auto-forward branches", Undo: "Undo", Redo: "Redo", CopyPullRequestURL: "Copy pull request URL", diff --git a/pkg/integration/tests/sync/fetch_and_auto_forward_branches_all_branches.go b/pkg/integration/tests/sync/fetch_and_auto_forward_branches_all_branches.go new file mode 100644 index 000000000..13025298d --- /dev/null +++ b/pkg/integration/tests/sync/fetch_and_auto_forward_branches_all_branches.go @@ -0,0 +1,54 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FetchAndAutoForwardBranchesAllBranches = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Fetch from remote and auto-forward branches with config set to 'allBranches'", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Git.AutoForwardBranches = "allBranches" + }, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(3) + shell.NewBranch("feature") + shell.NewBranch("diverged") + shell.CloneIntoRemote("origin") + shell.SetBranchUpstream("master", "origin/master") + shell.SetBranchUpstream("feature", "origin/feature") + shell.SetBranchUpstream("diverged", "origin/diverged") + shell.Checkout("master") + shell.HardReset("HEAD^") + shell.Checkout("feature") + shell.HardReset("HEAD~2") + shell.Checkout("diverged") + shell.HardReset("HEAD~2") + shell.EmptyCommit("local") + shell.NewBranch("checked-out") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Lines( + Contains("checked-out").IsSelected(), + Contains("diverged ↓2↑1"), + Contains("feature ↓2").DoesNotContain("↑"), + Contains("master ↓1").DoesNotContain("↑"), + ) + + t.Views().Files(). + IsFocused(). + Press(keys.Files.Fetch) + + // AutoForwardBranches is "allBranches": both master and feature get forwarded + t.Views().Branches(). + Lines( + Contains("checked-out").IsSelected(), + Contains("diverged ↓2↑1"), + Contains("feature ✓"), + Contains("master ✓"), + ) + }, +}) diff --git a/pkg/integration/tests/sync/fetch_and_auto_forward_branches_none.go b/pkg/integration/tests/sync/fetch_and_auto_forward_branches_none.go new file mode 100644 index 000000000..c4a23a48a --- /dev/null +++ b/pkg/integration/tests/sync/fetch_and_auto_forward_branches_none.go @@ -0,0 +1,54 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FetchAndAutoForwardBranchesNone = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Fetch from remote and auto-forward branches with config set to 'none'", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Git.AutoForwardBranches = "none" + }, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(3) + shell.NewBranch("feature") + shell.NewBranch("diverged") + shell.CloneIntoRemote("origin") + shell.SetBranchUpstream("master", "origin/master") + shell.SetBranchUpstream("feature", "origin/feature") + shell.SetBranchUpstream("diverged", "origin/diverged") + shell.Checkout("master") + shell.HardReset("HEAD^") + shell.Checkout("feature") + shell.HardReset("HEAD~2") + shell.Checkout("diverged") + shell.HardReset("HEAD~2") + shell.EmptyCommit("local") + shell.NewBranch("checked-out") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Lines( + Contains("checked-out").IsSelected(), + Contains("diverged ↓2↑1"), + Contains("feature ↓2").DoesNotContain("↑"), + Contains("master ↓1").DoesNotContain("↑"), + ) + + t.Views().Files(). + IsFocused(). + Press(keys.Files.Fetch) + + // AutoForwardBranches is "none": nothing should happen + t.Views().Branches(). + Lines( + Contains("checked-out").IsSelected(), + Contains("diverged ↓2↑1"), + Contains("feature ↓2").DoesNotContain("↑"), + Contains("master ↓1").DoesNotContain("↑"), + ) + }, +}) diff --git a/pkg/integration/tests/sync/fetch_and_auto_forward_branches_only_main_branches.go b/pkg/integration/tests/sync/fetch_and_auto_forward_branches_only_main_branches.go new file mode 100644 index 000000000..8d01fe229 --- /dev/null +++ b/pkg/integration/tests/sync/fetch_and_auto_forward_branches_only_main_branches.go @@ -0,0 +1,54 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FetchAndAutoForwardBranchesOnlyMainBranches = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Fetch from remote and auto-forward branches with config set to 'onlyMainBranches'", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Git.AutoForwardBranches = "onlyMainBranches" + }, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(3) + shell.NewBranch("feature") + shell.NewBranch("diverged") + shell.CloneIntoRemote("origin") + shell.SetBranchUpstream("master", "origin/master") + shell.SetBranchUpstream("feature", "origin/feature") + shell.SetBranchUpstream("diverged", "origin/diverged") + shell.Checkout("master") + shell.HardReset("HEAD^") + shell.Checkout("feature") + shell.HardReset("HEAD~2") + shell.Checkout("diverged") + shell.HardReset("HEAD~2") + shell.EmptyCommit("local") + shell.NewBranch("checked-out") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Lines( + Contains("checked-out").IsSelected(), + Contains("diverged ↓2↑1"), + Contains("feature ↓2").DoesNotContain("↑"), + Contains("master ↓1").DoesNotContain("↑"), + ) + + t.Views().Files(). + IsFocused(). + Press(keys.Files.Fetch) + + // AutoForwardBranches is "onlyMainBranches": master gets forwarded, but feature doesn't + t.Views().Branches(). + Lines( + Contains("checked-out").IsSelected(), + Contains("diverged ↓2↑1"), + Contains("feature ↓2").DoesNotContain("↑"), + Contains("master ✓"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 7ddbfc119..96a895798 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -360,6 +360,9 @@ var tests = []*components.IntegrationTest{ submodule.RemoveNested, submodule.Reset, submodule.ResetFolder, + sync.FetchAndAutoForwardBranchesAllBranches, + sync.FetchAndAutoForwardBranchesNone, + sync.FetchAndAutoForwardBranchesOnlyMainBranches, sync.FetchPrune, sync.FetchWhenSortedByDate, sync.ForcePush, diff --git a/schema/config.json b/schema/config.json index 7b912a85c..43fe0f2fc 100644 --- a/schema/config.json +++ b/schema/config.json @@ -325,6 +325,16 @@ "description": "If true, periodically refresh files and submodules", "default": true }, + "autoForwardBranches": { + "type": "string", + "enum": [ + "none", + "onlyMainBranches", + "allBranches" + ], + "description": "If not \"none\", lazygit will automatically forward branches to their upstream after fetching. Applies to branches that are not the currently checked out branch, and only to those that are strictly behind their upstream (as opposed to diverged).\nPossible values: 'none' | 'onlyMainBranches' | 'allBranches'", + "default": "onlyMainBranches" + }, "fetchAll": { "type": "boolean", "description": "If true, pass the --all arg to git fetch", From 74958542432553c68f8ab784dde01a42031bce0c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 18 Apr 2025 14:58:06 +0200 Subject: [PATCH 4/4] Add a breaking changes hint for the new auto-forward behavior --- pkg/i18n/english.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 85903dd96..3b961e7d1 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -2139,6 +2139,12 @@ gui: "0.44.0": `- The gui.branchColors config option is deprecated; it will be removed in a future version. Please use gui.branchColorPatterns instead. - The automatic coloring of branches starting with "feature/", "bugfix/", or "hotfix/" has been removed; if you want this, it's easy to set up using the new gui.branchColorPatterns option.`, "0.49.0": `- Executing shell commands (with the ':' prompt) no longer uses an interactive shell, which means that if you want to use your shell aliases in this prompt, you need to do a little bit of setup work. See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#using-aliases-or-functions-in-shell-commands for details.`, + "0.50.0": `- After fetching, main branches now get auto-forwarded to their upstream if they fall behind. This is useful for keeping your main or master branch up to date automatically. If you don't want this, you can disable it by setting the following in your config: + +git: + autoForwardBranches: none + +If, on the other hand, you want this even for feature branches, you can set it to 'allBranches' instead.`, }, } }