Skip to content

Commit 7512138

Browse files
authored
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
2 parents 13e9e1d + 078445d commit 7512138

File tree

8 files changed

+194
-45
lines changed

8 files changed

+194
-45
lines changed

pkg/app/daemon/daemon.go

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import (
1212
"github.com/jesseduffield/lazygit/pkg/common"
1313
"github.com/jesseduffield/lazygit/pkg/utils"
1414
"github.com/samber/lo"
15-
"github.com/stefanhaller/git-todo-parser/todo"
1615
)
1716

1817
// Sometimes lazygit will be invoked in daemon mode from a parent lazygit process.
@@ -39,6 +38,7 @@ const (
3938
DaemonKindMoveTodosDown
4039
DaemonKindInsertBreak
4140
DaemonKindChangeTodoActions
41+
DaemonKindDropMergeCommit
4242
DaemonKindMoveFixupCommitDown
4343
DaemonKindWriteRebaseTodo
4444
)
@@ -58,6 +58,7 @@ func getInstruction() Instruction {
5858
DaemonKindRemoveUpdateRefsForCopiedBranch: deserializeInstruction[*RemoveUpdateRefsForCopiedBranchInstruction],
5959
DaemonKindCherryPick: deserializeInstruction[*CherryPickCommitsInstruction],
6060
DaemonKindChangeTodoActions: deserializeInstruction[*ChangeTodoActionsInstruction],
61+
DaemonKindDropMergeCommit: deserializeInstruction[*DropMergeCommitInstruction],
6162
DaemonKindMoveFixupCommitDown: deserializeInstruction[*MoveFixupCommitDownInstruction],
6263
DaemonKindMoveTodosUp: deserializeInstruction[*MoveTodosUpInstruction],
6364
DaemonKindMoveTodosDown: deserializeInstruction[*MoveTodosDownInstruction],
@@ -235,7 +236,6 @@ func (self *ChangeTodoActionsInstruction) run(common *common.Common) error {
235236
changes := lo.Map(self.Changes, func(c ChangeTodoAction, _ int) utils.TodoChange {
236237
return utils.TodoChange{
237238
Hash: c.Hash,
238-
OldAction: todo.Pick,
239239
NewAction: c.NewAction,
240240
}
241241
})
@@ -244,6 +244,30 @@ func (self *ChangeTodoActionsInstruction) run(common *common.Common) error {
244244
})
245245
}
246246

