Repository: jesseduffield/lazygit (language: go)

Issue:
"Move patch into new commit" sometimes fails
**Describe the bug**
Sometimes when using "Move patch into new commit", I get `error: <changed file>: does not exist in index`.

**To Reproduce**
This was hard to pinpoint, but the following steps reproduce the behavior for me consistently:

1. In an empty directory, run the following commands
```
git init
touch test.txt
git add .
git commit -m 1

echo "one" > test.txt 
git add .
git commit -m 2

echo "
two" >> test.txt
git add .
git commit -m 3
```
2. In lazygit, try to move the empty line between "one" and "two" from the last commit into a new commit -> error!

**Expected behavior**
There is a new, 4th commit called `Split from "3"` only containing the empty line between "one" and "two"

**Version info:**
Lazygit: 0.33 and master at 448ff80d
git: 2.35.1

**Additional context**
So far, I've only tested this on 2 different Debian machines. I'm planning on debugging this some more, but would be happy about any input.


Discussion / hints:
In case anyone wants to take a shot at this, I pushed a failing test onto `2153-move-patch-into-new-commit-sometimes-fails`.
I systematically have the error.
ENV:
ubuntu 20.04, 
lazygit version=0.37.0
git version 2.25.1 (and exactly the same symptom on 2.40.0)
This is already fixed on master, see https://github.com/jesseduffield/lazygit/pull/2471.
> This is already fixed on master, see #2471.

Well ok, not quite.

There's a whole class of errors that is fixed by #2471, namely any kind of custom patch operation that results in a conflict. For these, you would previously get the `does not exist in index` error, now you get proper conflict markers as you would expect.

However, it doesn't fix the problem described in the reproduction recipe above. For this one, the custom patch operation conflicts with itself, so to say. What happens is that it basically does these steps:
1. `e` the commit in question
2. `<c-p> r` to reverse the custom patch
3. `<s-a>` to amend
4. `<c-p> a` to apply the patch again normally

Step 4. results in a conflict, as you can see if you perform the steps yourself. The `<c-p> n` action aborts the whole rebase in this case, so you don't see what the conflict is.

This happens whenever you stage only some of a range of adjacent changed lines of a hunk into a custom patch and try to move it into a new commit.

To be honest, I'm not sure how to solve this. I feel that there must be some way to adjust the patch after step 2 so that it applies cleanly in step 4, but I don't have the energy to figure this out right now.
It just occurred to me that for moving a patch to a new commit we actually don't have to apply the patch again after reverse-applying it; we can instead check out the original commit again, it has exactly the right tree state that we want for the new commit. This solves it nicely, PR will come soon.

However, this doesn't help for the case where you move the patch into another, later commit; we are facing the same problem here, applying the patch again in the later commit will conflict with its own, earlier reversal. The best I can think of here is to not abort the rebase when this happens, and let the user resolve the conflict manually if they want (or abort the rebase manually if they don't). This makes sense anyway, because the patch could have conflicted for other reasons.

The original idea of somehow adjusting the patch to make it apply cleanly after reverting it would solve this problem, too, but I'd still need to wrap my head around how exactly this adjustment would work; and I'm not totally sure it's worth the effort.

Other thoughts welcome.
@stefanhaller are you suggesting the following solution:
1) reverse-apply the patch on the original commit
2) cherry-pick the original commit on top of that, allowing git to work out the actual change

If that's true, could we use the same approach for moving the patch to another commit i.e. 
1) reverse-apply the patch on the original commit
2) cherry-pick the original commit on top of that, producing commit X
3) in the interactive rebase, move X above the destination commit and squash it into the destination commit?


I was actually suggesting `git checkout <original-sha> -- .`, and I tested that it works. Could be used instead of cherry-pick in your step 2.

Interesting suggestion, I'll have to think about it or try it. I suppose if the patch conflicts for other reasons at the destination commit, then it will be harder for the user to figure out what's going on, looking at that temporary commit X. We'd have to give it a good commit message to make it clear what's happening.
Good point
Here's a variation of your approach that might work better. I quickly tried it in a hacky way, and it seems to work well, but there are probably edge cases wrt deleted or added files to sort out:

