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 # If true, periodically refresh files and submodules
autoRefresh: true 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 # If true, pass the --all arg to git fetch
fetchAll: true fetchAll: true

View file

@ -285,3 +285,11 @@ func (self *BranchCommands) IsBranchMerged(branch *models.Branch, mainBranches *
return stdout == "", nil 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"] // outputs args vector e.g. ["git", "commit", "-m", "my message"]
Args() []string Args() []string
// Set a string to be used as stdin for the command.
SetStdin(input string) ICmdObj
AddEnvVars(...string) ICmdObj AddEnvVars(...string) ICmdObj
GetEnvVars() []string GetEnvVars() []string
@ -131,6 +134,12 @@ func (self *CmdObj) Args() []string {
return self.cmd.Args 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 { func (self *CmdObj) AddEnvVars(vars ...string) ICmdObj {
self.cmd.Env = append(self.cmd.Env, vars...) self.cmd.Env = append(self.cmd.Env, vars...)

View file

@ -244,6 +244,9 @@ type GitConfig struct {
AutoFetch bool `yaml:"autoFetch"` AutoFetch bool `yaml:"autoFetch"`
// If true, periodically refresh files and submodules // If true, periodically refresh files and submodules
AutoRefresh bool `yaml:"autoRefresh"` 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 // If true, pass the --all arg to git fetch
FetchAll bool `yaml:"fetchAll"` FetchAll bool `yaml:"fetchAll"`
// If true, lazygit will automatically stage files that used to have merge // If true, lazygit will automatically stage files that used to have merge
@ -822,6 +825,7 @@ func GetDefaultConfig() *UserConfig {
MainBranches: []string{"master", "main"}, MainBranches: []string{"master", "main"},
AutoFetch: true, AutoFetch: true,
AutoRefresh: true, AutoRefresh: true,
AutoForwardBranches: "onlyMainBranches",
FetchAll: true, FetchAll: true,
AutoStageResolvedConflicts: true, AutoStageResolvedConflicts: true,
BranchLogCmd: "git log --graph --color=always --abbrev-commit --decorate --date=relative --pretty=medium {{branchName}} --", 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 { []string{"none", "onlyArrow", "arrowAndNumber"}); err != nil {
return err 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 { if err := validateKeybindings(config.Keybinding); err != nil {
return err return err
} }

View file

@ -125,7 +125,11 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan stru
func (self *BackgroundRoutineMgr) backgroundFetch() (err error) { func (self *BackgroundRoutineMgr) backgroundFetch() (err error) {
err = self.gui.git.Sync.FetchBackground() 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 return err
} }

View file

@ -1189,26 +1189,23 @@ func (self *FilesController) onClickMain(opts gocui.ViewMouseBindingOpts) error
func (self *FilesController) fetch() error { func (self *FilesController) fetch() error {
return self.c.WithWaitingStatus(self.c.Tr.FetchingStatus, func(task gocui.Task) error { return self.c.WithWaitingStatus(self.c.Tr.FetchingStatus, func(task gocui.Task) error {
if err := self.fetchAux(task); err != nil { self.c.LogAction("Fetch")
return err 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. // 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 // 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 // a node and its parent node selected. If we are trying to discard changes to the

View file

@ -2,6 +2,7 @@ package helpers
import ( import (
"errors" "errors"
"fmt"
"strings" "strings"
"github.com/jesseduffield/gocui" "github.com/jesseduffield/gocui"
@ -263,3 +264,34 @@ func (self *BranchesHelper) deleteRemoteBranches(remoteBranches []*models.Remote
} }
return nil 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 RenameBranch string
CreateBranch string CreateBranch string
FastForwardBranch string FastForwardBranch string
AutoForwardBranches string
CherryPick string CherryPick string
CheckoutFile string CheckoutFile string
DiscardOldFileChange string DiscardOldFileChange string
@ -2059,6 +2060,7 @@ func EnglishTranslationSet() *TranslationSet {
MixedReset: "Mixed reset", MixedReset: "Mixed reset",
HardReset: "Hard reset", HardReset: "Hard reset",
FastForwardBranch: "Fast forward branch", FastForwardBranch: "Fast forward branch",
AutoForwardBranches: "Auto-forward branches",
Undo: "Undo", Undo: "Undo",
Redo: "Redo", Redo: "Redo",
CopyPullRequestURL: "Copy pull request URL", 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. "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.`, - 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.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.RemoveNested,
submodule.Reset, submodule.Reset,
submodule.ResetFolder, submodule.ResetFolder,
sync.FetchAndAutoForwardBranchesAllBranches,
sync.FetchAndAutoForwardBranchesNone,
sync.FetchAndAutoForwardBranchesOnlyMainBranches,
sync.FetchPrune, sync.FetchPrune,
sync.FetchWhenSortedByDate, sync.FetchWhenSortedByDate,
sync.ForcePush, sync.ForcePush,

View file

@ -325,6 +325,16 @@
"description": "If true, periodically refresh files and submodules", "description": "If true, periodically refresh files and submodules",
"default": true "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": { "fetchAll": {
"type": "boolean", "type": "boolean",
"description": "If true, pass the --all arg to git fetch", "description": "If true, pass the --all arg to git fetch",