Avoid conflicts and duplicate lines when moving a patch to an earlier commit (#6167)

When moving a custom patch to an earlier commit, or into a new commit
before the original one, lazygit applies the patch to the earlier commit
and then replayed the original commit unchanged. It relied on git
noticing during the replay that the patch's changes are already there.
This doesn't always work:

- If the patch contains only some of several adjacent added lines,
replaying the original commit stops with a conflict.
- If git can place the patch's added lines in two positions because they
repeat the line after them, the replay may put them in the other
position than the patch did. Git then keeps both copies, and the file
ends up containing the patch's lines twice. There's no conflict, so this
goes unnoticed.

Fix this by making the rebase pick a copy of the source commit instead.
The copy has the same tree, author and message, but its parent is the
original parent with the patch applied. Picking it only replays the
source commit's changes minus the patch, so git no longer has to
recognize the patch as already applied.
This commit is contained in:
Stefan Haller
2026-11-06 17:28:57 +01:00
committed by GitHub
16 changed files with 805 additions and 67 deletions
+114
View File
@@ -211,6 +211,11 @@ func (self *PatchCommands) MovePatchToSelectedCommit(commits []*models.Commit, s
return err
}
if err := self.pickCopyWithPatchInParent(commits[sourceCommitIdx]); err != nil {
_ = self.rebase.AbortRebase()
return err
}
// apply each patch forward
if err := self.ApplyCustomPatch(false, false); err != nil {
// Don't abort the rebase here; this might cause conflicts, so give
@@ -414,6 +419,11 @@ func (self *PatchCommands) PullPatchIntoNewCommitBefore(
return err
}
if err := self.pickCopyWithPatchInParent(commits[commitIdx]); err != nil {
_ = self.rebase.AbortRebase()
return err
}
if err := self.ApplyCustomPatch(false, false); err != nil {
_ = self.rebase.AbortRebase()
return err
@@ -427,6 +437,110 @@ func (self *PatchCommands) PullPatchIntoNewCommitBefore(
return self.rebase.ContinueRebase()
}
// When moving the patch to an earlier commit, or into a new commit before the
// one it was built from, we first apply it there, and the rebase then picks
// the commit that the patch was built from. Picking that commit unchanged
// would rely on git noticing that the patch is already applied, and git
// doesn't always notice this. If the patch contains only some of several
// adjacent added lines, the pick conflicts; if git can place the patch's added
// lines in more than one position, it may add them a second time. So we make
// the rebase pick a copy of the commit instead, whose parent already contains
// the patch.
func (self *PatchCommands) pickCopyWithPatchInParent(commit *models.Commit) error {
copyHash, err := self.copyCommitWithPatchInParent(commit)
if err != nil {
return err
}
return self.rebase.ChangeTodoCommit(commit.Hash(), copyHash)
}
// Creates a copy of the given commit, with the same tree, author, and message,
// whose parent is the commit's original parent with the custom patch applied.
// It does this without touching the working tree or the index.
func (self *PatchCommands) copyCommitWithPatchInParent(commit *models.Commit) (string, error) {
indexDir, err := os.MkdirTemp(self.os.GetTempDir(), "index")
if err != nil {
return "", err
}
defer os.RemoveAll(indexDir)
indexFileEnvVar := "GIT_INDEX_FILE=" + filepath.Join(indexDir, "index")
cmd := self.cmd.New(NewGitCmd("read-tree").Arg(commit.Parents()[0]).ToArgv())
err = cmd.AddEnvVars(indexFileEnvVar).DontLog().Run()
if err != nil {
return "", err
}
patchFilePath, err := self.SaveTemporaryPatch(self.PatchBuilder.PatchToApply(false, false))
if err != nil {
return "", err
}
cmd = self.cmd.New(NewGitCmd("apply").Arg("--cached", patchFilePath).ToArgv())
err = cmd.AddEnvVars(indexFileEnvVar).DontLog().Run()
if err != nil {
return "", err
}
cmd = self.cmd.New(NewGitCmd("write-tree").ToArgv())
tree, _, err := cmd.AddEnvVars(indexFileEnvVar).DontLog().RunWithOutputs()
if err != nil {
return "", err
}
cmd = self.cmd.New(NewGitCmd("cat-file").Arg("commit", commit.Hash()).ToArgv())
commitObject, _, err := cmd.DontLog().RunWithOutputs()
if err != nil {
return "", err
}
parentHash, err := self.writeCommitObject(
commitObjectWithHeader(commitObject, "tree", strings.TrimSpace(tree)))
if err != nil {
return "", err
}
return self.writeCommitObject(commitObjectWithHeader(commitObject, "parent", parentHash))
}
func (self *PatchCommands) writeCommitObject(commitObject string) (string, error) {
cmd := self.cmd.New(
NewGitCmd("hash-object").
Arg("-t", "commit", "-w", "--stdin").ToArgv())
hash, _, err := cmd.
SetStdin(commitObject).
DontLog().
RunWithOutputs()
return strings.TrimSpace(hash), err
}
// Returns the given raw commit object with the value of the given header
// replaced. The commit's signature is dropped, since it no longer matches.
func commitObjectWithHeader(commitObject string, name string, value string) string {
headers, message, _ := strings.Cut(commitObject, "\n\n")
newHeaders := []string{}
isInSignature := false
for line := range strings.SplitSeq(headers, "\n") {
// Continuation lines of a multi-line header start with a space
if isInSignature && strings.HasPrefix(line, " ") {
continue
}
isInSignature = strings.HasPrefix(line, "gpgsig ") || strings.HasPrefix(line, "gpgsig-sha256 ")
if isInSignature {
continue
}
if strings.HasPrefix(line, name+" ") {
line = name + " " + value
}
newHeaders = append(newHeaders, line)
}
return strings.Join(newHeaders, "\n") + "\n\n" + message
}
// 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
+76
View File
@@ -0,0 +1,76 @@
package git_commands
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCommitObjectWithHeader(t *testing.T) {
scenarios := []struct {
name string
commitObject string
headerName string
value string
expected string
}{
{
name: "replace parent",
commitObject: "tree 1111\nparent 2222\n" +
"author A U Thor <author@example.com> 1700000000 +0100\n" +
"committer C O Mitter <committer@example.com> 1700000001 +0100\n" +
"\nsubject\n\nbody\n",
headerName: "parent",
value: "3333",
expected: "tree 1111\nparent 3333\n" +
"author A U Thor <author@example.com> 1700000000 +0100\n" +
"committer C O Mitter <committer@example.com> 1700000001 +0100\n" +
"\nsubject\n\nbody\n",
},
{
name: "replace tree, keeping other headers",
commitObject: "tree 1111\nparent 2222\n" +
"author A U Thor <author@example.com> 1700000000 +0100\n" +
"committer C O Mitter <committer@example.com> 1700000001 +0100\n" +
"encoding ISO-8859-1\n" +
"\nsubject\n",
headerName: "tree",
value: "3333",
expected: "tree 3333\nparent 2222\n" +
"author A U Thor <author@example.com> 1700000000 +0100\n" +
"committer C O Mitter <committer@example.com> 1700000001 +0100\n" +
"encoding ISO-8859-1\n" +
"\nsubject\n",
},
{
name: "drop signature",
commitObject: "tree 1111\nparent 2222\n" +
"author A U Thor <author@example.com> 1700000000 +0100\n" +
"committer C O Mitter <committer@example.com> 1700000001 +0100\n" +
"gpgsig -----BEGIN PGP SIGNATURE-----\n \n iQEz\n -----END PGP SIGNATURE-----\n" +
"gpgsig-sha256 -----BEGIN SSH SIGNATURE-----\n U1NI\n -----END SSH SIGNATURE-----\n" +
"some-other-header value\n" +
"\nsubject\n",
headerName: "parent",
value: "3333",
expected: "tree 1111\nparent 3333\n" +
"author A U Thor <author@example.com> 1700000000 +0100\n" +
"committer C O Mitter <committer@example.com> 1700000001 +0100\n" +
"some-other-header value\n" +
"\nsubject\n",
},
{
name: "empty message",
commitObject: "tree 1111\nparent 2222\n\n",
headerName: "parent",
value: "3333",
expected: "tree 1111\nparent 3333\n\n",
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.expected, commitObjectWithHeader(s.commitObject, s.headerName, s.value))
})
}
}
+11
View File
@@ -352,6 +352,17 @@ func (self *RebaseCommands) EditRebaseTodo(commits []*models.Commit, action todo
)
}
// Makes the todo for the given commit in the git-rebase-todo file use a
// different commit instead
func (self *RebaseCommands) ChangeTodoCommit(hash string, newHash string) error {
return utils.ChangeTodoCommit(
filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo"),
hash,
newHash,
self.config.GetCoreCommentChar(),
)
}
func (self *RebaseCommands) DeleteUpdateRefTodos(commits []*models.Commit) error {
todosToDelete := lo.Map(commits, func(commit *models.Commit, _ int) utils.Todo {
return todoFromCommit(commit)
@@ -48,6 +48,11 @@ func (self *CustomPatchOptionsMenuAction) Call() error {
}
if self.c.Git().Patch.PatchBuilder.CanRebase && self.c.Git().Status.WorkingTreeState().None() {
var disabledReasonForMergeCommit *types.DisabledReason
if self.isPatchFromMergeCommit() {
disabledReasonForMergeCommit = &types.DisabledReason{Text: self.c.Tr.NotSupportedForPatchFromMergeCommit}
}
menuItems = append(menuItems, []*types.MenuItem{
{
Label: fmt.Sprintf(self.c.Tr.RemovePatchFromOriginalCommit, utils.ShortHash(self.c.Git().Patch.PatchBuilder.To)),
@@ -68,10 +73,11 @@ func (self *CustomPatchOptionsMenuAction) Call() error {
Keys: menuKey('n'),
},
{
Label: self.c.Tr.MovePatchIntoNewCommitBefore,
Tooltip: self.c.Tr.MovePatchIntoNewCommitBeforeTooltip,
OnPress: self.handlePullPatchIntoNewCommitBefore,
Keys: menuKey('N'),
Label: self.c.Tr.MovePatchIntoNewCommitBefore,
Tooltip: self.c.Tr.MovePatchIntoNewCommitBeforeTooltip,
OnPress: self.handlePullPatchIntoNewCommitBefore,
Keys: menuKey('N'),
DisabledReason: disabledReasonForMergeCommit,
},
}...)
@@ -79,7 +85,10 @@ func (self *CustomPatchOptionsMenuAction) Call() error {
selectedCommit := self.c.Contexts().LocalCommits.GetSelected()
if selectedCommit != nil && self.c.Git().Patch.PatchBuilder.To != selectedCommit.Hash() {
var disabledReason *types.DisabledReason
disabledReason := disabledReasonForMergeCommit
if selectedCommit.IsMerge() {
disabledReason = &types.DisabledReason{Text: self.c.Tr.CannotMovePatchToMergeCommit}
}
if self.c.Contexts().LocalCommits.AreMultipleItemsSelected() {
disabledReason = &types.DisabledReason{Text: self.c.Tr.RangeSelectNotSupported}
}
@@ -123,6 +132,11 @@ func (self *CustomPatchOptionsMenuAction) getPatchCommitIndex() int {
return -1
}
func (self *CustomPatchOptionsMenuAction) isPatchFromMergeCommit() bool {
commitIndex := self.getPatchCommitIndex()
return commitIndex != -1 && self.c.Model().Commits[commitIndex].IsMerge()
}
func (self *CustomPatchOptionsMenuAction) handleDeletePatchFromCommit() error {
commits := self.c.Model().Commits
commitIndex := self.getPatchCommitIndex()
@@ -162,39 +176,14 @@ func (self *CustomPatchOptionsMenuAction) handleMovePatchIntoWorkingTree() error
}
func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommit() error {
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 {
commits := self.c.Model().Commits
self.c.Helpers().Commits.CloseCommitMessagePanel()
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.MovePatchIntoNewCommit)
err := self.c.Git().Patch.PullPatchIntoNewCommit(commits, commitIndex, summary, description)
if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil {
return err
}
self.c.OnUIThread(func() error {
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
return nil
})
return nil
})
},
},
)
return nil
return self.pullPatchIntoNewCommit(false)
}
func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommitBefore() error {
return self.pullPatchIntoNewCommit(true)
}
func (self *CustomPatchOptionsMenuAction) pullPatchIntoNewCommit(beforeOriginalCommit bool) error {
commitIndex := self.getPatchCommitIndex()
self.c.Helpers().Commits.OpenCommitMessagePanel(
&helpers.OpenCommitMessagePanelOpts{
@@ -210,10 +199,21 @@ func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommitBefore() e
self.c.Helpers().Commits.CloseCommitMessagePanel()
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.MovePatchIntoNewCommit)
err := self.c.Git().Patch.PullPatchIntoNewCommitBefore(commits, commitIndex, summary, description)
if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil {
var result error
if beforeOriginalCommit {
result = self.c.Git().Patch.PullPatchIntoNewCommitBefore(commits, commitIndex, summary, description)
} else {
result = self.c.Git().Patch.PullPatchIntoNewCommit(commits, commitIndex, summary, description)
}
if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(result); err != nil {
return err
}
// If the rebase stopped at conflicts, CheckMergeOrRebase has asked the
// user how to handle them; focusing the commits view would close that
// popup.
if result != nil {
return nil
}
self.c.OnUIThread(func() error {
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
return nil
+4
View File
@@ -1013,6 +1013,8 @@ type TranslationSet struct {
RangeSelectUp string
RangeSelectDown string
RangeSelectNotSupported string
NotSupportedForPatchFromMergeCommit string
CannotMovePatchToMergeCommit string
NoItemSelected string
SelectedItemIsNotABranch string
SelectedItemDoesNotHaveFiles string
@@ -2230,6 +2232,8 @@ func EnglishTranslationSet() *TranslationSet {
RangeSelectUp: "Range select up",
RangeSelectDown: "Range select down",
RangeSelectNotSupported: "Action does not support range selection, please select a single item",
NotSupportedForPatchFromMergeCommit: "Not supported for a patch from a merge commit",
CannotMovePatchToMergeCommit: "Cannot move a patch to a merge commit",
NoItemSelected: "No item selected",
SelectedItemIsNotABranch: "Selected item is not a branch",
SelectedItemDoesNotHaveFiles: "Selected item does not have files to view",
@@ -0,0 +1,60 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var DisableMovingPatchFromMergeCommit = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Moving a patch from a merge commit to another commit, or into a new commit before it, is disabled",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.
NewBranch("main-branch").
CreateFileAndAdd("file1", "file1 content\n").
Commit("first commit").
NewBranch("feature").
CreateFileAndAdd("file2", "file2 content\n").
Commit("feature commit").
Checkout("main-branch").
CreateFileAndAdd("file3", "file3 content\n").
Commit("main commit").
Merge("feature")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("Merge branch 'feature' into main-branch").IsSelected(),
Contains("feature commit"),
Contains("main commit"),
Contains("first commit"),
).
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("file2").IsSelected(),
).
PressPrimaryAction()
t.Views().Information().Content(Contains("Building patch"))
t.Views().Commits().
Focus().
NavigateToLine(Contains("first commit"))
t.GlobalPress(keys.Universal.CreatePatchOptionsMenu)
t.ExpectPopup().Menu().
Title(Equals("Patch options")).
Select(Contains("Move patch to selected commit")).
Tooltip(Contains("Disabled: Not supported for a patch from a merge commit")).
Select(Contains("Move patch into new commit before the original commit")).
Tooltip(Contains("Disabled: Not supported for a patch from a merge commit")).
Cancel()
},
})
@@ -0,0 +1,71 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var DisableMovingPatchToMergeCommit = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Moving a patch to an earlier or later merge commit is disabled",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.
NewBranch("main-branch").
CreateFileAndAdd("file1", "file1 content\n").
Commit("first commit").
NewBranch("feature").
CreateFileAndAdd("file2", "file2 content\n").
Commit("feature commit").
Checkout("main-branch").
CreateFileAndAdd("file3", "file3 content\n").
Commit("main commit").
Merge("feature").
CreateFileAndAdd("file4", "file4 content\n").
Commit("top commit")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
checkMovingPatchToMergeCommitIsDisabled := func(commitToMoveFrom string, file string) {
t.Views().Commits().
Focus().
NavigateToLine(Contains(commitToMoveFrom)).
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Contains(file).IsSelected(),
).
PressPrimaryAction()
t.Views().Information().Content(Contains("Building patch"))
t.Views().Commits().
Focus().
NavigateToLine(Contains("Merge branch 'feature' into main-branch"))
t.GlobalPress(keys.Universal.CreatePatchOptionsMenu)
t.ExpectPopup().Menu().
Title(Equals("Patch options")).
Select(Contains("Move patch to selected commit")).
Tooltip(Contains("Disabled: Cannot move a patch to a merge commit")).
Cancel()
t.Common().ResetCustomPatch()
}
t.Views().Commits().
Lines(
Contains("top commit").IsSelected(),
Contains("Merge branch 'feature' into main-branch"),
Contains("feature commit"),
Contains("main commit"),
Contains("first commit"),
)
checkMovingPatchToMergeCommitIsDisabled("top commit", "file4")
checkMovingPatchToMergeCommitIsDisabled("main commit", "file3")
},
})
@@ -0,0 +1,69 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var MoveAmbiguouslyPlacedAddedLinesToEarlierCommit = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Move some added lines from a commit to an earlier commit, where a diff could also place those lines one line further down",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInDiffView = false
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "first line\n}\nlast line\n")
shell.Commit("first commit")
shell.CreateFileAndAdd("unrelated-file", "")
shell.Commit("destination commit")
// A diff can show the added "}" and "added line" as "added line" and "}"
// added one line further down, after the existing "}"
shell.UpdateFileAndAdd("file1", "first line\nextra line\n}\nadded line\n}\nlast line\n")
shell.Commit("commit to move from")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("commit to move from").IsSelected(),
Contains("destination commit"),
Contains("first commit"),
).
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("file1").IsSelected(),
).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectedLines(Contains("+extra line")).
NavigateToLine(Contains("+}")).
Press(keys.Universal.ToggleRangeSelect).
NavigateToLine(Contains("+added line")).
PressPrimaryAction()
t.Views().Information().Content(Contains("Building patch"))
t.Views().Commits().Focus().SelectNextItem()
t.Common().SelectPatchOption(Contains("Move patch to selected commit"))
t.Views().Commits().
IsFocused().
Lines(
Contains("commit to move from"),
Contains("destination commit").IsSelected(),
Contains("first commit"),
)
t.FileSystem().FileContent(
"file1",
Equals("first line\nextra line\n}\nadded line\n}\nlast line\n"),
)
},
})
@@ -0,0 +1,68 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var MoveAmbiguouslyPlacedAddedLinesToNewCommitBefore = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Move some added lines from a commit to a new commit before it, where a diff could also place those lines one line further down",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInDiffView = false
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "first line\n}\nlast line\n")
shell.Commit("first commit")
// A diff can show the added "}" and "added line" as "added line" and "}"
// added one line further down, after the existing "}"
shell.UpdateFileAndAdd("file1", "first line\nextra line\n}\nadded line\n}\nlast line\n")
shell.Commit("commit to move from")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("commit to move from").IsSelected(),
Contains("first commit"),
).
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("file1").IsSelected(),
).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectedLines(Contains("+extra line")).
NavigateToLine(Contains("+}")).
Press(keys.Universal.ToggleRangeSelect).
NavigateToLine(Contains("+added line")).
PressPrimaryAction()
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("commit to move from").IsSelected(),
Contains("new commit"),
Contains("first commit"),
)
t.FileSystem().FileContent(
"file1",
Equals("first line\nextra line\n}\nadded line\n}\nlast line\n"),
)
},
})
@@ -0,0 +1,66 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var MovePartOfAdjacentAddedLinesToEarlierCommit = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Move only one of two adjacent added lines from a commit to an earlier commit",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInDiffView = false
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "")
shell.Commit("first commit")
shell.CreateFileAndAdd("unrelated-file", "")
shell.Commit("destination commit")
shell.UpdateFileAndAdd("file1", "1st line\n2nd line\n")
shell.Commit("commit to move from")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("commit to move from").IsSelected(),
Contains("destination commit"),
Contains("first commit"),
).
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("file1").IsSelected(),
).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectedLines(Contains("+1st line")).
PressPrimaryAction()
t.Views().Information().Content(Contains("Building patch"))
t.Views().Commits().Focus().SelectNextItem()
t.Common().SelectPatchOption(Contains("Move patch to selected commit"))
t.Views().Commits().
IsFocused().
Lines(
Contains("commit to move from"),
Contains("destination commit").IsSelected(),
Contains("first commit"),
).
Tap(func() {
t.Views().Main().Content(Contains("+1st line").DoesNotContain("2nd line"))
}).
SelectPreviousItem().
Tap(func() {
t.Views().Main().Content(Contains(" 1st line\n+2nd line"))
})
},
})
@@ -0,0 +1,65 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var MovePartOfAdjacentAddedLinesToNewCommitBefore = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Move only one of two adjacent added lines from a commit to a new commit before it",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.UseHunkModeInDiffView = false
},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "")
shell.Commit("first commit")
shell.UpdateFileAndAdd("file1", "1st line\n2nd line\n")
shell.Commit("commit to move from")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("commit to move from").IsSelected(),
Contains("first commit"),
).
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Contains("file1").IsSelected(),
).
Press(keys.Universal.FocusMainView)
t.Views().Main().
IsFocused().
SelectedLines(Contains("+1st line")).
PressPrimaryAction()
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("commit to move from").IsSelected(),
Contains("new commit"),
Contains("first commit"),
).
Tap(func() {
t.Views().Main().Content(Contains(" 1st line\n+2nd line"))
}).
SelectNextItem().
Tap(func() {
t.Views().Main().Content(Contains("+1st line").DoesNotContain("2nd line"))
})
},
})
@@ -44,40 +44,13 @@ var MovePatchFromAddedFileToEarlierCommit = NewIntegrationTest(NewIntegrationTes
t.Views().Commits().Focus().SelectNextItem()
t.Common().SelectPatchOption(Contains("Move patch to selected commit"))
// The source commit tries to add a file that the moved line now causes to exist.
t.Common().AcknowledgeConflicts()
t.Views().Files().
IsFocused().
Lines(
Contains("AA").Contains("file"),
).
PressEnter()
t.Views().MergeConflicts().
IsFocused().
TopLines(
Contains("<<<<<<< HEAD"),
Contains("2nd line"),
Contains("======="),
Contains("1st line"),
Contains("2nd line"),
Contains("3rd line"),
Contains(">>>>>>>"),
).
SelectNextItem().
PressPrimaryAction()
t.Common().ContinueOnConflictsResolved("rebase")
t.Views().Commits().
Focus().
IsFocused().
Lines(
Contains("commit to move from").IsSelected(),
Contains("destination commit"),
Contains("commit to move from"),
Contains("destination commit").IsSelected(),
Contains("first commit"),
).
NavigateToLine(Contains("destination commit")).
PressEnter()
t.Views().CommitFiles().
@@ -0,0 +1,58 @@
package main_view
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var MovePatchToNewCommitBeforeWithConflict = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Move a patch into a new commit before the original commit, where a commit hook changes the new commit so that replaying the original commit conflicts",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFileAndAdd("file1", "1st line\n")
shell.Commit("first commit")
shell.UpdateFileAndAdd("file1", "1st line\n2nd line\n")
shell.CreateFileAndAdd("file2", "file2 content\n")
shell.Commit("commit to move from")
shell.CreateFile(".git/hooks/pre-commit", "#!/bin/sh\necho 'hook line' >> file1\ngit add file1\n")
shell.MakeExecutable(".git/hooks/pre-commit")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("commit to move from").IsSelected(),
Contains("first commit"),
).
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Equals("▼ /").IsSelected(),
Contains("file1"),
Contains("file2"),
).
NavigateToLine(Contains("file2")).
PressPrimaryAction()
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.Common().AcknowledgeConflicts()
t.Views().Files().
IsFocused().
Lines(
Contains("UU").Contains("file1"),
)
},
})
+7
View File
@@ -407,6 +407,8 @@ var tests = []*components.IntegrationTest{
main_view.CopySelectedDiffLines,
main_view.CustomPatchGoesThroughTheDiffRenderer,
main_view.CustomPatchIgnoresLineEndingConversion,
main_view.DisableMovingPatchFromMergeCommit,
main_view.DisableMovingPatchToMergeCommit,
main_view.DiscardAllChanges,
main_view.DiscardDiffLines,
main_view.DiscardFromACommitOnlyWhereItCanBeRewritten,
@@ -449,9 +451,13 @@ var tests = []*components.IntegrationTest{
main_view.KeepSelectedRangeWhenChangingContextSize,
main_view.KeepSelectionAfterMovingPatchOut,
main_view.KeepSelectionVisibleWhenDiffShrinks,
main_view.MoveAmbiguouslyPlacedAddedLinesToEarlierCommit,
main_view.MoveAmbiguouslyPlacedAddedLinesToNewCommitBefore,
main_view.MoveMultiFileRangeToIndex,
main_view.MoveOnWhenThePatchMarksRewrapTheDiff,
main_view.MovePartOfAdjacentAddedLinesToEarlierCommit,
main_view.MovePartOfAdjacentAddedLinesToIndex,
main_view.MovePartOfAdjacentAddedLinesToNewCommitBefore,
main_view.MovePartialPatchToIndex,
main_view.MovePartialPatchToLaterCommit,
main_view.MovePartialPatchToNewCommit,
@@ -467,6 +473,7 @@ var tests = []*components.IntegrationTest{
main_view.MovePatchToLaterCommit,
main_view.MovePatchToNewCommit,
main_view.MovePatchToNewCommitBefore,
main_view.MovePatchToNewCommitBeforeWithConflict,
main_view.MovePatchToNewCommitInStackedBranch,
main_view.NavigateByHunkAndFile,
main_view.NoDiffStatLinksUnderAnExternalDiff,
+27
View File
@@ -331,3 +331,30 @@ func dropMergeCommit(todos []todo.Todo, hash string) ([]todo.Todo, error) {
_, idx, _ := lo.FindIndexOf(todos, isMerge)
return slices.Delete(todos, idx, idx+1), nil
}
// Read a git-rebase-todo file, make the todo for the given commit use a
// different commit instead, and write it back
func ChangeTodoCommit(fileName string, hash string, newHash string, commentChar byte) error {
todos, err := ReadRebaseTodoFile(fileName, commentChar)
if err != nil {
return err
}
newTodos, err := changeTodoCommit(todos, hash, newHash)
if err != nil {
return err
}
return WriteRebaseTodoFile(fileName, newTodos, commentChar)
}
func changeTodoCommit(todos []todo.Todo, hash string, newHash string) ([]todo.Todo, error) {
idx, ok := findTodo(todos, Todo{Hash: hash})
if !ok {
// Should never happen
return nil, fmt.Errorf("Todo %s not found in git-rebase-todo", hash)
}
todos[idx].Commit = newHash
return todos, nil
}
+69
View File
@@ -596,6 +596,75 @@ func TestRebaseCommands_deleteTodos(t *testing.T) {
}
}
func TestRebaseCommands_changeTodoCommit(t *testing.T) {
scenarios := []struct {
name string
todos []todo.Todo
hash string
newHash string
expectedTodos []todo.Todo
expectedErr error
}{
{
name: "success",
todos: []todo.Todo{
{Command: todo.Pick, Commit: "1234", Msg: "first"},
{Command: todo.Pick, Commit: "5678", Msg: "second"},
{Command: todo.UpdateRef, Ref: "refs/heads/some_branch"},
{Command: todo.Pick, Commit: "abcd", Msg: "third"},
},
hash: "5678",
newHash: "ef01",
expectedTodos: []todo.Todo{
{Command: todo.Pick, Commit: "1234", Msg: "first"},
{Command: todo.Pick, Commit: "ef01", Msg: "second"},
{Command: todo.UpdateRef, Ref: "refs/heads/some_branch"},
{Command: todo.Pick, Commit: "abcd", Msg: "third"},
},
expectedErr: nil,
},
{
name: "abbreviated hash in todo",
todos: []todo.Todo{
{Command: todo.Pick, Commit: "1234"},
{Command: todo.Pick, Commit: "5678"},
},
hash: "56789abc",
newHash: "ef01",
expectedTodos: []todo.Todo{
{Command: todo.Pick, Commit: "1234"},
{Command: todo.Pick, Commit: "ef01"},
},
expectedErr: nil,
},
{
name: "failure",
todos: []todo.Todo{
{Command: todo.Pick, Commit: "1234"},
{Command: todo.Pick, Commit: "5678"},
},
hash: "abcd",
newHash: "ef01",
expectedTodos: nil,
expectedErr: errors.New("Todo abcd not found in git-rebase-todo"),
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
actualTodos, actualErr := changeTodoCommit(scenario.todos, scenario.hash, scenario.newHash)
if scenario.expectedErr == nil {
assert.NoError(t, actualErr)
} else {
assert.EqualError(t, actualErr, scenario.expectedErr.Error())
}
assert.EqualValues(t, scenario.expectedTodos, actualTodos)
})
}
}
func Test_equalHash(t *testing.T) {
scenarios := []struct {
a string