Repository: jesseduffield/lazygit (language: go)

Issue:
Non-sticky range diff
I often want to see the diff of a range of commits. The two main use cases are:
- I want to see a combined diff of all the commits in my branch to quickly get an overview over which files I touched.
- I'm considering squashing two or more commits, but I'm not sure yet if it's really better; I want to see the resulting diff of both commits to help decide. As a workaround, I could simply squash them, and when I'm not happy with the result, hit `z` to undo; however, that [doesn't work well with stacked branches](https://github.com/jesseduffield/lazygit/issues/2527#issuecomment-1532664747).

Both of these can be solved today using diffing mode (navigate down to one end of the range (one _after_ the end, that is), hit `shift-W`, navigate up to the other end). This results in exactly what I want to see (including being able to hit enter to see the files of the diff, even being able to create a custom patch from it). However, this is too many key-presses, it's not very easy to discover, and it's too sticky (it happens too often that I forget I'm in this mode, and then I move the selection around looking at diffs that make absolutely no sense).

I would find it totally intuitive if, whenever I range-select several commits (either a sticky range using `v`, or a non-sticky range using shift-arrow), I would see a diff of the range of those commits. This would perfectly match the general concept of "show me details of whatever is selected in the side panel", and it would solve the use cases above perfectly for me. I also want to be able to hit enter to work on the files of this range diff, like in diffing mode.

Other git clients have a similar feature, but it works slightly differently:
- Fork requires you to make a non-contiguous multiselection of two commits (using command-click, something that lazygit doesn't support). If you select more than two, including a contiguous range of commits, it shows an error, asking you to select exactly two. Also, it requires you to select one commit below the beginning of the range (like lazygit's shift-W mode), which I find unintuitive.
- Sublime Merge is like Fork, except that it also allows a contiguous range of commits. This is more convenient than Fork because it allows selecting with the keyboard (using shift-down), whereas Fork's non-contiguous selections can only be made with the mouse. But like Fork, it requires selecting one below the beginning of the range.

I understand that selecting one commit below the start of the range makes sense because it is closer to what you do on the command line. If you have commits A, B and C, then `git diff A..C` displays the combined diff of B and C, but doesn't include A's changes. That's what Fork and Sublime Merge (and shift-W in lazygit) try to emulate, it seems. But for a range selection of commits I find this very unintuitive: I want to see the combined diff of all the commits that I have selected, so I'm strongly proposing to do a `git diff A^..C` if A, B, and C are selected.

Note that I'm not proposing to replace diffing mode with this new non-sticky diff mode; diffing mode is probably still useful for diffing two things from different panels, e.g. a tag against a commit, or a commit against a branch.

I'm pretty far along implementing this, but I have a few questions that I'm unsure about:

1. It is unclear to me what to do if a range of reflog entries or stash entries is selected. These types of objects don't form a linear sequence, so it doesn't make sense to display a range diff for them. We could either keep the behavior of master for these cases (i.e. show the diff for the free end of the selection), or show a message such as "Cannot display diff for range of stash entries".
2. A similar situation arises when selecting a range of rebase todos in an interactive rebase. Since "pick" entries can be reordered, we'd be showing a diff for the range of those commits in their original position before the rebase, which could be very confusing. Same options as in 1., either use the single selection, or show a message that we can't show this diff.
3. Finally, what if there is a range selection when we are also in diffing mode? It gets very confusing here. It might be best to keep the master behavior in this case, and diff the free end of the selection against the diffing mode anchor. But again, we could consider showing an error message instead.


Discussion / hints:
After re-reading this I realize that my use of the term "non-sticky" could be confusing here. I'm using it for describing this new lightweight ad-hoc range selection diff mode, as opposed to the sticky shift-W mode. However, we also use sticky vs. non-sticky for the difference between a `v` range selection and a shift-arrow range selection, and this new non-sticky diff mode works for both. Confusing...
I like this idea a lot. I also share your intuition that it's better to do ^A..B than to do A..B but it does kinda lock us in to that pattern. If we used A..B everywhere then the logic is simple and consistent: it can be applied in any view that contains commits including the reflog view and the divergence view (e.g. when comparing two commits that are from the two different branches).

But, we can just say that that if you want A..B, you can do a sticky diff.

In answer to your questions:

> It is unclear to me what to do if a range of reflog entries or stash entries is selected. These types of objects don't form a linear sequence, so it doesn't make sense to display a range diff for them. We could either keep the behavior of master for these cases (i.e. show the diff for the free end of the selection), or show a message such as "Cannot display diff for range of stash entries".

If the purpose is to show the combined diff of all selected commits, then that's not something anybody will need in the reflog view, so I'd just stick to master's behaviour

> A similar situation arises when selecting a range of rebase todos in an interactive rebase. Since "pick" entries can be reordered, we'd be showing a diff for the range of those commits in their original position before the rebase, which could be very confusing. Same options as in 1., either use the single selection, or show a message that we can't show this diff.

Hmm. Wonder if there's a command to literally show a combined diff of N commits rather than specifying the two terminals. That might make this easier to implement for edge cases like that.

> Finally, what if there is a range selection when we are also in diffing mode? It gets very confusing here. It might be best to keep the master behavior in this case, and diff the free end of the selection against the diffing mode anchor. But again, we could consider showing an error message instead.

Agreed on keeping the master behaviour in that case. I don't think an error is necessary: diffing mode takes precedent over the non-diffing mode diff and that can be true whether a range is selected or not.
> (e.g. when comparing two commits that are from the two different branches).

Good that you bring this up, I had missed that. This would also be an error condition like the others below.

> But, we can just say that that if you want A..B, you can do a sticky diff.

I agree.

> If the purpose is to show the combined diff of all selected commits, then that's not something anybody will need in the reflog view, so I'd just stick to master's behaviour

Would you be opposed to an error message though? I would find that less confusing, and I think it will also be easier to implement.

> Hmm. Wonder if there's a command to literally show a combined diff of N commits rather than specifying the two terminals.

I don't think there is, and I'm also not sure how that could possibly work in case there are conflicts.

> Agreed on keeping the master behaviour in that case. I don't think an error is necessary: diffing mode takes precedent over the non-diffing mode diff and that can be true whether a range is selected or not.

Same here, would you be opposed to an error in that case?
> I don't think there is, and I'm also not sure how that could possibly work in case there are conflicts.

Good point, I forgot that git's diffs are based on snapshots, not patches.

As for errors: with the reflog view it feels over-the-top to me: the fact we show a combined diff is a nice bonus imo. It seems weird that if you are in fact just using the multi-select to perform some bulk action, we're going to show an error in case you're actually trying to see a combined diff. With sticky diff mode, I think it's more appropriate to show an error because it would be especially strange to do anything with a range select in that case.
> As for errors: with the reflog view it feels over-the-top to me: the fact we show a combined diff is a nice bonus imo. It seems weird that if you are in fact just using the multi-select to perform some bulk action, we're going to show an error in case you're actually trying to see a combined diff. With sticky diff mode, I think it's more appropriate to show an error because it would be especially strange to do anything with a range select in that case.

Good point, this makes sense. Too bad, this will make the implementation trickier. I'll see if I can solve this somehow.

Fix patch:
diff --git a/pkg/commands/models/branch.go b/pkg/commands/models/branch.go
index 04f869ebdbd..4dc48a88d8d 100644
--- a/pkg/commands/models/branch.go
+++ b/pkg/commands/models/branch.go
@@ -53,6 +53,10 @@ func (b *Branch) RefName() string {
 	return b.Name
 }
 
+func (b *Branch) ShortRefName() string {
+	return b.RefName()
+}
+
 func (b *Branch) ParentRefName() string {
 	return b.RefName() + "^"
 }
diff --git a/pkg/commands/models/commit.go b/pkg/commands/models/commit.go
index 95e3b9b18a4..50bcfab8fb0 100644
--- a/pkg/commands/models/commit.go
+++ b/pkg/commands/models/commit.go
@@ -70,6 +70,10 @@ func (c *Commit) RefName() string {
 	return c.Hash
 }
 
+func (c *Commit) ShortRefName() string {
+	return c.Hash[:7]
+}
+
 func (c *Commit) ParentRefName() string {
 	if c.IsFirstCommit() {
 		return EmptyTreeCommitHash
diff --git a/pkg/commands/models/remote_branch.go b/pkg/commands/models/remote_branch.go
index 6a26f05f924..1e89ef582d4 100644
--- a/pkg/commands/models/remote_branch.go
+++ b/pkg/commands/models/remote_branch.go
@@ -18,6 +18,10 @@ func (r *RemoteBranch) RefName() string {
 	return r.FullName()
 }
 
+func (r *RemoteBranch) ShortRefName() string {
+	return r.RefName()
+}
+
 func (r *RemoteBranch) ParentRefName() string {
 	return r.RefName() + "^"
 }
diff --git a/pkg/commands/models/stash_entry.go b/pkg/commands/models/stash_entry.go
index 2a1cc8435cb..a88ab61f6af 100644
--- a/pkg/commands/models/stash_entry.go
+++ b/pkg/commands/models/stash_entry.go
@@ -17,6 +17,10 @@ func (s *StashEntry) RefName() string {
 	return fmt.Sprintf("stash@{%d}", s.Index)
 }
 
+func (s *StashEntry) ShortRefName() string {
+	return s.RefName()
+}
+
 func (s *StashEntry) ParentRefName() string {
 	return s.RefName() + "^"
 }
diff --git a/pkg/commands/models/tag.go b/pkg/commands/models/tag.go
index 24cb83254f9..876e2cd77ed 100644
--- a/pkg/commands/models/tag.go
+++ b/pkg/commands/models/tag.go
@@ -16,6 +16,10 @@ func (t *Tag) RefName() string {
 	return t.Name
 }
 
+func (t *Tag) ShortRefName() string {
+	return t.RefName()
+}
+
 func (t *Tag) ParentRefName() string {
 	return t.RefName() + "^"
 }
diff --git a/pkg/gui/context/commit_files_context.go b/pkg/gui/context/commit_files_context.go
index 97c1728d180..4e938248129 100644
--- a/pkg/gui/context/commit_files_context.go
+++ b/pkg/gui/context/commit_files_context.go
@@ -1,6 +1,8 @@
 package context
 
 import (
+	"fmt"
+
 	"github.com/jesseduffield/gocui"
 	"github.com/jesseduffield/lazygit/pkg/commands/models"
 	"github.com/jesseduffield/lazygit/pkg/gui/filetree"
@@ -75,6 +77,25 @@ func (self *CommitFilesContext) GetDiffTerminals() []string {
 	return []string{self.GetRef().RefName()}
 }
 
+func (self *CommitFilesContext) GetFromAndToForDiff() (string, string) {
+	if refs := self.GetRefRange(); refs != nil {
+		return refs.From.ParentRefName(), refs.To.RefName()
+	}
+	ref := self.GetRef()
+	return ref.ParentRefName(), ref.RefName()
+}
+
 func (self *CommitFilesContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition {
 	return nil
 }
+
+func (self *CommitFilesContext) ReInit(ref types.Ref, refRange *types.RefRange) {
+	self.SetRef(ref)
+	self.SetRefRange(refRange)
+	if refRange != nil {
+		self.SetTitleRef(fmt.Sprintf("%s-%s", refRange.From.ShortRefName(), refRange.To.ShortRefName()))
+	} else {
+		self.SetTitleRef(ref.Description())
+	}
+	self.GetView().Title = self.Title()
+}
diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go
index eecb161077e..6d1a72aae3f 100644
--- a/pkg/gui/context/local_commits_context.go
+++ b/pkg/gui/context/local_commits_context.go
@@ -128,6 +128,19 @@ func (self *LocalCommitsContext) GetSelectedRef() types.Ref {
 	return commit
 }
 
+func (self *LocalCommitsContext) GetSelectedRefRangeForDiffFiles() *types.RefRange {
+	commits, startIdx, endIdx := self.GetSelectedItems()
+	if commits == nil || startIdx == endIdx {
+		return nil
+	}
+	from := commits[len(commits)-1]
+	to := commits[0]
+	if from.IsTODO() || to.IsTODO() {
+		return nil
+	}
+	return &types.RefRange{From: from, To: to}
+}
+
 // Returns the commit hash of the selected commit, or an empty string if no
 // commit is selected
 func (self *LocalCommitsContext) GetSelectedCommitHash() string {
diff --git a/pkg/gui/context/reflog_commits_context.go b/pkg/gui/context/reflog_commits_context.go
index 403e5e91c72..db33481e59d 100644
--- a/pkg/gui/context/reflog_commits_context.go
+++ b/pkg/gui/context/reflog_commits_context.go
@@ -71,6 +71,11 @@ func (self *ReflogCommitsContext) GetSelectedRef() types.Ref {
 	return commit
 }
 
+func (self *ReflogCommitsContext) GetSelectedRefRangeForDiffFiles() *types.RefRange {
+	// It doesn't make much sense to show a range diff between two reflog entries.
+	return nil
+}
+
 func (self *ReflogCommitsContext) GetCommits() []*models.Commit {
 	return self.getModel()
 }
diff --git a/pkg/gui/context/stash_context.go b/pkg/gui/context/stash_context.go
index c8d48768807..64c7c9fc979 100644
--- a/pkg/gui/context/stash_context.go
+++ b/pkg/gui/context/stash_context.go
@@ -61,6 +61,11 @@ func (self *StashContext) GetSelectedRef() types.Ref {
 	return stash
 }
 
+func (self *StashContext) GetSelectedRefRangeForDiffFiles() *types.RefRange {
+	// It doesn't make much sense to show a range diff between two stash entries.
+	return nil
+}
+
 func (self *StashContext) GetDiffTerminals() []string {
 	itemId := self.GetSelectedItemId()
 
diff --git a/pkg/gui/context/sub_commits_context.go b/pkg/gui/context/sub_commits_context.go
index 6adb7a53e98..cd19dcae228 100644
--- a/pkg/gui/context/sub_commits_context.go
+++ b/pkg/gui/context/sub_commits_context.go
@@ -186,6 +186,19 @@ func (self *SubCommitsContext) GetSelectedRef() types.Ref {
 	return commit
 }
 
+func (self *SubCommitsContext) GetSelectedRefRangeForDiffFiles() *types.RefRange {
+	commits, startIdx, endIdx := self.GetSelectedItems()
+	if commits == nil || startIdx == endIdx {
+		return nil
+	}
+	from := commits[len(commits)-1]
+	to := commits[0]
+	if from.Divergence != to.Divergence {
+		return nil
+	}
+	return &types.RefRange{From: from, To: to}
+}
+
 func (self *SubCommitsContext) GetCommits() []*models.Commit {
 	return self.getModel()
 }
diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go
index d2f156a90a0..c784b4a1dfa 100644
--- a/pkg/gui/controllers.go
+++ b/pkg/gui/controllers.go
@@ -259,7 +259,7 @@ func (gui *Gui) resetHelpersAndControllers() {
 		gui.State.Contexts.Stash,
 	} {
 		controllers.AttachControllers(context, controllers.NewSwitchToDiffFilesController(
-			common, context, gui.State.Contexts.CommitFiles,
+			common, context,
 		))
 	}
 
diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go
index 2a9ba8271ba..8c4f7cf14b2 100644
--- a/pkg/gui/controllers/commits_files_controller.go
+++ b/pkg/gui/controllers/commits_files_controller.go
@@ -136,9 +136,8 @@ func (self *CommitFilesController) GetOnRenderToMain() func() error {
 			return nil
 		}
 
-		ref := self.context().GetRef()
-		to := ref.RefName()
-		from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(ref.ParentRefName())
+		from, to := self.context().GetFromAndToForDiff()
+		from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
 
 		cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, node.GetPath(), false)
 		task := types.NewRunPtyTask(cmdObj.GetCmd())
@@ -250,9 +249,8 @@ func (self *CommitFilesController) canEditFiles(nodes []*filetree.CommitFileNode
 }
 
 func (self *CommitFilesController) openDiffTool(node *filetree.CommitFileNode) error {
-	ref := self.context().GetRef()
-	to := ref.RefName()
-	from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(ref.ParentRefName())
+	from, to := self.context().GetFromAndToForDiff()
+	from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
 	_, err := self.c.RunSubprocess(self.c.Git().Diff.OpenDiffToolCmdObj(
 		git_commands.DiffToolCmdOptions{
 			Filepath:    node.GetPath(),
@@ -307,7 +305,8 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm
 		})
 	}
 
-	if self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.To != self.context().GetRef().RefName() {
+	from, to, reverse := self.currentFromToReverseForPatchBuilding()
+	if self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse) {
 		return self.c.Confirm(types.ConfirmOpts{
 			Title:  self.c.Tr.DiscardPatch,
 			Prompt: self.c.Tr.DiscardPatchConfirm,
@@ -330,14 +329,20 @@ func (self *CommitFilesController) startPatchBuilder() error {
 	commitFilesContext := self.context()
 
 	canRebase := commitFilesContext.GetCanRebase()
-	ref := commitFilesContext.GetRef()
-	to := ref.RefName()
-	from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(ref.ParentRefName())
+	from, to, reverse := self.currentFromToReverseForPatchBuilding()
 
 	self.c.Git().Patch.PatchBuilder.Start(from, to, reverse, canRebase)
 	return nil
 }
 
+func (self *CommitFilesController) currentFromToReverseForPatchBuilding() (string, string, bool) {
+	commitFilesContext := self.context()
+
+	from, to := commitFilesContext.GetFromAndToForDiff()
+	from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
+	return from, to, reverse
+}
+
 func (self *CommitFilesController) enter(node *filetree.CommitFileNode) error {
 	return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1})
 }
@@ -357,7 +362,8 @@ func (self *CommitFilesController) enterCommitFile(node *filetree.CommitFileNode
 		return self.c.Context().Push(self.c.Contexts().CustomPatchBuilder, opts)
 	}
 
-	if self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.To != self.context().GetRef().RefName() {
+	from, to, reverse := self.currentFromToReverseForPatchBuilding()
+	if self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse) {
 		return self.c.Confirm(types.ConfirmOpts{
 			Title:  self.c.Tr.DiscardPatch,
 			Prompt: self.c.Tr.DiscardPatchConfirm,
diff --git a/pkg/gui/controllers/helpers/diff_helper.go b/pkg/gui/controllers/helpers/diff_helper.go
index 94ac291b9e1..42cdb99dd40 100644
--- a/pkg/gui/controllers/helpers/diff_helper.go
+++ b/pkg/gui/controllers/helpers/diff_helper.go
@@ -1,9 +1,13 @@
 package helpers
 
 import (
+	"strings"
+
 	"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
+	"github.com/jesseduffield/lazygit/pkg/commands/models"
 	"github.com/jesseduffield/lazygit/pkg/gui/context"
 	"github.com/jesseduffield/lazygit/pkg/gui/modes/diffing"
+	"github.com/jesseduffield/lazygit/pkg/gui/style"
 	"github.com/jesseduffield/lazygit/pkg/gui/types"
 	"github.com/samber/lo"
 )
@@ -19,7 +23,7 @@ func NewDiffHelper(c *HelperCommon) *DiffHelper {
 }
 
 func (self *DiffHelper) DiffArgs() []string {
-	output := []string{self.c.Modes().Diffing.Ref}
+	output := []string{"--stat", "-p", self.c.Modes().Diffing.Ref}
 
 	right := self.currentDiffTerminal()
 	if right != "" {
@@ -46,14 +50,46 @@ func (self *DiffHelper) DiffArgs() []string {
 	return output
 }
 
+// Returns an update task that can be passed to RenderToMainViews to render a
+// diff for the selected commit(s). We need to pass both the selected commit
+// and the refRange for a range selection. If the refRange is nil (meaning that
+// either there's no range, or it can't be diffed for some reason), then we want
+// to fall back to rendering the diff for the single commit.
+func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff(commit *models.Commit, refRange *types.RefRange) types.UpdateTask {
+	if refRange != nil {
+		from, to := refRange.From, refRange.To
+		args := []string{from.ParentRefName(), to.RefName(), "--stat", "-p"}
+		if self.c.GetAppState().IgnoreWhitespaceInDiffView {
+			args = append(args, "--ignore-all-space")
+		}
+		args = append(args, "--")
+		if path := self.c.Modes().Filtering.GetPath(); path != "" {
+			args = append(args, path)
+		}
+		cmdObj := self.c.Git().Diff.DiffCmdObj(args)
+		task := types.NewRunPtyTask(cmdObj.GetCmd())
+		task.Prefix = style.FgYellow.Sprintf("%s %s-%s\n\n", self.c.Tr.ShowingDiffForRange, from.ShortRefName(), to.ShortRefName())
+		return task
+	}
+
+	cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash, self.c.Modes().Filtering.GetPath())
+	return types.NewRunPtyTask(cmdObj.GetCmd())
+}
+
 func (self *DiffHelper) ExitDiffMode() error {
 	self.c.Modes().Diffing = diffing.New()
 	return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
 }
 
 func (self *DiffHelper) RenderDiff() error {
-	cmdObj := self.c.Git().Diff.DiffCmdObj(self.DiffArgs())
+	args := self.DiffArgs()
+	cmdObj := self.c.Git().Diff.DiffCmdObj(args)
 	task := types.NewRunPtyTask(cmdObj.GetCmd())
+	task.Prefix = style.FgMagenta.Sprintf(
+		"%s %s\n\n",
+		self.c.Tr.ShowingGitDiff,
+		"git diff "+strings.Join(args, " "),
+	)
 
 	return self.c.RenderToMainViews(types.RefreshMainOpts{
 		Pair: self.c.MainViewPairs().Normal,
diff --git a/pkg/gui/controllers/helpers/patch_building_helper.go b/pkg/gui/controllers/helpers/patch_building_helper.go
index 4c2fc40d0b2..df6e34216f8 100644
--- a/pkg/gui/controllers/helpers/patch_building_helper.go
+++ b/pkg/gui/controllers/helpers/patch_building_helper.go
@@ -73,9 +73,8 @@ func (self *PatchBuildingHelper) RefreshPatchBuildingPanel(opts types.OnFocusOpt
 		return nil
 	}
 
-	ref := self.c.Contexts().CommitFiles.CommitFileTreeViewModel.GetRef()
-	to := ref.RefName()
-	from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(ref.ParentRefName())
+	from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff()
+	from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
 	diff, err := self.c.Git().WorkingTree.ShowFileDiff(from, to, reverse, path, true)
 	if err != nil {
 		return err
diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go
index c43c3ddabb7..c1f187d07a8 100644
--- a/pkg/gui/controllers/helpers/refresh_helper.go
+++ b/pkg/gui/controllers/helpers/refresh_helper.go
@@ -285,8 +285,8 @@ func (self *RefreshHelper) refreshCommitsAndCommitFiles() {
 		// For now the awkwardness remains.
 		commit := self.c.Contexts().LocalCommits.GetSelected()
 		if commit != nil && commit.RefName() != "" {
-			self.c.Contexts().CommitFiles.SetRef(commit)
-			self.c.Contexts().CommitFiles.SetTitleRef(commit.RefName())
+			refRange := self.c.Contexts().LocalCommits.GetSelectedRefRangeForDiffFiles()
+			self.c.Contexts().CommitFiles.ReInit(commit, refRange)
 			_ = self.refreshCommitFilesContext()
 		}
 	}
@@ -387,9 +387,8 @@ func (self *RefreshHelper) RefreshAuthors(commits []*models.Commit) {
 }
 
 func (self *RefreshHelper) refreshCommitFilesContext() error {
-	ref := self.c.Contexts().CommitFiles.GetRef()
-	to := ref.RefName()
-	from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(ref.ParentRefName())
+	from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff()
+	from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
 
 	files, err := self.c.Git().Loaders.CommitFileLoader.GetFilesInDiff(from, to, reverse)
 	if err != nil {
diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go
index 2d4232f33a6..f0ca624c1a1 100644
--- a/pkg/gui/controllers/local_commits_controller.go
+++ b/pkg/gui/controllers/local_commits_controller.go
@@ -290,8 +290,8 @@ func (self *LocalCommitsController) GetOnRenderToMain() func() error {
 				task = types.NewRenderStringTask(
 					self.c.Tr.ExecCommandHere + "\n\n" + commit.Name)
 			} else {
-				cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash, self.c.Modes().Filtering.GetPath())
-				task = types.NewRunPtyTask(cmdObj.GetCmd())
+				refRange := self.context().GetSelectedRefRangeForDiffFiles()
+				task = self.c.Helpers().Diff.GetUpdateTaskForRenderingCommitsDiff(commit, refRange)
 			}
 
 			return self.c.RenderToMainViews(types.RefreshMainOpts{
diff --git a/pkg/gui/controllers/sub_commits_controller.go b/pkg/gui/controllers/sub_commits_controller.go
index 9ce708272f7..0f3ca990739 100644
--- a/pkg/gui/controllers/sub_commits_controller.go
+++ b/pkg/gui/controllers/sub_commits_controller.go
@@ -46,9 +46,8 @@ func (self *SubCommitsController) GetOnRenderToMain() func() error {
 			if commit == nil {
 				task = types.NewRenderStringTask("No commits")
 			} else {
-				cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash, self.c.Modes().Filtering.GetPath())
-
-				task = types.NewRunPtyTask(cmdObj.GetCmd())
+				refRange := self.context().GetSelectedRefRangeForDiffFiles()
+				task = self.c.Helpers().Diff.GetUpdateTaskForRenderingCommitsDiff(commit, refRange)
 			}
 
 			return self.c.RenderToMainViews(types.RefreshMainOpts{
diff --git a/pkg/gui/controllers/switch_to_diff_files_controller.go b/pkg/gui/controllers/switch_to_diff_files_controller.go
index 92faefb7973..2faa000bb8e 100644
--- a/pkg/gui/controllers/switch_to_diff_files_controller.go
+++ b/pkg/gui/controllers/switch_to_diff_files_controller.go
@@ -1,7 +1,6 @@
 package controllers
 
 import (
-	"github.com/jesseduffield/lazygit/pkg/gui/context"
 	"github.com/jesseduffield/lazygit/pkg/gui/types"
 )
 
@@ -13,36 +12,25 @@ type CanSwitchToDiffFiles interface {
 	types.IListContext
 	CanRebase() bool
 	GetSelectedRef() types.Ref
+	GetSelectedRefRangeForDiffFiles() *types.RefRange
 }
 
-// Not using our ListControllerTrait because our 'selected' item is not a list item
-// but an attribute on it i.e. the ref of an item.
+// Not using our ListControllerTrait because we have our own way of working with
+// range selections that's different from ListControllerTrait's
 type SwitchToDiffFilesController struct {
 	baseController
-	*ListControllerTrait[types.Ref]
-	c                *ControllerCommon
-	context          CanSwitchToDiffFiles
-	diffFilesContext *context.CommitFilesContext
+	c       *ControllerCommon
+	context CanSwitchToDiffFiles
 }
 
 func NewSwitchToDiffFilesController(
 	c *ControllerCommon,
 	context CanSwitchToDiffFiles,
-	diffFilesContext *context.CommitFilesContext,
 ) *SwitchToDiffFilesController {
 	return &SwitchToDiffFilesController{
 		baseController: baseController{},
-		ListControllerTrait: NewListControllerTrait[types.Ref](
-			c,
-			context,
-			context.GetSelectedRef,
-			func() ([]types.Ref, int, int) {
-				panic("Not implemented")
-			},
-		),
-		c:                c,
-		context:          context,
-		diffFilesContext: diffFilesContext,
+		c:              c,
+		context:        context,
 	}
 }
 
@@ -50,8 +38,8 @@ func (self *SwitchToDiffFilesController) GetKeybindings(opts types.KeybindingsOp
 	bindings := []*types.Binding{
 		{
 			Key:               opts.GetKey(opts.Config.Universal.GoInto),
-			Handler:           self.withItem(self.enter),
-			GetDisabledReason: self.require(self.singleItemSelected(self.itemRepresentsCommit)),
+			Handler:           self.enter,
+			GetDisabledReason: self.canEnter,
 			Description:       self.c.Tr.ViewItemFiles,
 		},
 	}
@@ -59,29 +47,43 @@ func (self *SwitchToDiffFilesController) GetKeybindings(opts types.KeybindingsOp
 	return bindings
 }
 
-func (self *SwitchToDiffFilesController) GetOnClick() func() error {
-	return self.withItemGraceful(self.enter)
+func (self *SwitchToDiffFilesController) Context() types.Context {
+	return self.context
 }
 
-func (self *SwitchToDiffFilesController) enter(ref types.Ref) error {
-	return self.viewFiles(SwitchToCommitFilesContextOpts{
-		Ref:       ref,
-		CanRebase: self.context.CanRebase(),
-		Context:   self.context,
-	})
+func (self *SwitchToDiffFilesController) GetOnClick() func() error {
+	return func() error {
+		if self.canEnter() == nil {
+			return self.enter()
+		}
+
+		return nil
+	}
 }
 
-func (self *SwitchToDiffFilesController) viewFiles(opts SwitchToCommitFilesContextOpts) error {
-	diffFilesContext := self.diffFilesContext
+func (self *SwitchToDiffFilesController) enter() error {
+	ref := self.context.GetSelectedRef()
+	refsRange := self.context.GetSelectedRefRangeForDiffFiles()
+	commitFilesContext := self.c.Contexts().CommitFiles
+
+	canRebase := self.context.CanRebase()
+	if canRebase {
+		if self.c.Modes().Diffing.Active() {
+			if self.c.Modes().Diffing.Ref != ref.RefName() {
+				canRebase = false
+			}
+		} else if refsRange != nil {
+			canRebase = false
+		}
+	}
 
-	diffFilesContext.SetSelection(0)
-	diffFilesContext.SetRef(opts.Ref)
-	diffFilesContext.SetTitleRef(opts.Ref.Description())
-	diffFilesContext.SetCanRebase(opts.CanRebase)
-	diffFilesContext.SetParentContext(opts.Context)
-	diffFilesContext.SetWindowName(opts.Context.GetWindowName())
-	diffFilesContext.ClearSearchString()
-	diffFilesContext.GetView().TitlePrefix = opts.Context.GetView().TitlePrefix
+	commitFilesContext.ReInit(ref, refsRange)
+	commitFilesContext.SetSelection(0)
+	commitFilesContext.SetCanRebase(canRebase)
+	commitFilesContext.SetParentContext(self.context)
+	commitFilesContext.SetWindowName(self.context.GetWindowName())
+	commitFilesContext.ClearSearchString()
+	commitFilesContext.GetView().TitlePrefix = self.context.GetView().TitlePrefix
 
 	if err := self.c.Refresh(types.RefreshOptions{
 		Scope: []types.RefreshableView{types.COMMIT_FILES},
@@ -89,10 +91,18 @@ func (self *SwitchToDiffFilesController) viewFiles(opts SwitchToCommitFilesConte
 		return err
 	}
 
-	return self.c.Context().Push(diffFilesContext)
+	return self.c.Context().Push(commitFilesContext)
 }
 
-func (self *SwitchToDiffFilesController) itemRepresentsCommit(ref types.Ref) *types.DisabledReason {
+func (self *SwitchToDiffFilesController) canEnter() *types.DisabledReason {
+	refRange := self.context.GetSelectedRefRangeForDiffFiles()
+	if refRange != nil {
+		return nil
+	}
+	ref := self.context.GetSelectedRef()
+	if ref == nil {
+		return &types.DisabledReason{Text: self.c.Tr.NoItemSelected}
+	}
 	if ref.RefName() == "" {
 		return &types.DisabledReason{Text: self.c.Tr.SelectedItemDoesNotHaveFiles}
 	}
diff --git a/pkg/gui/controllers/types.go b/pkg/gui/controllers/types.go
deleted file mode 100644
index f719b5de0d4..00000000000
--- a/pkg/gui/controllers/types.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package controllers
-
-import (
-	"github.com/jesseduffield/lazygit/pkg/gui/types"
-)
-
-// all fields mandatory (except `CanRebase` because it's boolean)
-type SwitchToCommitFilesContextOpts struct {
-	// this is something like a commit or branch
-	Ref types.Ref
-
-	// from the local commits view we're allowed to do rebase stuff with any patch
-	// we generate from the diff files context, but we don't have that same ability
-	// with say the sub commits context or the reflog context.
-	CanRebase bool
-
-	Context types.Context
-}
diff --git a/pkg/gui/filetree/commit_file_tree_view_model.go b/pkg/gui/filetree/commit_file_tree_view_model.go
index 95cb1a14035..cbbb2fbcf3c 100644
--- a/pkg/gui/filetree/commit_file_tree_view_model.go
+++ b/pkg/gui/filetree/commit_file_tree_view_model.go
@@ -16,6 +16,8 @@ type ICommitFileTreeViewModel interface {
 
 	GetRef() types.Ref
 	SetRef(types.Ref)
+	GetRefRange() *types.RefRange // can be nil, in which case GetRef should be used
+	SetRefRange(*types.RefRange)  // should be set to nil when selection is not a range
 	GetCanRebase() bool
 	SetCanRebase(bool)
 }
@@ -25,9 +27,14 @@ type CommitFileTreeViewModel struct {
 	types.IListCursor
 	ICommitFileTree
 
-	// this is e.g. the commit for which we're viewing the files
+	// this is e.g. the commit for which we're viewing the files, if there is no
+	// range selection, or if the range selection can't be used for some reason
 	ref types.Ref
 
+	// this is a commit range for which we're viewing the files. Can be nil, in
+	// which case ref is used.
+	refRange *types.RefRange
+
 	// we set this to true when you're viewing the files within the checked-out branch's commits.
 	// If you're viewing the files of some random other branch we can't do any rebase stuff.
 	canRebase bool
@@ -42,6 +49,7 @@ func NewCommitFileTreeViewModel(getFiles func() []*models.CommitFile, log *logru
 		ICommitFileTree: fileTree,
 		IListCursor:     listCursor,
 		ref:             nil,
+		refRange:        nil,
 		canRebase:       false,
 	}
 }
@@ -54,6 +62,14 @@ func (self *CommitFileTreeViewModel) SetRef(ref types.Ref) {
 	self.ref = ref
 }
 
+func (self *CommitFileTreeViewModel) GetRefRange() *types.RefRange {
+	return self.refRange
+}
+
+func (self *CommitFileTreeViewModel) SetRefRange(refsForRange *types.RefRange) {
+	self.refRange = refsForRange
+}
+
 func (self *CommitFileTreeViewModel) GetCanRebase() bool {
 	return self.canRebase
 }
diff --git a/pkg/gui/types/ref.go b/pkg/gui/types/ref.go
index e83d91b65f3..18fc7e99832 100644
--- a/pkg/gui/types/ref.go
+++ b/pkg/gui/types/ref.go
@@ -3,6 +3,12 @@ package types
 type Ref interface {
 	FullRefName() string
 	RefName() string
+	ShortRefName() string
 	ParentRefName() string
 	Description() string
 }
+
+type RefRange struct {
+	From Ref
+	To   Ref
+}
diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go
index aa88d397a97..b09a14d7913 100644
--- a/pkg/i18n/english.go
+++ b/pkg/i18n/english.go
@@ -445,7 +445,6 @@ type TranslationSet struct {
 	ScrollRight                           string
 	DiscardPatch                          string
 	DiscardPatchConfirm                   string
-	DiscardPatchSameCommitConfirm         string
 	CantPatchWhileRebasingError           string
 	ToggleAddToPatch                      string
 	ToggleAddToPatchTooltip               string
@@ -582,6 +581,7 @@ type TranslationSet struct {
 	OpenCommandLogMenu                    string
 	OpenCommandLogMenuTooltip             string
 	ShowingGitDiff                        string
+	ShowingDiffForRange                   string
 	CommitDiff                            string
 	CopyCommitHashToClipboard             string
 	CommitHash                            string
@@ -1435,7 +1435,6 @@ func EnglishTranslationSet() *TranslationSet {
 		ScrollRight:                          "Scroll right",
 		DiscardPatch:                         "Discard patch",
 		DiscardPatchConfirm:                  "You can only build a patch from one commit/stash-entry at a time. Discard current patch?",
-		DiscardPatchSameCommitConfirm:        "You currently have changes added to a patch for this commit. Discard current patch?",
 		CantPatchWhileRebasingError:          "You cannot build a patch or run patch commands while in a merging or rebasing state",
 		ToggleAddToPatch:                     "Toggle file included in patch",
 		ToggleAddToPatchTooltip:              "Toggle whether the file is included in the custom patch. See {{.doc}}.",
@@ -1571,6 +1570,7 @@ func EnglishTranslationSet() *TranslationSet {
 		OpenCommandLogMenu:                       "View command log options",
 		OpenCommandLogMenuTooltip:                "View options for the command log e.g. show/hide the command log and focus the command log.",
 		ShowingGitDiff:                           "Showing output for:",
+		ShowingDiffForRange:                      "Showing diff for range",
 		CommitDiff:                               "Commit diff",
 		CopyCommitHashToClipboard:                "Copy commit hash to clipboard",
 		CommitHash:                               "Commit hash",


Test patch:
diff --git a/pkg/integration/tests/diff/diff.go b/pkg/integration/tests/diff/diff.go
index ef1eb40a690..06e32041884 100644
--- a/pkg/integration/tests/diff/diff.go
+++ b/pkg/integration/tests/diff/diff.go
@@ -35,11 +35,11 @@ var Diff = NewIntegrationTest(NewIntegrationTestArgs{
 		t.Views().Branches().
 			IsFocused().
 			Tap(func() {
-				t.Views().Information().Content(Contains("Showing output for: git diff branch-a branch-a"))
+				t.Views().Information().Content(Contains("Showing output for: git diff --stat -p branch-a branch-a"))
 			}).
 			SelectNextItem().
 			Tap(func() {
-				t.Views().Information().Content(Contains("Showing output for: git diff branch-a branch-b"))
+				t.Views().Information().Content(Contains("Showing output for: git diff --stat -p branch-a branch-b"))
 				t.Views().Main().Content(Contains("+second line"))
 			}).
 			PressEnter()
@@ -67,7 +67,7 @@ var Diff = NewIntegrationTest(NewIntegrationTestArgs{
 			Press(keys.Universal.DiffingMenu)
 
 		t.ExpectPopup().Menu().Title(Equals("Diffing")).Select(Contains("Reverse diff direction")).Confirm()
-		t.Views().Information().Content(Contains("Showing output for: git diff branch-a branch-b -R"))
+		t.Views().Information().Content(Contains("Showing output for: git diff --stat -p branch-a branch-b -R"))
 		t.Views().Main().Content(Contains("-second line"))
 	},
 })
diff --git a/pkg/integration/tests/diff/diff_and_apply_patch.go b/pkg/integration/tests/diff/diff_and_apply_patch.go
index c0c95cc1762..6801e7bea47 100644
--- a/pkg/integration/tests/diff/diff_and_apply_patch.go
+++ b/pkg/integration/tests/diff/diff_and_apply_patch.go
@@ -32,13 +32,13 @@ var DiffAndApplyPatch = NewIntegrationTest(NewIntegrationTestArgs{
 
 		t.ExpectPopup().Menu().Title(Equals("Diffing")).Select(Equals("Diff branch-a")).Confirm()
 
-		t.Views().Information().Content(Contains("Showing output for: git diff branch-a branch-a"))
+		t.Views().Information().Content(Contains("Showing output for: git diff --stat -p branch-a branch-a"))
 
 		t.Views().Branches().
 			IsFocused().
 			SelectNextItem().
 			Tap(func() {
-				t.Views().Information().Content(Contains("Showing output for: git diff branch-a branch-b"))
+				t.Views().Information().Content(Contains("Showing output for: git diff --stat -p branch-a branch-b"))
 				t.Views().Main().Content(Contains("+second line"))
 			}).
 			PressEnter()
diff --git a/pkg/integration/tests/diff/diff_non_sticky_range.go b/pkg/integration/tests/diff/diff_non_sticky_range.go
new file mode 100644
index 00000000000..672f744a9c3
--- /dev/null
+++ b/pkg/integration/tests/diff/diff_non_sticky_range.go
@@ -0,0 +1,45 @@
+package diff
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var DiffNonStickyRange = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "View the combined diff of multiple commits using a range selection",
+	ExtraCmdArgs: []string{},
+	Skip:         false,
+	SetupConfig:  func(config *config.AppConfig) {},
+	SetupRepo: func(shell *Shell) {
+		shell.EmptyCommit("initial commit")
+		shell.CreateFileAndAdd("file1", "first line\n")
+		shell.Commit("first commit")
+		shell.UpdateFileAndAdd("file1", "first line\nsecond line\n")
+		shell.Commit("second commit")
+		shell.UpdateFileAndAdd("file1", "first line\nsecond line\nthird line\n")
+		shell.Commit("third commit")
+	},
+	Run: func(t *TestDriver, keys config.KeybindingConfig) {
+		t.Views().Commits().
+			Focus().
+			Lines(
+				Contains("third commit").IsSelected(),
+				Contains("second commit"),
+				Contains("first commit"),
+				Contains("initial commit"),
+			).
+			Press(keys.Universal.RangeSelectDown).
+			Press(keys.Universal.RangeSelectDown).
+			Tap(func() {
+				t.Views().Main().Content(Contains("Showing diff for range ").
+					Contains("+first line\n+second line\n+third line"))
+			}).
+			PressEnter()
+
+		t.Views().CommitFiles().
+			IsFocused().
+			SelectedLine(Contains("file1"))
+
+		t.Views().Main().Content(Contains("+first line\n+second line\n+third line"))
+	},
+})
diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go
index de074232f17..584d7a07d76 100644
--- a/pkg/integration/tests/test_list.go
+++ b/pkg/integration/tests/test_list.go
@@ -148,6 +148,7 @@ var tests = []*components.IntegrationTest{
 	diff.Diff,
 	diff.DiffAndApplyPatch,
 	diff.DiffCommits,
+	diff.DiffNonStickyRange,
 	diff.IgnoreWhitespace,
 	diff.RenameSimilarityThresholdChange,
 	file.CopyMenu,