1. Revers-apply the patch to the original commit, amend
2. `git checkout <original-sha> -- .`, but don't commit it
3. `git diff --cached > /tmp/temp-patch.txt`
4. `git reset --hard`
5. `git rebase --continue`
6. `git apply --index /tmp/temp-patch.txt`
And of course, "move patch out into index" also has the same problem, and can be fixed in the same way. Here's a PR that fixes all three problems: https://github.com/jesseduffield/lazygit/pull/2507

Fix patch:
diff --git a/pkg/commands/git_commands/patch.go b/pkg/commands/git_commands/patch.go
index a805c7f214a..643091f63b4 100644
--- a/pkg/commands/git_commands/patch.go
+++ b/pkg/commands/git_commands/patch.go
@@ -45,9 +45,7 @@ func (self *PatchCommands) DeletePatchesFromCommit(commits []*models.Commit, com
 
 	// apply each patch in reverse
 	if err := self.PatchManager.ApplyPatches(true); err != nil {
-		if err := self.rebase.AbortRebase(); err != nil {
-			return err
-		}
+		_ = self.rebase.AbortRebase()
 		return err
 	}
 
@@ -73,9 +71,8 @@ func (self *PatchCommands) MovePatchToSelectedCommit(commits []*models.Commit, s
 
 		// apply each patch forward
 		if err := self.PatchManager.ApplyPatches(false); err != nil {
-			if err := self.rebase.AbortRebase(); err != nil {
-				return err
-			}
+			// Don't abort the rebase here; this might cause conflicts, so give
+			// the user a chance to resolve them
 			return err
 		}
 
@@ -121,9 +118,7 @@ func (self *PatchCommands) MovePatchToSelectedCommit(commits []*models.Commit, s
 
 	// apply each patch in reverse
 	if err := self.PatchManager.ApplyPatches(true); err != nil {
-		if err := self.rebase.AbortRebase(); err != nil {
-			return err
-		}
+		_ = self.rebase.AbortRebase()
 		return err
 	}
 
@@ -132,6 +127,12 @@ func (self *PatchCommands) MovePatchToSelectedCommit(commits []*models.Commit, s
 		return err
 	}
 
+	patch, err := self.diffHeadAgainstCommit(commits[sourceCommitIdx])
+	if err != nil {
+		_ = self.rebase.AbortRebase()
+		return err
+	}
+
 	if self.rebase.onSuccessfulContinue != nil {
 		return errors.New("You are midway through another rebase operation. Please abort to start again")
 	}
@@ -139,10 +140,9 @@ func (self *PatchCommands) MovePatchToSelectedCommit(commits []*models.Commit, s
 	self.rebase.onSuccessfulContinue = func() error {
 		// now we should be up to the destination, so let's apply forward these patches to that.
 		// ideally we would ensure we're on the right commit but I'm not sure if that check is necessary
-		if err := self.PatchManager.ApplyPatches(false); err != nil {
-			if err := self.rebase.AbortRebase(); err != nil {
-				return err
-			}
+		if err := self.rebase.workingTree.ApplyPatch(patch, "index", "3way"); err != nil {
+			// Don't abort the rebase here; this might cause conflicts, so give
+			// the user a chance to resolve them
 			return err
 		}
 
@@ -175,9 +175,7 @@ func (self *PatchCommands) MovePatchIntoIndex(commits []*models.Commit, commitId
 
 	if err := self.PatchManager.ApplyPatches(true); err != nil {
 		if self.status.WorkingTreeState() == enums.REBASE_MODE_REBASING {
-			if err := self.rebase.AbortRebase(); err != nil {
-				return err
-			}
+			_ = self.rebase.AbortRebase()
 		}
 		return err
 	}
@@ -187,17 +185,21 @@ func (self *PatchCommands) MovePatchIntoIndex(commits []*models.Commit, commitId
 		return err
 	}
 
+	patch, err := self.diffHeadAgainstCommit(commits[commitIdx])
+	if err != nil {
+		_ = self.rebase.AbortRebase()
+		return err
+	}
+
 	if self.rebase.onSuccessfulContinue != nil {
 		return errors.New("You are midway through another rebase operation. Please abort to start again")
 	}
 
 	self.rebase.onSuccessfulContinue = func() error {
 		// add patches to index
-		if err := self.PatchManager.ApplyPatches(false); err != nil {
+		if err := self.rebase.workingTree.ApplyPatch(patch, "index", "3way"); err != nil {
 			if self.status.WorkingTreeState() == enums.REBASE_MODE_REBASING {
-				if err := self.rebase.AbortRebase(); err != nil {
-					return err
-				}
+				_ = self.rebase.AbortRebase()
 			}
 			return err
 		}
@@ -221,9 +223,7 @@ func (self *PatchCommands) PullPatchIntoNewCommit(commits []*models.Commit, comm
 	}
 
 	if err := self.PatchManager.ApplyPatches(true); err != nil {
-		if err := self.rebase.AbortRebase(); err != nil {
-			return err
-		}
+		_ = self.rebase.AbortRebase()
 		return err
 	}
 
@@ -232,18 +232,20 @@ func (self *PatchCommands) PullPatchIntoNewCommit(commits []*models.Commit, comm
 		return err
 	}
 
-	// add patches to index
-	if err := self.PatchManager.ApplyPatches(false); err != nil {
-		if err := self.rebase.AbortRebase(); err != nil {
-			return err
-		}
+	patch, err := self.diffHeadAgainstCommit(commits[commitIdx])
+	if err != nil {
+		_ = self.rebase.AbortRebase()
+		return err
+	}
+
+	if err := self.rebase.workingTree.ApplyPatch(patch, "index", "3way"); err != nil {
+		_ = self.rebase.AbortRebase()
 		return err
 	}
 
 	head_message, _ := self.commit.GetHeadCommitMessage()
 	new_message := fmt.Sprintf("Split from \"%s\"", head_message)
-	err := self.commit.CommitCmdObj(new_message).Run()
-	if err != nil {
+	if err := self.commit.CommitCmdObj(new_message).Run(); err != nil {
 		return err
 	}
 
@@ -254,3 +256,12 @@ func (self *PatchCommands) PullPatchIntoNewCommit(commits []*models.Commit, comm
 	self.PatchManager.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
+// only some lines of a range of adjacent added lines. To solve this, we
+// get the diff of HEAD and the original commit and then apply that.
+func (self *PatchCommands) diffHeadAgainstCommit(commit *models.Commit) (string, error) {
+	return self.cmd.New(fmt.Sprintf("git diff HEAD..%s", commit.Sha)).RunWithOutput()
+}
diff --git a/pkg/commands/git_commands/rebase.go b/pkg/commands/git_commands/rebase.go
index cff9b961a64..b0fa939c350 100644
--- a/pkg/commands/git_commands/rebase.go
+++ b/pkg/commands/git_commands/rebase.go
@@ -137,7 +137,7 @@ func (self *RebaseCommands) PrepareInteractiveRebaseCommand(baseShaOrRoot string
 		debug = "TRUE"
 	}
 
-	cmdStr := fmt.Sprintf("git rebase --interactive --autostash --keep-empty --no-autosquash %s", baseShaOrRoot)
+	cmdStr := fmt.Sprintf("git rebase --interactive --autostash --keep-empty --empty=keep --no-autosquash %s", baseShaOrRoot)
 	self.Log.WithField("command", cmdStr).Debug("RunCommand")
 
 	cmdObj := self.cmd.New(cmdStr)
diff --git a/pkg/commands/patch/patch_manager.go b/pkg/commands/patch/patch_manager.go
index 3a379b97ebc..84db20be619 100644
--- a/pkg/commands/patch/patch_manager.go
+++ b/pkg/commands/patch/patch_manager.go
@@ -286,3 +286,13 @@ func (p *PatchManager) IsEmpty() bool {
 func (p *PatchManager) NewPatchRequired(from string, to string, reverse bool) bool {
 	return from != p.From || to != p.To || reverse != p.reverse
 }
+
+func (p *PatchManager) AllFilesInPatch() []string {
+	files := make([]string, 0, len(p.fileInfoMap))
+
+	for filename := range p.fileInfoMap {
+		files = append(files, filename)
+	}
+
+	return files
+}
diff --git a/pkg/integration/components/shell.go b/pkg/integration/components/shell.go
index 3c5177949be..d55fa83032c 100644
--- a/pkg/integration/components/shell.go
+++ b/pkg/integration/components/shell.go
@@ -94,6 +94,16 @@ func (self *Shell) CreateFile(path string, content string) *Shell {
 	return self
 }
 
+func (self *Shell) DeleteFile(path string) *Shell {
+	fullPath := filepath.Join(self.dir, path)
+	err := os.Remove(fullPath)
+	if err != nil {
+		self.fail(fmt.Sprintf("error deleting file: %s\n%s", fullPath, err))
+	}
+
+	return self
+}
+
 func (self *Shell) CreateDir(path string) *Shell {
 	fullPath := filepath.Join(self.dir, path)
 	if err := os.MkdirAll(fullPath, 0o755); err != nil {
@@ -171,6 +181,13 @@ func (self *Shell) UpdateFileAndAdd(fileName string, fileContents string) *Shell
 		GitAdd(fileName)
 }
 
+// convenience method for deleting a file and adding it
+func (self *Shell) DeleteFileAndAdd(fileName string) *Shell {
+	return self.
+		DeleteFile(fileName).
+		GitAdd(fileName)
+}
+
 // creates commits 01, 02, 03, ..., n with a new file in each
 // The reason for padding with zeroes is so that it's easier to do string
 // matches on the commit messages when there are many of them


Test patch:
diff --git a/pkg/commands/git_commands/rebase_test.go b/pkg/commands/git_commands/rebase_test.go
index ed731ed04b5..b2cd88323ba 100644
--- a/pkg/commands/git_commands/rebase_test.go
+++ b/pkg/commands/git_commands/rebase_test.go
@@ -26,7 +26,7 @@ func TestRebaseRebaseBranch(t *testing.T) {
 			testName: "successful rebase",
 			arg:      "master",
 			runner: oscommands.NewFakeRunner(t).
-				Expect(`git rebase --interactive --autostash --keep-empty --no-autosquash master`, "", nil),
+				Expect(`git rebase --interactive --autostash --keep-empty --empty=keep --no-autosquash master`, "", nil),
 			test: func(err error) {
 				assert.NoError(t, err)
 			},
@@ -35,7 +35,7 @@ func TestRebaseRebaseBranch(t *testing.T) {
 			testName: "unsuccessful rebase",
 			arg:      "master",
 			runner: oscommands.NewFakeRunner(t).
-				Expect(`git rebase --interactive --autostash --keep-empty --no-autosquash master`, "", errors.New("error")),
+				Expect(`git rebase --interactive --autostash --keep-empty --empty=keep --no-autosquash master`, "", errors.New("error")),
 			test: func(err error) {
 				assert.Error(t, err)
 			},
@@ -125,7 +125,7 @@ func TestRebaseDiscardOldFileChanges(t *testing.T) {
 			commitIndex: 0,
 			fileName:    "test999.txt",
 			runner: oscommands.NewFakeRunner(t).
-				Expect(`git rebase --interactive --autostash --keep-empty --no-autosquash abcdef`, "", nil).
+				Expect(`git rebase --interactive --autostash --keep-empty --empty=keep --no-autosquash abcdef`, "", nil).
 				Expect(`git cat-file -e HEAD^:"test999.txt"`, "", nil).
 				Expect(`git checkout HEAD^ -- "test999.txt"`, "", nil).
 				Expect(`git commit --amend --no-edit --allow-empty`, "", nil).
diff --git a/pkg/integration/tests/patch_building/move_to_earlier_commit.go b/pkg/integration/tests/patch_building/move_to_earlier_commit.go
new file mode 100644
index 00000000000..5803737f049
--- /dev/null
+++ b/pkg/integration/tests/patch_building/move_to_earlier_commit.go
@@ -0,0 +1,88 @@
+package patch_building
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var MoveToEarlierCommit = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "Move a patch from a commit to an earlier commit",
+	ExtraCmdArgs: "",
+	Skip:         false,
+	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.CreateFileAndAdd("unrelated-file", "")
+		shell.Commit("destination 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")
+	},
+	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("dir").IsSelected(),
+				Contains("  M file1"),
+				Contains("  D file2"),
+				Contains("  A file3"),
+			).
+			PressPrimaryAction().
+			PressEscape()
+
+		t.Views().Information().Content(Contains("building patch"))
+
+		t.Views().Commits().
+			IsFocused().
+			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"),
+			).
+			PressEnter()
+
+		t.Views().CommitFiles().
+			IsFocused().
+			Lines(
+				Contains("dir").IsSelected(),
+				Contains("  M file1"),
+				Contains("  D file2"),
+				Contains("  A file3"),
+				Contains("A unrelated-file"),
+			).
+			PressEscape()
+
+		t.Views().Commits().
+			IsFocused().
+			SelectPreviousItem().
+			PressEnter()
+
+		// the original commit has no more files in it
+		t.Views().CommitFiles().
+			IsFocused().
+			Lines(
+				Contains("(none)"),
+			)
+	},
+})
diff --git a/pkg/integration/tests/patch_building/move_to_index_part_of_adjacent_added_lines.go b/pkg/integration/tests/patch_building/move_to_index_part_of_adjacent_added_lines.go
new file mode 100644
index 00000000000..c307ae4321e
--- /dev/null
+++ b/pkg/integration/tests/patch_building/move_to_index_part_of_adjacent_added_lines.go
@@ -0,0 +1,70 @@
+package patch_building
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var MoveToIndexPartOfAdjacentAddedLines = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "Move a patch from a commit to the index, with only some lines of a range of adjacent added lines in the patch",
+	ExtraCmdArgs: "",
+	Skip:         false,
+	SetupConfig:  func(config *config.AppConfig) {},
+	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")
+
+		shell.UpdateFileAndAdd("unrelated-file", "")
+		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("file1").IsSelected(),
+			).
+			PressEnter()
+
+		t.Views().PatchBuilding().
+			IsFocused().
+			PressEnter().
+			PressPrimaryAction()
+
+		t.Views().Information().Content(Contains("building patch"))
+
+		t.Common().SelectPatchOption(Contains("move patch out into index"))
+
+		t.Views().CommitFiles().
+			IsFocused().
+			Lines(
+				Contains("file1").IsSelected(),
+			).
+			Tap(func() {
+				t.Views().Main().
+					Content(Contains("+2nd line").
+						DoesNotContain("1st line"))
+			})
+
+		t.Views().Files().
+			Focus().
+			ContainsLines(
+				Contains("M").Contains("file1"),
+			)
+
+		t.Views().Main().
+			Content(Contains("+1st line\n 2nd line\n"))
+	},
+})
diff --git a/pkg/integration/tests/patch_building/move_to_later_commit.go b/pkg/integration/tests/patch_building/move_to_later_commit.go
new file mode 100644
index 00000000000..f80293e72c9
--- /dev/null
+++ b/pkg/integration/tests/patch_building/move_to_later_commit.go
@@ -0,0 +1,89 @@
+package patch_building
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var MoveToLaterCommit = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "Move a patch from a commit to a later commit",
+	ExtraCmdArgs: "",
+	Skip:         false,
+	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.CreateFileAndAdd("unrelated-file", "")
+		shell.Commit("destination commit")
+	},
+	Run: func(t *TestDriver, keys config.KeybindingConfig) {
+		t.Views().Commits().
+			Focus().
+			Lines(
+				Contains("destination 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.Views().Commits().
+			IsFocused().
+			SelectPreviousItem()
+
+		t.Common().SelectPatchOption(Contains("move patch to selected commit"))
+
+		t.Views().Commits().
+			IsFocused().
+			Lines(
+				Contains("destination commit").IsSelected(),
+				Contains("commit to move from"),
+				Contains("first commit"),
+			).
+			PressEnter()
+
+		t.Views().CommitFiles().
+			IsFocused().
+			Lines(
+				Contains("dir").IsSelected(),
+				Contains("  M file1"),
+				Contains("  D file2"),
+				Contains("  A file3"),
+				Contains("A unrelated-file"),
+			).
+			PressEscape()
+
+		t.Views().Commits().
+			IsFocused().
+			SelectNextItem().
+			PressEnter()
+
+		// the original commit has no more files in it
+		t.Views().CommitFiles().
+			IsFocused().
+			Lines(
+				Contains("(none)"),
+			)
+	},
+})
diff --git a/pkg/integration/tests/patch_building/move_to_later_commit_partial_hunk.go b/pkg/integration/tests/patch_building/move_to_later_commit_partial_hunk.go
new file mode 100644
index 00000000000..3ebf4a88604
--- /dev/null
+++ b/pkg/integration/tests/patch_building/move_to_later_commit_partial_hunk.go
@@ -0,0 +1,96 @@
+package patch_building
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var MoveToLaterCommitPartialHunk = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "Move a patch from a commit to a later commit, with only parts of a hunk in the patch",
+	ExtraCmdArgs: "",
+	Skip:         false,
+	SetupConfig:  func(config *config.AppConfig) {},
+	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")
+
+		shell.UpdateFileAndAdd("unrelated-file", "")
+		shell.Commit("destination commit")
+	},
+	Run: func(t *TestDriver, keys config.KeybindingConfig) {
+		t.Views().Commits().
+			Focus().
+			Lines(
+				Contains("destination commit").IsSelected(),
+				Contains("commit to move from"),
+				Contains("first commit"),
+			).
+			SelectNextItem().
+			PressEnter()
+
+		t.Views().CommitFiles().
+			IsFocused().
+			Lines(
+				Contains("file1").IsSelected(),
+			).
+			PressEnter()
+
+		t.Views().PatchBuilding().
+			IsFocused().
+			PressEnter().
+			PressPrimaryAction().
+			PressEscape()
+
+		t.Views().Information().Content(Contains("building patch"))
+
+		t.Views().CommitFiles().
+			IsFocused().
+			PressEscape()
+
+		t.Views().Commits().
+			IsFocused().
+			SelectPreviousItem()
+
+		t.Common().SelectPatchOption(Contains("move patch to selected commit"))
+
+		t.Views().Commits().
+			IsFocused().
+			Lines(
+				Contains("destination commit").IsSelected(),
+				Contains("commit to move from"),
+				Contains("first commit"),
+			).
+			PressEnter()
+
+		t.Views().CommitFiles().
+			IsFocused().
+			Lines(
+				Contains("file1").IsSelected(),
+				Contains("unrelated-file"),
+			).
+			Tap(func() {
+				t.Views().Main().
+					Content(Contains("+1st line\n 2nd line"))
+			}).
+			PressEscape()
+
+		t.Views().Commits().
+			IsFocused().
+			SelectNextItem().
+			PressEnter()
+
+		t.Views().CommitFiles().
+			IsFocused().
+			Lines(
+				Contains("file1").IsSelected(),
+			).
+			Tap(func() {
+				t.Views().Main().
+					Content(Contains("+2nd line").
+						DoesNotContain("1st line"))
+			})
+	},
+})
diff --git a/pkg/integration/tests/patch_building/move_to_new_commit.go b/pkg/integration/tests/patch_building/move_to_new_commit.go
index 483a1ab5337..491a8c9c510 100644
--- a/pkg/integration/tests/patch_building/move_to_new_commit.go
+++ b/pkg/integration/tests/patch_building/move_to_new_commit.go
@@ -11,13 +11,17 @@ var MoveToNewCommit = NewIntegrationTest(NewIntegrationTestArgs{
 	Skip:         false,
 	SetupConfig:  func(config *config.AppConfig) {},
 	SetupRepo: func(shell *Shell) {
-		shell.CreateFileAndAdd("file1", "file1 content")
+		shell.CreateDir("dir")
+		shell.CreateFileAndAdd("dir/file1", "file1 content")
+		shell.CreateFileAndAdd("dir/file2", "file2 content")
 		shell.Commit("first commit")
 
-		shell.UpdateFileAndAdd("file1", "file1 content with old changes")
-		shell.Commit("second 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("file1", "file1 content with new changes")
+		shell.UpdateFileAndAdd("dir/file1", "file1 content with new changes")
 		shell.Commit("third commit")
 	},
 	Run: func(t *TestDriver, keys config.KeybindingConfig) {
@@ -25,7 +29,7 @@ var MoveToNewCommit = NewIntegrationTest(NewIntegrationTestArgs{
 			Focus().
 			Lines(
 				Contains("third commit").IsSelected(),
-				Contains("second commit"),
+				Contains("commit to move from"),
 				Contains("first commit"),
 			).
 			SelectNextItem().
@@ -34,18 +38,35 @@ var MoveToNewCommit = NewIntegrationTest(NewIntegrationTestArgs{
 		t.Views().CommitFiles().
 			IsFocused().
 			Lines(
-				Contains("file1").IsSelected(),
+				Contains("dir").IsSelected(),
+				Contains("  M file1"),
+				Contains("  D file2"),
+				Contains("  A file3"),
 			).
-			PressPrimaryAction()
+			PressPrimaryAction().
+			PressEscape()
 
 		t.Views().Information().Content(Contains("building patch"))
 
 		t.Common().SelectPatchOption(Contains("move patch into new commit"))
 
+		t.Views().Commits().
+			IsFocused().
+			Lines(
+				Contains("third commit"),
+				Contains(`Split from "commit to move from"`).IsSelected(),
+				Contains("commit to move from"),
+				Contains("first commit"),
+			).
+			PressEnter()
+
 		t.Views().CommitFiles().
 			IsFocused().
 			Lines(
-				Contains("file1").IsSelected(),
+				Contains("dir").IsSelected(),
+				Contains("  M file1"),
+				Contains("  D file2"),
+				Contains("  A file3"),
 			).
 			PressEscape()
 
@@ -53,8 +74,8 @@ var MoveToNewCommit = NewIntegrationTest(NewIntegrationTestArgs{
 			IsFocused().
 			Lines(
 				Contains("third commit"),
-				Contains(`Split from "second commit"`).IsSelected(),
-				Contains("second commit"),
+				Contains(`Split from "commit to move from"`).IsSelected(),
+				Contains("commit to move from"),
 				Contains("first commit"),
 			).
 			SelectNextItem().
diff --git a/pkg/integration/tests/patch_building/move_to_new_commit_partial_hunk.go b/pkg/integration/tests/patch_building/move_to_new_commit_partial_hunk.go
new file mode 100644
index 00000000000..2e3c59f776b
--- /dev/null
+++ b/pkg/integration/tests/patch_building/move_to_new_commit_partial_hunk.go
@@ -0,0 +1,83 @@
+package patch_building
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var MoveToNewCommitPartialHunk = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "Move a patch from a commit to a new commit, with only parts of a hunk in the patch",
+	ExtraCmdArgs: "",
+	Skip:         false,
+	SetupConfig:  func(config *config.AppConfig) {},
+	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")
+
+		shell.UpdateFileAndAdd("file1", "1st line\n2nd line\n3rd line\n")
+		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("file1").IsSelected(),
+			).
+			PressEnter()
+
+		t.Views().PatchBuilding().
+			IsFocused().
+			PressEnter().
+			PressPrimaryAction()
+
+		t.Views().Information().Content(Contains("building patch"))
+
+		t.Common().SelectPatchOption(Contains("move patch into new commit"))
+
+		t.Views().CommitFiles().
+			IsFocused().
+			Lines(
+				Contains("file1").IsSelected(),
+			).
+			Tap(func() {
+				t.Views().Main().
+					Content(Contains("+1st line\n 2nd line"))
+			}).
+			PressEscape()
+
+		t.Views().Commits().
+			IsFocused().
+			Lines(
+				Contains("third commit"),
+				Contains(`Split from "commit to move from"`).IsSelected(),
+				Contains("commit to move from"),
+				Contains("first commit"),
+			).
+			SelectNextItem().
+			PressEnter()
+
+		t.Views().CommitFiles().
+			IsFocused().
+			Lines(
+				Contains("file1").IsSelected(),
+			).
+			Tap(func() {
+				t.Views().Main().
+					Content(Contains("+2nd line").
+						DoesNotContain("1st line"))
+			})
+	},
+})
diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go
index 3a0fa826930..807c5810d54 100644
--- a/pkg/integration/tests/test_list.go
+++ b/pkg/integration/tests/test_list.go
@@ -101,10 +101,15 @@ var tests = []*components.IntegrationTest{
 	patch_building.ApplyInReverse,
 	patch_building.ApplyInReverseWithConflict,
 	patch_building.CopyPatchToClipboard,
+	patch_building.MoveToEarlierCommit,
 	patch_building.MoveToIndex,
+	patch_building.MoveToIndexPartOfAdjacentAddedLines,
 	patch_building.MoveToIndexPartial,
 	patch_building.MoveToIndexWithConflict,
+	patch_building.MoveToLaterCommit,
+	patch_building.MoveToLaterCommitPartialHunk,
 	patch_building.MoveToNewCommit,
+	patch_building.MoveToNewCommitPartialHunk,
 	patch_building.RemoveFromCommit,
 	patch_building.ResetWithEscape,
 	patch_building.SelectAllFiles,