From 32e307538fa45c47623365fa5d5041d0fafc3943 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 6 Aug 2026 11:46:54 +0200 Subject: [PATCH 1/4] Make the conflict marker size a parameter of our marker matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git doesn't always write conflict markers of seven characters: the conflict-marker-size gitattribute overrides that per file, and it is set for good reasons — for file types whose regular content tends to contain marker-looking lines, such as documentation about merging, or test scripts. We hard-code seven characters everywhere we look for markers, so none of that works. Prepare for honoring the attribute by threading the marker size through everything that recognizes a marker, carried on the file model. Nothing fills it in yet, so we still use git's default size of seven everywhere, and matching is unchanged: a marker consists of exactly that many marker characters, and all but the "=======" one are followed by a space and a label. --- pkg/commands/models/file.go | 12 ++- pkg/gui/controllers/files_controller.go | 4 +- .../helpers/merge_conflicts_helper.go | 18 +++-- pkg/gui/controllers/helpers/refresh_helper.go | 3 +- pkg/gui/filetree/file_node.go | 2 +- pkg/gui/mergeconflicts/find_conflicts.go | 79 +++++++++++++------ pkg/gui/mergeconflicts/find_conflicts_test.go | 78 ++++++++++++++++-- pkg/gui/mergeconflicts/state.go | 17 +++- pkg/gui/mergeconflicts/state_test.go | 2 +- 9 files changed, 165 insertions(+), 50 deletions(-) diff --git a/pkg/commands/models/file.go b/pkg/commands/models/file.go index e48696a4f0c..9eedfb1fc49 100644 --- a/pkg/commands/models/file.go +++ b/pkg/commands/models/file.go @@ -18,10 +18,14 @@ type File struct { Deleted bool HasMergeConflicts bool HasInlineMergeConflicts bool - DisplayString string - ShortStatus string // e.g. 'AD', ' A', 'M ', '??' - LinesDeleted int - LinesAdded int + // How long the conflict markers in this file are, taken from its + // conflict-marker-size gitattribute; 0 if it doesn't have that attribute. We + // only look this up for files that have inline merge conflicts. + ConflictMarkerSize int + DisplayString string + ShortStatus string // e.g. 'AD', ' A', 'M ', '??' + LinesDeleted int + LinesAdded int // If true, this must be a worktree folder IsWorktree bool diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 567b0b6e5b6..d9ef55a715a 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -328,7 +328,7 @@ func (self *FilesController) renderSubmoduleConflict(node *filetree.FileNode) { // (it was resolved in an editor), in which case the caller should fall back to // showing the file's diff. func (self *FilesController) renderInlineMergeConflict(node *filetree.FileNode) bool { - hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.GetPath()) + hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.File) if err != nil { return true } @@ -1264,7 +1264,7 @@ func (self *FilesController) switchToMerge() error { return nil } - return self.c.Helpers().MergeConflicts.SwitchToMerge(file.Path) + return self.c.Helpers().MergeConflicts.SwitchToMerge(file) } func (self *FilesController) createStashMenu() error { diff --git a/pkg/gui/controllers/helpers/merge_conflicts_helper.go b/pkg/gui/controllers/helpers/merge_conflicts_helper.go index 34ae285f094..3928ecebfcb 100644 --- a/pkg/gui/controllers/helpers/merge_conflicts_helper.go +++ b/pkg/gui/controllers/helpers/merge_conflicts_helper.go @@ -1,6 +1,7 @@ package helpers import ( + "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" ) @@ -17,14 +18,14 @@ func NewMergeConflictsHelper( } } -func (self *MergeConflictsHelper) SetMergeState(path string) (bool, error) { +func (self *MergeConflictsHelper) SetMergeState(file *models.File) (bool, error) { self.context().GetMutex().Lock() defer self.context().GetMutex().Unlock() - return self.setMergeStateWithoutLock(path) + return self.setMergeStateWithoutLock(file.Path, file.ConflictMarkerSize) } -func (self *MergeConflictsHelper) setMergeStateWithoutLock(path string) (bool, error) { +func (self *MergeConflictsHelper) setMergeStateWithoutLock(path string, markerSize int) (bool, error) { content, err := self.c.Git().File.Cat(path) if err != nil { return false, err @@ -34,7 +35,7 @@ func (self *MergeConflictsHelper) setMergeStateWithoutLock(path string) (bool, e self.context().SetUserScrolling(false) } - self.context().GetState().SetContent(content, path) + self.context().GetState().SetContent(content, path, markerSize) return !self.context().GetState().NoConflicts(), nil } @@ -72,7 +73,8 @@ func (self *MergeConflictsHelper) SetConflictsAndRender() (bool, error) { self.context().GetMutex().Lock() defer self.context().GetMutex().Unlock() - hasConflicts, err := self.setMergeStateWithoutLock(self.context().GetState().GetPath()) + state := self.context().GetState() + hasConflicts, err := self.setMergeStateWithoutLock(state.GetPath(), state.GetMarkerSize()) if err != nil { return false, err } @@ -84,9 +86,9 @@ func (self *MergeConflictsHelper) SetConflictsAndRender() (bool, error) { return false, nil } -func (self *MergeConflictsHelper) SwitchToMerge(path string) error { - if self.context().GetState().GetPath() != path { - hasConflicts, err := self.SetMergeState(path) +func (self *MergeConflictsHelper) SwitchToMerge(file *models.File) error { + if self.context().GetState().GetPath() != file.Path { + hasConflicts, err := self.SetMergeState(file) if err != nil { return err } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index d0327216c8f..53505ba6c8c 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1305,7 +1305,8 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re // process working directory, which may already point at another // repo if the user switched while this refresh was in flight. hasConflicts, err := mergeconflicts.FileHasConflictMarkers( - filepath.Join(env.git.RepoPaths.WorktreePath(), file.Path)) + filepath.Join(env.git.RepoPaths.WorktreePath(), file.Path), + file.ConflictMarkerSize) if err != nil { self.c.Log.Error(err) } else if !hasConflicts { diff --git a/pkg/gui/filetree/file_node.go b/pkg/gui/filetree/file_node.go index 0836eaf02d9..04c98f1fafc 100644 --- a/pkg/gui/filetree/file_node.go +++ b/pkg/gui/filetree/file_node.go @@ -51,7 +51,7 @@ func (self *FileNode) GetHasInlineMergeConflicts() bool { if !file.HasInlineMergeConflicts { return false } - hasConflicts, _ := mergeconflicts.FileHasConflictMarkers(file.Path) + hasConflicts, _ := mergeconflicts.FileHasConflictMarkers(file.Path, file.ConflictMarkerSize) return hasConflicts }) } diff --git a/pkg/gui/mergeconflicts/find_conflicts.go b/pkg/gui/mergeconflicts/find_conflicts.go index 5fe45624e7a..a296baf74dc 100644 --- a/pkg/gui/mergeconflicts/find_conflicts.go +++ b/pkg/gui/mergeconflicts/find_conflicts.go @@ -2,7 +2,6 @@ package mergeconflicts import ( "bufio" - "bytes" "io" "os" "strings" @@ -22,7 +21,23 @@ const ( NOT_A_MARKER ) -func findConflicts(content string) []*mergeConflict { +// The number of characters a conflict marker consists of, unless the file's +// conflict-marker-size gitattribute says otherwise. +const defaultConflictMarkerSize = 7 + +// The marker size that everything in here takes is the conflict-marker-size +// gitattribute of the file being examined, which is 0 for a file that doesn't +// have that attribute. Git falls back to its default size in that case, so we +// do the same. +func effectiveMarkerSize(markerSize int) int { + if markerSize < 1 { + return defaultConflictMarkerSize + } + + return markerSize +} + +func findConflicts(content string, markerSize int) []*mergeConflict { conflicts := make([]*mergeConflict, 0) if content == "" { @@ -31,7 +46,7 @@ func findConflicts(content string) []*mergeConflict { var newConflict *mergeConflict for i, line := range utils.SplitLines(content) { - switch determineLineType(line) { + switch determineLineType(line, markerSize) { case START: newConflict = &mergeConflict{start: i, ancestor: -1} case ANCESTOR: @@ -57,35 +72,57 @@ func findConflicts(content string) []*mergeConflict { return conflicts } -var ( - CONFLICT_START = "<<<<<<< " - CONFLICT_END = ">>>>>>> " - CONFLICT_START_BYTES = []byte(CONFLICT_START) - CONFLICT_END_BYTES = []byte(CONFLICT_END) -) +func determineLineType(line string, markerSize int) LineType { + markerSize = effectiveMarkerSize(markerSize) -func determineLineType(line string) LineType { // TODO: find out whether we ever actually get this prefix trimmedLine := strings.TrimPrefix(line, "++") switch { - case strings.HasPrefix(trimmedLine, CONFLICT_START): + case isConflictMarker(trimmedLine, '<', markerSize): return START - case strings.HasPrefix(trimmedLine, "||||||| "): + case isConflictMarker(trimmedLine, '|', markerSize): return ANCESTOR - case trimmedLine == "=======": + case isTargetMarker(trimmedLine, markerSize): return TARGET - case strings.HasPrefix(trimmedLine, CONFLICT_END): + case isConflictMarker(trimmedLine, '>', markerSize): return END default: return NOT_A_MARKER } } +// Tells us whether the line begins with markerSize repetitions of markerChar. +func hasMarkerPrefix[T string | []byte](line T, markerChar byte, markerSize int) bool { + if len(line) < markerSize { + return false + } + + for i := range markerSize { + if line[i] != markerChar { + return false + } + } + + return true +} + +// A start, ancestor or end marker is followed by a space and a label, e.g. +// "<<<<<<< HEAD". +func isConflictMarker[T string | []byte](line T, markerChar byte, markerSize int) bool { + return hasMarkerPrefix(line, markerChar, markerSize) && + len(line) > markerSize && line[markerSize] == ' ' +} + +// The marker separating the two sides of a conflict never has a label after it. +func isTargetMarker(line string, markerSize int) bool { + return hasMarkerPrefix(line, '=', markerSize) && len(line) == markerSize +} + // tells us whether a file actually has inline merge conflicts. We need to run this // because git will continue showing a status of 'UU' even after the conflicts have // been resolved in the user's editor -func FileHasConflictMarkers(path string) (bool, error) { +func FileHasConflictMarkers(path string, markerSize int) (bool, error) { file, err := os.Open(path) if err != nil { return false, err @@ -93,22 +130,20 @@ func FileHasConflictMarkers(path string) (bool, error) { defer file.Close() - return fileHasConflictMarkersAux(file) + return fileHasConflictMarkersAux(file, markerSize) } // Efficiently scans through a file looking for merge conflict markers. Returns true if it does -func fileHasConflictMarkersAux(file io.Reader) (bool, error) { +func fileHasConflictMarkersAux(file io.Reader, markerSize int) (bool, error) { + markerSize = effectiveMarkerSize(markerSize) + scanner := bufio.NewScanner(file) scanner.Split(utils.ScanLinesAndTruncateWhenLongerThanBuffer(bufio.MaxScanTokenSize)) for scanner.Scan() { line := scanner.Bytes() // only searching for start/end markers because the others are more ambiguous - if bytes.HasPrefix(line, CONFLICT_START_BYTES) { - return true, nil - } - - if bytes.HasPrefix(line, CONFLICT_END_BYTES) { + if isConflictMarker(line, '<', markerSize) || isConflictMarker(line, '>', markerSize) { return true, nil } } diff --git a/pkg/gui/mergeconflicts/find_conflicts_test.go b/pkg/gui/mergeconflicts/find_conflicts_test.go index c763aa51f4f..97a61a3c4c5 100644 --- a/pkg/gui/mergeconflicts/find_conflicts_test.go +++ b/pkg/gui/mergeconflicts/find_conflicts_test.go @@ -8,9 +8,12 @@ import ( ) func TestDetermineLineType(t *testing.T) { + // A markerSize of 0 means the file has no conflict-marker-size gitattribute, + // so git's default size applies. type scenario struct { - line string - expected LineType + line string + markerSize int + expected LineType } scenarios := []scenario{ @@ -54,17 +57,62 @@ func TestDetermineLineType(t *testing.T) { line: "||||||| adf33b9", expected: ANCESTOR, }, + { + line: "<<<<<<<<", + expected: NOT_A_MARKER, + }, + { + line: strings.Repeat("<", 32) + " HEAD", + markerSize: 32, + expected: START, + }, + { + line: strings.Repeat("|", 32) + " adf33b9", + markerSize: 32, + expected: ANCESTOR, + }, + { + line: strings.Repeat("=", 32), + markerSize: 32, + expected: TARGET, + }, + { + line: strings.Repeat(">", 32) + " blah", + markerSize: 32, + expected: END, + }, + // A file gets a bigger marker size precisely because its regular content + // tends to contain marker-looking lines, so lines with the default size + // must not be mistaken for markers + { + line: "<<<<<<< HEAD", + markerSize: 32, + expected: NOT_A_MARKER, + }, + { + line: "=======", + markerSize: 32, + expected: NOT_A_MARKER, + }, + { + line: strings.Repeat("=", 33), + markerSize: 32, + expected: NOT_A_MARKER, + }, } for _, s := range scenarios { - assert.EqualValues(t, s.expected, determineLineType(s.line)) + assert.EqualValues(t, s.expected, determineLineType(s.line, s.markerSize), s.line) } } func TestFindConflictsAux(t *testing.T) { + // A markerSize of 0 means the file has no conflict-marker-size gitattribute, + // so git's default size applies. type scenario struct { - content string - expected bool + content string + markerSize int + expected bool } scenarios := []scenario{ @@ -92,12 +140,28 @@ func TestFindConflictsAux(t *testing.T) { content: "a\nb\nc\n<<<<<<< ", expected: true, }, + { + content: "a\nb\nc\n" + strings.Repeat("<", 32) + " HEAD", + markerSize: 32, + expected: true, + }, + { + content: "a\nb\nc\n" + strings.Repeat(">", 32) + " blah", + markerSize: 32, + expected: true, + }, + // Marker-looking lines of the default size are the file's regular content + { + content: "a\nb\nc\n<<<<<<< HEAD\n=======\n>>>>>>> blah", + markerSize: 32, + expected: false, + }, } for _, s := range scenarios { reader := strings.NewReader(s.content) - result, err := fileHasConflictMarkersAux(reader) + result, err := fileHasConflictMarkersAux(reader, s.markerSize) assert.NoError(t, err) - assert.EqualValues(t, s.expected, result) + assert.EqualValues(t, s.expected, result, s.content) } } diff --git a/pkg/gui/mergeconflicts/state.go b/pkg/gui/mergeconflicts/state.go index 047241353e4..d38e0c75400 100644 --- a/pkg/gui/mergeconflicts/state.go +++ b/pkg/gui/mergeconflicts/state.go @@ -12,6 +12,9 @@ type State struct { // path of the file with the conflicts path string + // the file's conflict-marker-size gitattribute, or 0 if it doesn't have one + markerSize int + // This is a stack of the file content. It is used to undo changes. // The last item is the current file content. contents []string @@ -74,12 +77,13 @@ func (s *State) currentConflict() *mergeConflict { } // this is for starting a new merge conflict session -func (s *State) SetContent(content string, path string) { - if content == s.GetContent() && path == s.path { +func (s *State) SetContent(content string, path string, markerSize int) { + if content == s.GetContent() && path == s.path && markerSize == s.markerSize { return } s.path = path + s.markerSize = markerSize s.contents = []string{} s.PushContent(content) } @@ -88,7 +92,7 @@ func (s *State) SetContent(content string, path string) { // state func (s *State) PushContent(content string) { s.contents = append(s.contents, content) - s.setConflicts(findConflicts(content)) + s.setConflicts(findConflicts(content, s.markerSize)) } func (s *State) GetContent() string { @@ -103,6 +107,10 @@ func (s *State) GetPath() string { return s.path } +func (s *State) GetMarkerSize() int { + return s.markerSize +} + func (s *State) Undo() bool { if len(s.contents) <= 1 { return false @@ -112,7 +120,7 @@ func (s *State) Undo() bool { newContent := s.GetContent() // We could be storing the old conflicts and selected index on a stack too. - s.setConflicts(findConflicts(newContent)) + s.setConflicts(findConflicts(newContent, s.markerSize)) return true } @@ -147,6 +155,7 @@ func (s *State) AllConflictsResolved() bool { func (s *State) Reset() { s.contents = []string{} s.path = "" + s.markerSize = 0 } // we're not resetting selectedIndex here because the user typically would want diff --git a/pkg/gui/mergeconflicts/state_test.go b/pkg/gui/mergeconflicts/state_test.go index 7a9ee8c2691..06f8fa6bb12 100644 --- a/pkg/gui/mergeconflicts/state_test.go +++ b/pkg/gui/mergeconflicts/state_test.go @@ -116,7 +116,7 @@ baz for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { - assert.EqualValues(t, s.expected, findConflicts(s.content)) + assert.EqualValues(t, s.expected, findConflicts(s.content, defaultConflictMarkerSize)) }) } } From bfaf188ab9608a27ed0896da43856cb2d27ba901 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 6 Aug 2026 11:53:37 +0200 Subject: [PATCH 2/4] Add tests demonstrating that we ignore the conflict-marker-size gitattribute When a file's conflict markers aren't seven characters long we don't recognize them at all. Two things go wrong: we consider the file's conflicts resolved, so we stage it and offer to continue the merge a moment after stopping at it; and pressing enter on it shows its diff instead of the merge conflicts view, leaving no way to resolve it in lazygit. --- .../conflict_marker_size_not_auto_staged.go | 49 +++++++++++++++++++ .../conflicts/conflict_marker_size_resolve.go | 45 +++++++++++++++++ pkg/integration/tests/shared/conflicts.go | 16 ++++++ pkg/integration/tests/test_list.go | 2 + 4 files changed, 112 insertions(+) create mode 100644 pkg/integration/tests/conflicts/conflict_marker_size_not_auto_staged.go create mode 100644 pkg/integration/tests/conflicts/conflict_marker_size_resolve.go diff --git a/pkg/integration/tests/conflicts/conflict_marker_size_not_auto_staged.go b/pkg/integration/tests/conflicts/conflict_marker_size_not_auto_staged.go new file mode 100644 index 00000000000..2b32ddd61ca --- /dev/null +++ b/pkg/integration/tests/conflicts/conflict_marker_size_not_auto_staged.go @@ -0,0 +1,49 @@ +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 ConflictMarkerSizeNotAutoStaged = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Doesn't auto-stage an unresolved file whose conflict-marker-size gitattribute makes its markers longer than usual", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shared.SetCustomConflictMarkerSize(shell) + shared.CreateMergeConflictFile(shell) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Common().PretendMergeOrRebaseStartedInLazygit() + + t.Views().Files(). + IsFocused(). + Lines( + Contains("UU file").IsSelected(), + ). + // Each refresh checks whether the conflicts are still there + Press(keys.Universal.Refresh). + /* EXPECTED: + // They are, so the file doesn't get staged and we don't get asked to + // continue the merge + Lines( + Contains("UU file").IsSelected(), + ). + // Once they really are resolved, we do + Tap(func() { + t.Shell().UpdateFile("file", "resolved content") + }). + Press(keys.Universal.Refresh). + Tap(func() { + t.Common().ContinueOnConflictsResolved("merge") + }). + IsEmpty() + ACTUAL: */ + Tap(func() { + t.Common().ContinueOnConflictsResolved("merge") + }). + IsEmpty() + }, +}) diff --git a/pkg/integration/tests/conflicts/conflict_marker_size_resolve.go b/pkg/integration/tests/conflicts/conflict_marker_size_resolve.go new file mode 100644 index 00000000000..6e40c633fe8 --- /dev/null +++ b/pkg/integration/tests/conflicts/conflict_marker_size_resolve.go @@ -0,0 +1,45 @@ +package conflicts + +import ( + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" + "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" +) + +var ConflictMarkerSizeResolve = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Resolves a conflict in a file whose conflict-marker-size gitattribute makes its markers longer than usual", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shared.SetCustomConflictMarkerSize(shell) + shared.CreateMergeConflictFileMultiple(shell) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + startMarker := strings.Repeat("<", shared.CustomConflictMarkerSize) + + t.Views().Files(). + IsFocused(). + Lines( + Contains("UU file").IsSelected(), + ). + PressEnter() + + /* EXPECTED: + t.Views().MergeConflicts(). + IsFocused(). + SelectedLines( + Contains(startMarker+" HEAD"), + Contains("First Change"), + Contains(strings.Repeat("=", shared.CustomConflictMarkerSize)), + ). + PressPrimaryAction(). + Content(DoesNotContain(startMarker + " HEAD\nFirst Change")) + ACTUAL: */ + // We don't recognize the markers, so instead of the merge conflicts view + // we get the file's diff + t.Views().Main().Content(Contains(startMarker + " HEAD")) + }, +}) diff --git a/pkg/integration/tests/shared/conflicts.go b/pkg/integration/tests/shared/conflicts.go index b84c8c7add0..c8319acf485 100644 --- a/pkg/integration/tests/shared/conflicts.go +++ b/pkg/integration/tests/shared/conflicts.go @@ -1,6 +1,8 @@ package shared import ( + "fmt" + . "github.com/jesseduffield/lazygit/pkg/integration/components" ) @@ -28,6 +30,20 @@ Second Change File ` +// A conflict-marker-size that isn't git's default of 7. It's set for file types +// whose regular content tends to contain marker-looking lines, e.g. +// documentation about merging, or test scripts. +const CustomConflictMarkerSize = 32 + +// Makes git write conflict markers of CustomConflictMarkerSize characters into +// the file that the setups below create conflicts in. Call this before one of +// them. +var SetCustomConflictMarkerSize = func(shell *Shell) { + shell.CreateFileAndAdd(".gitattributes", + fmt.Sprintf("file conflict-marker-size=%d\n", CustomConflictMarkerSize)). + Commit("set a custom conflict marker size") +} + // prepares us for a rebase/merge that has conflicts var MergeConflictsSetup = func(shell *Shell) { shell. diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index a6b5aeafdf0..e8511fcf8d3 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -165,6 +165,8 @@ var tests = []*components.IntegrationTest{ config.NegativeRefspec, config.RemoteNamedStar, config.SidePanelsInPerRepoConfig, + conflicts.ConflictMarkerSizeNotAutoStaged, + conflicts.ConflictMarkerSizeResolve, conflicts.ContinuePromptDismissedWhenResolvedExternally, conflicts.Filter, conflicts.MergeFileBoth, From 807b39a5a050624ed0b78215c42c4c08a33dbde2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 6 Aug 2026 11:59:01 +0200 Subject: [PATCH 3/4] Honor the conflict-marker-size gitattribute Ask git for the attribute of every conflicted file whenever we load the file status, so that we recognize the markers it actually wrote. Files that are set up this way are precisely the ones whose regular content tends to contain marker-looking lines, so matching a run of at least seven characters instead is not an option: we'd take the file's own content for markers and then never consider its conflicts resolved. One `git check-attr` call covers all conflicted files at once; asking per file would take seconds when hundreds of files are conflicted, and it would hurt worst on Windows, where spawning a process is expensive. Because the lookup rides along with the file status, it costs nothing when there are no conflicts, and editing .gitattributes during a merge takes effect on the next refresh. --- pkg/commands/git_commands/file_loader.go | 60 +++++++++++++++++++ pkg/commands/git_commands/file_loader_test.go | 56 +++++++++++++++++ .../conflict_marker_size_not_auto_staged.go | 6 -- .../conflicts/conflict_marker_size_resolve.go | 5 -- 4 files changed, 116 insertions(+), 11 deletions(-) diff --git a/pkg/commands/git_commands/file_loader.go b/pkg/commands/git_commands/file_loader.go index 7e2bdf0f309..3f960cb3071 100644 --- a/pkg/commands/git_commands/file_loader.go +++ b/pkg/commands/git_commands/file_loader.go @@ -8,6 +8,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/samber/lo" ) type FileLoaderConfig interface { @@ -88,6 +89,8 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File files = append(files, file) } + self.setConflictMarkerSizes(files) + // Go through the files to see if any of these files are actually worktrees // so that we can render them correctly worktreePaths := linkedWortkreePaths(self.Fs, self.repoPaths.RepoGitDirPath()) @@ -111,6 +114,63 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File return files } +// Looks up how long the conflict markers in the conflicted files are. We ask +// git for all of them at once, because spawning a process per file would be +// painfully slow when hundreds of files are conflicted (especially on Windows). +func (self *FileLoader) setConflictMarkerSizes(files []*models.File) { + conflictedFiles := lo.Filter(files, func(file *models.File, _ int) bool { + return file.HasInlineMergeConflicts + }) + if len(conflictedFiles) == 0 { + return + } + + paths := lo.Map(conflictedFiles, func(file *models.File, _ int) string { + return file.Path + }) + + markerSizes, err := self.getConflictMarkerSizes(paths) + if err != nil { + self.Log.Error(err) + return + } + + for _, file := range conflictedFiles { + file.ConflictMarkerSize = markerSizes[file.Path] + } +} + +func (self *FileLoader) getConflictMarkerSizes(paths []string) (map[string]int, error) { + cmdArgs := NewGitCmd("check-attr"). + Arg("-z"). + Arg("--stdin"). + Arg("conflict-marker-size"). + ToArgv() + + // -z makes git both read the paths and write its output NUL-separated, so + // that paths containing newlines don't throw us off. + output, _, err := self.cmd.New(cmdArgs). + SetStdin(strings.Join(paths, "\x00")). + DontLog(). + RunWithOutputs() + if err != nil { + return nil, err + } + + markerSizes := map[string]int{} + fields := strings.Split(output, "\x00") + // Each path yields a path/attribute/value triple; the value is either a + // number or something like "unspecified", in which case we leave the marker + // size at 0 to say that git's default applies. + for i := 0; i+2 < len(fields); i += 3 { + if markerSize, err := strconv.Atoi(fields[i+2]); err == nil && markerSize > 0 { + markerSizes[fields[i]] = markerSize + } + } + + return markerSizes, nil +} + type FileDiff struct { LinesAdded int LinesDeleted int diff --git a/pkg/commands/git_commands/file_loader_test.go b/pkg/commands/git_commands/file_loader_test.go index ec1f502f1a8..23602ff9ea6 100644 --- a/pkg/commands/git_commands/file_loader_test.go +++ b/pkg/commands/git_commands/file_loader_test.go @@ -37,6 +37,10 @@ func TestFileGetStatusFiles(t *testing.T) { ExpectGitArgs([]string{"diff", "--numstat", "-z", "HEAD"}, "4\t1\tfile1.txt\x001\t0\tfile2.txt\x002\t2\tfile3.txt\x000\t2\tfile4.txt\x002\t2\tfile5.txt", nil, + ). + ExpectGitArgs([]string{"check-attr", "-z", "--stdin", "conflict-marker-size"}, + "file5.txt\x00conflict-marker-size\x00unspecified\x00", + nil, ), showNumstatInFilesView: true, expectedFiles: []*models.File{ @@ -112,6 +116,58 @@ func TestFileGetStatusFiles(t *testing.T) { }, }, }, + { + testName: "Conflicted files with a conflict-marker-size attribute", + similarityThreshold: 50, + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, + "UU file1.txt\x00UU file2.txt\x00UU file3.txt\x00 M file4.txt", + nil, + ). + ExpectGitArgs([]string{"check-attr", "-z", "--stdin", "conflict-marker-size"}, + "file1.txt\x00conflict-marker-size\x0032\x00"+ + "file2.txt\x00conflict-marker-size\x00unspecified\x00"+ + "file3.txt\x00conflict-marker-size\x00nonsense\x00", + nil, + ), + expectedFiles: []*models.File{ + { + Path: "file1.txt", + HasUnstagedChanges: true, + Tracked: true, + HasMergeConflicts: true, + HasInlineMergeConflicts: true, + ConflictMarkerSize: 32, + DisplayString: "UU file1.txt", + ShortStatus: "UU", + }, + { + Path: "file2.txt", + HasUnstagedChanges: true, + Tracked: true, + HasMergeConflicts: true, + HasInlineMergeConflicts: true, + DisplayString: "UU file2.txt", + ShortStatus: "UU", + }, + { + Path: "file3.txt", + HasUnstagedChanges: true, + Tracked: true, + HasMergeConflicts: true, + HasInlineMergeConflicts: true, + DisplayString: "UU file3.txt", + ShortStatus: "UU", + }, + { + Path: "file4.txt", + HasUnstagedChanges: true, + Tracked: true, + DisplayString: " M file4.txt", + ShortStatus: " M", + }, + }, + }, { testName: "File with new line char", similarityThreshold: 50, diff --git a/pkg/integration/tests/conflicts/conflict_marker_size_not_auto_staged.go b/pkg/integration/tests/conflicts/conflict_marker_size_not_auto_staged.go index 2b32ddd61ca..52931c19275 100644 --- a/pkg/integration/tests/conflicts/conflict_marker_size_not_auto_staged.go +++ b/pkg/integration/tests/conflicts/conflict_marker_size_not_auto_staged.go @@ -25,7 +25,6 @@ var ConflictMarkerSizeNotAutoStaged = NewIntegrationTest(NewIntegrationTestArgs{ ). // Each refresh checks whether the conflicts are still there Press(keys.Universal.Refresh). - /* EXPECTED: // They are, so the file doesn't get staged and we don't get asked to // continue the merge Lines( @@ -40,10 +39,5 @@ var ConflictMarkerSizeNotAutoStaged = NewIntegrationTest(NewIntegrationTestArgs{ t.Common().ContinueOnConflictsResolved("merge") }). IsEmpty() - ACTUAL: */ - Tap(func() { - t.Common().ContinueOnConflictsResolved("merge") - }). - IsEmpty() }, }) diff --git a/pkg/integration/tests/conflicts/conflict_marker_size_resolve.go b/pkg/integration/tests/conflicts/conflict_marker_size_resolve.go index 6e40c633fe8..13a64793162 100644 --- a/pkg/integration/tests/conflicts/conflict_marker_size_resolve.go +++ b/pkg/integration/tests/conflicts/conflict_marker_size_resolve.go @@ -27,7 +27,6 @@ var ConflictMarkerSizeResolve = NewIntegrationTest(NewIntegrationTestArgs{ ). PressEnter() - /* EXPECTED: t.Views().MergeConflicts(). IsFocused(). SelectedLines( @@ -37,9 +36,5 @@ var ConflictMarkerSizeResolve = NewIntegrationTest(NewIntegrationTestArgs{ ). PressPrimaryAction(). Content(DoesNotContain(startMarker + " HEAD\nFirst Change")) - ACTUAL: */ - // We don't recognize the markers, so instead of the merge conflicts view - // we get the file's diff - t.Views().Main().Content(Contains(startMarker + " HEAD")) }, }) From 96c0fd8ad666997059b711cbc1bf44edd0d9ef58 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 6 Aug 2026 12:00:18 +0200 Subject: [PATCH 4/4] Recognize conflict markers that have no label Git only writes the space after a marker when there is a label to write after it, and the label can be empty: `git checkout -m` with the diff3 conflict style, for instance, has no name for the common ancestor, so it writes a bare "|||||||" line. --- pkg/gui/mergeconflicts/find_conflicts.go | 6 ++++-- pkg/gui/mergeconflicts/find_conflicts_test.go | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/pkg/gui/mergeconflicts/find_conflicts.go b/pkg/gui/mergeconflicts/find_conflicts.go index a296baf74dc..e57c166353a 100644 --- a/pkg/gui/mergeconflicts/find_conflicts.go +++ b/pkg/gui/mergeconflicts/find_conflicts.go @@ -108,10 +108,12 @@ func hasMarkerPrefix[T string | []byte](line T, markerChar byte, markerSize int) } // A start, ancestor or end marker is followed by a space and a label, e.g. -// "<<<<<<< HEAD". +// "<<<<<<< HEAD". The label can be missing though, in which case git doesn't +// write the space either; `git checkout -m` with the diff3 conflict style does +// that for the ancestor marker, for example. func isConflictMarker[T string | []byte](line T, markerChar byte, markerSize int) bool { return hasMarkerPrefix(line, markerChar, markerSize) && - len(line) > markerSize && line[markerSize] == ' ' + (len(line) == markerSize || line[markerSize] == ' ') } // The marker separating the two sides of a conflict never has a label after it. diff --git a/pkg/gui/mergeconflicts/find_conflicts_test.go b/pkg/gui/mergeconflicts/find_conflicts_test.go index 97a61a3c4c5..28839126fb0 100644 --- a/pkg/gui/mergeconflicts/find_conflicts_test.go +++ b/pkg/gui/mergeconflicts/find_conflicts_test.go @@ -61,6 +61,19 @@ func TestDetermineLineType(t *testing.T) { line: "<<<<<<<<", expected: NOT_A_MARKER, }, + // Markers without a label + { + line: "<<<<<<<", + expected: START, + }, + { + line: "|||||||", + expected: ANCESTOR, + }, + { + line: ">>>>>>>", + expected: END, + }, { line: strings.Repeat("<", 32) + " HEAD", markerSize: 32, @@ -136,6 +149,10 @@ func TestFindConflictsAux(t *testing.T) { content: " <<<<<<< ", expected: false, }, + { + content: ">>>>>>>", + expected: true, + }, { content: "a\nb\nc\n<<<<<<< ", expected: true,