1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2025-05-27 23:08:02 +02:00

Add custom patch command "Move patch into new commit before the original commit" (#4552)

- **PR Description**

This is often useful to extract preparatory refactoring commits from a
bigger one. It works best when selecting only entire hunks or even
entire files; if partial hunks are in the patch, you are likely to get
conflicts.

Along the way, fix rewording merge commits. (Not because I find this
super important, but just because I came across the code while working
on this.)

- **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 2025-05-11 13:55:19 +02:00 committed by GitHub
commit a27db87fb6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 438 additions and 9 deletions

View File

@ -314,6 +314,29 @@ func (self *PatchCommands) PullPatchIntoNewCommit(
return self.rebase.ContinueRebase()
}
func (self *PatchCommands) PullPatchIntoNewCommitBefore(
commits []*models.Commit,
commitIdx int,
commitSummary string,
commitDescription string,
) error {
if err := self.rebase.BeginInteractiveRebaseForCommit(commits, commitIdx+1, true); err != nil {
return err
}
if err := self.ApplyCustomPatch(false, false); err != nil {
_ = self.rebase.AbortRebase()
return err
}
if err := self.commit.CommitCmdObj(commitSummary, commitDescription, false).Run(); err != nil {
return err
}
self.PatchBuilder.Reset()
return self.rebase.ContinueRebase()
}
// We have just applied a patch in reverse to discard it from a commit; if we
// now try to apply the patch again to move it to a later commit, or to the
// index, then this would conflict "with itself" in case the patch contained

View File

@ -400,7 +400,19 @@ func (self *RebaseCommands) SquashAllAboveFixupCommits(commit *models.Commit) er
func (self *RebaseCommands) BeginInteractiveRebaseForCommit(
commits []*models.Commit, commitIndex int, keepCommitsThatBecomeEmpty bool,
) error {
return self.BeginInteractiveRebaseForCommitRange(commits, commitIndex, commitIndex, keepCommitsThatBecomeEmpty)
if commitIndex < len(commits) && commits[commitIndex].IsMerge() {
if self.config.NeedsGpgSubprocessForCommit() {
return errors.New(self.Tr.DisabledForGPG)
}
return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{
baseHashOrRoot: getBaseHashOrRoot(commits, commitIndex),
instruction: daemon.NewInsertBreakInstruction(),
keepCommitsThatBecomeEmpty: keepCommitsThatBecomeEmpty,
}).Run()
} else {
return self.BeginInteractiveRebaseForCommitRange(commits, commitIndex, commitIndex, keepCommitsThatBecomeEmpty)
}
}
func (self *RebaseCommands) BeginInteractiveRebaseForCommitRange(

View File

@ -7,6 +7,7 @@ import (
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
)
type CustomPatchOptionsMenuAction struct {
@ -46,7 +47,7 @@ func (self *CustomPatchOptionsMenuAction) Call() error {
if self.c.Git().Patch.PatchBuilder.CanRebase && self.c.Git().Status.WorkingTreeState().None() {
menuItems = append(menuItems, []*types.MenuItem{
{
Label: fmt.Sprintf(self.c.Tr.RemovePatchFromOriginalCommit, self.c.Git().Patch.PatchBuilder.To),
Label: fmt.Sprintf(self.c.Tr.RemovePatchFromOriginalCommit, utils.ShortHash(self.c.Git().Patch.PatchBuilder.To)),
Tooltip: self.c.Tr.RemovePatchFromOriginalCommitTooltip,
OnPress: self.handleDeletePatchFromCommit,
Key: 'd',
@ -63,6 +64,12 @@ func (self *CustomPatchOptionsMenuAction) Call() error {
OnPress: self.handlePullPatchIntoNewCommit,
Key: 'n',
},
{
Label: self.c.Tr.MovePatchIntoNewCommitBefore,
Tooltip: self.c.Tr.MovePatchIntoNewCommitBeforeTooltip,
OnPress: self.handlePullPatchIntoNewCommitBefore,
Key: 'N',
},
}...)
if self.c.Context().Current().GetKey() == self.c.Contexts().LocalCommits.GetKey() {
@ -222,6 +229,41 @@ func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommit() error {
return nil
}
func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommitBefore() error {
if ok, err := self.validateNormalWorkingTreeState(); !ok {
return err
}
self.returnFocusFromPatchExplorerIfNecessary()
commitIndex := self.getPatchCommitIndex()
self.c.Helpers().Commits.OpenCommitMessagePanel(
&helpers.OpenCommitMessagePanelOpts{
// Pass a commit index of one less than the moved-from commit, so that
// you can press up arrow once to recall the original commit message:
CommitIndex: commitIndex - 1,
InitialMessage: "",
SummaryTitle: self.c.Tr.CommitSummaryTitle,
DescriptionTitle: self.c.Tr.CommitDescriptionTitle,
PreserveMessage: false,
OnConfirm: func(summary string, description string) error {
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
self.c.Helpers().Commits.CloseCommitMessagePanel()
self.c.LogAction(self.c.Tr.Actions.MovePatchIntoNewCommit)
err := self.c.Git().Patch.PullPatchIntoNewCommitBefore(self.c.Model().Commits, commitIndex, summary, description)
if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil {
return err
}
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
return nil
})
},
},
)
return nil
}
func (self *CustomPatchOptionsMenuAction) handleApplyPatch(reverse bool) error {
self.returnFocusFromPatchExplorerIfNecessary()

View File

@ -813,6 +813,8 @@ type TranslationSet struct {
MovePatchOutIntoIndexTooltip string
MovePatchIntoNewCommit string
MovePatchIntoNewCommitTooltip string
MovePatchIntoNewCommitBefore string
MovePatchIntoNewCommitBeforeTooltip string
MovePatchToSelectedCommit string
MovePatchToSelectedCommitTooltip string
CopyPatchToClipboard string
@ -1896,8 +1898,10 @@ func EnglishTranslationSet() *TranslationSet {
RemovePatchFromOriginalCommitTooltip: "Remove the current patch from its commit. This is achieved by starting an interactive rebase at the commit, applying the patch in reverse, and then continuing the rebase. If later commits depend on the patch, you may need to resolve conflicts.",
MovePatchOutIntoIndex: "Move patch out into index",
MovePatchOutIntoIndexTooltip: "Move the patch out of its commit and into the index. This is achieved by starting an interactive rebase at the commit, applying the patch in reverse, continuing the rebase to completion, and then applying the patch to the index. If later commits depend on the patch, you may need to resolve conflicts.",
MovePatchIntoNewCommit: "Move patch into new commit",
MovePatchIntoNewCommit: "Move patch into new commit after the original commit",
MovePatchIntoNewCommitTooltip: "Move the patch out of its commit and into a new commit sitting on top of the original commit. This is achieved by starting an interactive rebase at the original commit, applying the patch in reverse, then applying the patch to the index and committing it as a new commit, before continuing the rebase to completion. If later commits depend on the patch, you may need to resolve conflicts.",
MovePatchIntoNewCommitBefore: "Move patch into new commit before the original commit",
MovePatchIntoNewCommitBeforeTooltip: "Move the patch out of its commit and into a new commit before the original commit. This works best when the custom patch contains only entire hunks or even entire files; if it contains partial hunks, you are likely to get conflicts.",
MovePatchToSelectedCommit: "Move patch to selected commit (%s)",
MovePatchToSelectedCommitTooltip: "Move the patch out of its original commit and into the selected commit. This is achieved by starting an interactive rebase at the original commit, applying the patch in reverse, then continuing the rebase up to the selected commit, before applying the patch forward and amending the selected commit. The rebase is then continued to completion. If commits between the source and destination commit depend on the patch, you may need to resolve conflicts.",
CopyPatchToClipboard: "Copy patch to clipboard",

View File

@ -15,8 +15,6 @@ var CherryPickMerge = NewIntegrationTest(NewIntegrationTestArgs{
EmptyCommit("base").
NewBranch("first-branch").
NewBranch("second-branch").
Checkout("first-branch").
Checkout("second-branch").
CreateFileAndAdd("file1.txt", "content").
Commit("one").
CreateFileAndAdd("file2.txt", "content").

View File

@ -0,0 +1,55 @@
package interactive_rebase
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RewordLastCommitOfStackedBranch = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Rewords the last commit of a branch in the middle of a stack",
ExtraCmdArgs: []string{},
Skip: false,
GitVersion: AtLeast("2.38.0"),
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Git.MainBranches = []string{"master"}
config.GetAppState().GitLogShowGraph = "never"
},
SetupRepo: func(shell *Shell) {
shell.
CreateNCommits(1).
NewBranch("branch1").
CreateNCommitsStartingAt(2, 2).
NewBranch("branch2").
CreateNCommitsStartingAt(2, 4)
shell.SetConfig("rebase.updateRefs", "true")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("CI commit 05").IsSelected(),
Contains("CI commit 04"),
Contains("CI * commit 03"),
Contains("CI commit 02"),
Contains("CI commit 01"),
).
NavigateToLine(Contains("commit 03")).
Press(keys.Commits.RenameCommit).
Tap(func() {
t.ExpectPopup().CommitMessagePanel().
Title(Equals("Reword commit")).
InitialText(Equals("commit 03")).
Clear().
Type("renamed 03").
Confirm()
}).
Lines(
Contains("CI commit 05"),
Contains("CI commit 04"),
Contains("CI * renamed 03"),
Contains("CI commit 02"),
Contains("CI commit 01"),
)
},
})

View File

@ -0,0 +1,50 @@
package interactive_rebase
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RewordMergeCommit = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Rewords a merge commit which is not the current head commit",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.
EmptyCommit("base").
NewBranch("first-branch").
CreateFileAndAdd("file1.txt", "content").
Commit("one").
Checkout("master").
Merge("first-branch").
NewBranch("second-branch").
EmptyCommit("two")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("CI ◯ two").IsSelected(),
Contains("CI ⏣─╮ Merge branch 'first-branch'"),
Contains("CI │ ◯ one"),
Contains("CI ◯─╯ base"),
).
SelectNextItem().
Press(keys.Commits.RenameCommit).
Tap(func() {
t.ExpectPopup().CommitMessagePanel().
Title(Equals("Reword commit")).
InitialText(Equals("Merge branch 'first-branch'")).
Clear().
Type("renamed merge").
Confirm()
}).
Lines(
Contains("CI ◯ two"),
Contains("CI ⏣─╮ renamed merge").IsSelected(),
Contains("CI │ ◯ one"),
Contains("CI ◯ ╯ base"),
)
},
})

View File

@ -48,7 +48,7 @@ var MoveToNewCommit = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Information().Content(Contains("Building patch"))
t.Common().SelectPatchOption(Contains("Move patch into new commit"))
t.Common().SelectPatchOption(Contains("Move patch into new commit after the original commit"))
t.ExpectPopup().CommitMessagePanel().
InitialText(Equals("")).

View File

@ -0,0 +1,91 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var MoveToNewCommitBefore = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Move a patch from a commit to a new commit before the original one",
ExtraCmdArgs: []string{},
Skip: false,
GitVersion: AtLeast("2.26.0"),
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateDir("dir")
shell.CreateFileAndAdd("dir/file1", "file1 content")
shell.CreateFileAndAdd("dir/file2", "file2 content")
shell.Commit("first commit")
shell.UpdateFileAndAdd("dir/file1", "file1 content with old changes")
shell.DeleteFileAndAdd("dir/file2")
shell.CreateFileAndAdd("dir/file3", "file3 content")
shell.Commit("commit to move from")
shell.UpdateFileAndAdd("dir/file1", "file1 content with new changes")
shell.Commit("third commit")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("third commit").IsSelected(),
Contains("commit to move from"),
Contains("first commit"),
).
SelectNextItem().
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("dir").IsSelected(),
Contains(" M file1"),
Contains(" D file2"),
Contains(" A file3"),
).
PressPrimaryAction().
PressEscape()
t.Views().Information().Content(Contains("Building patch"))
t.Common().SelectPatchOption(Contains("Move patch into new commit before the original commit"))
t.ExpectPopup().CommitMessagePanel().
InitialText(Equals("")).
Type("new commit").Confirm()
t.Views().Commits().
IsFocused().
Lines(
Contains("third commit"),
Contains("commit to move from").IsSelected(),
Contains("new commit"),
Contains("first commit"),
).
SelectNextItem().
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("dir").IsSelected(),
Contains(" M file1"),
Contains(" D file2"),
Contains(" A file3"),
).
PressEscape()
t.Views().Commits().
IsFocused().
SelectPreviousItem().
PressEnter()
// the original commit has no more files in it
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("(none)"),
)
},
})