247+
type DropMergeCommitInstruction struct {
248+
Hash string
249+
}
250+
251+
func NewDropMergeCommitInstruction(hash string) Instruction {
252+
return &DropMergeCommitInstruction{
253+
Hash: hash,
254+
}
255+
}
256+
257+
func (self *DropMergeCommitInstruction) Kind() DaemonKind {
258+
return DaemonKindDropMergeCommit
259+
}
260+
261+
func (self *DropMergeCommitInstruction) SerializedInstructions() string {
262+
return serializeInstruction(self)
263+
}
264+
265+
func (self *DropMergeCommitInstruction) run(common *common.Common) error {
266+
return handleInteractiveRebase(common, func(path string) error {
267+
return utils.DropMergeCommit(path, self.Hash, getCommentChar())
268+
})
269+
}
270+
247271
// Takes the hash of some commit, and the hash of a fixup commit that was created
248272
// at the end of the branch, then moves the fixup commit down to right after the
249273
// original commit, changing its type to "fixup" (only if ChangeToFixup is true)
@@ -296,8 +320,7 @@ func (self *MoveTodosUpInstruction) SerializedInstructions() string {
296320
func (self *MoveTodosUpInstruction) run(common *common.Common) error {
297321
todosToMove := lo.Map(self.Hashes, func(hash string, _ int) utils.Todo {
298322
return utils.Todo{
299-
Hash: hash,
300-
Action: todo.Pick,
323+
Hash: hash,
301324
}
302325
})
303326

@@ -327,8 +350,7 @@ func (self *MoveTodosDownInstruction) SerializedInstructions() string {
327350
func (self *MoveTodosDownInstruction) run(common *common.Common) error {
328351
todosToMove := lo.Map(self.Hashes, func(hash string, _ int) utils.Todo {
329352
return utils.Todo{
330-
Hash: hash,
331-
Action: todo.Pick,
353+
Hash: hash,
332354
}
333355
})
334356

pkg/commands/git_commands/rebase.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -324,9 +324,9 @@ func (self *RebaseCommands) MoveFixupCommitDown(commits []*models.Commit, target
324324

325325
func todoFromCommit(commit *models.Commit) utils.Todo {
326326
if commit.Action == todo.UpdateRef {
327-
return utils.Todo{Ref: commit.Name, Action: commit.Action}
327+
return utils.Todo{Ref: commit.Name}
328328
} else {
329-
return utils.Todo{Hash: commit.Hash, Action: commit.Action}
329+
return utils.Todo{Hash: commit.Hash}
330330
}
331331
}
332332

@@ -335,7 +335,6 @@ func (self *RebaseCommands) EditRebaseTodo(commits []*models.Commit, action todo
335335
commitsWithAction := lo.Map(commits, func(commit *models.Commit, _ int) utils.TodoChange {
336336
return utils.TodoChange{
337337
Hash: commit.Hash,
338-
OldAction: commit.Action,
339338
NewAction: action,
340339
}
341340
})
@@ -565,6 +564,13 @@ func (self *RebaseCommands) CherryPickCommitsDuringRebase(commits []*models.Comm
565564
return utils.PrependStrToTodoFile(filePath, []byte(todo))
566565
}
567566

567+
func (self *RebaseCommands) DropMergeCommit(commits []*models.Commit, commitIndex int) error {
568+
return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{
569+
baseHashOrRoot: getBaseHashOrRoot(commits, commitIndex+1),
570+
instruction: daemon.NewDropMergeCommitInstruction(commits[commitIndex].Hash),
571+
}).Run()
572+
}
573+
568574
// we can't start an interactive rebase from the first commit without passing the
569575
// '--root' arg
570576
func getBaseHashOrRoot(commits []*models.Commit, index int) string {

pkg/gui/controllers/local_commits_controller.go

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -497,12 +497,17 @@ func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, start
497497
return self.updateTodos(todo.Drop, selectedCommits)
498498
}
499499

500+
isMerge := selectedCommits[0].IsMerge()
501+
500502
self.c.Confirm(types.ConfirmOpts{
501503
Title: self.c.Tr.DropCommitTitle,
502-
Prompt: self.c.Tr.DropCommitPrompt,
504+
Prompt: lo.Ternary(isMerge, self.c.Tr.DropMergeCommitPrompt, self.c.Tr.DropCommitPrompt),
503505
HandleConfirm: func() error {
504506
return self.c.WithWaitingStatus(self.c.Tr.DroppingStatus, func(gocui.Task) error {
505507
self.c.LogAction(self.c.Tr.Actions.DropCommit)
508+
if isMerge {
509+
return self.dropMergeCommit(startIdx)
510+
}
506511
return self.interactiveRebase(todo.Drop, startIdx, endIdx)
507512
})
508513
},
@@ -511,6 +516,11 @@ func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, start
511516
return nil
512517
}
513518

519+
func (self *LocalCommitsController) dropMergeCommit(commitIdx int) error {
520+
err := self.c.Git().Rebase.DropMergeCommit(self.c.Model().Commits, commitIdx)
521+
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
522+
}
523+
514524
func (self *LocalCommitsController) edit(selectedCommits []*models.Commit, startIdx int, endIdx int) error {
515525
if self.isRebasing() {
516526
return self.updateTodos(todo.Edit, selectedCommits)
@@ -1358,11 +1368,15 @@ func (self *LocalCommitsController) canFindCommitForSquashFixupsInCurrentBranch(
13581368
return nil
13591369
}
13601370

1361-
func (self *LocalCommitsController) canSquashOrFixup(_selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason {
1371+
func (self *LocalCommitsController) canSquashOrFixup(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason {
13621372
if endIdx >= len(self.c.Model().Commits)-1 {
13631373
return &types.DisabledReason{Text: self.c.Tr.CannotSquashOrFixupFirstCommit}
13641374
}
13651375

1376+
if lo.SomeBy(selectedCommits, func(c *models.Commit) bool { return c.IsMerge() }) {
1377+
return &types.DisabledReason{Text: self.c.Tr.CannotSquashOrFixupMergeCommit}
1378+
}
1379+
13661380
return nil
13671381
}
13681382

@@ -1420,6 +1434,10 @@ func (self *LocalCommitsController) midRebaseCommandEnabled(selectedCommits []*m
14201434
// Ensures that if we are mid-rebase, we're only selecting commits that can be moved
14211435
func (self *LocalCommitsController) midRebaseMoveCommandEnabled(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason {
14221436
if !self.isRebasing() {
1437+
if lo.SomeBy(selectedCommits, func(c *models.Commit) bool { return c.IsMerge() }) {
1438+
return &types.DisabledReason{Text: self.c.Tr.CannotMoveMergeCommit}
1439+
}
1440+
14231441
return nil
14241442
}
14251443

@@ -1440,6 +1458,10 @@ func (self *LocalCommitsController) midRebaseMoveCommandEnabled(selectedCommits
14401458

14411459
func (self *LocalCommitsController) canDropCommits(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason {
14421460
if !self.isRebasing() {
1461+
if len(selectedCommits) > 1 && lo.SomeBy(selectedCommits, func(c *models.Commit) bool { return c.IsMerge() }) {
1462+
return &types.DisabledReason{Text: self.c.Tr.DroppingMergeRequiresSingleSelection}
1463+
}
1464+
14431465
return nil
14441466
}
14451467

pkg/i18n/english.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ type TranslationSet struct {
140140
Quit string
141141
SquashTooltip string
142142
CannotSquashOrFixupFirstCommit string
143+
CannotSquashOrFixupMergeCommit string
143144
Fixup string
144145
FixupTooltip string
145146
SureFixupThisCommit string
@@ -160,6 +161,7 @@ type TranslationSet struct {
160161
MoveDownCommit string
161162
MoveUpCommit string
162163
CannotMoveAnyFurther string
164+
CannotMoveMergeCommit string
163165
EditCommit string
164166
EditCommitTooltip string
165167
AmendCommitTooltip string
@@ -320,6 +322,7 @@ type TranslationSet struct {
320322
YouDied string
321323
RewordNotSupported string
322324
ChangingThisActionIsNotAllowed string
325+
DroppingMergeRequiresSingleSelection string
323326
CherryPickCopy string
324327
CherryPickCopyTooltip string
325328
CherryPickCopyRangeTooltip string
@@ -347,6 +350,7 @@ type TranslationSet struct {
347350
DropCommitTitle string
348351
DropCommitPrompt string
349352
DropUpdateRefPrompt string
353+
DropMergeCommitPrompt string
350354
PullingStatus string
351355
PushingStatus string
352356
FetchingStatus string
@@ -1134,6 +1138,7 @@ func EnglishTranslationSet() *TranslationSet {
11341138
UpdateRefHere: "Update branch '{{.ref}}' here",
11351139
ExecCommandHere: "Execute the following command here:",
11361140
CannotSquashOrFixupFirstCommit: "There's no commit below to squash into",
1141+
CannotSquashOrFixupMergeCommit: "Cannot squash or fixup a merge commit",
11371142
Fixup: "Fixup",
11381143
SureFixupThisCommit: "Are you sure you want to 'fixup' the selected commit(s) into the commit below?",
11391144
SureSquashThisCommit: "Are you sure you want to squash the selected commit(s) into the commit below?",
@@ -1153,6 +1158,7 @@ func EnglishTranslationSet() *TranslationSet {
11531158
MoveDownCommit: "Move commit down one",
11541159
MoveUpCommit: "Move commit up one",
11551160
CannotMoveAnyFurther: "Cannot move any further",
1161+
CannotMoveMergeCommit: "Cannot move a merge commit",
11561162
EditCommit: "Edit (start interactive rebase)",
11571163
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.",
11581164
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 {
13201326
YouDied: "YOU DIED!",
13211327
RewordNotSupported: "Rewording commits while interactively rebasing is not currently supported",
13221328
ChangingThisActionIsNotAllowed: "Changing this kind of rebase todo entry is not allowed",
1329+
DroppingMergeRequiresSingleSelection: "Dropping a merge commit requires a single selected item",
13231330
CherryPickCopy: "Copy (cherry-pick)",
13241331
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.",
13251332
CherryPickCopyRangeTooltip: "Mark commits as copied from the last copied commit to the selected commit.",
@@ -1346,6 +1353,7 @@ func EnglishTranslationSet() *TranslationSet {
13461353
AmendCommitPrompt: "Are you sure you want to amend this commit with your staged files?",
13471354
DropCommitTitle: "Drop commit",
13481355
DropCommitPrompt: "Are you sure you want to drop the selected commit(s)?",
1356+
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.",
13491357
DropUpdateRefPrompt: "Are you sure you want to delete the selected update-ref todo(s)? This is irreversible except by aborting the rebase.",
13501358
PullingStatus: "Pulling",
13511359
PushingStatus: "Pushing",
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package interactive_rebase
2+
3+
import (
4+
"github.com/jesseduffield/lazygit/pkg/config"
5+
. "github.com/jesseduffield/lazygit/pkg/integration/components"
6+
"github.com/jesseduffield/lazygit/pkg/integration/tests/shared"
7+
)
8+
9+
var DropMergeCommit = NewIntegrationTest(NewIntegrationTestArgs{
10+
Description: "Drops a merge commit outside of an interactive rebase",
11+
ExtraCmdArgs: []string{},
12+
Skip: false,
13+
GitVersion: AtLeast("2.22.0"), // first version that supports the --rebase-merges option
14+
SetupConfig: func(config *config.AppConfig) {},
15+
SetupRepo: func(shell *Shell) {
16+
shared.CreateMergeCommit(shell)
17+
},
18+
Run: func(t *TestDriver, keys config.KeybindingConfig) {
19+
t.Views().Commits().
20+
Focus().
21+
Lines(
22+
Contains("CI ⏣─╮ Merge branch 'second-change-branch' into first-change-branch").IsSelected(),
23+
Contains("CI │ ◯ * second-change-branch unrelated change"),
24+
Contains("CI │ ◯ second change"),
25+
Contains("CI ◯ │ first change"),
26+
Contains("CI ◯─╯ * original"),
27+
Contains("CI ◯ three"),
28+
Contains("CI ◯ two"),
29+
Contains("CI ◯ one"),
30+
).
31+
Press(keys.Universal.Remove).
32+
Tap(func() {
33+
t.ExpectPopup().Confirmation().
34+
Title(Equals("Drop commit")).
35+
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.")).
36+
Confirm()
37+
}).
38+
Lines(
39+
Contains("CI ◯ first change").IsSelected(),
40+
Contains("CI ◯ * original"),
41+
Contains("CI ◯ three"),
42+
Contains("CI ◯ two"),
43+
Contains("CI ◯ one"),
44+
)
45+
},
46+
})

pkg/integration/tests/test_list.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ var tests = []*components.IntegrationTest{
208208
interactive_rebase.DeleteUpdateRefTodo,
209209
interactive_rebase.DontShowBranchHeadsForTodoItems,
210210
interactive_rebase.DropCommitInCopiedBranchWithUpdateRef,
211+
interactive_rebase.DropMergeCommit,
211212
interactive_rebase.DropTodoCommitWithUpdateRef,
212213
interactive_rebase.DropWithCustomCommentChar,
213214
interactive_rebase.EditAndAutoAmend,

pkg/utils/rebase_todo.go

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,18 @@ import (
66
"fmt"
77
"os"
88
"slices"
9-
"strings"
109

1110
"github.com/samber/lo"
1211
"github.com/stefanhaller/git-todo-parser/todo"
1312
)
1413

1514
type Todo struct {
16-
Hash string // for todos that have one, e.g. pick, drop, fixup, etc.
17-
Ref string // for update-ref todos
18-
Action todo.TodoCommand
15+
Hash string // for todos that have one, e.g. pick, drop, fixup, etc.
16+
Ref string // for update-ref todos
1917
}
2018

21-
// In order to change a TODO in git-rebase-todo, we need to specify the old action,
22-
// because sometimes the same hash appears multiple times in the file (e.g. in a pick
23-
// and later in a merge)
2419
type TodoChange struct {
2520
Hash string
26-
OldAction todo.TodoCommand
2721
NewAction todo.TodoCommand
2822
}
2923

@@ -40,7 +34,7 @@ func EditRebaseTodo(filePath string, changes []TodoChange, commentChar byte) err
4034
t := &todos[i]
4135
// This is a nested loop, but it's ok because the number of todos should be small
4236
for _, change := range changes {
43-
if t.Command == change.OldAction && equalHash(t.Commit, change.Hash) {
37+
if equalHash(t.Commit, change.Hash) {
4438
matchCount++
4539
t.Command = change.NewAction
4640
}
@@ -56,18 +50,18 @@ func EditRebaseTodo(filePath string, changes []TodoChange, commentChar byte) err
5650
}
5751

5852
func equalHash(a, b string) bool {
59-
return strings.HasPrefix(a, b) || strings.HasPrefix(b, a)
53+
if len(a) == 0 && len(b) == 0 {
54+
return true
55+
}
56+
57+
commonLength := min(len(a), len(b))
58+
return commonLength > 0 && a[:commonLength] == b[:commonLength]
6059
}
6160

6261
func findTodo(todos []todo.Todo, todoToFind Todo) (int, bool) {
6362
_, idx, ok := lo.FindIndexOf(todos, func(t todo.Todo) bool {
64-
// Comparing just the hash is not enough; we need to compare both the
65-
// action and the hash, as the hash could appear multiple times (e.g. in a
66-
// pick and later in a merge). For update-ref todos we also must compare
67-
// the Ref.
68-
return t.Command == todoToFind.Action &&
69-
equalHash(t.Commit, todoToFind.Hash) &&
70-
t.Ref == todoToFind.Ref
63+
// For update-ref todos we also must compare the Ref (they have an empty hash)
64+
return equalHash(t.Commit, todoToFind.Hash) && t.Ref == todoToFind.Ref
7165
})
7266
return idx, ok
7367
}
@@ -296,3 +290,29 @@ func RemoveUpdateRefsForCopiedBranch(fileName string, commentChar byte) error {
296290
func isRenderedTodo(t todo.Todo) bool {
297291
return t.Commit != "" || t.Command == todo.UpdateRef
298292
}
293+
294+
func DropMergeCommit(fileName string, hash string, commentChar byte) error {
295+
todos, err := ReadRebaseTodoFile(fileName, commentChar)
296+
if err != nil {
297+
return err
298+
}
299+
300+
newTodos, err := dropMergeCommit(todos, hash)
301+
if err != nil {
302+
return err
303+
}
304+
305+
return WriteRebaseTodoFile(fileName, newTodos, commentChar)
306+
}
307+
308+
func dropMergeCommit(todos []todo.Todo, hash string) ([]todo.Todo, error) {
309+
isMerge := func(t todo.Todo) bool {
310+
return t.Command == todo.Merge && t.Flag == "-C" && equalHash(t.Commit, hash)
311+
}
312+
if lo.CountBy(todos, isMerge) != 1 {
313+
return nil, fmt.Errorf("Expected exactly one merge commit with hash %s", hash)
314+
}
315+
316+
_, idx, _ := lo.FindIndexOf(todos, isMerge)
317+
return slices.Delete(todos, idx, idx+1), nil
318+
}

0 commit comments

Comments
 (0)