Show todo items for pending cherry-picks and reverts (#4442)

- **PR Description**

This is part two of a four part series of PRs that improve the
cherry-pick and revert experience.

With this PR we include pending cherry-picks and reverts in the commit
list (like rebase todos) when a cherry-pick or revert stops with
conflicts; also, we show the conflicting item in the list like we do
with conflicting rebase todos.

As with the previous PR, this is not really very useful yet because you
can't revert a range of commits, and we don't use git cherry-pick for
copy/paste. Both of these will change in later PRs in this series, so
again this is preparation for that.
This commit is contained in:
Stefan Haller 2025-04-20 15:58:54 +02:00 committed by GitHub
commit b7d01d67a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 684 additions and 106 deletions

View file

@ -71,15 +71,13 @@ type GetCommitsOptions struct {
// GetCommits obtains the commits of the current branch
func (self *CommitLoader) GetCommits(opts GetCommitsOptions) ([]*models.Commit, error) {
commits := []*models.Commit{}
var rebasingCommits []*models.Commit
if opts.IncludeRebaseCommits && opts.FilterPath == "" {
var err error
rebasingCommits, err = self.MergeRebasingCommits(commits)
commits, err = self.MergeRebasingCommits(commits)
if err != nil {
return nil, err
}
commits = append(commits, rebasingCommits...)
}
wg := sync.WaitGroup{}
@ -126,7 +124,7 @@ func (self *CommitLoader) GetCommits(opts GetCommitsOptions) ([]*models.Commit,
if commit.Hash == firstPushedCommit {
passedFirstPushedCommit = true
}
if commit.Status != models.StatusRebasing {
if !commit.IsTODO() {
if passedFirstPushedCommit {
commit.Status = models.StatusPushed
} else {
@ -171,19 +169,26 @@ func (self *CommitLoader) MergeRebasingCommits(commits []*models.Commit) ([]*mod
}
}
if !self.getWorkingTreeState().Rebasing {
// not in rebase mode so return original commits
return result, nil
workingTreeState := self.getWorkingTreeState()
addConflictedRebasingCommit := true
if workingTreeState.CherryPicking || workingTreeState.Reverting {
sequencerCommits, err := self.getHydratedSequencerCommits(workingTreeState)
if err != nil {
return nil, err
}
result = append(sequencerCommits, result...)
addConflictedRebasingCommit = false
}
rebasingCommits, err := self.getHydratedRebasingCommits()
if err != nil {
return nil, err
if workingTreeState.Rebasing {
rebasingCommits, err := self.getHydratedRebasingCommits(addConflictedRebasingCommit)
if err != nil {
return nil, err
}
if len(rebasingCommits) > 0 {
result = append(rebasingCommits, result...)
}
}
if len(rebasingCommits) > 0 {
result = append(rebasingCommits, result...)
}
return result, nil
}
@ -242,14 +247,36 @@ func (self *CommitLoader) extractCommitFromLine(line string, showDivergence bool
}
}
func (self *CommitLoader) getHydratedRebasingCommits() ([]*models.Commit, error) {
commits := self.getRebasingCommits()
func (self *CommitLoader) getHydratedRebasingCommits(addConflictingCommit bool) ([]*models.Commit, error) {
todoFileHasShortHashes := self.version.IsOlderThan(2, 25, 2)
return self.getHydratedTodoCommits(self.getRebasingCommits(addConflictingCommit), todoFileHasShortHashes)
}
if len(commits) == 0 {
func (self *CommitLoader) getHydratedSequencerCommits(workingTreeState models.WorkingTreeState) ([]*models.Commit, error) {
commits := self.getSequencerCommits()
if len(commits) > 0 {
// If we have any commits in .git/sequencer/todo, then the last one of
// those is the conflicting one.
commits[len(commits)-1].Status = models.StatusConflicted
} else {
// For single-commit cherry-picks and reverts, git apparently doesn't
// use the sequencer; in that case, CHERRY_PICK_HEAD or REVERT_HEAD is
// our conflicting commit, so synthesize it here.
conflicedCommit := self.getConflictedSequencerCommit(workingTreeState)
if conflicedCommit != nil {
commits = append(commits, conflicedCommit)
}
}
return self.getHydratedTodoCommits(commits, true)
}
func (self *CommitLoader) getHydratedTodoCommits(todoCommits []*models.Commit, todoFileHasShortHashes bool) ([]*models.Commit, error) {
if len(todoCommits) == 0 {
return nil, nil
}
commitHashes := lo.FilterMap(commits, func(commit *models.Commit, _ int) (string, bool) {
commitHashes := lo.FilterMap(todoCommits, func(commit *models.Commit, _ int) (string, bool) {
return commit.Hash, commit.Hash != ""
})
@ -273,7 +300,7 @@ func (self *CommitLoader) getHydratedRebasingCommits() ([]*models.Commit, error)
return nil, err
}
findFullCommit := lo.Ternary(self.version.IsOlderThan(2, 25, 2),
findFullCommit := lo.Ternary(todoFileHasShortHashes,
func(hash string) *models.Commit {
for s, c := range fullCommits {
if strings.HasPrefix(s, hash) {
@ -286,8 +313,8 @@ func (self *CommitLoader) getHydratedRebasingCommits() ([]*models.Commit, error)
return fullCommits[hash]
})
hydratedCommits := make([]*models.Commit, 0, len(commits))
for _, rebasingCommit := range commits {
hydratedCommits := make([]*models.Commit, 0, len(todoCommits))
for _, rebasingCommit := range todoCommits {
if rebasingCommit.Hash == "" {
hydratedCommits = append(hydratedCommits, rebasingCommit)
} else if commit := findFullCommit(rebasingCommit.Hash); commit != nil {
@ -304,7 +331,7 @@ func (self *CommitLoader) getHydratedRebasingCommits() ([]*models.Commit, error)
// git-rebase-todo example:
// pick ac446ae94ee560bdb8d1d057278657b251aaef17 ac446ae
// pick afb893148791a2fbd8091aeb81deba4930c73031 afb8931
func (self *CommitLoader) getRebasingCommits() []*models.Commit {
func (self *CommitLoader) getRebasingCommits(addConflictingCommit bool) []*models.Commit {
bytesContent, err := self.readFile(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo"))
if err != nil {
self.Log.Error(fmt.Sprintf("error occurred reading git-rebase-todo: %s", err.Error()))
@ -322,13 +349,10 @@ func (self *CommitLoader) getRebasingCommits() []*models.Commit {
// See if the current commit couldn't be applied because it conflicted; if
// so, add a fake entry for it
if conflictedCommitHash := self.getConflictedCommit(todos); conflictedCommitHash != "" {
commits = append(commits, &models.Commit{
Hash: conflictedCommitHash,
Name: "",
Status: models.StatusRebasing,
Action: models.ActionConflict,
})
if addConflictingCommit {
if conflictedCommit := self.getConflictedCommit(todos); conflictedCommit != nil {
commits = append(commits, conflictedCommit)
}
}
for _, t := range todos {
@ -351,36 +375,34 @@ func (self *CommitLoader) getRebasingCommits() []*models.Commit {
return commits
}
func (self *CommitLoader) getConflictedCommit(todos []todo.Todo) string {
func (self *CommitLoader) getConflictedCommit(todos []todo.Todo) *models.Commit {
bytesContent, err := self.readFile(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/done"))
if err != nil {
self.Log.Error(fmt.Sprintf("error occurred reading rebase-merge/done: %s", err.Error()))
return ""
return nil
}
doneTodos, err := todo.Parse(bytes.NewBuffer(bytesContent), self.config.GetCoreCommentChar())
if err != nil {
self.Log.Error(fmt.Sprintf("error occurred while parsing rebase-merge/done file: %s", err.Error()))
return ""
return nil
}
amendFileExists := false
if _, err := os.Stat(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/amend")); err == nil {
amendFileExists = true
}
amendFileExists, _ := self.os.FileExists(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/amend"))
messageFileExists, _ := self.os.FileExists(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/message"))
return self.getConflictedCommitImpl(todos, doneTodos, amendFileExists)
return self.getConflictedCommitImpl(todos, doneTodos, amendFileExists, messageFileExists)
}
func (self *CommitLoader) getConflictedCommitImpl(todos []todo.Todo, doneTodos []todo.Todo, amendFileExists bool) string {
func (self *CommitLoader) getConflictedCommitImpl(todos []todo.Todo, doneTodos []todo.Todo, amendFileExists bool, messageFileExists bool) *models.Commit {
// Should never be possible, but just to be safe:
if len(doneTodos) == 0 {
self.Log.Error("no done entries in rebase-merge/done file")
return ""
return nil
}
lastTodo := doneTodos[len(doneTodos)-1]
if lastTodo.Command == todo.Break || lastTodo.Command == todo.Exec || lastTodo.Command == todo.Reword {
return ""
return nil
}
// In certain cases, git reschedules commands that failed. One example is if
@ -391,7 +413,7 @@ func (self *CommitLoader) getConflictedCommitImpl(todos []todo.Todo, doneTodos [
// same, the command was rescheduled.
if len(doneTodos) > 0 && len(todos) > 0 && doneTodos[len(doneTodos)-1] == todos[0] {
// Command was rescheduled, no need to display it
return ""
return nil
}
// Older versions of git have a bug whereby, if a command is rescheduled,
@ -416,26 +438,99 @@ func (self *CommitLoader) getConflictedCommitImpl(todos []todo.Todo, doneTodos [
if len(doneTodos) >= 3 && len(todos) > 0 && doneTodos[len(doneTodos)-2] == todos[0] &&
doneTodos[len(doneTodos)-1] == doneTodos[len(doneTodos)-3] {
// Command was rescheduled, no need to display it
return ""
return nil
}
if lastTodo.Command == todo.Edit {
if amendFileExists {
// Special case for "edit": if the "amend" file exists, the "edit"
// command was successful, otherwise it wasn't
return ""
return nil
}
if !messageFileExists {
// As an additional check, see if the "message" file exists; if it
// doesn't, it must be because a multi-commit cherry-pick or revert
// was performed in the meantime, which deleted both the amend file
// and the message file.
return nil
}
}
// I don't think this is ever possible, but again, just to be safe:
if lastTodo.Commit == "" {
self.Log.Error("last command in rebase-merge/done file doesn't have a commit")
return ""
return nil
}
// Any other todo that has a commit associated with it must have failed with
// a conflict, otherwise we wouldn't have stopped the rebase:
return lastTodo.Commit
return &models.Commit{
Hash: lastTodo.Commit,
Action: lastTodo.Command,
Status: models.StatusConflicted,
}
}
func (self *CommitLoader) getSequencerCommits() []*models.Commit {
bytesContent, err := self.readFile(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "sequencer/todo"))
if err != nil {
self.Log.Error(fmt.Sprintf("error occurred reading sequencer/todo: %s", err.Error()))
// we assume an error means the file doesn't exist so we just return
return nil
}
commits := []*models.Commit{}
todos, err := todo.Parse(bytes.NewBuffer(bytesContent), self.config.GetCoreCommentChar())
if err != nil {
self.Log.Error(fmt.Sprintf("error occurred while parsing sequencer/todo file: %s", err.Error()))
return nil
}
for _, t := range todos {
if t.Commit == "" {
// Command does not have a commit associated, skip
continue
}
commits = utils.Prepend(commits, &models.Commit{
Hash: t.Commit,
Name: t.Msg,
Status: models.StatusRebasing,
Action: t.Command,
})
}
return commits
}
func (self *CommitLoader) getConflictedSequencerCommit(workingTreeState models.WorkingTreeState) *models.Commit {
var shaFile string
var action todo.TodoCommand
if workingTreeState.CherryPicking {
shaFile = "CHERRY_PICK_HEAD"
action = todo.Pick
} else if workingTreeState.Reverting {
shaFile = "REVERT_HEAD"
action = todo.Revert
} else {
return nil
}
bytesContent, err := self.readFile(filepath.Join(self.repoPaths.WorktreeGitDirPath(), shaFile))
if err != nil {
self.Log.Error(fmt.Sprintf("error occurred reading %s: %s", shaFile, err.Error()))
// we assume an error means the file doesn't exist so we just return
return nil
}
lines := strings.Split(string(bytesContent), "\n")
if len(lines) == 0 {
return nil
}
return &models.Commit{
Hash: lines[0],
Status: models.StatusConflicted,
Action: action,
}
}
func setCommitMergedStatuses(ancestor string, commits []*models.Commit) {

View file

@ -328,18 +328,19 @@ func TestGetCommits(t *testing.T) {
func TestCommitLoader_getConflictedCommitImpl(t *testing.T) {
scenarios := []struct {
testName string
todos []todo.Todo
doneTodos []todo.Todo
amendFileExists bool
expectedHash string
testName string
todos []todo.Todo
doneTodos []todo.Todo
amendFileExists bool
messageFileExists bool
expectedResult *models.Commit
}{
{
testName: "no done todos",
todos: []todo.Todo{},
doneTodos: []todo.Todo{},
amendFileExists: false,
expectedHash: "",
expectedResult: nil,
},
{
testName: "common case (conflict)",
@ -355,7 +356,11 @@ func TestCommitLoader_getConflictedCommitImpl(t *testing.T) {
},
},
amendFileExists: false,
expectedHash: "fa1afe1",
expectedResult: &models.Commit{
Hash: "fa1afe1",
Action: todo.Pick,
Status: models.StatusConflicted,
},
},
{
testName: "last command was 'break'",
@ -364,7 +369,7 @@ func TestCommitLoader_getConflictedCommitImpl(t *testing.T) {
{Command: todo.Break},
},
amendFileExists: false,
expectedHash: "",
expectedResult: nil,
},
{
testName: "last command was 'exec'",
@ -376,7 +381,7 @@ func TestCommitLoader_getConflictedCommitImpl(t *testing.T) {
},
},
amendFileExists: false,
expectedHash: "",
expectedResult: nil,
},
{
testName: "last command was 'reword'",
@ -385,7 +390,7 @@ func TestCommitLoader_getConflictedCommitImpl(t *testing.T) {
{Command: todo.Reword},
},
amendFileExists: false,
expectedHash: "",
expectedResult: nil,
},
{
testName: "'pick' was rescheduled",
@ -402,7 +407,7 @@ func TestCommitLoader_getConflictedCommitImpl(t *testing.T) {
},
},
amendFileExists: false,
expectedHash: "",
expectedResult: nil,
},
{
testName: "'pick' was rescheduled, buggy git version",
@ -427,7 +432,7 @@ func TestCommitLoader_getConflictedCommitImpl(t *testing.T) {
},
},
amendFileExists: false,
expectedHash: "",
expectedResult: nil,
},
{
testName: "conflicting 'pick' after 'exec'",
@ -452,7 +457,11 @@ func TestCommitLoader_getConflictedCommitImpl(t *testing.T) {
},
},
amendFileExists: false,
expectedHash: "fa1afe1",
expectedResult: &models.Commit{
Hash: "fa1afe1",
Action: todo.Pick,
Status: models.StatusConflicted,
},
},
{
testName: "'edit' with amend file",
@ -464,10 +473,10 @@ func TestCommitLoader_getConflictedCommitImpl(t *testing.T) {
},
},
amendFileExists: true,
expectedHash: "",
expectedResult: nil,
},
{
testName: "'edit' without amend file",
testName: "'edit' without amend file but message file",
todos: []todo.Todo{},
doneTodos: []todo.Todo{
{
@ -475,8 +484,26 @@ func TestCommitLoader_getConflictedCommitImpl(t *testing.T) {
Commit: "fa1afe1",
},
},
amendFileExists: false,
expectedHash: "fa1afe1",
amendFileExists: false,
messageFileExists: true,
expectedResult: &models.Commit{
Hash: "fa1afe1",
Action: todo.Edit,
Status: models.StatusConflicted,
},
},
{
testName: "'edit' without amend and without message file",
todos: []todo.Todo{},
doneTodos: []todo.Todo{
{
Command: todo.Edit,
Commit: "fa1afe1",
},
},
amendFileExists: false,
messageFileExists: false,
expectedResult: nil,
},
}
for _, scenario := range scenarios {
@ -496,8 +523,8 @@ func TestCommitLoader_getConflictedCommitImpl(t *testing.T) {
},
}
hash := builder.getConflictedCommitImpl(scenario.todos, scenario.doneTodos, scenario.amendFileExists)
assert.Equal(t, scenario.expectedHash, hash)
hash := builder.getConflictedCommitImpl(scenario.todos, scenario.doneTodos, scenario.amendFileExists, scenario.messageFileExists)
assert.Equal(t, scenario.expectedResult, hash)
})
}
}

View file

@ -18,7 +18,7 @@ const (
StatusPushed
StatusMerged
StatusRebasing
StatusSelected
StatusConflicted
StatusReflog
)
@ -26,8 +26,6 @@ const (
// Conveniently for us, the todo package starts the enum at 1, and given
// that it doesn't have a "none" value, we're setting ours to 0
ActionNone todo.TodoCommand = 0
// "Comment" is the last one of the todo package's enum entries
ActionConflict = todo.Comment + 1
)
type Divergence int

View file

@ -829,12 +829,12 @@ func (self *FilesController) handleAmendCommitPress() error {
func (self *FilesController) isResolvingConflicts() bool {
commits := self.c.Model().Commits
for _, c := range commits {
if c.Status != models.StatusRebasing {
break
}
if c.Action == models.ActionConflict {
if c.Status == models.StatusConflicted {
return true
}
if !c.IsTODO() {
break
}
}
return false
}

View file

@ -122,7 +122,7 @@ func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error {
func (self *MergeAndRebaseHelper) hasExecTodos() bool {
for _, commit := range self.c.Model().Commits {
if commit.Status != models.StatusRebasing {
if !commit.IsTODO() {
break
}
if commit.Action == todo.Exec {

View file

@ -684,6 +684,11 @@ func (self *LocalCommitsController) isRebasing() bool {
return self.c.Model().WorkingTreeStateAtLastCommitRefresh.Any()
}
func (self *LocalCommitsController) isCherryPickingOrReverting() bool {
return self.c.Model().WorkingTreeStateAtLastCommitRefresh.CherryPicking ||
self.c.Model().WorkingTreeStateAtLastCommitRefresh.Reverting
}
func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, startIdx int, endIdx int) error {
if self.isRebasing() {
if err := self.c.Git().Rebase.MoveTodosDown(selectedCommits); err != nil {
@ -1389,7 +1394,7 @@ func (self *LocalCommitsController) canMoveDown(selectedCommits []*models.Commit
if self.isRebasing() {
commits := self.c.Model().Commits
if !commits[endIdx+1].IsTODO() || commits[endIdx+1].Action == models.ActionConflict {
if !commits[endIdx+1].IsTODO() || commits[endIdx+1].Status == models.StatusConflicted {
return &types.DisabledReason{Text: self.c.Tr.CannotMoveAnyFurther}
}
}
@ -1405,7 +1410,7 @@ func (self *LocalCommitsController) canMoveUp(selectedCommits []*models.Commit,
if self.isRebasing() {
commits := self.c.Model().Commits
if !commits[startIdx-1].IsTODO() || commits[startIdx-1].Action == models.ActionConflict {
if !commits[startIdx-1].IsTODO() || commits[startIdx-1].Status == models.StatusConflicted {
return &types.DisabledReason{Text: self.c.Tr.CannotMoveAnyFurther}
}
}
@ -1415,6 +1420,10 @@ func (self *LocalCommitsController) canMoveUp(selectedCommits []*models.Commit,
// Ensures that if we are mid-rebase, we're only selecting valid commits (non-conflict TODO commits)
func (self *LocalCommitsController) midRebaseCommandEnabled(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason {
if self.isCherryPickingOrReverting() {
return &types.DisabledReason{Text: self.c.Tr.NotAllowedMidCherryPickOrRevert}
}
if !self.isRebasing() {
return nil
}
@ -1434,6 +1443,10 @@ func (self *LocalCommitsController) midRebaseCommandEnabled(selectedCommits []*m
// Ensures that if we are mid-rebase, we're only selecting commits that can be moved
func (self *LocalCommitsController) midRebaseMoveCommandEnabled(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason {
if self.isCherryPickingOrReverting() {
return &types.DisabledReason{Text: self.c.Tr.NotAllowedMidCherryPickOrRevert}
}
if !self.isRebasing() {
if lo.SomeBy(selectedCommits, func(c *models.Commit) bool { return c.IsMerge() }) {
return &types.DisabledReason{Text: self.c.Tr.CannotMoveMergeCommit}
@ -1458,6 +1471,10 @@ func (self *LocalCommitsController) midRebaseMoveCommandEnabled(selectedCommits
}
func (self *LocalCommitsController) canDropCommits(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason {
if self.isCherryPickingOrReverting() {
return &types.DisabledReason{Text: self.c.Tr.NotAllowedMidCherryPickOrRevert}
}
if !self.isRebasing() {
if len(selectedCommits) > 1 && lo.SomeBy(selectedCommits, func(c *models.Commit) bool { return c.IsMerge() }) {
return &types.DisabledReason{Text: self.c.Tr.DroppingMergeRequiresSingleSelection}
@ -1502,6 +1519,10 @@ func isChangeOfRebaseTodoAllowed(oldAction todo.TodoCommand) bool {
}
func (self *LocalCommitsController) pickEnabled(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason {
if self.isCherryPickingOrReverting() {
return &types.DisabledReason{Text: self.c.Tr.NotAllowedMidCherryPickOrRevert}
}
if !self.isRebasing() {
// if not rebasing, we're going to do a pull so we don't care about the selection
return nil

View file

@ -186,7 +186,7 @@ func GetCommitListDisplayStrings(
unfilteredIdx := i + startIdx
bisectStatus = getBisectStatus(unfilteredIdx, commit.Hash, bisectInfo, bisectBounds)
isYouAreHereCommit := false
if showYouAreHereLabel && (commit.Action == models.ActionConflict || unfilteredIdx == rebaseOffset) {
if showYouAreHereLabel && (commit.Status == models.StatusConflicted || unfilteredIdx == rebaseOffset) {
isYouAreHereCommit = true
showYouAreHereLabel = false
}
@ -395,8 +395,7 @@ func displayCommit(
actionString := ""
if commit.Action != models.ActionNone {
todoString := lo.Ternary(commit.Action == models.ActionConflict, "conflict", commit.Action.String())
actionString = actionColorMap(commit.Action).Sprint(todoString) + " "
actionString = actionColorMap(commit.Action, commit.Status).Sprint(commit.Action.String()) + " "
}
tagString := ""
@ -429,8 +428,13 @@ func displayCommit(
mark := ""
if isYouAreHereCommit {
color := lo.Ternary(commit.Action == models.ActionConflict, style.FgRed, style.FgYellow)
youAreHere := color.Sprintf("<-- %s ---", common.Tr.YouAreHere)
color := style.FgYellow
text := common.Tr.YouAreHere
if commit.Status == models.StatusConflicted {
color = style.FgRed
text = common.Tr.ConflictLabel
}
youAreHere := color.Sprintf("<-- %s ---", text)
mark = fmt.Sprintf("%s ", youAreHere)
} else if isMarkedBaseCommit {
rebaseFromHere := style.FgYellow.Sprint(common.Tr.MarkedCommitMarker)
@ -501,7 +505,7 @@ func getHashColor(
hashColor = style.FgYellow
case models.StatusMerged:
hashColor = style.FgGreen
case models.StatusRebasing:
case models.StatusRebasing, models.StatusConflicted:
hashColor = style.FgBlue
case models.StatusReflog:
hashColor = style.FgBlue
@ -519,7 +523,11 @@ func getHashColor(
return hashColor
}
func actionColorMap(action todo.TodoCommand) style.TextStyle {
func actionColorMap(action todo.TodoCommand, status models.CommitStatus) style.TextStyle {
if status == models.StatusConflicted {
return style.FgRed
}
switch action {
case todo.Pick:
return style.FgCyan
@ -529,8 +537,6 @@ func actionColorMap(action todo.TodoCommand) style.TextStyle {
return style.FgGreen
case todo.Fixup:
return style.FgMagenta
case models.ActionConflict:
return style.FgRed
default:
return style.FgYellow
}

View file

@ -349,9 +349,11 @@ type TranslationSet struct {
ErrorOccurred string
NoRoom string
YouAreHere string
ConflictLabel string
YouDied string
RewordNotSupported string
ChangingThisActionIsNotAllowed string
NotAllowedMidCherryPickOrRevert string
DroppingMergeRequiresSingleSelection string
CherryPickCopy string
CherryPickCopyTooltip string
@ -1416,9 +1418,11 @@ func EnglishTranslationSet() *TranslationSet {
ErrorOccurred: "An error occurred! Please create an issue at",
NoRoom: "Not enough room",
YouAreHere: "YOU ARE HERE",
ConflictLabel: "CONFLICT",
YouDied: "YOU DIED!",
RewordNotSupported: "Rewording commits while interactively rebasing is not currently supported",
ChangingThisActionIsNotAllowed: "Changing this kind of rebase todo entry is not allowed",
NotAllowedMidCherryPickOrRevert: "This action is not allowed while cherry-picking or reverting",
DroppingMergeRequiresSingleSelection: "Dropping a merge commit requires a single selected item",
CherryPickCopy: "Copy (cherry-pick)",
CherryPickCopyTooltip: "Mark commit as copied. Then, within the local commits view, you can press `{{.paste}}` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `{{.escape}}` to cancel the selection.",

View file

@ -53,7 +53,7 @@ var RebaseAndDrop = NewIntegrationTest(NewIntegrationTestArgs{
TopLines(
MatchesRegexp(`pick.*to keep`).IsSelected(),
MatchesRegexp(`pick.*to remove`),
MatchesRegexp(`conflict.*YOU ARE HERE.*first change`),
MatchesRegexp(`pick.*CONFLICT.*first change`),
MatchesRegexp("second-change-branch unrelated change"),
MatchesRegexp("second change"),
MatchesRegexp("original"),
@ -63,7 +63,7 @@ var RebaseAndDrop = NewIntegrationTest(NewIntegrationTestArgs{
TopLines(
MatchesRegexp(`pick.*to keep`),
MatchesRegexp(`drop.*to remove`).IsSelected(),
MatchesRegexp(`conflict.*YOU ARE HERE.*first change`),
MatchesRegexp(`pick.*CONFLICT.*first change`),
MatchesRegexp("second-change-branch unrelated change"),
MatchesRegexp("second change"),
MatchesRegexp("original"),

View file

@ -30,7 +30,7 @@ var AmendWhenThereAreConflictsAndAmend = NewIntegrationTest(NewIntegrationTestAr
Focus().
Lines(
Contains("pick").Contains("commit three"),
Contains("conflict").Contains("<-- YOU ARE HERE --- file1 changed in branch"),
Contains("pick").Contains("<-- CONFLICT --- file1 changed in branch"),
Contains("commit two"),
Contains("file1 changed in master"),
Contains("base commit"),

View file

@ -34,7 +34,7 @@ var AmendWhenThereAreConflictsAndCancel = NewIntegrationTest(NewIntegrationTestA
Focus().
Lines(
Contains("pick").Contains("commit three"),
Contains("conflict").Contains("<-- YOU ARE HERE --- file1 changed in branch"),
Contains("pick").Contains("<-- CONFLICT --- file1 changed in branch"),
Contains("commit two"),
Contains("file1 changed in master"),
Contains("base commit"),

View file

@ -0,0 +1,87 @@
package commit
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RevertWithConflictMultipleCommits = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Reverts a range of commits, the first of which conflicts",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
// TODO: use our revert UI once we support range-select for reverts
cfg.GetUserConfig().CustomCommands = []config.CustomCommand{
{
Key: "X",
Context: "commits",
Command: "git -c core.editor=: revert HEAD^ HEAD^^",
},
}
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("myfile", "")
shell.Commit("add empty file")
shell.CreateFileAndAdd("otherfile", "")
shell.Commit("unrelated change")
shell.CreateFileAndAdd("myfile", "first line\n")
shell.Commit("add first line")
shell.UpdateFileAndAdd("myfile", "first line\nsecond line\n")
shell.Commit("add second line")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("CI ◯ add second line").IsSelected(),
Contains("CI ◯ add first line"),
Contains("CI ◯ unrelated change"),
Contains("CI ◯ add empty file"),
).
Press("X").
Tap(func() {
t.ExpectPopup().Alert().
Title(Equals("Error")).
// The exact error message is different on different git versions,
// but they all contain the word 'conflict' somewhere.
Content(Contains("conflict")).
Confirm()
}).
Lines(
Contains("revert").Contains("CI unrelated change"),
Contains("revert").Contains("CI <-- CONFLICT --- add first line"),
Contains("CI ◯ add second line"),
Contains("CI ◯ add first line"),
Contains("CI ◯ unrelated change"),
Contains("CI ◯ add empty file"),
)
t.Views().Options().Content(Contains("View revert options: m"))
t.Views().Information().Content(Contains("Reverting (Reset)"))
t.Views().Files().Focus().
Lines(
Contains("UU myfile").IsSelected(),
).
PressEnter()
t.Views().MergeConflicts().IsFocused().
SelectNextItem().
PressPrimaryAction()
t.ExpectPopup().Alert().
Title(Equals("Continue")).
Content(Contains("All merge conflicts resolved. Continue the revert?")).
Confirm()
t.Views().Commits().
Lines(
Contains(`CI ◯ Revert "unrelated change"`),
Contains(`CI ◯ Revert "add first line"`),
Contains("CI ◯ add second line"),
Contains("CI ◯ add first line"),
Contains("CI ◯ unrelated change"),
Contains("CI ◯ add empty file"),
)
},
})

View file

@ -39,16 +39,10 @@ var RevertWithConflictSingleCommit = NewIntegrationTest(NewIntegrationTestArgs{
Confirm()
}).
Lines(
/* EXPECTED:
Proper display of revert commits is not implemented yet; we'll do this in the next PR
Contains("revert").Contains("CI <-- CONFLICT --- add first line"),
Contains("CI ◯ add second line"),
Contains("CI ◯ add first line"),
Contains("CI ◯ add empty file"),
ACTUAL: */
Contains("CI ◯ <-- YOU ARE HERE --- add second line"),
Contains("CI ◯ add first line"),
Contains("CI ◯ add empty file"),
)
t.Views().Options().Content(Contains("View revert options: m"))

View file

@ -44,7 +44,7 @@ func doTheRebaseForAmendTests(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Lines(
Contains("pick").Contains("commit three"),
Contains("conflict").Contains("<-- YOU ARE HERE --- file1 changed in branch"),
Contains("pick").Contains("<-- CONFLICT --- file1 changed in branch"),
Contains("commit two"),
Contains("file1 changed in master"),
Contains("base commit"),

View file

@ -35,7 +35,7 @@ var AmendCommitWithConflict = NewIntegrationTest(NewIntegrationTestArgs{
}).
Lines(
Contains("pick").Contains("three"),
Contains("conflict").Contains("<-- YOU ARE HERE --- fixup! two"),
Contains("fixup").Contains("<-- CONFLICT --- fixup! two"),
Contains("two"),
Contains("one"),
)
@ -66,7 +66,7 @@ var AmendCommitWithConflict = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Commits().
Lines(
Contains("<-- YOU ARE HERE --- three"),
Contains("<-- CONFLICT --- three"),
Contains("two"),
Contains("one"),
)

View file

@ -33,10 +33,10 @@ var EditTheConflCommit = NewIntegrationTest(NewIntegrationTestArgs{
Focus().
Lines(
Contains("pick").Contains("commit two"),
Contains("conflict").Contains("<-- YOU ARE HERE --- commit three"),
Contains("pick").Contains("<-- CONFLICT --- commit three"),
Contains("commit one"),
).
NavigateToLine(Contains("<-- YOU ARE HERE --- commit three")).
NavigateToLine(Contains("<-- CONFLICT --- commit three")).
Press(keys.Commits.RenameCommit)
t.ExpectToast(Contains("Disabled: Rewording commits while interactively rebasing is not currently supported"))

View file

@ -0,0 +1,63 @@
package interactive_rebase
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var InteractiveRebaseWithConflictForEditCommand = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Rebase a branch interactively, and edit a commit that will conflict",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("initial commit")
shell.CreateFileAndAdd("file.txt", "master content")
shell.Commit("master commit")
shell.NewBranchFrom("branch", "master^")
shell.CreateNCommits(3)
shell.CreateFileAndAdd("file.txt", "branch content")
shell.Commit("this will conflict")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("this will conflict").IsSelected(),
Contains("commit 03"),
Contains("commit 02"),
Contains("commit 01"),
Contains("initial commit"),
)
t.Views().Branches().
Focus().
NavigateToLine(Contains("master")).
Press(keys.Branches.RebaseBranch)
t.ExpectPopup().Menu().
Title(Equals("Rebase 'branch'")).
Select(Contains("Interactive rebase")).
Confirm()
t.Views().Commits().
IsFocused().
NavigateToLine(Contains("this will conflict")).
Press(keys.Universal.Edit)
t.Common().ContinueRebase()
t.ExpectPopup().Menu().
Title(Equals("Conflicts!")).
Cancel()
t.Views().Commits().
Lines(
Contains("edit").Contains("<-- CONFLICT --- this will conflict").IsSelected(),
Contains("commit 03"),
Contains("commit 02"),
Contains("commit 01"),
Contains("master commit"),
Contains("initial commit"),
)
},
})

View file

@ -0,0 +1,57 @@
package interactive_rebase
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RevertDuringRebaseWhenStoppedOnEdit = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Revert a series of commits while stopped in a rebase",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
// TODO: use our revert UI once we support range-select for reverts
cfg.GetUserConfig().CustomCommands = []config.CustomCommand{
{
Key: "X",
Context: "commits",
Command: "git -c core.editor=: revert HEAD^ HEAD^^",
},
}
},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("master commit")
shell.NewBranch("branch")
shell.CreateNCommits(4)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("commit 04").IsSelected(),
Contains("commit 03"),
Contains("commit 02"),
Contains("commit 01"),
Contains("master commit"),
).
NavigateToLine(Contains("commit 03")).
Press(keys.Universal.Edit).
Lines(
Contains("pick").Contains("commit 04"),
Contains("<-- YOU ARE HERE --- commit 03").IsSelected(),
Contains("commit 02"),
Contains("commit 01"),
Contains("master commit"),
).
Press("X").
Lines(
Contains("pick").Contains("commit 04"),
Contains(`<-- YOU ARE HERE --- Revert "commit 01"`).IsSelected(),
Contains(`Revert "commit 02"`),
Contains("commit 03"),
Contains("commit 02"),
Contains("commit 01"),
Contains("master commit"),
)
},
})

View file

@ -0,0 +1,114 @@
package interactive_rebase
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RevertMultipleCommitsInInteractiveRebase = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Reverts a range of commits, the first of which conflicts, in the middle of an interactive rebase",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(cfg *config.AppConfig) {
// TODO: use our revert UI once we support range-select for reverts
cfg.GetUserConfig().CustomCommands = []config.CustomCommand{
{
Key: "X",
Context: "commits",
Command: "git -c core.editor=: revert HEAD^ HEAD^^",
},
}
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("myfile", "")
shell.Commit("add empty file")
shell.CreateFileAndAdd("otherfile", "")
shell.Commit("unrelated change 1")
shell.CreateFileAndAdd("myfile", "first line\n")
shell.Commit("add first line")
shell.UpdateFileAndAdd("myfile", "first line\nsecond line\n")
shell.Commit("add second line")
shell.EmptyCommit("unrelated change 2")
shell.EmptyCommit("unrelated change 3")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("CI ◯ unrelated change 3").IsSelected(),
Contains("CI ◯ unrelated change 2"),
Contains("CI ◯ add second line"),
Contains("CI ◯ add first line"),
Contains("CI ◯ unrelated change 1"),
Contains("CI ◯ add empty file"),
).
NavigateToLine(Contains("add second line")).
Press(keys.Universal.Edit).
Press("X").
Tap(func() {
t.ExpectPopup().Alert().
Title(Equals("Error")).
// The exact error message is different on different git versions,
// but they all contain the word 'conflict' somewhere.
Content(Contains("conflict")).
Confirm()
}).
Lines(
Contains("CI unrelated change 3"),
Contains("CI unrelated change 2"),
Contains("revert").Contains("CI unrelated change 1"),
Contains("revert").Contains("CI <-- CONFLICT --- add first line"),
Contains("CI ◯ add second line"),
Contains("CI ◯ add first line"),
Contains("CI ◯ unrelated change 1"),
Contains("CI ◯ add empty file"),
)
t.Views().Options().Content(Contains("View revert options: m"))
t.Views().Information().Content(Contains("Reverting (Reset)"))
t.Views().Files().Focus().
Lines(
Contains("UU myfile").IsSelected(),
).
PressEnter()
t.Views().MergeConflicts().IsFocused().
SelectNextItem().
PressPrimaryAction()
t.ExpectPopup().Alert().
Title(Equals("Continue")).
Content(Contains("All merge conflicts resolved. Continue the revert?")).
Confirm()
t.Views().Commits().
Lines(
Contains("pick").Contains("CI unrelated change 3"),
Contains("pick").Contains("CI unrelated change 2"),
Contains(`CI ◯ <-- YOU ARE HERE --- Revert "unrelated change 1"`),
Contains(`CI ◯ Revert "add first line"`),
Contains("CI ◯ add second line"),
Contains("CI ◯ add first line"),
Contains("CI ◯ unrelated change 1"),
Contains("CI ◯ add empty file"),
)
t.Views().Options().Content(Contains("View rebase options: m"))
t.Views().Information().Content(Contains("Rebasing (Reset)"))
t.Common().ContinueRebase()
t.Views().Commits().
Lines(
Contains("CI ◯ unrelated change 3"),
Contains("CI ◯ unrelated change 2"),
Contains(`CI ◯ Revert "unrelated change 1"`),
Contains(`CI ◯ Revert "add first line"`),
Contains("CI ◯ add second line"),
Contains("CI ◯ add first line"),
Contains("CI ◯ unrelated change 1"),
Contains("CI ◯ add empty file"),
)
},
})

View file

@ -0,0 +1,107 @@
package interactive_rebase
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RevertSingleCommitInInteractiveRebase = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Reverts a commit that conflicts in the middle of an interactive rebase",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("myfile", "")
shell.Commit("add empty file")
shell.CreateFileAndAdd("myfile", "first line\n")
shell.Commit("add first line")
shell.UpdateFileAndAdd("myfile", "first line\nsecond line\n")
shell.Commit("add second line")
shell.EmptyCommit("unrelated change 1")
shell.EmptyCommit("unrelated change 2")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("CI ◯ unrelated change 2").IsSelected(),
Contains("CI ◯ unrelated change 1"),
Contains("CI ◯ add second line"),
Contains("CI ◯ add first line"),
Contains("CI ◯ add empty file"),
).
NavigateToLine(Contains("add second line")).
Press(keys.Universal.Edit).
SelectNextItem().
Press(keys.Commits.RevertCommit).
Tap(func() {
t.ExpectPopup().Confirmation().
Title(Equals("Revert commit")).
Content(MatchesRegexp(`Are you sure you want to revert \w+?`)).
Confirm()
t.ExpectPopup().Menu().
Title(Equals("Conflicts!")).
Select(Contains("View conflicts")).
Cancel() // stay in commits panel
}).
Lines(
Contains("CI unrelated change 2"),
Contains("CI unrelated change 1"),
Contains("revert").Contains("CI <-- CONFLICT --- add first line"),
Contains("CI ◯ add second line"),
Contains("CI ◯ add first line").IsSelected(),
Contains("CI ◯ add empty file"),
).
Press(keys.Commits.MoveDownCommit).
Tap(func() {
t.ExpectToast(Equals("Disabled: This action is not allowed while cherry-picking or reverting"))
}).
Press(keys.Universal.Remove).
Tap(func() {
t.ExpectToast(Equals("Disabled: This action is not allowed while cherry-picking or reverting"))
})
t.Views().Options().Content(Contains("View revert options: m"))
t.Views().Information().Content(Contains("Reverting (Reset)"))
t.Views().Files().Focus().
Lines(
Contains("UU myfile").IsSelected(),
).
PressEnter()
t.Views().MergeConflicts().IsFocused().
SelectNextItem().
PressPrimaryAction()
t.ExpectPopup().Alert().
Title(Equals("Continue")).
Content(Contains("All merge conflicts resolved. Continue the revert?")).
Confirm()
t.Views().Commits().
Lines(
Contains("pick").Contains("CI unrelated change 2"),
Contains("pick").Contains("CI unrelated change 1"),
Contains(`CI ◯ <-- YOU ARE HERE --- Revert "add first line"`),
Contains("CI ◯ add second line"),
Contains("CI ◯ add first line"),
Contains("CI ◯ add empty file"),
)
t.Views().Options().Content(Contains("View rebase options: m"))
t.Views().Information().Content(Contains("Rebasing (Reset)"))
t.Common().ContinueRebase()
t.Views().Commits().
Lines(
Contains("CI ◯ unrelated change 2"),
Contains("CI ◯ unrelated change 1"),
Contains(`CI ◯ Revert "add first line"`),
Contains("CI ◯ add second line"),
Contains("CI ◯ add first line"),
Contains("CI ◯ add empty file"),
)
},
})

View file

@ -4,13 +4,13 @@ import (
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
func handleConflictsFromSwap(t *TestDriver) {
func handleConflictsFromSwap(t *TestDriver, expectedCommand string) {
t.Common().AcknowledgeConflicts()
t.Views().Commits().
Lines(
Contains("pick").Contains("commit two"),
Contains("conflict").Contains("<-- YOU ARE HERE --- commit three"),
Contains(expectedCommand).Contains("<-- CONFLICT --- commit three"),
Contains("commit one"),
)

View file

@ -44,6 +44,6 @@ var SwapInRebaseWithConflict = NewIntegrationTest(NewIntegrationTestArgs{
t.Common().ContinueRebase()
})
handleConflictsFromSwap(t)
handleConflictsFromSwap(t, "pick")
},
})

View file

@ -47,6 +47,6 @@ var SwapInRebaseWithConflictAndEdit = NewIntegrationTest(NewIntegrationTestArgs{
t.Common().ContinueRebase()
})
handleConflictsFromSwap(t)
handleConflictsFromSwap(t, "edit")
},
})

View file

@ -28,6 +28,6 @@ var SwapWithConflict = NewIntegrationTest(NewIntegrationTestArgs{
).
Press(keys.Commits.MoveDownCommit)
handleConflictsFromSwap(t)
handleConflictsFromSwap(t, "pick")
},
})

View file

@ -49,7 +49,7 @@ var PullRebaseInteractiveConflict = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Commits().
Lines(
Contains("pick").Contains("five"),
Contains("conflict").Contains("YOU ARE HERE").Contains("four"),
Contains("pick").Contains("CONFLICT").Contains("four"),
Contains("three"),
Contains("two"),
Contains("one"),

View file

@ -50,7 +50,7 @@ var PullRebaseInteractiveConflictDrop = NewIntegrationTest(NewIntegrationTestArg
Focus().
Lines(
Contains("pick").Contains("five").IsSelected(),
Contains("conflict").Contains("YOU ARE HERE").Contains("four"),
Contains("pick").Contains("CONFLICT").Contains("four"),
Contains("three"),
Contains("two"),
Contains("one"),
@ -58,7 +58,7 @@ var PullRebaseInteractiveConflictDrop = NewIntegrationTest(NewIntegrationTestArg
Press(keys.Universal.Remove).
Lines(
Contains("drop").Contains("five").IsSelected(),
Contains("conflict").Contains("YOU ARE HERE").Contains("four"),
Contains("pick").Contains("CONFLICT").Contains("four"),
Contains("three"),
Contains("two"),
Contains("one"),

View file

@ -128,6 +128,7 @@ var tests = []*components.IntegrationTest{
commit.ResetAuthorRange,
commit.Revert,
commit.RevertMerge,
commit.RevertWithConflictMultipleCommits,
commit.RevertWithConflictSingleCommit,
commit.Reword,
commit.Search,
@ -249,6 +250,7 @@ var tests = []*components.IntegrationTest{
interactive_rebase.FixupFirstCommit,
interactive_rebase.FixupSecondCommit,
interactive_rebase.InteractiveRebaseOfCopiedBranch,
interactive_rebase.InteractiveRebaseWithConflictForEditCommand,
interactive_rebase.MidRebaseRangeSelect,
interactive_rebase.Move,
interactive_rebase.MoveAcrossBranchBoundaryOutsideRebase,
@ -262,6 +264,9 @@ var tests = []*components.IntegrationTest{
interactive_rebase.QuickStartKeepSelectionRange,
interactive_rebase.Rebase,
interactive_rebase.RebaseWithCommitThatBecomesEmpty,
interactive_rebase.RevertDuringRebaseWhenStoppedOnEdit,
interactive_rebase.RevertMultipleCommitsInInteractiveRebase,
interactive_rebase.RevertSingleCommitInInteractiveRebase,
interactive_rebase.RewordCommitWithEditorAndFail,
interactive_rebase.RewordFirstCommit,
interactive_rebase.RewordLastCommit,