From 173001c3e7c23a7521ddba292619fe8a6455cc30 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 23 Sep 2026 13:21:32 +0200 Subject: [PATCH] Add a function to tell whether a branch has commits of its own A branch that has diverged from its upstream doesn't necessarily hold any work 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 every one of them was on the remote branch at some point. A later commit offers to reset such a branch to its upstream, and for that it has to tell this case apart from a branch that holds commits created here. 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 its current value or in one of those. HasLocalOnlyCommits asks git for a commit of the branch that none of them contains. Two details of the reflog are worth knowing. The value the ref had before its oldest entry is that entry's old value, and @{} is the only way to name it. It doesn't exist when the oldest entry is the one that created the ref, and asking for it then is an error, not an empty result. Reflogs can also be missing altogether, because core.logAllRefUpdates defaults to false in a bare repository. Neither case must stop us from recognizing a branch that is strictly behind its upstream, so the current value of the remote-tracking branch is always used; missing previous values only make the answer more conservative. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/commands/git_commands/branch.go | 72 +++++++++++++++ pkg/commands/git_commands/branch_test.go | 109 +++++++++++++++++++++++ 2 files changed, 181 insertions(+) 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() + }) + } +}