View File

@ -0,0 +1,77 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var MoveToNewCommitBeforeNoKeepEmpty = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Move a patch from a commit to a new commit before the original one, for older git versions that don't keep the empty commit",
ExtraCmdArgs: []string{},
Skip: false,
GitVersion: Before("2.26.0"),
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateDir("dir")
shell.CreateFileAndAdd("dir/file1", "file1 content")
shell.CreateFileAndAdd("dir/file2", "file2 content")
shell.Commit("first commit")
shell.UpdateFileAndAdd("dir/file1", "file1 content with old changes")
shell.DeleteFileAndAdd("dir/file2")
shell.CreateFileAndAdd("dir/file3", "file3 content")
shell.Commit("commit to move from")
shell.UpdateFileAndAdd("dir/file1", "file1 content with new changes")
shell.Commit("third commit")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("third commit").IsSelected(),
Contains("commit to move from"),
Contains("first commit"),
).
SelectNextItem().
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("dir").IsSelected(),
Contains(" M file1"),
Contains(" D file2"),
Contains(" A file3"),
).
PressPrimaryAction().
PressEscape()
t.Views().Information().Content(Contains("Building patch"))
t.Common().SelectPatchOption(Contains("Move patch into new commit before the original commit"))
t.ExpectPopup().CommitMessagePanel().
InitialText(Equals("")).
Type("new commit").Confirm()
t.Views().Commits().
IsFocused().
Lines(
Contains("third commit"),
Contains("new commit").IsSelected(),
Contains("first commit"),
).
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("dir").IsSelected(),
Contains(" M file1"),
Contains(" D file2"),
Contains(" A file3"),
).
PressEscape()
},
})

