jesseduffield.lazygit/pkg/gui/controllers/helpers/refresh_helper_test.go
Stefan Haller 1c17ee1a92 Auto-select the conflicted commit when stopping in rebase
When a rebase (or multi-commit cherry-pick or revert) stops with a
conflict, it is often useful to look at the diff of the "<-- CONFLICT"
commit to double-check that the conflict resolution matches the diff of
the original commit. To make that easier, select that commit
automatically.
2026-08-15 15:24:36 +02:00

383 lines
10 KiB
Go

package helpers
import (
"path/filepath"
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/hosting_service"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/context/traits"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
"github.com/stefanhaller/git-todo-parser/todo"
"github.com/stretchr/testify/assert"
)
func TestCaptureLocalCommitSelectionRange(t *testing.T) {
testCases := []struct {
name string
commits []*models.Commit
selectedIdx int
rangeStartIdx int
expected *localCommitSelectionRange
}{
{
name: "captures selected commit and range start",
commits: makeCommits("a", "b"),
selectedIdx: 1,
rangeStartIdx: 0,
expected: &localCommitSelectionRange{
selectedHash: "b",
rangeStartHash: "a",
mode: traits.RangeSelectModeSticky,
},
},
{
name: "ignores invalid range start index",
commits: makeCommits("a"),
selectedIdx: 0,
rangeStartIdx: 1,
expected: nil,
},
{
name: "ignores empty selected hash",
commits: append(makeCommits("a"), makeTodoCommit(todo.UpdateRef)),
selectedIdx: 1,
rangeStartIdx: 0,
expected: nil,
},
{
name: "ignores empty range start hash",
commits: append(makeCommits("a"), makeTodoCommit(todo.Exec)),
selectedIdx: 0,
rangeStartIdx: 1,
expected: nil,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
selectionRange := captureLocalCommitSelectionRange(
testCase.commits,
testCase.selectedIdx,
testCase.rangeStartIdx,
traits.RangeSelectModeSticky,
)
assert.Equal(t, testCase.expected, selectionRange)
})
}
}
func TestFindLocalCommitSelectionRange(t *testing.T) {
type expectation struct {
selectedIdx int
rangeStartIdx int
found bool
}
selectionRange := localCommitSelectionRange{
selectedHash: "b",
rangeStartHash: "c",
mode: traits.RangeSelectModeSticky,
}
testCases := []struct {
name string
commits []*models.Commit
expected expectation
}{
{
name: "finds selection after commits are inserted above it",
commits: makeCommits("new", "a", "b", "c"),
expected: expectation{
selectedIdx: 2,
rangeStartIdx: 3,
found: true,
},
},
{
name: "finds selection that did not move",
commits: makeCommits("a", "b", "c"),
expected: expectation{
selectedIdx: 1,
rangeStartIdx: 2,
found: true,
},
},
{
name: "reports not found when a hash is missing",
commits: makeCommits("a", "b"),
expected: expectation{},
},
{
name: "skips todo entries with the same hash as a selected commit",
commits: []*models.Commit{
makeTodoCommitWithHash("b", todo.Revert),
makeCommits("a")[0],
makeCommits("b")[0],
makeCommits("c")[0],
},
expected: expectation{
selectedIdx: 2,
rangeStartIdx: 3,
found: true,
},
},
{
name: "falls back to a todo entry when the selected commit became one",
commits: []*models.Commit{
makeTodoCommitWithHash("b", todo.Pick),
makeCommits("c")[0],
},
expected: expectation{
selectedIdx: 0,
rangeStartIdx: 1,
found: true,
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
selectedIdx, rangeStartIdx, found := findLocalCommitSelectionRange(testCase.commits, &selectionRange)
actual := expectation{
selectedIdx: selectedIdx,
rangeStartIdx: rangeStartIdx,
found: found,
}
assert.Equal(t, testCase.expected, actual)
})
}
}
func TestFindNewConflictedCommit(t *testing.T) {
testCases := []struct {
name string
previousCommits []*models.Commit
commits []*models.Commit
expectedIdx *int
}{
{
name: "finds a newly conflicted commit",
previousCommits: makeCommits("a", "b"),
commits: []*models.Commit{
makeCommits("a")[0],
makeConflictedCommit("b"),
},
expectedIdx: lo.ToPtr(1),
},
{
name: "finds a different conflicted commit",
previousCommits: []*models.Commit{
makeConflictedCommit("a"),
},
commits: []*models.Commit{
makeConflictedCommit("b"),
},
expectedIdx: lo.ToPtr(0),
},
{
name: "ignores the same conflicted commit",
previousCommits: []*models.Commit{
makeConflictedCommit("a"),
},
commits: []*models.Commit{
makeConflictedCommit("a"),
},
expectedIdx: nil,
},
{
name: "reports not found when there is no conflict",
previousCommits: makeCommits("a"),
commits: makeCommits("a", "b"),
expectedIdx: nil,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
idx := findNewConflictedCommit(testCase.previousCommits, testCase.commits)
assert.Equal(t, testCase.expectedIdx != nil, idx != nil)
if idx != nil {
assert.Equal(t, *testCase.expectedIdx, *idx)
}
})
}
}
func TestGetGithubBaseRemote(t *testing.T) {
cases := []struct {
name string
githubRemotes []githubRemoteInfo
configuredRemote string
expected string
}{
{
name: "configured remote wins",
githubRemotes: makeGithubRemoteInfoList("origin", "upstream", "fork"),
configuredRemote: "fork",
expected: "fork",
},
{
name: "configured remote not in github remotes returns nil",
githubRemotes: makeGithubRemoteInfoList("origin"),
configuredRemote: "missing",
expected: "",
},
{
name: "single github remote is auto-picked",
githubRemotes: makeGithubRemoteInfoList("myremote"),
configuredRemote: "",
expected: "myremote",
},
{
name: "upstream is preferred when multiple github remotes exist",
githubRemotes: makeGithubRemoteInfoList("origin", "upstream", "fork"),
configuredRemote: "",
expected: "upstream",
},
{
name: "no upstream and multiple remotes returns nil",
githubRemotes: makeGithubRemoteInfoList("origin", "fork"),
configuredRemote: "",
expected: "",
},
{
name: "empty list returns nil",
githubRemotes: nil,
configuredRemote: "",
expected: "",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
result := getGithubBaseRemote(c.githubRemotes, c.configuredRemote)
if c.expected == "" {
assert.Nil(t, result)
} else {
assert.NotNil(t, result)
assert.Equal(t, c.expected, result.remote.Name)
}
})
}
}
func TestGetAuthenticatedGithubRemotes(t *testing.T) {
githubRemotes := []githubRemoteInfo{
makeGithubRemoteInfo("origin", "github.com"),
makeGithubRemoteInfo("fork", "github.com"),
makeGithubRemoteInfo("enterprise", "ghe.example.com"),
makeGithubRemoteInfo("missing-auth", "no-token.example.com"),
}
callsByHost := map[string]int{}
result := getAuthenticatedGithubRemotes(githubRemotes, func(host string) string {
callsByHost[host]++
switch host {
case "github.com":
return "github-token"
case "ghe.example.com":
return "ghe-token"
default:
return ""
}
})
assert.Equal(t, []githubRemoteInfo{
makeAuthenticatedGithubRemoteInfo("origin", "github.com", "github-token"),
makeAuthenticatedGithubRemoteInfo("fork", "github.com", "github-token"),
makeAuthenticatedGithubRemoteInfo("enterprise", "ghe.example.com", "ghe-token"),
}, result)
// Two remotes share github.com; the lookup runs only once.
assert.Equal(t, map[string]int{
"github.com": 1,
"ghe.example.com": 1,
"no-token.example.com": 1,
}, callsByHost)
}
func TestMarkWorktreeFiles(t *testing.T) {
worktreePath := filepath.Join("/", "path", "to", "repo")
worktrees := []*models.Worktree{
{Path: worktreePath},
{Path: filepath.Join(worktreePath, "worktree1")},
{Path: filepath.Join(worktreePath, "dir", "worktree2")},
{Path: filepath.Join("/", "path", "to", "worktree3")},
}
t.Run("marks the files that are worktrees, and takes their slash off", func(t *testing.T) {
files := []*models.File{
{Path: "file"},
{Path: "worktree1/"},
{Path: "dir/worktree2/"},
{Path: "dir/"},
}
assert.True(t, markWorktreeFiles(files, worktrees, worktreePath))
assert.Equal(t, []*models.File{
{Path: "file"},
{Path: "worktree1", IsWorktree: true},
{Path: "dir/worktree2", IsWorktree: true},
{Path: "dir/"},
}, files)
})
t.Run("reports no change when there is nothing to mark", func(t *testing.T) {
files := []*models.File{{Path: "file"}, {Path: "dir/"}}
assert.False(t, markWorktreeFiles(files, worktrees, worktreePath))
})
t.Run("unmarks a file whose worktree is gone", func(t *testing.T) {
files := []*models.File{{Path: "worktree1", IsWorktree: true}}
assert.True(t, markWorktreeFiles(files, nil, worktreePath))
assert.Equal(t, []*models.File{{Path: "worktree1"}}, files)
})
}
func makeGithubRemoteInfoList(names ...string) []githubRemoteInfo {
return lo.Map(names, func(name string, _ int) githubRemoteInfo {
return makeGithubRemoteInfo(name, name)
})
}
func makeGithubRemoteInfo(name string, webDomain string) githubRemoteInfo {
return githubRemoteInfo{
remote: &models.Remote{Name: name},
serviceInfo: hosting_service.ServiceInfo{
RepoName: name,
WebDomain: webDomain,
},
}
}
func makeAuthenticatedGithubRemoteInfo(name string, webDomain string, authToken string) githubRemoteInfo {
info := makeGithubRemoteInfo(name, webDomain)
info.authToken = authToken
return info
}
func makeCommits(hashes ...string) []*models.Commit {
hashPool := &utils.StringPool{}
return lo.Map(hashes, func(hash string, _ int) *models.Commit {
return models.NewCommit(hashPool, models.NewCommitOpts{Hash: hash})
})
}
func makeTodoCommit(action todo.TodoCommand) *models.Commit {
return models.NewCommit(&utils.StringPool{}, models.NewCommitOpts{Action: action})
}
func makeTodoCommitWithHash(hash string, action todo.TodoCommand) *models.Commit {
return models.NewCommit(&utils.StringPool{}, models.NewCommitOpts{Hash: hash, Action: action})
}
func makeConflictedCommit(hash string) *models.Commit {
return models.NewCommit(&utils.StringPool{}, models.NewCommitOpts{Hash: hash, Status: models.StatusConflicted})
}