Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs-master/Stacked_Branches.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,20 @@ force-pushing every branch one by one; you are asked to confirm the force push
once for all of them. Only branches that already have an upstream are included.
Each of them is pushed to where `git push` would push it if it were checked out,
so your push configuration applies to them as usual.

When somebody else rebases the stack and force-pushes it, all your branches
show up as diverged, for example `↓5↑3`, even though the commits they are ahead
by are only the old versions of the ones that are now on the remote. Lazygit
tells this apart from a branch that carries work of your own, and shows the
divergence dimmed for such a branch. Pressing `f` on it resets it to its
upstream instead of refusing, so you don't have to check the branch out and pull
it. Lazygit only does this when every commit of the branch was on its remote
branch at some point. It finds that out from the reflog of the remote-tracking
branch. Reflogs are enabled by default, except in a bare repository; if you work
in one with linked worktrees, set `core.logAllRefUpdates` to true there to make
this work.

`f` works on a [range selection](Range_Select.md) too, so you can select the
whole stack and bring all of it back in sync at once. If any of the selected
branches can't be updated, none of them is, so that you don't end up with half
of the stack updated.
2 changes: 1 addition & 1 deletion docs-master/keybindings/Keybindings_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` d `` | Delete | View delete options for local/remote branch. |
| `` r `` | Rebase | Rebase the checked-out branch onto the selected branch. |
| `` M `` | Merge | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` f `` | Fast-forward | Fast-forward selected branch from its upstream. |
| `` f `` | Fast-forward | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). |
| `` T `` | New tag | |
| `` s `` | Sort order | |
| `` g `` | Reset | |
Expand Down
2 changes: 1 addition & 1 deletion docs-master/keybindings/Keybindings_ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` d `` | 삭제 | View delete options for local/remote branch. |
| `` r `` | 체크아웃된 브랜치를 이 브랜치에 리베이스 | Rebase the checked-out branch onto the selected branch. |
| `` M `` | 현재 브랜치에 병합 | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` f `` | Fast-forward this branch from its upstream | Fast-forward selected branch from its upstream. |
| `` f `` | Fast-forward this branch from its upstream | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). |
| `` T `` | 태그를 생성 | |
| `` s `` | Sort order | |
| `` g `` | View reset options | |
Expand Down
2 changes: 1 addition & 1 deletion docs-master/keybindings/Keybindings_nl.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` d `` | Verwijderen | View delete options for local/remote branch. |
| `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. |
| `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` f `` | Fast-forward deze branch vanaf zijn upstream | Fast-forward selected branch from its upstream. |
| `` f `` | Fast-forward deze branch vanaf zijn upstream | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). |
| `` T `` | Creëer tag | |
| `` s `` | Sort order | |
| `` g `` | Bekijk reset opties | |
Expand Down
2 changes: 1 addition & 1 deletion docs-master/keybindings/Keybindings_ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
| `` d `` | Delete | View delete options for local/remote branch. |
| `` r `` | Перебазировать переключённую ветку на эту ветку | Rebase the checked-out branch onto the selected branch. |
| `` M `` | Слияние с текущей переключённой веткой | View options for merging the selected item into the current branch (regular merge, squash merge) |
| `` f `` | Перемотать эту ветку вперёд из её upstream-ветки | Fast-forward selected branch from its upstream. |
| `` f `` | Перемотать эту ветку вперёд из её upstream-ветки | Fast-forward selected branch from its upstream. If the branch has diverged from its upstream because the upstream branch was rewritten, and it has no commits of its own, it is reset to its upstream instead. This needs reflogs to be enabled; a bare repository doesn't keep them by default (core.logAllRefUpdates). |
| `` T `` | Создать тег | |
| `` s `` | Порядок сортировки | |
| `` g `` | Просмотреть параметры сброса | |
Expand Down
2 changes: 1 addition & 1 deletion pkg/commands/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ func NewGitCommandAux(
gitHubCommands := git_commands.NewGitHubCommands(gitCommon)
hostingServiceCommands := git_commands.NewHostingServiceCommand(gitCommon)

branchLoader := git_commands.NewBranchLoader(cmn, gitCommon, cmd, branchCommands.CurrentBranchInfo, configCommands)
branchLoader := git_commands.NewBranchLoader(cmn, gitCommon, cmd, branchCommands.CurrentBranchInfo, branchCommands.HasLocalOnlyCommits, configCommands)
commitFileLoader := git_commands.NewCommitFileLoader(cmn, cmd)
commitLoader := git_commands.NewCommitLoader(cmn, cmd, statusCommands.WorkingTreeState, gitCommon)
reflogCommitLoader := git_commands.NewReflogCommitLoader(cmn, cmd)
Expand Down
99 changes: 95 additions & 4 deletions pkg/commands/git_commands/branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,11 +285,26 @@ func (self *BranchCommands) Merge(branchName string, variant MergeVariant) error
return self.cmd.New(cmdArgs).Run()
}

