Drop merge commits (#4094)

- **PR Description**

Allow deleting a merge commit. We only allow this when the merge commit
is the only selected item, and only outside of a rebase. The reason for
this is that we don't show the "label" and "reset" todos in lazygit, so
deleting a merge commit would leave the commits from the branch that is
being merged in the list as "pick" commits, with no indication that they
are going to be dropped because they are on a different branch, and the
merge commit that would have brought them in is gone. This could be very
confusing.

Fixes #3164.

- **Please check if the PR fulfills these requirements**

* [x] Cheatsheets are up-to-date (run `go generate ./...`)
* [x] Code has been formatted (see
[here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#code-formatting))
* [x] Tests have been added/updated (see
[here](https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md)
for the integration test guide)
* [x] Text is internationalised (see
[here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#internationalisation))
* [ ] If a new UserConfig entry was added, make sure it can be
hot-reloaded (see
[here](https://github.com/jesseduffield/lazygit/blob/master/docs/dev/Codebase_Guide.md#using-userconfig))
* [ ] Docs have been updated if necessary
* [x] You've read through your own file changes for silly mistakes etc
This commit is contained in:
Stefan Haller 2024-12-23 12:17:26 +01:00 committed by GitHub
commit 75121384a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 194 additions and 45 deletions

View file

@ -12,7 +12,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/utils" "github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo" "github.com/samber/lo"
"github.com/stefanhaller/git-todo-parser/todo"
) )
// Sometimes lazygit will be invoked in daemon mode from a parent lazygit process. // Sometimes lazygit will be invoked in daemon mode from a parent lazygit process.
@ -39,6 +38,7 @@ const (
DaemonKindMoveTodosDown DaemonKindMoveTodosDown
DaemonKindInsertBreak DaemonKindInsertBreak
DaemonKindChangeTodoActions DaemonKindChangeTodoActions
DaemonKindDropMergeCommit
DaemonKindMoveFixupCommitDown DaemonKindMoveFixupCommitDown
DaemonKindWriteRebaseTodo DaemonKindWriteRebaseTodo
) )
@ -58,6 +58,7 @@ func getInstruction() Instruction {
DaemonKindRemoveUpdateRefsForCopiedBranch: deserializeInstruction[*RemoveUpdateRefsForCopiedBranchInstruction], DaemonKindRemoveUpdateRefsForCopiedBranch: deserializeInstruction[*RemoveUpdateRefsForCopiedBranchInstruction],
DaemonKindCherryPick: deserializeInstruction[*CherryPickCommitsInstruction], DaemonKindCherryPick: deserializeInstruction[*CherryPickCommitsInstruction],
DaemonKindChangeTodoActions: deserializeInstruction[*ChangeTodoActionsInstruction], DaemonKindChangeTodoActions: deserializeInstruction[*ChangeTodoActionsInstruction],
DaemonKindDropMergeCommit: deserializeInstruction[*DropMergeCommitInstruction],
DaemonKindMoveFixupCommitDown: deserializeInstruction[*MoveFixupCommitDownInstruction], DaemonKindMoveFixupCommitDown: deserializeInstruction[*MoveFixupCommitDownInstruction],
DaemonKindMoveTodosUp: deserializeInstruction[*MoveTodosUpInstruction], DaemonKindMoveTodosUp: deserializeInstruction[*MoveTodosUpInstruction],
DaemonKindMoveTodosDown: deserializeInstruction[*MoveTodosDownInstruction], DaemonKindMoveTodosDown: deserializeInstruction[*MoveTodosDownInstruction],
@ -235,7 +236,6 @@ func (self *ChangeTodoActionsInstruction) run(common *common.Common) error {
changes := lo.Map(self.Changes, func(c ChangeTodoAction, _ int) utils.TodoChange { changes := lo.Map(self.Changes, func(c ChangeTodoAction, _ int) utils.TodoChange {
return utils.TodoChange{ return utils.TodoChange{
Hash: c.Hash, Hash: c.Hash,
OldAction: todo.Pick,
NewAction: c.NewAction, NewAction: c.NewAction,
} }
}) })
@ -244,6 +244,30 @@ func (self *ChangeTodoActionsInstruction) run(common *common.Common) error {
}) })
} }
type DropMergeCommitInstruction struct {
Hash string
}
func NewDropMergeCommitInstruction(hash string) Instruction {
return &DropMergeCommitInstruction{
Hash: hash,
}
}
func (self *DropMergeCommitInstruction) Kind() DaemonKind {
return DaemonKindDropMergeCommit
}
func (self *DropMergeCommitInstruction) SerializedInstructions() string {
return serializeInstruction(self)
}
func (self *DropMergeCommitInstruction) run(common *common.Common) error {
return handleInteractiveRebase(common, func(path string) error {
return utils.DropMergeCommit(path, self.Hash, getCommentChar())
})
}
// Takes the hash of some commit, and the hash of a fixup commit that was created // Takes the hash of some commit, and the hash of a fixup commit that was created
// at the end of the branch, then moves the fixup commit down to right after the // at the end of the branch, then moves the fixup commit down to right after the
// original commit, changing its type to "fixup" (only if ChangeToFixup is true) // original commit, changing its type to "fixup" (only if ChangeToFixup is true)
@ -296,8 +320,7 @@ func (self *MoveTodosUpInstruction) SerializedInstructions() string {
func (self *MoveTodosUpInstruction) run(common *common.Common) error { func (self *MoveTodosUpInstruction) run(common *common.Common) error {
todosToMove := lo.Map(self.Hashes, func(hash string, _ int) utils.Todo { todosToMove := lo.Map(self.Hashes, func(hash string, _ int) utils.Todo {
return utils.Todo{ return utils.Todo{
Hash: hash, Hash: hash,
Action: todo.Pick,
} }
}) })
@ -327,8 +350,7 @@ func (self *MoveTodosDownInstruction) SerializedInstructions() string {
func (self *MoveTodosDownInstruction) run(common *common.Common) error { func (self *MoveTodosDownInstruction) run(common *common.Common) error {
todosToMove := lo.Map(self.Hashes, func(hash string, _ int) utils.Todo { todosToMove := lo.Map(self.Hashes, func(hash string, _ int) utils.Todo {
return utils.Todo{ return utils.Todo{
Hash: hash, Hash: hash,
Action: todo.Pick,
} }
}) })

View file

@ -324,9 +324,9 @@ func (self *RebaseCommands) MoveFixupCommitDown(commits []*models.Commit, target
func todoFromCommit(commit *models.Commit) utils.Todo { func todoFromCommit(commit *models.Commit) utils.Todo {
if commit.Action == todo.UpdateRef { if commit.Action == todo.UpdateRef {
return utils.Todo{Ref: commit.Name, Action: commit.Action} return utils.Todo{Ref: commit.Name}
} else { } else {
return utils.Todo{Hash: commit.Hash, Action: commit.Action} return utils.Todo{Hash: commit.Hash}
} }
} }
@ -335,7 +335,6 @@ func (self *RebaseCommands) EditRebaseTodo(commits []*models.Commit, action todo
commitsWithAction := lo.Map(commits, func(commit *models.Commit, _ int) utils.TodoChange { commitsWithAction := lo.Map(commits, func(commit *models.Commit, _ int) utils.TodoChange {
return utils.TodoChange{ return utils.TodoChange{
Hash: commit.Hash, Hash: commit.Hash,
OldAction: commit.Action,
NewAction: action, NewAction: action,
} }
}) })
@ -565,6 +564,13 @@ func (self *RebaseCommands) CherryPickCommitsDuringRebase(commits []*models.Comm
return utils.PrependStrToTodoFile(filePath, []byte(todo)) return utils.PrependStrToTodoFile(filePath, []byte(todo))
} }
func (self *RebaseCommands) DropMergeCommit(commits []*models.Commit, commitIndex int) error {
return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{
baseHashOrRoot: getBaseHashOrRoot(commits, commitIndex+1),
instruction: daemon.NewDropMergeCommitInstruction(commits[commitIndex].Hash),
}).Run()
}
// we can't start an interactive rebase from the first commit without passing the // we can't start an interactive rebase from the first commit without passing the
// '--root' arg // '--root' arg
func getBaseHashOrRoot(commits []*models.Commit, index int) string { func getBaseHashOrRoot(commits []*models.Commit, index int) string {

View file

@ -497,12 +497,17 @@ func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, start
return self.updateTodos(todo.Drop, selectedCommits) return self.updateTodos(todo.Drop, selectedCommits)
} }
isMerge := selectedCommits[0].IsMerge()
self.c.Confirm(types.ConfirmOpts{ self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.DropCommitTitle, Title: self.c.Tr.DropCommitTitle,
Prompt: self.c.Tr.DropCommitPrompt, Prompt: lo.Ternary(isMerge, self.c.Tr.DropMergeCommitPrompt, self.c.Tr.DropCommitPrompt),
HandleConfirm: func() error { HandleConfirm: func() error {
return self.c.WithWaitingStatus(self.c.Tr.DroppingStatus, func(gocui.Task) error { return self.c.WithWaitingStatus(self.c.Tr.DroppingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.DropCommit) self.c.LogAction(self.c.Tr.Actions.DropCommit)
if isMerge {
return self.dropMergeCommit(startIdx)
}
return self.interactiveRebase(todo.Drop, startIdx, endIdx) return self.interactiveRebase(todo.Drop, startIdx, endIdx)
}) })
}, },
@ -511,6 +516,11 @@ func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, start
return nil return nil
} }
func (self *LocalCommitsController) dropMergeCommit(commitIdx int) error {
err := self.c.Git().Rebase.DropMergeCommit(self.c.Model().Commits, commitIdx)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
}
func (self *LocalCommitsController) edit(selectedCommits []*models.Commit, startIdx int, endIdx int) error { func (self *LocalCommitsController) edit(selectedCommits []*models.Commit, startIdx int, endIdx int) error {
if self.isRebasing() { if self.isRebasing() {
return self.updateTodos(todo.Edit, selectedCommits) return self.updateTodos(todo.Edit, selectedCommits)
@ -1358,11 +1368,15 @@ func (self *LocalCommitsController) canFindCommitForSquashFixupsInCurrentBranch(
return nil return nil
} }
func (self *LocalCommitsController) canSquashOrFixup(_selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason { func (self *LocalCommitsController) canSquashOrFixup(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason {
if endIdx >= len(self.c.Model().Commits)-1 { if endIdx >= len(self.c.Model().Commits)-1 {
return &types.DisabledReason{Text: self.c.Tr.CannotSquashOrFixupFirstCommit} return &types.DisabledReason{Text: self.c.Tr.CannotSquashOrFixupFirstCommit}
} }
if lo.SomeBy(selectedCommits, func(c *models.Commit) bool { return c.IsMerge() }) {
return &types.DisabledReason{Text: self.c.Tr.CannotSquashOrFixupMergeCommit}
}
return nil return nil
} }
@ -1420,6 +1434,10 @@ func (self *LocalCommitsController) midRebaseCommandEnabled(selectedCommits []*m
// Ensures that if we are mid-rebase, we're only selecting commits that can be moved // 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 { func (self *LocalCommitsController) midRebaseMoveCommandEnabled(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason {
if !self.isRebasing() { if !self.isRebasing() {
if lo.SomeBy(selectedCommits, func(c *models.Commit) bool { return c.IsMerge() }) {
return &types.DisabledReason{Text: self.c.Tr.CannotMoveMergeCommit}
}
return nil return nil
} }
@ -1440,6 +1458,10 @@ func (self *LocalCommitsController) midRebaseMoveCommandEnabled(selectedCommits
func (self *LocalCommitsController) canDropCommits(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason { func (self *LocalCommitsController) canDropCommits(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason {
if !self.isRebasing() { 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}
}
return nil return nil
} }

View file

@ -140,6 +140,7 @@ type TranslationSet struct {
Quit string Quit string
SquashTooltip string SquashTooltip string
CannotSquashOrFixupFirstCommit string CannotSquashOrFixupFirstCommit string
CannotSquashOrFixupMergeCommit string
Fixup string Fixup string
FixupTooltip string FixupTooltip string
SureFixupThisCommit string SureFixupThisCommit string
@ -160,6 +161,7 @@ type TranslationSet struct {
MoveDownCommit string MoveDownCommit string
MoveUpCommit string MoveUpCommit string
CannotMoveAnyFurther string CannotMoveAnyFurther string
CannotMoveMergeCommit string
EditCommit string EditCommit string
EditCommitTooltip string EditCommitTooltip string
AmendCommitTooltip string AmendCommitTooltip string
@ -320,6 +322,7 @@ type TranslationSet struct {
YouDied string YouDied string
RewordNotSupported string RewordNotSupported string
ChangingThisActionIsNotAllowed string ChangingThisActionIsNotAllowed string
DroppingMergeRequiresSingleSelection string
CherryPickCopy string CherryPickCopy string
CherryPickCopyTooltip string CherryPickCopyTooltip string
CherryPickCopyRangeTooltip string CherryPickCopyRangeTooltip string
@ -347,6 +350,7 @@ type TranslationSet struct {
DropCommitTitle string DropCommitTitle string
DropCommitPrompt string DropCommitPrompt string
DropUpdateRefPrompt string DropUpdateRefPrompt string
DropMergeCommitPrompt string
PullingStatus string PullingStatus string
PushingStatus string PushingStatus string
FetchingStatus string FetchingStatus string
@ -1134,6 +1138,7 @@ func EnglishTranslationSet() *TranslationSet {
UpdateRefHere: "Update branch '{{.ref}}' here", UpdateRefHere: "Update branch '{{.ref}}' here",
ExecCommandHere: "Execute the following command here:", ExecCommandHere: "Execute the following command here:",
CannotSquashOrFixupFirstCommit: "There's no commit below to squash into", CannotSquashOrFixupFirstCommit: "There's no commit below to squash into",
CannotSquashOrFixupMergeCommit: "Cannot squash or fixup a merge commit",
Fixup: "Fixup", Fixup: "Fixup",
SureFixupThisCommit: "Are you sure you want to 'fixup' the selected commit(s) into the commit below?", SureFixupThisCommit: "Are you sure you want to 'fixup' the selected commit(s) into the commit below?",
SureSquashThisCommit: "Are you sure you want to squash the selected commit(s) into the commit below?", SureSquashThisCommit: "Are you sure you want to squash the selected commit(s) into the commit below?",
@ -1153,6 +1158,7 @@ func EnglishTranslationSet() *TranslationSet {
MoveDownCommit: "Move commit down one", MoveDownCommit: "Move commit down one",
MoveUpCommit: "Move commit up one", MoveUpCommit: "Move commit up one",
CannotMoveAnyFurther: "Cannot move any further", CannotMoveAnyFurther: "Cannot move any further",
CannotMoveMergeCommit: "Cannot move a merge commit",
EditCommit: "Edit (start interactive rebase)", EditCommit: "Edit (start interactive rebase)",
EditCommitTooltip: "Edit the selected commit. Use this to start an interactive rebase from the selected commit. When already mid-rebase, this will mark the selected commit for editing, which means that upon continuing the rebase, the rebase will pause at the selected commit to allow you to make changes.", EditCommitTooltip: "Edit the selected commit. Use this to start an interactive rebase from the selected commit. When already mid-rebase, this will mark the selected commit for editing, which means that upon continuing the rebase, the rebase will pause at the selected commit to allow you to make changes.",
AmendCommitTooltip: "Amend commit with staged changes. If the selected commit is the HEAD commit, this will perform `git commit --amend`. Otherwise the commit will be amended via a rebase.", AmendCommitTooltip: "Amend commit with staged changes. If the selected commit is the HEAD commit, this will perform `git commit --amend`. Otherwise the commit will be amended via a rebase.",
@ -1320,6 +1326,7 @@ func EnglishTranslationSet() *TranslationSet {
YouDied: "YOU DIED!", YouDied: "YOU DIED!",
RewordNotSupported: "Rewording commits while interactively rebasing is not currently supported", RewordNotSupported: "Rewording commits while interactively rebasing is not currently supported",
ChangingThisActionIsNotAllowed: "Changing this kind of rebase todo entry is not allowed", ChangingThisActionIsNotAllowed: "Changing this kind of rebase todo entry is not allowed",
DroppingMergeRequiresSingleSelection: "Dropping a merge commit requires a single selected item",
CherryPickCopy: "Copy (cherry-pick)", 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.", 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.",
CherryPickCopyRangeTooltip: "Mark commits as copied from the last copied commit to the selected commit.", CherryPickCopyRangeTooltip: "Mark commits as copied from the last copied commit to the selected commit.",
@ -1346,6 +1353,7 @@ func EnglishTranslationSet() *TranslationSet {
AmendCommitPrompt: "Are you sure you want to amend this commit with your staged files?", AmendCommitPrompt: "Are you sure you want to amend this commit with your staged files?",
DropCommitTitle: "Drop commit", DropCommitTitle: "Drop commit",
DropCommitPrompt: "Are you sure you want to drop the selected commit(s)?", DropCommitPrompt: "Are you sure you want to drop the selected commit(s)?",
DropMergeCommitPrompt: "Are you sure you want to drop the selected merge commit? Note that it will also drop all the commits that were merged in by it.",
DropUpdateRefPrompt: "Are you sure you want to delete the selected update-ref todo(s)? This is irreversible except by aborting the rebase.", DropUpdateRefPrompt: "Are you sure you want to delete the selected update-ref todo(s)? This is irreversible except by aborting the rebase.",
PullingStatus: "Pulling", PullingStatus: "Pulling",
PushingStatus: "Pushing", PushingStatus: "Pushing",

View file

@ -0,0 +1,46 @@
package interactive_rebase
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
"github.com/jesseduffield/lazygit/pkg/integration/tests/shared"
)
var DropMergeCommit = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Drops a merge commit outside of an interactive rebase",
ExtraCmdArgs: []string{},
Skip: false,
GitVersion: AtLeast("2.22.0"), // first version that supports the --rebase-merges option
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shared.CreateMergeCommit(shell)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("CI ⏣─╮ Merge branch 'second-change-branch' into first-change-branch").IsSelected(),
Contains("CI │ ◯ * second-change-branch unrelated change"),
Contains("CI │ ◯ second change"),
Contains("CI ◯ │ first change"),
Contains("CI ◯─╯ * original"),
Contains("CI ◯ three"),
Contains("CI ◯ two"),
Contains("CI ◯ one"),
).
Press(keys.Universal.Remove).
Tap(func() {
t.ExpectPopup().Confirmation().
Title(Equals("Drop commit")).
Content(Equals("Are you sure you want to drop the selected merge commit? Note that it will also drop all the commits that were merged in by it.")).
Confirm()
}).
Lines(
Contains("CI ◯ first change").IsSelected(),
Contains("CI ◯ * original"),
Contains("CI ◯ three"),
Contains("CI ◯ two"),
Contains("CI ◯ one"),
)
},
})

View file

@ -208,6 +208,7 @@ var tests = []*components.IntegrationTest{
interactive_rebase.DeleteUpdateRefTodo, interactive_rebase.DeleteUpdateRefTodo,
interactive_rebase.DontShowBranchHeadsForTodoItems, interactive_rebase.DontShowBranchHeadsForTodoItems,
interactive_rebase.DropCommitInCopiedBranchWithUpdateRef, interactive_rebase.DropCommitInCopiedBranchWithUpdateRef,
interactive_rebase.DropMergeCommit,
interactive_rebase.DropTodoCommitWithUpdateRef, interactive_rebase.DropTodoCommitWithUpdateRef,
interactive_rebase.DropWithCustomCommentChar, interactive_rebase.DropWithCustomCommentChar,
interactive_rebase.EditAndAutoAmend, interactive_rebase.EditAndAutoAmend,

View file

@ -6,24 +6,18 @@ import (
"fmt" "fmt"
"os" "os"
"slices" "slices"
"strings"
"github.com/samber/lo" "github.com/samber/lo"
"github.com/stefanhaller/git-todo-parser/todo" "github.com/stefanhaller/git-todo-parser/todo"
) )
type Todo struct { type Todo struct {
Hash string // for todos that have one, e.g. pick, drop, fixup, etc. Hash string // for todos that have one, e.g. pick, drop, fixup, etc.
Ref string // for update-ref todos Ref string // for update-ref todos
Action todo.TodoCommand
} }
// In order to change a TODO in git-rebase-todo, we need to specify the old action,
// because sometimes the same hash appears multiple times in the file (e.g. in a pick
// and later in a merge)
type TodoChange struct { type TodoChange struct {
Hash string Hash string
OldAction todo.TodoCommand
NewAction todo.TodoCommand NewAction todo.TodoCommand
} }
@ -40,7 +34,7 @@ func EditRebaseTodo(filePath string, changes []TodoChange, commentChar byte) err
t := &todos[i] t := &todos[i]
// This is a nested loop, but it's ok because the number of todos should be small // This is a nested loop, but it's ok because the number of todos should be small
for _, change := range changes { for _, change := range changes {
if t.Command == change.OldAction && equalHash(t.Commit, change.Hash) { if equalHash(t.Commit, change.Hash) {
matchCount++ matchCount++
t.Command = change.NewAction t.Command = change.NewAction
} }
@ -56,18 +50,18 @@ func EditRebaseTodo(filePath string, changes []TodoChange, commentChar byte) err
} }
func equalHash(a, b string) bool { func equalHash(a, b string) bool {
return strings.HasPrefix(a, b) || strings.HasPrefix(b, a) if len(a) == 0 && len(b) == 0 {
return true
}
commonLength := min(len(a), len(b))
return commonLength > 0 && a[:commonLength] == b[:commonLength]
} }
func findTodo(todos []todo.Todo, todoToFind Todo) (int, bool) { func findTodo(todos []todo.Todo, todoToFind Todo) (int, bool) {
_, idx, ok := lo.FindIndexOf(todos, func(t todo.Todo) bool { _, idx, ok := lo.FindIndexOf(todos, func(t todo.Todo) bool {
// Comparing just the hash is not enough; we need to compare both the // For update-ref todos we also must compare the Ref (they have an empty hash)
// action and the hash, as the hash could appear multiple times (e.g. in a return equalHash(t.Commit, todoToFind.Hash) && t.Ref == todoToFind.Ref
// pick and later in a merge). For update-ref todos we also must compare
// the Ref.
return t.Command == todoToFind.Action &&
equalHash(t.Commit, todoToFind.Hash) &&
t.Ref == todoToFind.Ref
}) })
return idx, ok return idx, ok
} }
@ -296,3 +290,29 @@ func RemoveUpdateRefsForCopiedBranch(fileName string, commentChar byte) error {
func isRenderedTodo(t todo.Todo) bool { func isRenderedTodo(t todo.Todo) bool {
return t.Commit != "" || t.Command == todo.UpdateRef return t.Commit != "" || t.Command == todo.UpdateRef
} }
func DropMergeCommit(fileName string, hash string, commentChar byte) error {
todos, err := ReadRebaseTodoFile(fileName, commentChar)
if err != nil {
return err
}
newTodos, err := dropMergeCommit(todos, hash)
if err != nil {
return err
}
return WriteRebaseTodoFile(fileName, newTodos, commentChar)
}
func dropMergeCommit(todos []todo.Todo, hash string) ([]todo.Todo, error) {
isMerge := func(t todo.Todo) bool {
return t.Command == todo.Merge && t.Flag == "-C" && equalHash(t.Commit, hash)
}
if lo.CountBy(todos, isMerge) != 1 {
return nil, fmt.Errorf("Expected exactly one merge commit with hash %s", hash)
}
_, idx, _ := lo.FindIndexOf(todos, isMerge)
return slices.Delete(todos, idx, idx+1), nil
}

View file

@ -2,6 +2,7 @@ package utils
import ( import (
"errors" "errors"
"fmt"
"testing" "testing"
"github.com/stefanhaller/git-todo-parser/todo" "github.com/stefanhaller/git-todo-parser/todo"
@ -25,7 +26,7 @@ func TestRebaseCommands_moveTodoDown(t *testing.T) {
{Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "5678"},
{Command: todo.Pick, Commit: "abcd"}, {Command: todo.Pick, Commit: "abcd"},
}, },
todoToMoveDown: Todo{Hash: "5678", Action: todo.Pick}, todoToMoveDown: Todo{Hash: "5678"},
expectedErr: "", expectedErr: "",
expectedTodos: []todo.Todo{ expectedTodos: []todo.Todo{
{Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "5678"},
@ -40,7 +41,7 @@ func TestRebaseCommands_moveTodoDown(t *testing.T) {
{Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "5678"},
{Command: todo.Pick, Commit: "abcd"}, {Command: todo.Pick, Commit: "abcd"},
}, },
todoToMoveDown: Todo{Hash: "abcd", Action: todo.Pick}, todoToMoveDown: Todo{Hash: "abcd"},
expectedErr: "", expectedErr: "",
expectedTodos: []todo.Todo{ expectedTodos: []todo.Todo{
{Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "1234"},
@ -55,7 +56,7 @@ func TestRebaseCommands_moveTodoDown(t *testing.T) {
{Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "5678"},
{Command: todo.UpdateRef, Ref: "refs/heads/some_branch"}, {Command: todo.UpdateRef, Ref: "refs/heads/some_branch"},
}, },
todoToMoveDown: Todo{Ref: "refs/heads/some_branch", Action: todo.UpdateRef}, todoToMoveDown: Todo{Ref: "refs/heads/some_branch"},
expectedErr: "", expectedErr: "",
expectedTodos: []todo.Todo{ expectedTodos: []todo.Todo{
{Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "1234"},
@ -72,7 +73,7 @@ func TestRebaseCommands_moveTodoDown(t *testing.T) {
{Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "5678"},
{Command: todo.Pick, Commit: "def0"}, {Command: todo.Pick, Commit: "def0"},
}, },
todoToMoveDown: Todo{Hash: "5678", Action: todo.Pick}, todoToMoveDown: Todo{Hash: "5678"},
expectedErr: "", expectedErr: "",
expectedTodos: []todo.Todo{ expectedTodos: []todo.Todo{
{Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "1234"},
@ -91,7 +92,7 @@ func TestRebaseCommands_moveTodoDown(t *testing.T) {
{Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "5678"},
{Command: todo.Pick, Commit: "abcd"}, {Command: todo.Pick, Commit: "abcd"},
}, },
todoToMoveDown: Todo{Hash: "def0", Action: todo.Pick}, todoToMoveDown: Todo{Hash: "def0"},
expectedErr: "Todo def0 not found in git-rebase-todo", expectedErr: "Todo def0 not found in git-rebase-todo",
expectedTodos: []todo.Todo{}, expectedTodos: []todo.Todo{},
}, },
@ -102,7 +103,7 @@ func TestRebaseCommands_moveTodoDown(t *testing.T) {
{Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "5678"},
{Command: todo.Pick, Commit: "abcd"}, {Command: todo.Pick, Commit: "abcd"},
}, },
todoToMoveDown: Todo{Hash: "1234", Action: todo.Pick}, todoToMoveDown: Todo{Hash: "1234"},
expectedErr: "Destination position for moving todo is out of range", expectedErr: "Destination position for moving todo is out of range",
expectedTodos: []todo.Todo{}, expectedTodos: []todo.Todo{},
}, },
@ -114,7 +115,7 @@ func TestRebaseCommands_moveTodoDown(t *testing.T) {
{Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "1234"},
{Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "5678"},
}, },
todoToMoveDown: Todo{Hash: "1234", Action: todo.Pick}, todoToMoveDown: Todo{Hash: "1234"},
expectedErr: "Destination position for moving todo is out of range", expectedErr: "Destination position for moving todo is out of range",
expectedTodos: []todo.Todo{}, expectedTodos: []todo.Todo{},
}, },
@ -151,7 +152,7 @@ func TestRebaseCommands_moveTodoUp(t *testing.T) {
{Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "5678"},
{Command: todo.Pick, Commit: "abcd"}, {Command: todo.Pick, Commit: "abcd"},
}, },
todoToMoveUp: Todo{Hash: "5678", Action: todo.Pick}, todoToMoveUp: Todo{Hash: "5678"},
expectedErr: "", expectedErr: "",
expectedTodos: []todo.Todo{ expectedTodos: []todo.Todo{
{Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "1234"},
@ -166,7 +167,7 @@ func TestRebaseCommands_moveTodoUp(t *testing.T) {
{Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "5678"},
{Command: todo.Pick, Commit: "abcd"}, {Command: todo.Pick, Commit: "abcd"},
}, },
todoToMoveUp: Todo{Hash: "1234", Action: todo.Pick}, todoToMoveUp: Todo{Hash: "1234"},
expectedErr: "", expectedErr: "",
expectedTodos: []todo.Todo{ expectedTodos: []todo.Todo{
{Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "5678"},
@ -181,7 +182,7 @@ func TestRebaseCommands_moveTodoUp(t *testing.T) {
{Command: todo.UpdateRef, Ref: "refs/heads/some_branch"}, {Command: todo.UpdateRef, Ref: "refs/heads/some_branch"},
{Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "5678"},
}, },
todoToMoveUp: Todo{Ref: "refs/heads/some_branch", Action: todo.UpdateRef}, todoToMoveUp: Todo{Ref: "refs/heads/some_branch"},
expectedErr: "", expectedErr: "",
expectedTodos: []todo.Todo{ expectedTodos: []todo.Todo{
{Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "1234"},
@ -198,7 +199,7 @@ func TestRebaseCommands_moveTodoUp(t *testing.T) {
{Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "5678"},
{Command: todo.Pick, Commit: "def0"}, {Command: todo.Pick, Commit: "def0"},
}, },
todoToMoveUp: Todo{Hash: "abcd", Action: todo.Pick}, todoToMoveUp: Todo{Hash: "abcd"},
expectedErr: "", expectedErr: "",
expectedTodos: []todo.Todo{ expectedTodos: []todo.Todo{
{Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "1234"},
@ -217,7 +218,7 @@ func TestRebaseCommands_moveTodoUp(t *testing.T) {
{Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "5678"},
{Command: todo.Pick, Commit: "abcd"}, {Command: todo.Pick, Commit: "abcd"},
}, },
todoToMoveUp: Todo{Hash: "def0", Action: todo.Pick}, todoToMoveUp: Todo{Hash: "def0"},
expectedErr: "Todo def0 not found in git-rebase-todo", expectedErr: "Todo def0 not found in git-rebase-todo",
expectedTodos: []todo.Todo{}, expectedTodos: []todo.Todo{},
}, },
@ -228,7 +229,7 @@ func TestRebaseCommands_moveTodoUp(t *testing.T) {
{Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "5678"},
{Command: todo.Pick, Commit: "abcd"}, {Command: todo.Pick, Commit: "abcd"},
}, },
todoToMoveUp: Todo{Hash: "abcd", Action: todo.Pick}, todoToMoveUp: Todo{Hash: "abcd"},
expectedErr: "Destination position for moving todo is out of range", expectedErr: "Destination position for moving todo is out of range",
expectedTodos: []todo.Todo{}, expectedTodos: []todo.Todo{},
}, },
@ -240,7 +241,7 @@ func TestRebaseCommands_moveTodoUp(t *testing.T) {
{Command: todo.Label, Label: "myLabel"}, {Command: todo.Label, Label: "myLabel"},
{Command: todo.Reset, Label: "otherlabel"}, {Command: todo.Reset, Label: "otherlabel"},
}, },
todoToMoveUp: Todo{Hash: "5678", Action: todo.Pick}, todoToMoveUp: Todo{Hash: "5678"},
expectedErr: "Destination position for moving todo is out of range", expectedErr: "Destination position for moving todo is out of range",
expectedTodos: []todo.Todo{}, expectedTodos: []todo.Todo{},
}, },
@ -416,8 +417,8 @@ func TestRebaseCommands_deleteTodos(t *testing.T) {
{Command: todo.Pick, Commit: "abcd"}, {Command: todo.Pick, Commit: "abcd"},
}, },
todosToDelete: []Todo{ todosToDelete: []Todo{
{Ref: "refs/heads/some_branch", Action: todo.UpdateRef}, {Ref: "refs/heads/some_branch"},
{Hash: "abcd", Action: todo.Pick}, {Hash: "abcd"},
}, },
expectedTodos: []todo.Todo{ expectedTodos: []todo.Todo{
{Command: todo.Pick, Commit: "1234"}, {Command: todo.Pick, Commit: "1234"},
@ -432,7 +433,7 @@ func TestRebaseCommands_deleteTodos(t *testing.T) {
{Command: todo.Pick, Commit: "5678"}, {Command: todo.Pick, Commit: "5678"},
}, },
todosToDelete: []Todo{ todosToDelete: []Todo{
{Hash: "abcd", Action: todo.Pick}, {Hash: "abcd"},
}, },
expectedTodos: []todo.Todo{}, expectedTodos: []todo.Todo{},
expectedErr: errors.New("Todo abcd not found in git-rebase-todo"), expectedErr: errors.New("Todo abcd not found in git-rebase-todo"),
@ -453,3 +454,26 @@ func TestRebaseCommands_deleteTodos(t *testing.T) {
}) })
} }
} }
func Test_equalHash(t *testing.T) {
scenarios := []struct {
a string
b string
expected bool
}{
{"", "", true},
{"", "123", false},
{"123", "", false},
{"123", "123", true},
{"123", "123abc", true},
{"123abc", "123", true},
{"123", "a", false},
{"1", "abc", false},
}
for _, scenario := range scenarios {
t.Run(fmt.Sprintf("'%s' vs. '%s'", scenario.a, scenario.b), func(t *testing.T) {
assert.Equal(t, scenario.expected, equalHash(scenario.a, scenario.b))
})
}
}