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 1240e3cf7..6df28a523 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1189,26 +1189,23 @@ 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 + 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) } - return self.c.Refresh(types.RefreshOptions{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 }) } -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 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..3b961e7d1 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", @@ -2137,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.`, }, } } 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",