View File

@ -39,7 +39,7 @@ var MoveToNewCommitFromAddedFile = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Information().Content(Contains("Building patch"))
t.Common().SelectPatchOption(Contains("Move patch into new commit"))
t.Common().SelectPatchOption(Contains("Move patch into new commit after the original commit"))
t.ExpectPopup().CommitMessagePanel().
InitialText(Equals("")).

View File

@ -39,7 +39,7 @@ var MoveToNewCommitFromDeletedFile = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Information().Content(Contains("Building patch"))
t.Common().SelectPatchOption(Contains("Move patch into new commit"))
t.Common().SelectPatchOption(Contains("Move patch into new commit after the original commit"))
t.ExpectPopup().CommitMessagePanel().
InitialText(Equals("")).

View File

@ -0,0 +1,72 @@
package patch_building
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var MoveToNewCommitInLastCommitOfStackedBranch = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Move a patch from a commit to a new commit, in the last commit of a branch in the middle of a stack",
ExtraCmdArgs: []string{},
Skip: false,
GitVersion: AtLeast("2.38.0"),
SetupConfig: func(config *config.AppConfig) {
config.GetAppState().GitLogShowGraph = "never"
},
SetupRepo: func(shell *Shell) {
shell.
EmptyCommit("commit 01").
NewBranch("branch1").
EmptyCommit("commit 02").
CreateFileAndAdd("file1", "file1 content").
CreateFileAndAdd("file2", "file2 content").
Commit("commit 03").
NewBranch("branch2").
CreateNCommitsStartingAt(2, 4)
shell.SetConfig("rebase.updateRefs", "true")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("CI commit 05").IsSelected(),
Contains("CI commit 04"),
Contains("CI * commit 03"),
Contains("CI commit 02"),
Contains("CI commit 01"),
).
NavigateToLine(Contains("commit 03")).
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Equals("▼ /").IsSelected(),
Equals(" A file1"),
Equals(" A file2"),
).
SelectNextItem().
PressPrimaryAction().
PressEscape()
t.Views().Information().Content(Contains("Building patch"))
t.Common().SelectPatchOption(Contains("Move patch into new commit after the original commit"))
t.ExpectPopup().CommitMessagePanel().
InitialText(Equals("")).
Type("new commit").Confirm()
t.Views().Commits().
IsFocused().
Lines(
Contains("CI commit 05"),
Contains("CI commit 04"),
Contains("CI * new commit").IsSelected(),
Contains("CI commit 03"),
Contains("CI commit 02"),
Contains("CI commit 01"),
)
},
})

