diff --git a/pkg/commands/git_commands/branch.go b/pkg/commands/git_commands/branch.go index a55278b5b..017b87ed4 100644 --- a/pkg/commands/git_commands/branch.go +++ b/pkg/commands/git_commands/branch.go @@ -353,6 +353,78 @@ func (self *BranchCommands) IsBranchMerged(branch *models.Branch, mainBranches * return stdout == "", nil } +// 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 @{}. 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 +} + func (self *BranchCommands) UpdateBranchRefs(updateCommands string) error { cmdArgs := NewGitCmd("update-ref"). Arg("--stdin"). diff --git a/pkg/commands/git_commands/branch_test.go b/pkg/commands/git_commands/branch_test.go index a0c0096b9..97e5ba544 100644 --- a/pkg/commands/git_commands/branch_test.go +++ b/pkg/commands/git_commands/branch_test.go @@ -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() + }) + } +}