Auto-forward main branches after fetching (#4493)

- **PR Description**

Add a new user config for auto-forwarding branches after fetching; by
default this is set to "onlyMainBranches", but it can be set to
"allBranches" to extend it to feature branches too, or to "none" to
disable it.

This is used both when fetching manually by pressing `f` in the files
panel, and for automatic background fetching.
This commit is contained in:
Stefan Haller 2025-04-21 18:08:36 +02:00 committed by GitHub
commit bd1e34b39d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 262 additions and 17 deletions

View file

@ -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

View file

@ -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()
}

View file

@ -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...)

View file

@ -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}} --",

View file

@ -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
}

View file

@ -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
}

View file

@ -1189,24 +1189,21 @@ 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})
})
}
func (self *FilesController) fetchAux(task gocui.Task) (err error) {
self.c.LogAction("Fetch")
err = self.c.Git().Sync.Fetch(task)
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})
_ = 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
})
}
// Couldn't think of a better term than 'normalised'. Alas.

View file

@ -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
}

View file

@ -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.`,
},
}
}

View file

@ -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 ✓"),
)
},
})

View file

@ -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("↑"),
)
},
})

View file

@ -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 ✓"),
)
},
})

View file

@ -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,

View file

@ -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",