View File

@ -44,7 +44,7 @@ var MoveToNewCommitPartialHunk = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Information().Content(Contains("Building patch"))
t.Common().SelectPatchOption(Contains("Move patch into new commit"))
t.Common().SelectPatchOption(Contains("Move patch into new commit after the original commit"))
t.ExpectPopup().CommitMessagePanel().
InitialText(Equals("")).

View File

@ -274,6 +274,8 @@ var tests = []*components.IntegrationTest{
interactive_rebase.RewordCommitWithEditorAndFail,
interactive_rebase.RewordFirstCommit,
interactive_rebase.RewordLastCommit,
interactive_rebase.RewordLastCommitOfStackedBranch,
interactive_rebase.RewordMergeCommit,
interactive_rebase.RewordYouAreHereCommit,
interactive_rebase.RewordYouAreHereCommitWithEditor,
interactive_rebase.ShowExecTodos,
@ -308,8 +310,11 @@ var tests = []*components.IntegrationTest{
patch_building.MoveToLaterCommit,
patch_building.MoveToLaterCommitPartialHunk,
patch_building.MoveToNewCommit,
patch_building.MoveToNewCommitBefore,
patch_building.MoveToNewCommitBeforeNoKeepEmpty,
patch_building.MoveToNewCommitFromAddedFile,
patch_building.MoveToNewCommitFromDeletedFile,
patch_building.MoveToNewCommitInLastCommitOfStackedBranch,
patch_building.MoveToNewCommitPartialHunk,
patch_building.RemoveFromCommit,
patch_building.RemovePartsOfAddedFile,