// Returns whether refName can be fast-forward merged into the current branch
func (self *BranchCommands) CanDoFastForwardMerge(refName string) bool {
// Fast-forwards the branch that is checked out in the given worktree to the
// given ref. Fails if that can't be done without a merge commit. Pass empty
// strings for the worktree to use the current one.
func (self *BranchCommands) FastForwardMerge(refName string, worktreeGitDir string, worktreePath string) error {
cmdArgs := NewGitCmd("merge").
Arg("--ff-only").
Arg(refName).
GitDirIf(worktreeGitDir != "", worktreeGitDir).
WorktreePathIf(worktreePath != "", worktreePath).
ToArgv()

return self.cmd.New(cmdArgs).Run()
}

// Returns whether the first ref is an ancestor of the second one, which also
// means that the second one can be fast-forward merged into the first one
func (self *BranchCommands) IsAncestor(ancestorRefName string, refName string) bool {
cmdArgs := NewGitCmd("merge-base").
Arg("--is-ancestor").
Arg("HEAD", refName).
Arg(ancestorRefName, refName).
ToArgv()
err := self.cmd.New(cmdArgs).DontLog().Run()
return err == nil
Expand Down Expand Up @@ -353,9 +368,85 @@ func (self *BranchCommands) IsBranchMerged(branch *models.Branch, mainBranches *
return stdout == "", nil
}

func (self *BranchCommands) UpdateBranchRefs(updateCommands string) error {
// Returns whether the given branch has commits of its own, meaning commits
// that its remote branch never contained. Those are the commits that would be
// lost if we reset the branch to its upstream.
//
// A branch that has diverged from its upstream doesn't necessarily have any
// commits of its own. If somebody else rewrote the remote branch and
// force-pushed it, our branch is still at the commits it had before, and all
// of those were on the remote branch at some point. The reflog of the
// remote-tracking branch records the values it had before it was rewritten, so
// a commit that was ever on the remote branch is contained in one of them.
func (self *BranchCommands) HasLocalOnlyCommits(branch *models.Branch) (bool, error) {
upstreamValues := append(
[]string{branch.FullUpstreamRefName()},
self.previousUpstreamValues(branch.FullUpstreamRefName())...,
)

cmdArgs := NewGitCmd("rev-list").
Arg("--max-count=1").
// A value that the remote-tracking branch had long ago might not be
// available any more, e.g. in a partial clone. Skip it rather than
// failing; it only means we exclude fewer commits.
Arg("--ignore-missing").
Arg(branch.FullRefName()).
Arg(lo.Map(upstreamValues, func(value string, _ int) string {
return "^" + value
})...).
Arg("--").
ToArgv()

stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
if err != nil {
return false, err
}

return stdout != "", nil
}

// Returns the values that the given remote-tracking branch had before its
// current one, as far back as its reflog goes. Returns nothing if the reflog
// is unavailable, for example because core.logAllRefUpdates is false; a branch
// that is strictly behind its upstream is recognized without it.
func (self *BranchCommands) previousUpstreamValues(upstreamRef string) []string {
cmdArgs := NewGitCmd("reflog").
Arg("show").
Arg("--format=%H").
Arg(upstreamRef).
ToArgv()

stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
if err != nil {
return nil
}

// Each entry holds the value that the ref was updated to.
values := utils.SplitLines(stdout)

// The value it had before the oldest entry is that entry's old value, and
// the only way to name it is <ref>@{<number of entries>}. It doesn't exist
// if the oldest entry is the one that created the ref, and asking for it
// then is an error rather than an empty result.
cmdArgs = NewGitCmd("rev-parse").
Arg("-q", "--verify").
Arg(fmt.Sprintf("%s@{%d}", upstreamRef, len(values))).
ToArgv()

if stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs(); err == nil {
values = append(values, strings.TrimSpace(stdout))
}

return values
}

// Moves branches by writing refs directly. The reflog message is what
// `git reflog <branch>` shows for the update; it is the only hint about who
// moved the branch, as no git command shows up in the reflog for this.
func (self *BranchCommands) UpdateBranchRefs(updateCommands string, reflogMessage string) error {
cmdArgs := NewGitCmd("update-ref").
Arg("--stdin").
Arg("-m", reflogMessage).
ToArgv()

return self.cmd.New(cmdArgs).SetStdin(updateCommands).Run()
Expand Down
52 changes: 47 additions & 5 deletions pkg/commands/git_commands/branch_loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ type BranchLoader struct {
*GitCommon
cmd oscommands.ICmdObjBuilder
getCurrentBranchInfo func() (BranchInfo, error)
hasLocalOnlyCommits func(*models.Branch) (bool, error)
config BranchLoaderConfigCommands
}

Expand All @@ -52,13 +53,15 @@ func NewBranchLoader(
gitCommon *GitCommon,
cmd oscommands.ICmdObjBuilder,
getCurrentBranchInfo func() (BranchInfo, error),
hasLocalOnlyCommits func(*models.Branch) (bool, error),
config BranchLoaderConfigCommands,
) *BranchLoader {
return &BranchLoader{
Common: cmn,
GitCommon: gitCommon,
cmd: cmd,
getCurrentBranchInfo: getCurrentBranchInfo,
hasLocalOnlyCommits: hasLocalOnlyCommits,
config: config,
}
}
Expand All @@ -67,7 +70,7 @@ func NewBranchLoader(
func (self *BranchLoader) Load(reflogCommits []*models.Commit,
mainBranches *MainBranches,
oldBranches []*models.Branch,
loadBehindCounts bool,
loadExtraInfo bool,
onWorker func(func() error),
renderFunc func(),
) ([]*models.Branch, error) {
Expand Down Expand Up @@ -135,24 +138,63 @@ func (self *BranchLoader) Load(reflogCommits []*models.Commit,
branch.UpstreamBranch = match.Merge
}

// If the branch already existed, take over its BehindBaseBranch value
// to reduce flicker
// If the branch already existed, take over the values that are
// determined in the background, to reduce flicker
if oldBranch, found := lo.Find(oldBranches, func(b *models.Branch) bool {
return b.Name == branch.Name
}); found {
branch.BehindBaseBranch.Store(oldBranch.BehindBaseBranch.Load())
branch.UpstreamRewritten.Store(oldBranch.UpstreamRewritten.Load())
}
}

if loadBehindCounts && self.UserConfig().Gui.ShowDivergenceFromBaseBranch != "none" {
if loadExtraInfo {
if self.UserConfig().Gui.ShowDivergenceFromBaseBranch != "none" {
onWorker(func() error {
return self.GetBehindBaseBranchValuesForAllBranches(branches, mainBranches, renderFunc)
})
}

onWorker(func() error {
return self.GetBehindBaseBranchValuesForAllBranches(branches, mainBranches, renderFunc)
return self.checkForRewrittenUpstreams(branches, renderFunc)
})
}

return branches, nil
}

// For each branch that has diverged from its upstream, determines whether the
// divergence comes from the upstream branch having been rewritten, and stores
// the answer in the branch. A branch that we can't determine it for keeps the
// answer "no", so that we don't offer anything we aren't sure about.
func (self *BranchLoader) checkForRewrittenUpstreams(branches []*models.Branch, renderFunc func()) error {
t := time.Now()
errg := errgroup.Group{}

for _, branch := range branches {
if !branch.IsAheadForPull() || !branch.IsBehindForPull() {
branch.UpstreamRewritten.Store(false)
continue
}

errg.Go(func() error {
hasLocalOnlyCommits, err := self.hasLocalOnlyCommits(branch)
if err != nil {
// Not worth bothering the user about; it only means that we
// don't show this branch differently.
self.Log.Errorf("Failed to check whether branch %s has commits of its own: %v", branch.Name, err)
}
branch.UpstreamRewritten.Store(err == nil && !hasLocalOnlyCommits)
return nil
})
}

err := errg.Wait()
self.Log.Debugf("time to check for rewritten upstreams for all branches: %s", time.Since(t))
renderFunc()
return err
}

func (self *BranchLoader) GetBehindBaseBranchValuesForAllBranches(
branches []*models.Branch,
mainBranches *MainBranches,
Expand Down
61 changes: 61 additions & 0 deletions pkg/commands/git_commands/branch_loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import (
"testing"
"time"

"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/sasha-s/go-deadlock"
"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -291,3 +293,62 @@ func TestGetBehindBaseBranchValuesForAllBranches_LegacyPath(t *testing.T) {

runner.CheckForMissingCalls()
}

func TestCheckForRewrittenUpstreams(t *testing.T) {
branch := func(name string, ahead string, behind string) *models.Branch {
return &models.Branch{
Name: name,
UpstreamRemote: "origin",
UpstreamBranch: name,
AheadForPull: ahead,
BehindForPull: behind,
}
}

notDiverged := branch("not-diverged", "0", "2")
rewritten := branch("rewritten", "3", "5")
ownCommits := branch("own-commits", "3", "5")
failing := branch("failing", "1", "1")

// A branch that is no longer diverged must lose the value it had before
notDiverged.UpstreamRewritten.Store(true)

branches := []*models.Branch{notDiverged, rewritten, ownCommits, failing}

var mutex deadlock.Mutex
queried := []string{}
hasLocalOnlyCommits := func(branch *models.Branch) (bool, error) {
mutex.Lock()
queried = append(queried, branch.Name)
mutex.Unlock()

switch branch.Name {
case "own-commits":
return true, nil
case "failing":
return false, errors.New("error")
default:
return false, nil
}
}

gitCommon := buildGitCommon(commonDeps{})
loader := &BranchLoader{
Common: gitCommon.Common,
GitCommon: gitCommon,
cmd: gitCommon.cmd,
hasLocalOnlyCommits: hasLocalOnlyCommits,
}

rendered := false
err := loader.checkForRewrittenUpstreams(branches, func() { rendered = true })
assert.NoError(t, err)
assert.True(t, rendered, "renderFunc should have been called")

assert.ElementsMatch(t, []string{"rewritten", "own-commits", "failing"}, queried,
"only diverged branches should be looked at")
assert.False(t, notDiverged.UpstreamRewritten.Load())
assert.True(t, rewritten.UpstreamRewritten.Load())
assert.False(t, ownCommits.UpstreamRewritten.Load())
assert.False(t, failing.UpstreamRewritten.Load(), "a failed check should not claim anything")
}
Loading
Loading