Repository: jesseduffield/lazygit (language: go)

Issue:
Automatic staging of files during a merge conflict / interactive rebase
Technically this is not a bug, but an inconvenient behavior that should be possible to disable, in my opinion.

During a merge or interactive rebase, when conflicts are encountered, lazygit attempts to resolve them automatically by staging all files, and, if it's possible to proceed, prompts the user with "All merge conflicts resolved. Continue?". I find this behavior highly disruptive, because there are plenty of scenarios in which resolving a merge conflict is not as simple as `git add .`, even if there are no conflict markers present in files.
In general, this makes me not want to rely on lazygit during some of these merges, because I'm afraid of it ruining my staging area while I want to carefully review the unstaged changes and stage the relevant bits little by little manually.

**To Reproduce**

1. Cause a merge conflict that can be resolved with `git add .`.
2. Open lazygit.
3. Within a few seconds, lazygit stages all files and prompts the user with "All merge conflicts resolved. Continue?".

**Expected behavior**

Lazygit should not stage any files and should not prompt the user in any way, giving them manual control over the rebase.
I would like to see a config option to turn off this behavior entirely.

**Version info:**
commit=v0.40.2, build date=2023-08-12T17:47:33Z, build source=binaryRelease, version=0.40.2, os=linux, arch=amd64, git version=2.42.1  
git version 2.42.1

**Additional context**
I run into this problem the most frequently when I'm working on projects with git submodules. What tends to happen:

1. I have 2 matching feature branches in both the parent repo and its submodule. They are being developed in parallel, i.e. I may have commits [A, B, C] on the submodule feature branch, and corresponding commits [A, B, C] on the parent repo feature branch, which bump the submodule.
2. I want to rebase both repos' feature branches on top of their masters. The way to do this in git is to first rebase the submodule branch, and then rebase the parent repo branch. But during the parent repo rebase, it is necessary to stop at each of the A, B and C commits, go to the submodule, check out the correct commit, go back to the main repo, git add, and only then the conflict is properly resolved. `git add .` is a legal way to resolve the conflict, but it leads to "squashing" all [A, B, C] commits of the submodule repo into a single commit on the main repo, which is undesired.


Discussion / hints:
[side note/rant]: Submodules suck. They may be ok for pulling in third-party dependencies that only change every so often, but for stuff that people are actively developing, they are a PITA. I'm so glad that we switched to a monorepo at work.

