Honor the conflict-marker-size gitattribute (#5902)

If the `conflict-marker-size` git attribute is used to set the marker
size to a non-default value (!= 7), lazygit's handling of conflicted
files was totally broken. Stopping at a commit with conflicts in a
rebase would show the `UU` files for a moment, and then, a few seconds
later, would stage all conflicted files and offer to continue the rebase
(with the conflicts baked into the resulting commits if you confirmed).
Even if you cancelled the continue prompt, it wasn't possible to use
git's conflict panel to resolve the conflicts; it would only show the
regular diff for those files, not its conflicts editor.

Fix this by querying the `conflict-marker-size` git attribute for all
conflicting files and use that to match the conflict markers.

Fixes #4367.
This commit is contained in:
Stefan Haller
2026-08-08 12:58:14 +02:00
committed by GitHub
15 changed files with 401 additions and 50 deletions
+60
View File
@@ -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
@@ -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,
+8 -4
View File
@@ -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
+2 -2
View File
@@ -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 {
@@ -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
}
@@ -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 {
+1 -1
View File
@@ -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
})
}
+59 -22
View File
@@ -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,59 @@ 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". 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] == ' ')
}
// 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 +132,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
}
}
+88 -7
View File
@@ -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,75 @@ func TestDetermineLineType(t *testing.T) {
line: "||||||| adf33b9",
expected: ANCESTOR,
},
{
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,
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{
@@ -88,16 +149,36 @@ func TestFindConflictsAux(t *testing.T) {
content: " <<<<<<< ",
expected: false,
},
{
content: ">>>>>>>",
expected: true,
},
{
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)
}
}
+13 -4
View File
@@ -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
+1 -1
View File
@@ -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))
})
}
}
@@ -0,0 +1,43 @@
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).
// 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()
},
})
@@ -0,0 +1,40 @@
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()
t.Views().MergeConflicts().
IsFocused().
SelectedLines(
Contains(startMarker+" HEAD"),
Contains("First Change"),
Contains(strings.Repeat("=", shared.CustomConflictMarkerSize)),
).
PressPrimaryAction().
Content(DoesNotContain(startMarker + " HEAD\nFirst Change"))
},
})
+16
View File
@@ -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.
+2
View File
@@ -165,6 +165,8 @@ var tests = []*components.IntegrationTest{
config.NegativeRefspec,
config.RemoteNamedStar,
config.SidePanelsInPerRepoConfig,
conflicts.ConflictMarkerSizeNotAutoStaged,
conflicts.ConflictMarkerSizeResolve,
conflicts.ContinuePromptDismissedWhenResolvedExternally,
conflicts.Filter,
conflicts.MergeFileBoth,