mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-05 21:46:49 -04:00
Use f to "fast-forward" branches whose upstream branch was rewritten (#6067)
Lazygit shows local branches that have diverged from their upstream with a yellow `↓3↑5`. There can be two reasons for diverging, and right now it's impossible to tell which of these is the case: (1) you rebased the branch locally (or rewrote its history), in which case you want to force-push it, or (2) someone else rebased the branch, in which case you want to pull it. (We'll ignore the case where both of these happened, in which case you need to manually reconcile the work that each side did, e.g. through cherry-picking; you want to avoid this situation.) With this PR, lazygit distinguishes the two scenarios, and for (2) it shows the `↓3↑5` in a dim yellow. What's more, you don't even have to check out the branch to pull it; you can press `f` to reset it to its upstream without checking it out, like you would fast-forward a branch that is behind its upstream. In fact the command is still called "Fast-forward", which is technically not quite correct, but it feels the same to me. Fast-forwarding a diverged branch in this way is very useful if an entire stack of branches was rebased remotely; you can simply range-select all those branches and hit `f` to bring them up to date with their upstreams.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 | |
|
||||
|
||||
@@ -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 | |
|
||||
|
||||
@@ -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 | |
|
||||
|
||||
@@ -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 `` | Просмотреть параметры сброса | |
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -44,6 +44,7 @@ type BranchLoader struct {
|
||||
*GitCommon
|
||||
cmd oscommands.ICmdObjBuilder
|
||||
getCurrentBranchInfo func() (BranchInfo, error)
|
||||
hasLocalOnlyCommits func(*models.Branch) (bool, error)
|
||||
config BranchLoaderConfigCommands
|
||||
}
|
||||
|
||||
@@ -52,6 +53,7 @@ func NewBranchLoader(
|
||||
gitCommon *GitCommon,
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
getCurrentBranchInfo func() (BranchInfo, error),
|
||||
hasLocalOnlyCommits func(*models.Branch) (bool, error),
|
||||
config BranchLoaderConfigCommands,
|
||||
) *BranchLoader {
|
||||
return &BranchLoader{
|
||||
@@ -59,6 +61,7 @@ func NewBranchLoader(
|
||||
GitCommon: gitCommon,
|
||||
cmd: cmd,
|
||||
getCurrentBranchInfo: getCurrentBranchInfo,
|
||||
hasLocalOnlyCommits: hasLocalOnlyCommits,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -317,3 +318,111 @@ func TestBranchCurrentBranchInfo(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchHasLocalOnlyCommits(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
runner *oscommands.FakeCmdObjRunner
|
||||
test func(bool, error)
|
||||
}
|
||||
|
||||
branch := &models.Branch{
|
||||
Name: "branch",
|
||||
UpstreamRemote: "origin",
|
||||
UpstreamBranch: "branch",
|
||||
}
|
||||
|
||||
scenarios := []scenario{
|
||||
{
|
||||
"branch is strictly behind its upstream, and there are no reflogs",
|
||||
oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"}, "", nil).
|
||||
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{0}"}, "", errors.New("error")).
|
||||
ExpectGitArgs([]string{
|
||||
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
|
||||
"^refs/remotes/origin/branch", "--",
|
||||
}, "", nil),
|
||||
func(hasLocalOnlyCommits bool, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, hasLocalOnlyCommits)
|
||||
},
|
||||
},
|
||||
{
|
||||
"the upstream branch was rewritten, so all our commits were on it before",
|
||||
oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"},
|
||||
"1111111111111111111111111111111111111111\n2222222222222222222222222222222222222222\n", nil).
|
||||
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{2}"},
|
||||
"3333333333333333333333333333333333333333\n", nil).
|
||||
ExpectGitArgs([]string{
|
||||
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
|
||||
"^refs/remotes/origin/branch",
|
||||
"^1111111111111111111111111111111111111111",
|
||||
"^2222222222222222222222222222222222222222",
|
||||
"^3333333333333333333333333333333333333333",
|
||||
"--",
|
||||
}, "", nil),
|
||||
func(hasLocalOnlyCommits bool, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, hasLocalOnlyCommits)
|
||||
},
|
||||
},
|
||||
{
|
||||
"the oldest reflog entry is the one that created the ref",
|
||||
oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"},
|
||||
"1111111111111111111111111111111111111111\n", nil).
|
||||
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{1}"}, "", errors.New("error")).
|
||||
ExpectGitArgs([]string{
|
||||
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
|
||||
"^refs/remotes/origin/branch",
|
||||
"^1111111111111111111111111111111111111111",
|
||||
"--",
|
||||
}, "", nil),
|
||||
func(hasLocalOnlyCommits bool, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, hasLocalOnlyCommits)
|
||||
},
|
||||
},
|
||||
{
|
||||
"the branch has a commit that was never on the upstream branch",
|
||||
oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"},
|
||||
"1111111111111111111111111111111111111111\n", nil).
|
||||
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{1}"},
|
||||
"2222222222222222222222222222222222222222\n", nil).
|
||||
ExpectGitArgs([]string{
|
||||
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
|
||||
"^refs/remotes/origin/branch",
|
||||
"^1111111111111111111111111111111111111111",
|
||||
"^2222222222222222222222222222222222222222",
|
||||
"--",
|
||||
}, "4444444444444444444444444444444444444444\n", nil),
|
||||
func(hasLocalOnlyCommits bool, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, hasLocalOnlyCommits)
|
||||
},
|
||||
},
|
||||
{
|
||||
"bubbles up an error from rev-list",
|
||||
oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"reflog", "show", "--format=%H", "refs/remotes/origin/branch"}, "", nil).
|
||||
ExpectGitArgs([]string{"rev-parse", "-q", "--verify", "refs/remotes/origin/branch@{0}"}, "", errors.New("error")).
|
||||
ExpectGitArgs([]string{
|
||||
"rev-list", "--max-count=1", "--ignore-missing", "refs/heads/branch",
|
||||
"^refs/remotes/origin/branch", "--",
|
||||
}, "", errors.New("error")),
|
||||
func(hasLocalOnlyCommits bool, err error) {
|
||||
assert.Error(t, err)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
instance := buildBranchCommands(commonDeps{runner: s.runner})
|
||||
s.test(instance.HasLocalOnlyCommits(branch))
|
||||
s.runner.CheckForMissingCalls()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package git_commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type SyncCommands struct {
|
||||
@@ -111,15 +114,25 @@ func (self *SyncCommands) Pull(task gocui.Task, opts PullOptions) error {
|
||||
return self.cmd.New(cmdArgs).AddEnvVars("GIT_SEQUENCE_EDITOR=:").PromptOnCredentialRequest(task).Run()
|
||||
}
|
||||
|
||||
func (self *SyncCommands) FastForward(
|
||||
// Fetches the given branches of the given remote, updating their
|
||||
// remote-tracking branches. Local branches are left alone, including the ones
|
||||
// that track them.
|
||||
func (self *SyncCommands) FetchRemoteBranches(
|
||||
task gocui.Task,
|
||||
branchName string,
|
||||
remoteName string,
|
||||
remoteBranchName string,
|
||||
remoteBranchNames []string,
|
||||
) error {
|
||||
// The explicit destinations and the leading + make sure that the
|
||||
// remote-tracking branches are updated even when the remote branches were
|
||||
// rewritten, whatever the remote's fetch refspec says
|
||||
refspecs := lo.Map(remoteBranchNames, func(remoteBranchName string, _ int) string {
|
||||
return fmt.Sprintf("+refs/heads/%s:refs/remotes/%s/%s",
|
||||
remoteBranchName, remoteName, remoteBranchName)
|
||||
})
|
||||
|
||||
cmdArgs := self.fetchCommandBuilder(false).
|
||||
Arg(remoteName).
|
||||
Arg("refs/heads/" + remoteBranchName + ":" + branchName).
|
||||
Arg(refspecs...).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).PromptOnCredentialRequest(task).Run()
|
||||
|
||||
@@ -524,6 +524,42 @@ func (self *WorkingTreeCommands) ResetSoft(ref string) error {
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
}
|
||||
|
||||
// ResetKeep runs `git reset --keep` in the given worktree, which moves the
|
||||
// checked out branch to the given ref while keeping local modifications. It
|
||||
// fails rather than overwriting a file that differs between the two commits.
|
||||
// Pass empty strings for the worktree to use the current one.
|
||||
func (self *WorkingTreeCommands) ResetKeep(ref string, worktreeGitDir string, worktreePath string) error {
|
||||
cmdArgs := NewGitCmd("reset").Arg("--keep", ref).
|
||||
GitDirIf(worktreeGitDir != "", worktreeGitDir).
|
||||
WorktreePathIf(worktreePath != "", worktreePath).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
}
|
||||
|
||||
// Returns whether the given worktree has changes to tracked files, either in
|
||||
// its working tree or in its index. Untracked files don't count, and neither do
|
||||
// submodules. A submodule that is checked out at a different commit than the
|
||||
// one recorded, or that has changes of its own, doesn't get in the way of
|
||||
// moving the branch, because moving it leaves the submodules alone. Pass empty
|
||||
// strings for the worktree to use the current one.
|
||||
func (self *WorkingTreeCommands) HasChangesToTrackedFiles(worktreeGitDir string, worktreePath string) (bool, error) {
|
||||
cmdArgs := NewGitCmd("status").
|
||||
Arg("--porcelain").
|
||||
Arg("--untracked-files=no").
|
||||
Arg("--ignore-submodules").
|
||||
GitDirIf(worktreeGitDir != "", worktreeGitDir).
|
||||
WorktreePathIf(worktreePath != "", worktreePath).
|
||||
ToArgv()
|
||||
|
||||
stdout, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return stdout != "", nil
|
||||
}
|
||||
|
||||
func (self *WorkingTreeCommands) ResetMixed(ref string) error {
|
||||
cmdArgs := NewGitCmd("reset").Arg("--mixed", ref).
|
||||
ToArgv()
|
||||
|
||||
@@ -140,12 +140,14 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
|
||||
rebasedBranch, ok := self.rebasedBranch(worktree)
|
||||
if ok {
|
||||
worktree.Branch = rebasedBranch
|
||||
worktree.IsRebasingOrBisecting = true
|
||||
continue
|
||||
}
|
||||
|
||||
bisectedBranch, ok := self.bisectedBranch(worktree)
|
||||
if ok {
|
||||
worktree.Branch = bisectedBranch
|
||||
worktree.IsRebasingOrBisecting = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,13 @@ type Branch struct {
|
||||
// determined yet, or up to date with base branch. (We don't need to
|
||||
// distinguish the two, as we don't draw anything in both cases.)
|
||||
BehindBaseBranch atomic.Int32
|
||||
|
||||
// Whether the branch has diverged from its upstream because the upstream
|
||||
// branch was rewritten, and not because the branch has commits of its own.
|
||||
// Such a branch can be reset to its upstream without losing anything.
|
||||
// False for branches that haven't diverged, and for those we haven't
|
||||
// determined it for yet.
|
||||
UpstreamRewritten atomic.Bool
|
||||
}
|
||||
|
||||
func (b *Branch) FullRefName() string {
|
||||
|
||||
@@ -19,6 +19,9 @@ type Worktree struct {
|
||||
// * the worktree is mid-rebase on the branch
|
||||
// * the worktree is mid-bisect on the branch
|
||||
Branch string
|
||||
// If true, the worktree is mid-rebase or mid-bisect on Branch, so its HEAD
|
||||
// is detached rather than pointing at the branch
|
||||
IsRebasingOrBisecting bool
|
||||
// The HEAD sha of the worktree. Always populated (even when Branch is set).
|
||||
// Used for display when Branch is empty (detached HEAD state).
|
||||
Head string
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
|
||||
@@ -144,8 +143,8 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Branches.FastForward),
|
||||
Handler: self.withItem(self.fastForward),
|
||||
GetDisabledReason: self.require(self.singleItemSelected(self.branchIsReal)),
|
||||
Handler: self.withItems(self.fastForward),
|
||||
GetDisabledReason: self.require(self.itemRangeSelected(self.branchesAreReal)),
|
||||
Description: self.c.Tr.FastForward,
|
||||
Tooltip: self.c.Tr.FastForwardTooltip,
|
||||
},
|
||||
@@ -655,54 +654,23 @@ func (self *BranchesController) rebase(branch *models.Branch) error {
|
||||
return self.c.Helpers().MergeAndRebase.RebaseOntoRef(branch.Name)
|
||||
}
|
||||
|
||||
func (self *BranchesController) fastForward(branch *models.Branch) error {
|
||||
if !branch.IsTrackingRemote() {
|
||||
func (self *BranchesController) fastForward(branches []*models.Branch) error {
|
||||
if !lo.EveryBy(branches, func(branch *models.Branch) bool { return branch.IsTrackingRemote() }) {
|
||||
return errors.New(self.c.Tr.FwdNoUpstream)
|
||||
}
|
||||
if !branch.RemoteBranchStoredLocally() {
|
||||
if !lo.EveryBy(branches, func(branch *models.Branch) bool { return branch.RemoteBranchStoredLocally() }) {
|
||||
return errors.New(self.c.Tr.FwdNoLocalUpstream)
|
||||
}
|
||||
if branch.IsAheadForPull() {
|
||||
// A branch that is only ahead has nothing to fast-forward to. One that is
|
||||
// both ahead and behind may still be reset to its upstream, so let the
|
||||
// helper look into it.
|
||||
if lo.SomeBy(branches, func(branch *models.Branch) bool {
|
||||
return branch.IsAheadForPull() && !branch.IsBehindForPull()
|
||||
}) {
|
||||
return errors.New(self.c.Tr.FwdCommitsToPush)
|
||||
}
|
||||
|
||||
action := self.c.Tr.Actions.FastForwardBranch
|
||||
worktree, ok := self.worktreeForBranch(branch)
|
||||
|
||||
return self.c.WithInlineStatus(branch, types.ItemOperationFastForwarding, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error {
|
||||
if ok {
|
||||
self.c.LogAction(action)
|
||||
|
||||
worktreeGitDir := ""
|
||||
worktreePath := ""
|
||||
// if it is the current worktree path, no need to specify the path
|
||||
if !worktree.IsCurrent {
|
||||
worktreeGitDir = worktree.GitDir
|
||||
worktreePath = worktree.Path
|
||||
}
|
||||
|
||||
err := self.c.Git().Sync.Pull(
|
||||
task,
|
||||
git_commands.PullOptions{
|
||||
RemoteName: branch.UpstreamRemote,
|
||||
BranchName: branch.UpstreamBranch,
|
||||
FastForwardOnly: true,
|
||||
WorktreeGitDir: worktreeGitDir,
|
||||
WorktreePath: worktreePath,
|
||||
},
|
||||
)
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{})
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.LogAction(action)
|
||||
|
||||
err := self.c.Git().Sync.FastForward(
|
||||
task, branch.Name, branch.UpstreamRemote, branch.UpstreamBranch,
|
||||
)
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}})
|
||||
return err
|
||||
})
|
||||
return self.c.Helpers().BranchesHelper.FastForwardBranches(branches)
|
||||
}
|
||||
|
||||
func (self *BranchesController) createTag(branch *models.Branch) error {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
@@ -433,6 +434,200 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool, fe
|
||||
return fetchErr
|
||||
}
|
||||
|
||||
// One of the branches that a fast-forward is about to bring to its upstream
|
||||
type branchToForward struct {
|
||||
branch *models.Branch
|
||||
// the worktree that the branch is checked out in, nil if there is none
|
||||
worktree *models.Worktree
|
||||
// whether the branch has to be reset to its upstream because it has
|
||||
// diverged from it, as opposed to being moved forward
|
||||
reset bool
|
||||
}
|
||||
|
||||
// Updates the given branches to their upstream branches, fetching those first.
|
||||
// A branch that is behind its upstream is moved forward to it; one that has
|
||||
// diverged from it is reset to it, as long as it has no commits of its own. If
|
||||
// any of the branches can't be updated, none of them is.
|
||||
func (self *BranchesHelper) FastForwardBranches(branches []*models.Branch) error {
|
||||
// The worktrees come from the model, so they have to be looked up here,
|
||||
// before the work moves to a worker
|
||||
toForward := lo.Map(branches, func(branch *models.Branch, _ int) *branchToForward {
|
||||
worktree, _ := self.worktreeForBranch(branch)
|
||||
return &branchToForward{branch: branch, worktree: worktree}
|
||||
})
|
||||
anyCheckedOut := lo.SomeBy(toForward, func(f *branchToForward) bool { return f.worktree != nil })
|
||||
|
||||
// Updating that worktree would move its detached HEAD, which belongs to the
|
||||
// rebase or bisect, and leave the branch alone
|
||||
for _, f := range toForward {
|
||||
if f.worktree != nil && f.worktree.IsRebasingOrBisecting {
|
||||
return errors.New(utils.ResolvePlaceholderString(
|
||||
self.c.Tr.FwdBranchRebasingOrBisecting,
|
||||
map[string]string{"branchName": f.branch.Name, "worktreeName": f.worktree.Name},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return self.withFastForwardingStatus(branches, func(task gocui.Task) error {
|
||||
defer func() {
|
||||
if anyCheckedOut {
|
||||
// The files of those worktrees have changed as well
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{})
|
||||
} else {
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}})
|
||||
}
|
||||
}()
|
||||
|
||||
self.c.LogAction(self.c.Tr.Actions.FastForwardBranch)
|
||||
|
||||
if err := self.fetchUpstreamBranches(task, branches); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Look at all the branches before moving any of them, so that one we
|
||||
// have to refuse leaves the others alone too
|
||||
for _, f := range toForward {
|
||||
if err := self.planForwardingBranch(f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return self.forwardBranches(toForward)
|
||||
})
|
||||
}
|
||||
|
||||
// Runs f with all the given branches shown as being fast-forwarded
|
||||
func (self *BranchesHelper) withFastForwardingStatus(branches []*models.Branch, f func(gocui.Task) error) error {
|
||||
return self.c.WithInlineStatus(branches[0], types.ItemOperationFastForwarding, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error {
|
||||
for _, branch := range branches[1:] {
|
||||
self.c.State().SetItemOperation(branch, types.ItemOperationFastForwarding)
|
||||
}
|
||||
defer func() {
|
||||
for _, branch := range branches[1:] {
|
||||
self.c.State().ClearItemOperation(branch)
|
||||
}
|
||||
}()
|
||||
|
||||
return f(task)
|
||||
})
|
||||
}
|
||||
|
||||
func (self *BranchesHelper) fetchUpstreamBranches(task gocui.Task, branches []*models.Branch) error {
|
||||
remotes := lo.Uniq(lo.Map(branches, func(branch *models.Branch, _ int) string {
|
||||
return branch.UpstreamRemote
|
||||
}))
|
||||
|
||||
for _, remote := range remotes {
|
||||
remoteBranches := []string{}
|
||||
for _, branch := range branches {
|
||||
if branch.UpstreamRemote == remote {
|
||||
remoteBranches = append(remoteBranches, branch.UpstreamBranch)
|
||||
}
|
||||
}
|
||||
|
||||
if err := self.c.Git().Sync.FetchRemoteBranches(task, remote, remoteBranches); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Works out whether the branch has to be reset to its upstream, and returns an
|
||||
// error if it can't be brought there at all.
|
||||
func (self *BranchesHelper) planForwardingBranch(f *branchToForward) error {
|
||||
f.reset = !self.c.Git().Branch.IsAncestor(
|
||||
f.branch.FullRefName(), f.branch.FullUpstreamRefName())
|
||||
if !f.reset {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Moving the branch to its upstream means giving up the commits it is
|
||||
// ahead by, so make sure that none of them is ours
|
||||
hasLocalOnlyCommits, err := self.c.Git().Branch.HasLocalOnlyCommits(f.branch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasLocalOnlyCommits {
|
||||
return errors.New(utils.ResolvePlaceholderString(
|
||||
self.c.Tr.FwdLocalOnlyCommits,
|
||||
map[string]string{"branchName": f.branch.Name},
|
||||
))
|
||||
}
|
||||
|
||||
if f.worktree != nil {
|
||||
// Resetting the branch changes the files of the worktree under the
|
||||
// user's feet, so only do it while they have no changes of their own
|
||||
// there
|
||||
worktreeGitDir, worktreePath := self.worktreeArgs(f.worktree)
|
||||
hasChanges, err := self.c.Git().WorkingTree.HasChangesToTrackedFiles(worktreeGitDir, worktreePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasChanges {
|
||||
return errors.New(utils.ResolvePlaceholderString(
|
||||
self.c.Tr.FwdUncommittedChanges,
|
||||
map[string]string{"branchName": f.branch.Name},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *BranchesHelper) forwardBranches(toForward []*branchToForward) error {
|
||||
// The branches that aren't checked out anywhere are nothing but refs to
|
||||
// update, so they can all be done in one go
|
||||
updateCommands := ""
|
||||
for _, f := range toForward {
|
||||
if f.worktree == nil {
|
||||
updateCommands += fmt.Sprintf("update %s %s %s\n",
|
||||
f.branch.FullRefName(), f.branch.FullUpstreamRefName(), f.branch.CommitHash)
|
||||
}
|
||||
}
|
||||
|
||||
if updateCommands != "" {
|
||||
self.c.LogCommand(strings.TrimRight(updateCommands, "\n"), false)
|
||||
if err := self.c.Git().Branch.UpdateBranchRefs(updateCommands, "lazygit: update to upstream branch"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// A branch that is checked out somewhere needs the files of that worktree
|
||||
// to be updated along with it
|
||||
for _, f := range toForward {
|
||||
if f.worktree == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
worktreeGitDir, worktreePath := self.worktreeArgs(f.worktree)
|
||||
|
||||
var err error
|
||||
if f.reset {
|
||||
err = self.c.Git().WorkingTree.ResetKeep(
|
||||
f.branch.FullUpstreamRefName(), worktreeGitDir, worktreePath)
|
||||
} else {
|
||||
err = self.c.Git().Branch.FastForwardMerge(
|
||||
f.branch.FullUpstreamRefName(), worktreeGitDir, worktreePath)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Returns the git dir and the path to pass for the given worktree; both are
|
||||
// empty for the current one, which git commands use by default anyway.
|
||||
func (self *BranchesHelper) worktreeArgs(worktree *models.Worktree) (string, string) {
|
||||
if worktree.IsCurrent {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return worktree.GitDir, worktree.Path
|
||||
}
|
||||
|
||||
func (self *BranchesHelper) AutoForwardBranches(background bool) error {
|
||||
if self.c.UserConfig().Git.AutoForwardBranches == "none" {
|
||||
return nil
|
||||
@@ -463,7 +658,7 @@ func (self *BranchesHelper) AutoForwardBranches(background bool) error {
|
||||
|
||||
self.c.LogAction(self.c.Tr.Actions.AutoForwardBranches)
|
||||
self.c.LogCommand(strings.TrimRight(updateCommands, "\n"), false)
|
||||
err := self.c.Git().Branch.UpdateBranchRefs(updateCommands)
|
||||
err := self.c.Git().Branch.UpdateBranchRefs(updateCommands, "lazygit: fast-forward to upstream branch")
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}, Background: background})
|
||||
|
||||
|
||||
@@ -525,7 +525,7 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e
|
||||
}
|
||||
|
||||
wantFastForward, wantNonFastForward := self.fastForwardMergeUserPreference()
|
||||
canFastForward := self.c.Git().Branch.CanDoFastForwardMerge(refName)
|
||||
canFastForward := self.c.Git().Branch.IsAncestor("HEAD", refName)
|
||||
|
||||
var firstRegularMergeItem *types.MenuItem
|
||||
var secondRegularMergeItem *types.MenuItem
|
||||
|
||||
@@ -1150,14 +1150,14 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs(env refreshEnv) ([]*mode
|
||||
|
||||
// self.refreshStatus is called at the end of this because that's when we can
|
||||
// be sure there is a State.Model.Branches array to pick the current branch from
|
||||
func (self *RefreshHelper) refreshBranches(captured capturedBranchState, waitForWorktrees func(), waitForRemotes func(), branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch {
|
||||
func (self *RefreshHelper) refreshBranches(captured capturedBranchState, waitForWorktrees func(), waitForRemotes func(), branchSelection types.BranchSelectionBehavior, loadExtraInfo bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch {
|
||||
loadSeq := self.branchLoadSeq.Add(1)
|
||||
|
||||
branches, err := env.git.Loaders.BranchLoader.Load(
|
||||
reflogCommits,
|
||||
captured.mainBranches,
|
||||
captured.oldBranches,
|
||||
loadBehindCounts,
|
||||
loadExtraInfo,
|
||||
func(f func() error) {
|
||||
self.onWorker(env.background, func(_ gocui.Task) error {
|
||||
err := f()
|
||||
|
||||
@@ -26,6 +26,8 @@ type branchColorPattern struct {
|
||||
|
||||
var branchColorPatterns []branchColorPattern
|
||||
|
||||
var dimYellow = style.FgYellow.SetDim()
|
||||
|
||||
func GetBranchListDisplayStrings(
|
||||
branches []*models.Branch,
|
||||
getItemOperation func(item types.HasUrn) types.ItemOperation,
|
||||
@@ -223,7 +225,12 @@ func BranchStatus(
|
||||
} else if branch.RemoteBranchNotStoredLocally() {
|
||||
result = style.FgMagenta.Sprint("?")
|
||||
} else if branch.IsBehindForPull() && branch.IsAheadForPull() {
|
||||
result = style.FgYellow.Sprintf("↓%s↑%s", branch.BehindForPull, branch.AheadForPull)
|
||||
// A branch that diverged only because its upstream was rewritten
|
||||
// has no commits of its own, and fast-forwarding it resolves the
|
||||
// divergence. Dim it to set it apart from a branch whose
|
||||
// divergence needs a decision.
|
||||
divergenceStyle := lo.Ternary(branch.UpstreamRewritten.Load(), dimYellow, style.FgYellow)
|
||||
result = divergenceStyle.Sprintf("↓%s↑%s", branch.BehindForPull, branch.AheadForPull)
|
||||
} else if branch.IsBehindForPull() {
|
||||
result = style.FgYellow.Sprintf("↓%s", branch.BehindForPull)
|
||||
} else if branch.IsAheadForPull() {
|
||||
|
||||
@@ -457,3 +457,30 @@ func TestGetBranchTextStyle(t *testing.T) {
|
||||
SetCustomBranches(patterns)
|
||||
assert.Equal(t, style.FgRed, GetBranchTextStyle("feature/ISSUE-1"))
|
||||
}
|
||||
|
||||
func TestBranchStatus(t *testing.T) {
|
||||
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelMillions)
|
||||
defer color.ForceSetColorLevel(oldColorLevel)
|
||||
|
||||
c := common.NewDummyCommon()
|
||||
|
||||
divergedBranch := func(upstreamRewritten bool) *models.Branch {
|
||||
branch := &models.Branch{
|
||||
Name: "branch",
|
||||
UpstreamRemote: "origin",
|
||||
UpstreamBranch: "branch",
|
||||
AheadForPull: "3",
|
||||
BehindForPull: "5",
|
||||
}
|
||||
branch.UpstreamRewritten.Store(upstreamRewritten)
|
||||
return branch
|
||||
}
|
||||
|
||||
status := func(branch *models.Branch) string {
|
||||
return BranchStatus(branch, types.ItemOperationNone, c.Tr, time.Time{}, c.UserConfig())
|
||||
}
|
||||
|
||||
assert.Equal(t, "\x1b[33m↓5↑3\x1b[0m", status(divergedBranch(false)))
|
||||
assert.Equal(t, "\x1b[33;2m↓5↑3\x1b[0m", status(divergedBranch(true)),
|
||||
"a branch whose upstream was rewritten should be dimmed")
|
||||
}
|
||||
|
||||
+7
-1
@@ -374,6 +374,9 @@ type TranslationSet struct {
|
||||
FwdNoUpstream string
|
||||
FwdNoLocalUpstream string
|
||||
FwdCommitsToPush string
|
||||
FwdLocalOnlyCommits string
|
||||
FwdUncommittedChanges string
|
||||
FwdBranchRebasingOrBisecting string
|
||||
PullRequestNoUpstream string
|
||||
PullRequestChecksPassing string
|
||||
PullRequestChecksPending string
|
||||
@@ -1488,7 +1491,7 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
ToggleStagingViewTooltip: "Switch to other view (staged/unstaged changes).",
|
||||
ReturnToFilesPanel: `Return to files panel`,
|
||||
FastForward: `Fast-forward`,
|
||||
FastForwardTooltip: "Fast-forward selected branch from its upstream.",
|
||||
FastForwardTooltip: "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).",
|
||||
FastForwarding: "Fast-forwarding",
|
||||
FoundConflictsTitle: "Conflicts!",
|
||||
ViewConflictsMenuItem: "View conflicts",
|
||||
@@ -1547,6 +1550,9 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
FwdNoUpstream: "Cannot fast-forward a branch with no upstream",
|
||||
FwdNoLocalUpstream: "Cannot fast-forward a branch whose remote is not registered locally",
|
||||
FwdCommitsToPush: "Cannot fast-forward a branch with commits to push",
|
||||
FwdLocalOnlyCommits: "Cannot fast-forward '{{.branchName}}' because it has commits which were never on its remote branch",
|
||||
FwdUncommittedChanges: "Cannot fast-forward '{{.branchName}}' because the worktree it is checked out in has uncommitted changes",
|
||||
FwdBranchRebasingOrBisecting: "Cannot fast-forward '{{.branchName}}' because it is being rebased or bisected in worktree {{.worktreeName}}",
|
||||
PullRequestNoUpstream: "Cannot open a pull request for a branch with no upstream",
|
||||
PullRequestChecksPassing: "Passing",
|
||||
PullRequestChecksPending: "Pending",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardBehindBranch = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Fast-forward a branch that is behind its upstream branch, without checking it out",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.EmptyCommit("one")
|
||||
shell.NewBranch("feature")
|
||||
shell.EmptyCommit("two")
|
||||
shell.EmptyCommit("three")
|
||||
|
||||
shell.CloneIntoRemote("origin")
|
||||
shell.SetBranchUpstream("feature", "origin/feature")
|
||||
|
||||
// remove the two commits so that there is something to fast-forward to
|
||||
shell.HardReset("HEAD~2")
|
||||
shell.Checkout("master")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("master").IsSelected(),
|
||||
Contains("feature ↓2"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Branches.FastForward).
|
||||
Lines(
|
||||
Contains("master"),
|
||||
Contains("feature ✓").IsSelected(),
|
||||
).
|
||||
PressEnter()
|
||||
|
||||
t.Views().SubCommits().
|
||||
IsFocused().
|
||||
Lines(
|
||||
Contains("three"),
|
||||
Contains("two"),
|
||||
Contains("one"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardBehindBranchCheckedOut = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Fast-forward the checked out branch when it is behind its upstream branch",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file", "content")
|
||||
shell.Commit("one")
|
||||
shell.EmptyCommit("two")
|
||||
shell.EmptyCommit("three")
|
||||
|
||||
shell.CloneIntoRemote("origin")
|
||||
shell.SetBranchUpstream("master", "origin/master")
|
||||
|
||||
// remove the two commits so that there is something to fast-forward to
|
||||
shell.HardReset("HEAD~2")
|
||||
|
||||
// a change that the fast-forward doesn't get in the way of
|
||||
shell.UpdateFile("file", "changed")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Lines(
|
||||
Contains("one"),
|
||||
)
|
||||
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("master ↓2").IsSelected(),
|
||||
).
|
||||
Press(keys.Branches.FastForward).
|
||||
Lines(
|
||||
Contains("master ✓").IsSelected(),
|
||||
)
|
||||
|
||||
t.Views().Commits().
|
||||
Lines(
|
||||
Contains("three"),
|
||||
Contains("two"),
|
||||
Contains("one"),
|
||||
)
|
||||
|
||||
// Moving the branch forward doesn't touch the change
|
||||
t.Views().Files().
|
||||
Lines(
|
||||
Contains("file"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardBehindBranchWithoutReflogs = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Fast-forward a branch that is behind its upstream branch in a repo that keeps no reflogs",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
// This is the default in a bare repository, so it is what people with a
|
||||
// bare repo and linked worktrees work with
|
||||
shell.SetConfig("core.logAllRefUpdates", "false")
|
||||
|
||||
shell.EmptyCommit("one")
|
||||
shell.NewBranch("feature")
|
||||
shell.EmptyCommit("two")
|
||||
shell.EmptyCommit("three")
|
||||
|
||||
shell.CloneIntoRemote("origin")
|
||||
shell.SetBranchUpstream("feature", "origin/feature")
|
||||
|
||||
// remove the two commits so that there is something to fast-forward to
|
||||
shell.HardReset("HEAD~2")
|
||||
shell.Checkout("master")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("master").IsSelected(),
|
||||
Contains("feature ↓2"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Branches.FastForward).
|
||||
Lines(
|
||||
Contains("master"),
|
||||
Contains("feature ✓").IsSelected(),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardBranchBeingRebasedInWorktree = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Try to fast-forward a branch that is being rebased in another worktree",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file", "content")
|
||||
shell.Commit("one")
|
||||
shell.EmptyCommit("two")
|
||||
shell.EmptyCommit("three")
|
||||
|
||||
shell.CloneIntoRemote("origin")
|
||||
shell.SetBranchUpstream("master", "origin/master")
|
||||
|
||||
// remove a commit so that there is something to fast-forward to
|
||||
shell.HardReset("HEAD~1")
|
||||
|
||||
shell.NewBranch("other")
|
||||
shell.AddWorktreeCheckout("master", "../linked-worktree")
|
||||
|
||||
// the failing exec stops the rebase after picking "two", with HEAD
|
||||
// detached from master
|
||||
shell.RunCommandExpectError([]string{"git", "-C", "../linked-worktree", "rebase", "--exec", "false", "HEAD~1"})
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("other").IsSelected(),
|
||||
Contains("master (worktree linked-worktree) ↓1"),
|
||||
).
|
||||
NavigateToLine(Contains("master")).
|
||||
Press(keys.Branches.FastForward)
|
||||
|
||||
t.ExpectPopup().Alert().
|
||||
Title(Equals("Error")).
|
||||
Content(Equals("Cannot fast-forward 'master' because it is being rebased or bisected in worktree linked-worktree")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Worktrees().
|
||||
Focus().
|
||||
NavigateToLine(Contains("linked-worktree")).
|
||||
Press(keys.Universal.Select)
|
||||
|
||||
t.Views().Information().Content(Contains("Rebasing"))
|
||||
|
||||
t.Views().Commits().
|
||||
Lines(
|
||||
Contains("─── Pending rebase todos"),
|
||||
Contains("─── Commits"),
|
||||
Contains("two"),
|
||||
Contains("one"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardRewrittenBranch = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Fast-forward a branch that has diverged from its upstream because the upstream branch was rewritten",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
createBranchRewrittenOnTheRemote(shell)
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("master").IsSelected(),
|
||||
Contains("feature ↓2↑2"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Branches.FastForward).
|
||||
Lines(
|
||||
Contains("master"),
|
||||
Contains("feature ✓").IsSelected(),
|
||||
).
|
||||
PressEnter()
|
||||
|
||||
t.Views().SubCommits().
|
||||
IsFocused().
|
||||
Lines(
|
||||
Contains("three-rewritten"),
|
||||
Contains("two-rewritten"),
|
||||
Contains("one"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardRewrittenBranchCheckedOut = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Fast-forward the checked out branch after its upstream branch was rewritten",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
createBranchRewrittenOnTheRemote(shell)
|
||||
|
||||
shell.Checkout("feature")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Lines(
|
||||
Contains("three"),
|
||||
Contains("two"),
|
||||
Contains("one"),
|
||||
)
|
||||
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("feature ↓2↑2").IsSelected(),
|
||||
Contains("master"),
|
||||
).
|
||||
Press(keys.Branches.FastForward).
|
||||
Lines(
|
||||
Contains("feature ✓").IsSelected(),
|
||||
Contains("master"),
|
||||
)
|
||||
|
||||
t.Views().Commits().
|
||||
Lines(
|
||||
Contains("three-rewritten"),
|
||||
Contains("two-rewritten"),
|
||||
Contains("one"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardRewrittenBranchWithLocalCommits = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Try to fast-forward a branch that has a commit of its own on top of a rewritten upstream branch",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
createBranchRewrittenOnTheRemote(shell)
|
||||
|
||||
shell.Checkout("feature")
|
||||
shell.EmptyCommit("mine")
|
||||
shell.Checkout("master")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("master").IsSelected(),
|
||||
Contains("feature ↓2↑3"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Branches.FastForward)
|
||||
|
||||
t.ExpectPopup().Alert().Title(Equals("Error")).
|
||||
Content(Contains("Cannot fast-forward 'feature' because it has commits")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Branches().
|
||||
Lines(
|
||||
Contains("master"),
|
||||
Contains("feature ↓2↑3").IsSelected(),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardRewrittenBranchWithStaleSubmodule = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Fast-forward a branch with a rewritten upstream branch while its worktree has a submodule checked out at a different commit than the one recorded",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.EmptyCommit("initial")
|
||||
shell.CloneIntoSubmodule("sub", "sub")
|
||||
createBranchRewrittenOnTheRemote(shell)
|
||||
|
||||
shell.Checkout("feature")
|
||||
shell.RunCommand([]string{"git", "-C", "sub", "commit", "--allow-empty", "-m", "newer"})
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Files().
|
||||
Lines(
|
||||
Contains("sub"),
|
||||
)
|
||||
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("feature ↓2↑2").IsSelected(),
|
||||
Contains("master"),
|
||||
).
|
||||
Press(keys.Branches.FastForward).
|
||||
Lines(
|
||||
Contains("feature ✓").IsSelected(),
|
||||
Contains("master"),
|
||||
)
|
||||
|
||||
// The submodule was left alone
|
||||
t.Views().Files().
|
||||
Lines(
|
||||
Contains("sub"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardRewrittenBranchWithUncommittedChanges = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Try to fast-forward a branch with a rewritten upstream branch while its worktree has uncommitted changes",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file", "content")
|
||||
createBranchRewrittenOnTheRemote(shell)
|
||||
|
||||
shell.Checkout("feature")
|
||||
shell.UpdateFile("file", "changed")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("feature ↓2↑2").IsSelected(),
|
||||
Contains("master"),
|
||||
).
|
||||
Press(keys.Branches.FastForward)
|
||||
|
||||
t.ExpectPopup().Alert().Title(Equals("Error")).
|
||||
Content(Contains("Cannot fast-forward 'feature' because the worktree")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Branches().
|
||||
Lines(
|
||||
Contains("feature ↓2↑2").IsSelected(),
|
||||
Contains("master"),
|
||||
)
|
||||
|
||||
t.Views().Files().
|
||||
Lines(
|
||||
Contains("file"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardRewrittenBranchWithoutReflogs = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Try to fast-forward a branch with a rewritten upstream branch in a repo that keeps no reflogs",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
// Without reflogs there is no record of the values the remote-tracking
|
||||
// branch had before, so lazygit can't tell whether the commits that
|
||||
// the branch is ahead by were ever on the remote branch
|
||||
shell.SetConfig("core.logAllRefUpdates", "false")
|
||||
|
||||
createBranchRewrittenOnTheRemote(shell)
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("master").IsSelected(),
|
||||
Contains("feature ↓2↑2"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Branches.FastForward)
|
||||
|
||||
t.ExpectPopup().Alert().Title(Equals("Error")).
|
||||
Content(Contains("Cannot fast-forward 'feature' because it has commits")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Branches().
|
||||
Lines(
|
||||
Contains("master"),
|
||||
Contains("feature ↓2↑2").IsSelected(),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardRewrittenStackOfBranches = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Fast-forward a whole stack of branches whose upstream branches were rewritten",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Git.LocalBranchSortOrder = "alphabetical"
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
createStackRewrittenOnTheRemote(shell)
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("master").IsSelected(),
|
||||
Contains("branch1 ↓1↑1"),
|
||||
Contains("branch2 ↓2↑2"),
|
||||
Contains("branch3 ↓3↑3"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Universal.ToggleRangeSelect).
|
||||
SelectNextItem().
|
||||
SelectNextItem().
|
||||
Lines(
|
||||
Contains("master"),
|
||||
Contains("branch1 ↓1↑1").IsSelected(),
|
||||
Contains("branch2 ↓2↑2").IsSelected(),
|
||||
Contains("branch3 ↓3↑3").IsSelected(),
|
||||
).
|
||||
Press(keys.Branches.FastForward).
|
||||
Lines(
|
||||
Contains("master"),
|
||||
Contains("branch1 ✓").IsSelected(),
|
||||
Contains("branch2 ✓").IsSelected(),
|
||||
Contains("branch3 ✓").IsSelected(),
|
||||
)
|
||||
},
|
||||
})
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var FastForwardRewrittenStackOfBranchesWithLocalCommits = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Try to fast-forward a stack of branches of which one has a commit of its own",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Git.LocalBranchSortOrder = "alphabetical"
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
createStackRewrittenOnTheRemote(shell)
|
||||
|
||||
shell.Checkout("branch2")
|
||||
shell.EmptyCommit("mine")
|
||||
shell.Checkout("master")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Branches().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("master").IsSelected(),
|
||||
Contains("branch1 ↓1↑1"),
|
||||
Contains("branch2 ↓2↑3"),
|
||||
Contains("branch3 ↓3↑3"),
|
||||
).
|
||||
SelectNextItem().
|
||||
Press(keys.Universal.ToggleRangeSelect).
|
||||
SelectNextItem().
|
||||
SelectNextItem().
|
||||
Press(keys.Branches.FastForward)
|
||||
|
||||
t.ExpectPopup().Alert().Title(Equals("Error")).
|
||||
Content(Contains("Cannot fast-forward 'branch2' because it has commits")).
|
||||
Confirm()
|
||||
|
||||
// None of them was touched, not even the ones we could have forwarded
|
||||
t.Views().Branches().
|
||||
Lines(
|
||||
Contains("master"),
|
||||
Contains("branch1 ↓1↑1").IsSelected(),
|
||||
Contains("branch2 ↓2↑3").IsSelected(),
|
||||
Contains("branch3 ↓3↑3").IsSelected(),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -55,6 +55,72 @@ func createRebasedStackOfBranches(shell *Shell) {
|
||||
shell.EmptyCommit("three-rebased")
|
||||
}
|
||||
|
||||
// Creates the branch "feature" with two commits on top of master, pushes it,
|
||||
// and then rewrites those two commits and force-pushes them the way somebody
|
||||
// else rebasing the branch would. The local branch stays where it was, so it
|
||||
// has diverged from its remote branch without having any commits of its own.
|
||||
// Leaves master checked out.
|
||||
func createBranchRewrittenOnTheRemote(shell *Shell) {
|
||||
shell.EmptyCommit("one")
|
||||
shell.NewBranch("feature")
|
||||
shell.EmptyCommit("two")
|
||||
shell.EmptyCommit("three")
|
||||
|
||||
shell.CloneIntoRemote("origin")
|
||||
shell.SetBranchUpstream("feature", "origin/feature")
|
||||
|
||||
shell.CreateLightweightTag("before-rewrite", "feature")
|
||||
shell.HardReset("master")
|
||||
shell.EmptyCommit("two-rewritten")
|
||||
shell.EmptyCommit("three-rewritten")
|
||||
shell.RunCommand([]string{"git", "push", "--force", "origin", "feature"})
|
||||
shell.HardReset("before-rewrite")
|
||||
shell.RunCommand([]string{"git", "tag", "-d", "before-rewrite"})
|
||||
|
||||
shell.Checkout("master")
|
||||
}
|
||||
|
||||
// Creates the branches branch1, branch2 and branch3, each on top of the
|
||||
// previous one and pushed to origin, and then rewrites their commits and
|
||||
// force-pushes them the way somebody else rebasing the stack would. The local
|
||||
// branches stay where they were, so all of them have diverged from their
|
||||
// remote branches without having any commits of their own. Leaves master
|
||||
// checked out.
|
||||
func createStackRewrittenOnTheRemote(shell *Shell) {
|
||||
shell.EmptyCommit("base")
|
||||
shell.NewBranch("branch1")
|
||||
shell.EmptyCommit("one")
|
||||
shell.NewBranch("branch2")
|
||||
shell.EmptyCommit("two")
|
||||
shell.NewBranch("branch3")
|
||||
shell.EmptyCommit("three")
|
||||
|
||||
shell.CloneIntoRemote("origin")
|
||||
shell.SetBranchUpstream("branch1", "origin/branch1")
|
||||
shell.SetBranchUpstream("branch2", "origin/branch2")
|
||||
shell.SetBranchUpstream("branch3", "origin/branch3")
|
||||
|
||||
shell.CreateLightweightTag("before-rewrite", "branch3")
|
||||
shell.Checkout("branch1")
|
||||
shell.HardReset("master")
|
||||
shell.EmptyCommit("one-rewritten")
|
||||
shell.Checkout("branch2")
|
||||
shell.HardReset("branch1")
|
||||
shell.EmptyCommit("two-rewritten")
|
||||
shell.Checkout("branch3")
|
||||
shell.HardReset("branch2")
|
||||
shell.EmptyCommit("three-rewritten")
|
||||
shell.RunCommand([]string{"git", "push", "--force", "origin", "branch1", "branch2", "branch3"})
|
||||
|
||||
// Put the local branches back where they were
|
||||
shell.HardReset("before-rewrite")
|
||||
shell.RunCommand([]string{"git", "branch", "-f", "branch2", "before-rewrite~"})
|
||||
shell.RunCommand([]string{"git", "branch", "-f", "branch1", "before-rewrite~2"})
|
||||
shell.RunCommand([]string{"git", "tag", "-d", "before-rewrite"})
|
||||
|
||||
shell.Checkout("master")
|
||||
}
|
||||
|
||||
func assertSuccessfullyPushed(t *TestDriver) {
|
||||
t.Views().Status().Content(Equals("✓ repo → master"))
|
||||
|
||||
|
||||
@@ -474,6 +474,18 @@ var tests = []*components.IntegrationTest{
|
||||
submodule.Stage,
|
||||
submodule.StageAllWithDirtySubmodule,
|
||||
submodule.StageDirtyOnly,
|
||||
sync.FastForwardBehindBranch,
|
||||
sync.FastForwardBehindBranchCheckedOut,
|
||||
sync.FastForwardBehindBranchWithoutReflogs,
|
||||
sync.FastForwardBranchBeingRebasedInWorktree,
|
||||
sync.FastForwardRewrittenBranch,
|
||||
sync.FastForwardRewrittenBranchCheckedOut,
|
||||
sync.FastForwardRewrittenBranchWithLocalCommits,
|
||||
sync.FastForwardRewrittenBranchWithStaleSubmodule,
|
||||
sync.FastForwardRewrittenBranchWithUncommittedChanges,
|
||||
sync.FastForwardRewrittenBranchWithoutReflogs,
|
||||
sync.FastForwardRewrittenStackOfBranches,
|
||||
sync.FastForwardRewrittenStackOfBranchesWithLocalCommits,
|
||||
sync.FetchAndAutoForwardBranchesAllBranches,
|
||||
sync.FetchAndAutoForwardBranchesAllBranchesCheckedOutInOtherWorktree,
|
||||
sync.FetchAndAutoForwardBranchesNone,
|
||||
|
||||
Reference in New Issue
Block a user