On to the real issue: yes, I can see how lazygit's behavior can be annoying, and I agree it would be good to have an option to disable it. Personally I happen to like it, most of the time, so I would keep it enabled. In fact, I often have the opposite problem, which is that lazygit doesn't stage changes aggressively enough. The use case is that you fix the conflicts in your editor (I rarely use lazygit's merge editor for that); once you saved the last one, lazygit stages things and brings up the "Continue?" prompt, but in the background where you don't see it. Meanwhile, I find that things don't compile yet, so I continue to fix some compiler errors; now, when I return to lazygit, answering "yes" to the continue prompt doesn't work because not all files are staged.
> Personally I happen to like it, most of the time, so I would keep it enabled. In fact, I often have the opposite problem, which is that lazygit doesn't stage changes aggressively enough.

That's a good counterexample, I have that happen to me sometimes as well. Part of the reason I dislike the "Continue?" prompt is because it's not always clear what has been staged and what hasn't. Generally in such cases I'd rather be forced to double-check than trust a tool that only does what I want 99% of the time (especially given how annoying **correcting** a rebase/merge is in git).

I totally agree that the current behavior should be opt-out, not opt-in. New users might get surprised by it, but find it useful, or disable it if they don't. On the contrary, if it was opt-out, it could go over many potential users's heads, since it's not an obvious feature.
I couldn't agree more
I've run into this problem often enough now when doing lazygit interactive rebase + VSCodium merge conflict editor. I'm not using submodules at all, so the problem exists without them. Also, it occurs when I'm not using lazygits merge editor. It just randomly happens in the background while I'm trying to resolve conflicts in VSCodium. I had to abort some big rebases because I couldn't salvage my worktree anymore. An option to disable this behaviour would be greatly appreciated! (IMHO it should be the default, then maybe add a tooltip to raise awareness for the option)
This caught me off guard where it was happening in the background and I did not know what happened to the files in conflict. With Git LFS enabled, merge conflict resolution by Lazygit is plain wrong with this behavior. Unreal is capable of handling binary merge conflicts of its assets, but with Lazygit running, I cannot resolve such conflicts.

I would prefer this behaviour to be `OFF` by default, but what matters more is that it is driven by a config.

Fix patch:
diff --git a/docs/Config.md b/docs/Config.md
index ce0c4bee1f6..b4c4a92d6a0 100644
--- a/docs/Config.md
+++ b/docs/Config.md
@@ -305,6 +305,12 @@ git:
   # If true, pass the --all arg to git fetch
   fetchAll: true
 
+  # If true, lazygit will automatically stage files that used to have merge
+  # conflicts but no longer do; and it will also ask you if you want to
+  # continue a merge or rebase if you've resolved all conflicts. If false, it
+  # won't do either of these things.
+  autoStageResolvedConflicts: true
+
   # Command used when displaying the current branch git log in the main window
   branchLogCmd: git log --graph --color=always --abbrev-commit --decorate --date=relative --pretty=medium {{branchName}} --
 
diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go
index d74174376f7..01c546048c2 100644
--- a/pkg/config/user_config.go
+++ b/pkg/config/user_config.go
@@ -224,6 +224,11 @@ type GitConfig struct {
 	AutoRefresh bool `yaml:"autoRefresh"`
 	// If true, pass the --all arg to git fetch
 	FetchAll bool `yaml:"fetchAll"`
+	// If true, lazygit will automatically stage files that used to have merge
+	// conflicts but no longer do; and it will also ask you if you want to
+	// continue a merge or rebase if you've resolved all conflicts. If false, it
+	// won't do either of these things.
+	AutoStageResolvedConflicts bool `yaml:"autoStageResolvedConflicts"`
 	// Command used when displaying the current branch git log in the main window
 	BranchLogCmd string `yaml:"branchLogCmd"`
 	// Command used to display git log of all branches in the main window.
@@ -753,6 +758,7 @@ func GetDefaultConfig() *UserConfig {
 			AutoFetch:                    true,
 			AutoRefresh:                  true,
 			FetchAll:                     true,
+			AutoStageResolvedConflicts:   true,
 			BranchLogCmd:                 "git log --graph --color=always --abbrev-commit --decorate --date=relative --pretty=medium {{branchName}} --",
 			AllBranchesLogCmd:            "git log --graph --all --color=always --abbrev-commit --decorate --date=relative  --pretty=medium",
 			DisableForcePushing:          false,
diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go
index c1f187d07a8..01be0d26787 100644
--- a/pkg/gui/controllers/helpers/refresh_helper.go
+++ b/pkg/gui/controllers/helpers/refresh_helper.go
@@ -546,33 +546,35 @@ func (self *RefreshHelper) refreshFilesAndSubmodules() error {
 func (self *RefreshHelper) refreshStateFiles() error {
 	fileTreeViewModel := self.c.Contexts().Files.FileTreeViewModel
 
-	// If git thinks any of our files have inline merge conflicts, but they actually don't,
-	// we stage them.
-	// Note that if files with merge conflicts have both arisen and have been resolved
-	// between refreshes, we won't stage them here. This is super unlikely though,
-	// and this approach spares us from having to call `git status` twice in a row.
-	// Although this also means that at startup we won't be staging anything until
-	// we call git status again.
-	pathsToStage := []string{}
 	prevConflictFileCount := 0
-	for _, file := range self.c.Model().Files {
-		if file.HasMergeConflicts {
-			prevConflictFileCount++
-		}
-		if file.HasInlineMergeConflicts {
-			hasConflicts, err := mergeconflicts.FileHasConflictMarkers(file.Name)
-			if err != nil {
-				self.c.Log.Error(err)
-			} else if !hasConflicts {
-				pathsToStage = append(pathsToStage, file.Name)
+	if self.c.UserConfig().Git.AutoStageResolvedConflicts {
+		// If git thinks any of our files have inline merge conflicts, but they actually don't,
+		// we stage them.
+		// Note that if files with merge conflicts have both arisen and have been resolved
+		// between refreshes, we won't stage them here. This is super unlikely though,
+		// and this approach spares us from having to call `git status` twice in a row.
+		// Although this also means that at startup we won't be staging anything until
+		// we call git status again.
+		pathsToStage := []string{}
+		for _, file := range self.c.Model().Files {
+			if file.HasMergeConflicts {
+				prevConflictFileCount++
+			}
+			if file.HasInlineMergeConflicts {
+				hasConflicts, err := mergeconflicts.FileHasConflictMarkers(file.Name)
+				if err != nil {
+					self.c.Log.Error(err)
+				} else if !hasConflicts {
+					pathsToStage = append(pathsToStage, file.Name)
+				}
 			}
 		}
-	}
 
-	if len(pathsToStage) > 0 {
-		self.c.LogAction(self.c.Tr.Actions.StageResolvedFiles)
-		if err := self.c.Git().WorkingTree.StageFiles(pathsToStage); err != nil {
-			return err
+		if len(pathsToStage) > 0 {
+			self.c.LogAction(self.c.Tr.Actions.StageResolvedFiles)
+			if err := self.c.Git().WorkingTree.StageFiles(pathsToStage); err != nil {
+				return err
+			}
 		}
 	}
 
diff --git a/pkg/gui/filetree/file_node.go b/pkg/gui/filetree/file_node.go
index d9b28d1ca6d..d55bc56aa24 100644
--- a/pkg/gui/filetree/file_node.go
+++ b/pkg/gui/filetree/file_node.go
@@ -1,6 +1,9 @@
 package filetree
 
-import "github.com/jesseduffield/lazygit/pkg/commands/models"
+import (
+	"github.com/jesseduffield/lazygit/pkg/commands/models"
+	"github.com/jesseduffield/lazygit/pkg/gui/mergeconflicts"
+)
 
 // FileNode wraps a node and provides some file-specific methods for it.
 type FileNode struct {
@@ -44,7 +47,13 @@ func (self *FileNode) GetHasStagedChanges() bool {
 }
 
 func (self *FileNode) GetHasInlineMergeConflicts() bool {
-	return self.SomeFile(func(file *models.File) bool { return file.HasInlineMergeConflicts })
+	return self.SomeFile(func(file *models.File) bool {
+		if !file.HasInlineMergeConflicts {
+			return false
+		}
+		hasConflicts, _ := mergeconflicts.FileHasConflictMarkers(file.Name)
+		return hasConflicts
+	})
 }
 
 func (self *FileNode) GetIsTracked() bool {
diff --git a/schema/config.json b/schema/config.json
index 28318ddd60d..f1aa4544b4d 100644
--- a/schema/config.json
+++ b/schema/config.json
@@ -573,6 +573,11 @@
           "description": "If true, pass the --all arg to git fetch",
           "default": true
         },
+        "autoStageResolvedConflicts": {
+          "type": "boolean",
+          "description": "If true, lazygit will automatically stage files that used to have merge\nconflicts but no longer do; and it will also ask you if you want to\ncontinue a merge or rebase if you've resolved all conflicts. If false, it\nwon't do either of these things.",
+          "default": true
+        },
         "branchLogCmd": {
           "type": "string",
           "description": "Command used when displaying the current branch git log in the main window",


Test patch:
diff --git a/pkg/integration/tests/conflicts/resolve_no_auto_stage.go b/pkg/integration/tests/conflicts/resolve_no_auto_stage.go
new file mode 100644
index 00000000000..35a509126d7
--- /dev/null
+++ b/pkg/integration/tests/conflicts/resolve_no_auto_stage.go
@@ -0,0 +1,81 @@
+package conflicts
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+	"github.com/jesseduffield/lazygit/pkg/integration/tests/shared"
+)
+
+var ResolveNoAutoStage = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "Resolving conflicts without auto-staging",
+	ExtraCmdArgs: []string{},
+	Skip:         false,
+	SetupConfig: func(config *config.AppConfig) {
+		config.GetUserConfig().Git.AutoStageResolvedConflicts = false
+	},
+	SetupRepo: func(shell *Shell) {
+		shared.CreateMergeConflictFiles(shell)
+	},
+	Run: func(t *TestDriver, keys config.KeybindingConfig) {
+		t.Views().Files().
+			IsFocused().
+			Lines(
+				Contains("UU").Contains("file1").IsSelected(),
+				Contains("UU").Contains("file2"),
+			).
+			PressEnter()
+
+		t.Views().MergeConflicts().
+			IsFocused().
+			SelectedLines(
+				Contains("<<<<<<< HEAD"),
+				Contains("First Change"),
+				Contains("======="),
+			).
+			PressPrimaryAction()
+
+		t.Views().Files().
+			IsFocused().
+			// Resolving the conflict didn't auto-stage it
+			Lines(
+				Contains("UU").Contains("file1").IsSelected(),
+				Contains("UU").Contains("file2"),
+			).
+			// So do that manually
+			PressPrimaryAction().
+			Lines(
+				Contains("UU").Contains("file2").IsSelected(),
+			).
+			// Trying to stage a file that still has conflicts is not allowed:
+			PressPrimaryAction().
+			Tap(func() {
+				t.ExpectPopup().Alert().
+					Title(Equals("Error")).
+					Content(Contains("Cannot stage/unstage directory containing files with inline merge conflicts.")).
+					Confirm()
+			}).
+			PressEnter()
+
+		// coincidentally these files have the same conflict
+		t.Views().MergeConflicts().
+			IsFocused().
+			SelectedLines(
+				Contains("<<<<<<< HEAD"),
+				Contains("First Change"),
+				Contains("======="),
+			).
+			PressPrimaryAction()
+
+		t.Views().Files().
+			IsFocused().
+			// Again, resolving the conflict didn't auto-stage it
+			Lines(
+				Contains("UU").Contains("file2").IsSelected(),
+			).
+			// Doing that manually now works:
+			PressPrimaryAction().
+			Lines(
+				Contains("A").Contains("file3").IsSelected(),
+			)
+	},
+})
diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go
index 584d7a07d76..d12d4c95f8d 100644
--- a/pkg/integration/tests/test_list.go
+++ b/pkg/integration/tests/test_list.go
@@ -115,6 +115,7 @@ var tests = []*components.IntegrationTest{
 	conflicts.Filter,
 	conflicts.ResolveExternally,
 	conflicts.ResolveMultipleFiles,
+	conflicts.ResolveNoAutoStage,
 	conflicts.UndoChooseHunk,
 	custom_commands.AccessCommitProperties,
 	custom_commands.BasicCommand,