From eaaa937315c37fe64dab2f1327f2f35ae4613bc9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 18 Apr 2025 14:02:11 +0200 Subject: [PATCH] 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",