diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-05-04 03:06:03 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-05-04 03:06:03 +0300 |
commit | b0838ee4f37f6f53b92ddbe08c0862f44ad9f8d3 (patch) | |
tree | f30629b2e4ea0aff4b47a2d1b6d4c6a4fc05a8e3 | |
parent | f59b942e9476914194331bb088b9ddfd78aa234f (diff) | |
parent | aa4297c302139f2b27c4210b451d97c0f350186b (diff) |
Automatic merge of gitlab-org/gitaly master
-rw-r--r-- | internal/git/localrepo/merge.go | 83 | ||||
-rw-r--r-- | internal/git/localrepo/merge_test.go | 855 | ||||
-rw-r--r-- | internal/gitaly/service/operations/merge.go | 2 |
3 files changed, 685 insertions, 255 deletions
diff --git a/internal/git/localrepo/merge.go b/internal/git/localrepo/merge.go index 79f3bd60b..7fd38b22b 100644 --- a/internal/git/localrepo/merge.go +++ b/internal/git/localrepo/merge.go @@ -27,6 +27,11 @@ const ( MergeStageTheirs = MergeStage(3) ) +// ErrMergeTreeUnrelatedHistory is used to denote the error when trying to merge two +// trees without unrelated history. This occurs when we don't use set the +// `allowUnrelatedHistories` option in the config. +var ErrMergeTreeUnrelatedHistory = errors.New("unrelated histories") + type mergeTreeConfig struct { allowUnrelatedHistories bool conflictingFileNamesOnly bool @@ -65,6 +70,7 @@ func (repo *Repo) MergeTree( } flags := []git.Option{ + git.Flag{Name: "-z"}, git.Flag{Name: "--write-tree"}, } @@ -100,9 +106,7 @@ func (repo *Repo) MergeTree( if exitCode > 1 { if text.ChompBytes(stderr.Bytes()) == "fatal: refusing to merge unrelated histories" { - return "", &MergeTreeError{ - InfoMessage: "unrelated histories", - } + return "", ErrMergeTreeUnrelatedHistory } return "", fmt.Errorf("merge-tree: %w", err) } @@ -110,7 +114,7 @@ func (repo *Repo) MergeTree( return parseMergeTreeError(objectHash, config, stdout.String()) } - oid, err := objectHash.FromHex(text.ChompBytes(stdout.Bytes())) + oid, err := objectHash.FromHex(strings.Split(stdout.String(), "\x00")[0]) if err != nil { return "", fmt.Errorf("hex to oid: %w", err) } @@ -122,35 +126,32 @@ func (repo *Repo) MergeTree( // a MergeTreeResult struct. The format for the output can be found at // https://git-scm.com/docs/git-merge-tree#OUTPUT. func parseMergeTreeError(objectHash git.ObjectHash, cfg mergeTreeConfig, output string) (git.ObjectID, error) { - var mergeTreeError MergeTreeError - - lines := strings.SplitN(output, "\n\n", 2) + var mergeTreeConflictError MergeTreeConflictError - // When the output is of unexpected length - if len(lines) < 2 { - return "", errors.New("error parsing merge tree result") + oidAndConflictsBuf, infoMsg, ok := strings.Cut(output, "\x00\x00") + if !ok { + return "", fmt.Errorf("couldn't parse merge tree output: %s", output) } - mergeTreeError.InfoMessage = strings.TrimSuffix(lines[1], "\n") - oidAndConflicts := strings.Split(lines[0], "\n") - + oidAndConflicts := strings.Split(oidAndConflictsBuf, "\x00") + // When the output is of unexpected length if len(oidAndConflicts) < 2 { - return "", &mergeTreeError + return "", errors.New("couldn't split oid and file info") } - oid, err := objectHash.FromHex(text.ChompBytes([]byte(oidAndConflicts[0]))) + oid, err := objectHash.FromHex(oidAndConflicts[0]) if err != nil { return "", fmt.Errorf("hex to oid: %w", err) } - mergeTreeError.ConflictingFileInfo = make([]ConflictingFileInfo, len(oidAndConflicts[1:])) + mergeTreeConflictError.ConflictingFileInfo = make([]ConflictingFileInfo, len(oidAndConflicts[1:])) // From git-merge-tree(1), the information is of the format `<mode> <object> <stage> <filename>` // unless the `--name-only` option is used, in which case only the filename is output. // Note: that there is \t before the filename (https://gitlab.com/gitlab-org/git/blob/v2.40.0/builtin/merge-tree.c#L481) for i, infoLine := range oidAndConflicts[1:] { if cfg.conflictingFileNamesOnly { - mergeTreeError.ConflictingFileInfo[i].FileName = infoLine + mergeTreeConflictError.ConflictingFileInfo[i].FileName = infoLine } else { infoAndFilename := strings.Split(infoLine, "\t") if len(infoAndFilename) != 2 { @@ -162,7 +163,7 @@ func parseMergeTreeError(objectHash git.ObjectHash, cfg mergeTreeConfig, output return "", fmt.Errorf("parsing conflicting file info: %s", infoLine) } - mergeTreeError.ConflictingFileInfo[i].OID, err = objectHash.FromHex(info[1]) + mergeTreeConflictError.ConflictingFileInfo[i].OID, err = objectHash.FromHex(info[1]) if err != nil { return "", fmt.Errorf("hex to oid: %w", err) } @@ -176,12 +177,37 @@ func parseMergeTreeError(objectHash git.ObjectHash, cfg mergeTreeConfig, output return "", fmt.Errorf("invalid value for stage: %d", stage) } - mergeTreeError.ConflictingFileInfo[i].Stage = MergeStage(stage) - mergeTreeError.ConflictingFileInfo[i].FileName = infoAndFilename[1] + mergeTreeConflictError.ConflictingFileInfo[i].Stage = MergeStage(stage) + mergeTreeConflictError.ConflictingFileInfo[i].FileName = infoAndFilename[1] } } - return oid, &mergeTreeError + fields := strings.Split(infoMsg, "\x00") + // The git output contains a null characted at the end, which creates a stray empty field. + fields = fields[:len(fields)-1] + + for i := 0; i < len(fields); { + c := ConflictInfoMessage{} + + numOfPaths, err := strconv.Atoi(fields[i]) + if err != nil { + return "", fmt.Errorf("converting stage to int: %w", err) + } + + if i+numOfPaths+2 > len(fields) { + return "", fmt.Errorf("incorrect number of fields: %s", infoMsg) + } + + c.Paths = fields[i+1 : i+numOfPaths+1] + c.Type = fields[i+numOfPaths+1] + c.Message = fields[i+numOfPaths+2] + + mergeTreeConflictError.ConflictInfoMessage = append(mergeTreeConflictError.ConflictInfoMessage, c) + + i = i + numOfPaths + 3 + } + + return oid, &mergeTreeConflictError } // ConflictingFileInfo holds the conflicting file info output from git-merge-tree(1). @@ -191,15 +217,22 @@ type ConflictingFileInfo struct { Stage MergeStage } -// MergeTreeError encapsulates any conflicting file info and messages that occur +// ConflictInfoMessage holds the information message output from git-merge-tree(1). +type ConflictInfoMessage struct { + Paths []string + Type string + Message string +} + +// MergeTreeConflictError encapsulates any conflicting file info and messages that occur // when a merge-tree(1) command fails. -type MergeTreeError struct { +type MergeTreeConflictError struct { ConflictingFileInfo []ConflictingFileInfo - InfoMessage string + ConflictInfoMessage []ConflictInfoMessage } // Error returns the error string for a conflict error. -func (c *MergeTreeError) Error() string { +func (c *MergeTreeConflictError) Error() string { // TODO: for now, it's better that this error matches the git2go // error but once we deprecate the git2go code path in // merges, we can change this error to print out the conflicting files diff --git a/internal/git/localrepo/merge_test.go b/internal/git/localrepo/merge_test.go index 39426f229..4f00d7e74 100644 --- a/internal/git/localrepo/merge_test.go +++ b/internal/git/localrepo/merge_test.go @@ -1,10 +1,10 @@ package localrepo import ( - "context" "errors" "fmt" "strconv" + "strings" "testing" "github.com/stretchr/testify/require" @@ -15,16 +15,29 @@ import ( ) func TestMergeTree(t *testing.T) { + t.Parallel() + cfg := testcfg.Build(t) + type setupData struct { + ours git.ObjectID + theirs git.ObjectID + + expectedTreeEntries []gittest.TreeEntry + expectedErr error + } + testCases := []struct { - desc string - expectedErr error - setupFunc func(t *testing.T, ctx context.Context, repoPath string) (ours, theirs git.ObjectID, expectedTreeEntries []gittest.TreeEntry) + desc string + mergeTreeOptions []MergeTreeOption + setup func(t *testing.T, repoPath string) setupData }{ { desc: "normal merge", - setupFunc: func(t *testing.T, ctx context.Context, repoPath string) (git.ObjectID, git.ObjectID, []gittest.TreeEntry) { + mergeTreeOptions: []MergeTreeOption{ + WithConflictingFileNamesOnly(), + }, + setup: func(t *testing.T, repoPath string) setupData { tree1 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ { Mode: "100644", @@ -32,11 +45,7 @@ func TestMergeTree(t *testing.T) { Content: "foo", }, }) - baseCommit := gittest.WriteCommit(t, cfg, repoPath, - gittest.WithCommitterName("Andy"), - gittest.WithAuthorName("Andy"), - gittest.WithTree(tree1), - ) + baseCommit := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree1)) tree2 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ { Mode: "100644", @@ -61,44 +70,65 @@ func TestMergeTree(t *testing.T) { Content: "bar", }, }) - ours := gittest.WriteCommit(t, cfg, repoPath, - gittest.WithTree(tree2), - gittest.WithParents(baseCommit), - gittest.WithAuthorName("Woody"), - gittest.WithCommitterName("Woody"), - ) - theirs := gittest.WriteCommit(t, cfg, repoPath, - gittest.WithTree(tree3), - gittest.WithParents(baseCommit), - gittest.WithAuthorName("Buzz"), - gittest.WithCommitterName("Buzz"), - ) + ours := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree2), gittest.WithParents(baseCommit)) + theirs := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree3), gittest.WithParents(baseCommit)) - return ours, theirs, []gittest.TreeEntry{ + return setupData{ + ours: ours, + theirs: theirs, + expectedTreeEntries: []gittest.TreeEntry{ + { + Mode: "100644", + Path: "file1", + Content: "foo", + }, + { + Mode: "100644", + Path: "file2", + Content: "baz", + }, + { + Mode: "100644", + Path: "file3", + Content: "bar", + }, + }, + } + }, + }, + { + desc: "no shared ancestors", + mergeTreeOptions: []MergeTreeOption{ + WithConflictingFileNamesOnly(), + }, + setup: func(t *testing.T, repoPath string) setupData { + tree1 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ { Mode: "100644", Path: "file1", Content: "foo", }, + }) + tree2 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ { Mode: "100644", Path: "file2", Content: "baz", }, - { - Mode: "100644", - Path: "file3", - Content: "bar", - }, + }) + ours := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree1)) + theirs := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree2)) + + return setupData{ + ours: ours, + theirs: theirs, + expectedErr: ErrMergeTreeUnrelatedHistory, } }, }, { - desc: "no shared ancestors", - expectedErr: &MergeTreeError{ - InfoMessage: "unrelated histories", - }, - setupFunc: func(t *testing.T, ctx context.Context, repoPath string) (git.ObjectID, git.ObjectID, []gittest.TreeEntry) { + desc: "with single file conflict", + setup: func(t *testing.T, repoPath string) setupData { tree1 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ { Mode: "100644", @@ -106,40 +136,152 @@ func TestMergeTree(t *testing.T) { Content: "foo", }, }) + baseCommit := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree1)) + blob1 := gittest.WriteBlob(t, cfg, repoPath, []byte("baz")) tree2 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ { Mode: "100644", - Path: "file2", - Content: "baz", + Path: "file1", + Content: "foo", + }, + { + OID: blob1, + Mode: "100644", + Path: "file2", }, }) - ours := gittest.WriteCommit(t, cfg, repoPath, - gittest.WithTree(tree1), - gittest.WithAuthorName("Woody"), - gittest.WithCommitterName("Woody"), - ) - theirs := gittest.WriteCommit(t, cfg, repoPath, - gittest.WithTree(tree2), - gittest.WithAuthorName("Buzz"), - gittest.WithCommitterName("Buzz"), - ) + blob2 := gittest.WriteBlob(t, cfg, repoPath, []byte("bar")) + tree3 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ + { + Mode: "100644", + Path: "file1", + Content: "foo", + }, + { + OID: blob2, + Mode: "100644", + Path: "file2", + }, + }) + ours := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree2), gittest.WithParents(baseCommit)) + theirs := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree3), gittest.WithParents(baseCommit)) - return ours, theirs, nil + return setupData{ + ours: ours, + theirs: theirs, + expectedErr: &MergeTreeConflictError{ + ConflictingFileInfo: []ConflictingFileInfo{ + { + FileName: "file2", + OID: blob1, + Stage: MergeStageOurs, + }, + { + FileName: "file2", + OID: blob2, + Stage: MergeStageTheirs, + }, + }, + ConflictInfoMessage: []ConflictInfoMessage{ + { + Paths: []string{"file2"}, + Type: "Auto-merging", + Message: "Auto-merging file2\n", + }, + { + Paths: []string{"file2"}, + Type: "CONFLICT (contents)", + Message: "CONFLICT (add/add): Merge conflict in file2\n", + }, + }, + }, + } }, }, { - desc: "with conflicts", - expectedErr: &MergeTreeError{ - ConflictingFileInfo: []ConflictingFileInfo{ + desc: "with single file conflict with ancestor", + setup: func(t *testing.T, repoPath string) setupData { + blob1 := gittest.WriteBlob(t, cfg, repoPath, []byte("foo")) + tree1 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ + { + OID: blob1, + Mode: "100644", + Path: "file1", + }, + }) + baseCommit := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree1)) + + blob2 := gittest.WriteBlob(t, cfg, repoPath, []byte("baz")) + tree2 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ + { + OID: blob2, + Mode: "100644", + Path: "file1", + }, + { + Mode: "100644", + Path: "file2", + Content: "goo", + }, + }) + + blob3 := gittest.WriteBlob(t, cfg, repoPath, []byte("bar")) + tree3 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ + { + OID: blob3, + Mode: "100644", + Path: "file1", + }, { - FileName: "file2", - OID: "", - Stage: 0, + Mode: "100644", + Path: "file2", + Content: "goo", + }, + }) + + ours := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree2), gittest.WithParents(baseCommit)) + theirs := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree3), gittest.WithParents(baseCommit)) + + return setupData{ + ours: ours, + theirs: theirs, + expectedErr: &MergeTreeConflictError{ + ConflictingFileInfo: []ConflictingFileInfo{ + { + FileName: "file1", + OID: blob1, + Stage: MergeStageAncestor, + }, + { + FileName: "file1", + OID: blob2, + Stage: MergeStageOurs, + }, + { + FileName: "file1", + OID: blob3, + Stage: MergeStageTheirs, + }, + }, + ConflictInfoMessage: []ConflictInfoMessage{ + { + Paths: []string{"file1"}, + Type: "Auto-merging", + Message: "Auto-merging file1\n", + }, + { + Paths: []string{"file1"}, + Type: "CONFLICT (contents)", + Message: "CONFLICT (content): Merge conflict in file1\n", + }, + }, }, - }, - InfoMessage: "Auto-merging file2\nCONFLICT (add/add): Merge conflict in file2", + } }, - setupFunc: func(t *testing.T, ctx context.Context, repoPath string) (git.ObjectID, git.ObjectID, []gittest.TreeEntry) { + }, + { + desc: "with single file conflict due to rename", + setup: func(t *testing.T, repoPath string) setupData { tree1 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ { Mode: "100644", @@ -147,55 +289,393 @@ func TestMergeTree(t *testing.T) { Content: "foo", }, }) - baseCommit := gittest.WriteCommit(t, cfg, repoPath, - gittest.WithCommitterName("Andy"), - gittest.WithAuthorName("Andy"), - gittest.WithTree(tree1), - ) + baseCommit := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree1)) + + blob2 := gittest.WriteBlob(t, cfg, repoPath, []byte("foo")) tree2 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ { + OID: blob2, + Mode: "100644", + Path: "file2", + }, + }) + + blob3 := gittest.WriteBlob(t, cfg, repoPath, []byte("bar")) + tree3 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ + { + OID: blob3, + Mode: "100644", + Path: "file3", + }, + }) + + ours := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree2), gittest.WithParents(baseCommit)) + theirs := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree3), gittest.WithParents(baseCommit)) + + return setupData{ + ours: ours, + theirs: theirs, + expectedErr: &MergeTreeConflictError{ + ConflictingFileInfo: []ConflictingFileInfo{ + { + FileName: "file2", + OID: blob2, + Stage: MergeStageAncestor, + }, + { + FileName: "file2", + OID: blob2, + Stage: MergeStageOurs, + }, + }, + ConflictInfoMessage: []ConflictInfoMessage{ + { + Paths: []string{"file2", "file1"}, + Type: "CONFLICT (rename/delete)", + Message: fmt.Sprintf("CONFLICT (rename/delete): file1 renamed to file2 in %s, but deleted in %s.\n", ours, theirs), + }, + }, + }, + } + }, + }, + { + desc: "with multiple file conflicts", + setup: func(t *testing.T, repoPath string) setupData { + tree1 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ + { Mode: "100644", Path: "file1", Content: "foo", }, + }) + baseCommit := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree1)) + + blob2a := gittest.WriteBlob(t, cfg, repoPath, []byte("baz")) + blob2b := gittest.WriteBlob(t, cfg, repoPath, []byte("xyz")) + tree2 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ { - Mode: "100644", - Path: "file2", - Content: "baz", + OID: blob2a, + Mode: "100644", + Path: "file2", + }, + { + OID: blob2b, + Mode: "100644", + Path: "file3", }, }) + + blob3a := gittest.WriteBlob(t, cfg, repoPath, []byte("bar")) + blob3b := gittest.WriteBlob(t, cfg, repoPath, []byte("mno")) tree3 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ { + OID: blob3a, + Mode: "100644", + Path: "file2", + }, + { + OID: blob3b, + Mode: "100644", + Path: "file3", + }, + }) + + ours := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree2), gittest.WithParents(baseCommit)) + theirs := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree3), gittest.WithParents(baseCommit)) + + return setupData{ + ours: ours, + theirs: theirs, + expectedErr: &MergeTreeConflictError{ + ConflictingFileInfo: []ConflictingFileInfo{ + { + FileName: "file2", + OID: blob2a, + Stage: MergeStageOurs, + }, + { + FileName: "file2", + OID: blob3a, + Stage: MergeStageTheirs, + }, + { + FileName: "file3", + OID: blob2b, + Stage: MergeStageOurs, + }, + { + FileName: "file3", + OID: blob3b, + Stage: MergeStageTheirs, + }, + }, + ConflictInfoMessage: []ConflictInfoMessage{ + { + Paths: []string{"file2"}, + Type: "Auto-merging", + Message: "Auto-merging file2\n", + }, + { + Paths: []string{"file2"}, + Type: "CONFLICT (contents)", + Message: "CONFLICT (add/add): Merge conflict in file2\n", + }, + { + Paths: []string{"file3"}, + Type: "Auto-merging", + Message: "Auto-merging file3\n", + }, + { + Paths: []string{"file3"}, + Type: "CONFLICT (contents)", + Message: "CONFLICT (add/add): Merge conflict in file3\n", + }, + }, + }, + } + }, + }, + { + desc: "with multiple file conflicts and common ancestor", + setup: func(t *testing.T, repoPath string) setupData { + blob := gittest.WriteBlob(t, cfg, repoPath, []byte("foo")) + tree1 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ + { + OID: blob, + Mode: "100644", + Path: "file1", + }, + }) + baseCommit := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree1)) + + blob2a := gittest.WriteBlob(t, cfg, repoPath, []byte("baz")) + blob2b := gittest.WriteBlob(t, cfg, repoPath, []byte("xyz")) + tree2 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ + { + OID: blob2a, + Mode: "100644", + Path: "file1", + }, + { + OID: blob2b, + Mode: "100644", + Path: "file2", + }, + }) + + blob3a := gittest.WriteBlob(t, cfg, repoPath, []byte("bar")) + blob3b := gittest.WriteBlob(t, cfg, repoPath, []byte("mno")) + tree3 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ + { + OID: blob3a, + Mode: "100644", + Path: "file1", + }, + { + OID: blob3b, + Mode: "100644", + Path: "file2", + }, + }) + + ours := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree2), gittest.WithParents(baseCommit)) + theirs := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree3), gittest.WithParents(baseCommit)) + + return setupData{ + ours: ours, + theirs: theirs, + expectedErr: &MergeTreeConflictError{ + ConflictingFileInfo: []ConflictingFileInfo{ + { + FileName: "file1", + OID: blob, + Stage: MergeStageAncestor, + }, + { + FileName: "file1", + OID: blob2a, + Stage: MergeStageOurs, + }, + { + FileName: "file1", + OID: blob3a, + Stage: MergeStageTheirs, + }, + { + FileName: "file2", + OID: blob2b, + Stage: MergeStageOurs, + }, + { + FileName: "file2", + OID: blob3b, + Stage: MergeStageTheirs, + }, + }, + ConflictInfoMessage: []ConflictInfoMessage{ + { + Paths: []string{"file1"}, + Type: "Auto-merging", + Message: "Auto-merging file1\n", + }, + { + Paths: []string{"file1"}, + Type: "CONFLICT (contents)", + Message: "CONFLICT (content): Merge conflict in file1\n", + }, + { + Paths: []string{"file2"}, + Type: "Auto-merging", + Message: "Auto-merging file2\n", + }, + { + Paths: []string{"file2"}, + Type: "CONFLICT (contents)", + Message: "CONFLICT (add/add): Merge conflict in file2\n", + }, + }, + }, + } + }, + }, + { + desc: "with multiple file conflicts due to file deletion", + setup: func(t *testing.T, repoPath string) setupData { + blob := gittest.WriteBlob(t, cfg, repoPath, []byte("foo")) + tree1 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ + { + OID: blob, + Mode: "100644", + Path: "file1", + }, + }) + baseCommit := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree1)) + + blob2b := gittest.WriteBlob(t, cfg, repoPath, []byte("xyz")) + tree2 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ + { + OID: blob2b, + Mode: "100644", + Path: "file2", + }, + }) + + blob3a := gittest.WriteBlob(t, cfg, repoPath, []byte("bar")) + blob3b := gittest.WriteBlob(t, cfg, repoPath, []byte("mno")) + tree3 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ + { + OID: blob3a, + Mode: "100644", + Path: "file1", + }, + { + OID: blob3b, + Mode: "100644", + Path: "file2", + }, + }) + + ours := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree2), gittest.WithParents(baseCommit)) + theirs := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree3), gittest.WithParents(baseCommit)) + + return setupData{ + ours: ours, + theirs: theirs, + expectedErr: &MergeTreeConflictError{ + ConflictingFileInfo: []ConflictingFileInfo{ + { + FileName: "file1", + OID: blob, + Stage: MergeStageAncestor, + }, + { + FileName: "file1", + OID: blob3a, + Stage: MergeStageTheirs, + }, + { + FileName: "file2", + OID: blob2b, + Stage: MergeStageOurs, + }, + { + FileName: "file2", + OID: blob3b, + Stage: MergeStageTheirs, + }, + }, + ConflictInfoMessage: []ConflictInfoMessage{ + { + Paths: []string{"file1"}, + Type: "CONFLICT (modify/delete)", + Message: fmt.Sprintf("CONFLICT (modify/delete): file1 deleted in %s and modified in %s. Version %s of file1 left in tree.\n", ours, theirs, theirs), + }, + { + Paths: []string{"file2"}, + Type: "Auto-merging", + Message: "Auto-merging file2\n", + }, + { + Paths: []string{"file2"}, + Type: "CONFLICT (contents)", + Message: "CONFLICT (add/add): Merge conflict in file2\n", + }, + }, + }, + } + }, + }, + { + desc: "allow unrelated histories", + mergeTreeOptions: []MergeTreeOption{ + WithConflictingFileNamesOnly(), + WithAllowUnrelatedHistories(), + }, + setup: func(t *testing.T, repoPath string) setupData { + tree1 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ + { Mode: "100644", Path: "file1", Content: "foo", }, + }) + tree2 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ { Mode: "100644", Path: "file2", - Content: "bar", + Content: "baz", }, }) - ours := gittest.WriteCommit(t, cfg, repoPath, - gittest.WithTree(tree2), - gittest.WithParents(baseCommit), - gittest.WithAuthorName("Woody"), - gittest.WithCommitterName("Woody"), - ) - theirs := gittest.WriteCommit(t, cfg, repoPath, - gittest.WithTree(tree3), - gittest.WithParents(baseCommit), - gittest.WithAuthorName("Buzz"), - gittest.WithCommitterName("Buzz"), - ) + ours := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree1)) + theirs := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTree(tree2)) - return ours, theirs, nil + return setupData{ + ours: ours, + theirs: theirs, + expectedTreeEntries: []gittest.TreeEntry{ + { + Mode: "100644", + Path: "file1", + Content: "foo", + }, + { + Mode: "100644", + Path: "file2", + Content: "baz", + }, + }, + } }, }, } for _, tc := range testCases { + tc := tc + t.Run(tc.desc, func(t *testing.T) { + t.Parallel() + ctx := testhelper.Context(t) repoProto, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{ @@ -203,83 +683,22 @@ func TestMergeTree(t *testing.T) { }) repo := NewTestRepo(t, cfg, repoProto) - ours, theirs, treeEntries := tc.setupFunc(t, ctx, repoPath) + data := tc.setup(t, repoPath) - mergeTreeResult, err := repo.MergeTree(ctx, string(ours), string(theirs), WithConflictingFileNamesOnly()) + mergeTreeResult, err := repo.MergeTree(ctx, string(data.ours), string(data.theirs), tc.mergeTreeOptions...) - require.Equal(t, tc.expectedErr, err) - if tc.expectedErr == nil { + require.Equal(t, data.expectedErr, err) + if data.expectedErr == nil { gittest.RequireTree( t, cfg, repoPath, string(mergeTreeResult), - treeEntries, + data.expectedTreeEntries, ) } }) } - - t.Run("allow unrelated histories", func(t *testing.T) { - ctx := testhelper.Context(t) - - repoProto, repoPath := gittest.CreateRepository(t, ctx, cfg, gittest.CreateRepositoryConfig{ - SkipCreationViaService: true, - }) - repo := NewTestRepo(t, cfg, repoProto) - - tree1 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ - { - Mode: "100644", - Path: "file1", - Content: "foo", - }, - }) - tree2 := gittest.WriteTree(t, cfg, repoPath, []gittest.TreeEntry{ - { - Mode: "100644", - Path: "file2", - Content: "baz", - }, - }) - ours := gittest.WriteCommit(t, cfg, repoPath, - gittest.WithTree(tree1), - gittest.WithAuthorName("Woody"), - gittest.WithCommitterName("Woody"), - ) - theirs := gittest.WriteCommit(t, cfg, repoPath, - gittest.WithTree(tree2), - gittest.WithAuthorName("Buzz"), - gittest.WithCommitterName("Buzz"), - ) - - mergeTreeResult, err := repo.MergeTree( - ctx, - string(ours), - string(theirs), - WithAllowUnrelatedHistories(), - ) - require.NoError(t, err) - - gittest.RequireTree( - t, - cfg, - repoPath, - string(mergeTreeResult), - []gittest.TreeEntry{ - { - Mode: "100644", - Path: "file1", - Content: "foo", - }, - { - Mode: "100644", - Path: "file2", - Content: "baz", - }, - }, - ) - }) } func TestParseResult(t *testing.T) { @@ -290,102 +709,74 @@ func TestParseResult(t *testing.T) { expectedErr error }{ { - desc: "single file conflict", - output: fmt.Sprintf(`%s -100644 %s 2%sfile -100644 %s 3%sfile - -Auto-merging file -CONFLICT (content): Merge conflict in file -`, gittest.DefaultObjectHash.EmptyTreeOID, gittest.DefaultObjectHash.EmptyTreeOID, "\t", gittest.DefaultObjectHash.EmptyTreeOID, "\t"), - oid: gittest.DefaultObjectHash.EmptyTreeOID, - expectedErr: &MergeTreeError{ - ConflictingFileInfo: []ConflictingFileInfo{ - { - FileName: "file", - OID: gittest.DefaultObjectHash.EmptyTreeOID, - Stage: MergeStageOurs, - }, - { - FileName: "file", - OID: gittest.DefaultObjectHash.EmptyTreeOID, - Stage: MergeStageTheirs, - }, - }, - InfoMessage: "Auto-merging file\nCONFLICT (content): Merge conflict in file", - }, - }, - { - desc: "multiple files conflict", - output: fmt.Sprintf(`%s -100644 %s 2%sfile1 -100644 %s 3%sfile2 - -Auto-merging file -CONFLICT (content): Merge conflict in file1 -`, gittest.DefaultObjectHash.EmptyTreeOID, gittest.DefaultObjectHash.EmptyTreeOID, "\t", gittest.DefaultObjectHash.EmptyTreeOID, "\t"), - oid: gittest.DefaultObjectHash.EmptyTreeOID, - expectedErr: &MergeTreeError{ - ConflictingFileInfo: []ConflictingFileInfo{ - { - FileName: "file1", - OID: gittest.DefaultObjectHash.EmptyTreeOID, - Stage: MergeStageOurs, - }, - { - FileName: "file2", - OID: gittest.DefaultObjectHash.EmptyTreeOID, - Stage: MergeStageTheirs, - }, - }, - InfoMessage: "Auto-merging file\nCONFLICT (content): Merge conflict in file1", - }, - }, - { desc: "no tab in conflicting file info", - output: fmt.Sprintf(`%s -100644 %s 2 file1 - -Auto-merging file -CONFLICT (content): Merge conflict in file1 -`, gittest.DefaultObjectHash.EmptyTreeOID, gittest.DefaultObjectHash.EmptyTreeOID), + output: strings.Join([]string{ + gittest.DefaultObjectHash.EmptyTreeOID.String(), + fmt.Sprintf("100644 %s 1a", gittest.DefaultObjectHash.EmptyTreeOID), + fmt.Sprintf("100644 %s 2\ta", gittest.DefaultObjectHash.EmptyTreeOID), + fmt.Sprintf("100644 %s 3\ta", gittest.DefaultObjectHash.EmptyTreeOID), + "", + "1", + "a", + "Auto-merging", + "Auto-merging a\n", + "", + }, "\x00"), oid: gittest.DefaultObjectHash.EmptyTreeOID, - expectedErr: fmt.Errorf("parsing conflicting file info: 100644 %s 2 file1", gittest.DefaultObjectHash.EmptyTreeOID), + expectedErr: fmt.Errorf("parsing conflicting file info: 100644 %s 1a", gittest.DefaultObjectHash.EmptyTreeOID), }, { desc: "incorrect number of fields in conflicting file info", - output: fmt.Sprintf(`%s -%s 2%sfile1 - -Auto-merging file -CONFLICT (content): Merge conflict in file1 -`, gittest.DefaultObjectHash.EmptyTreeOID, gittest.DefaultObjectHash.EmptyTreeOID, "\t"), + output: strings.Join([]string{ + gittest.DefaultObjectHash.EmptyTreeOID.String(), + fmt.Sprintf("100644 %s 1\ta", gittest.DefaultObjectHash.EmptyTreeOID), + fmt.Sprintf("%s 2\ta", gittest.DefaultObjectHash.EmptyTreeOID), + fmt.Sprintf("100644 %s 3\ta", gittest.DefaultObjectHash.EmptyTreeOID), + "", + "1", + "a", + "Auto-merging", + "Auto-merging a\n", + "", + }, "\x00"), oid: gittest.DefaultObjectHash.EmptyTreeOID, - expectedErr: fmt.Errorf("parsing conflicting file info: %s 2\tfile1", gittest.DefaultObjectHash.EmptyTreeOID), + expectedErr: fmt.Errorf("parsing conflicting file info: %s 2\ta", gittest.DefaultObjectHash.EmptyTreeOID), }, { desc: "invalid OID in conflicting file info", - output: fmt.Sprintf(`%s -100644 23 2%sfile1 - -Auto-merging file -CONFLICT (content): Merge conflict in file1 -`, gittest.DefaultObjectHash.EmptyTreeOID, "\t"), + output: strings.Join([]string{ + gittest.DefaultObjectHash.EmptyTreeOID.String(), + fmt.Sprintf("100644 %s 1\ta", "$$$"), + fmt.Sprintf("100644 %s 2\ta", gittest.DefaultObjectHash.EmptyTreeOID), + fmt.Sprintf("100644 %s 3\ta", gittest.DefaultObjectHash.EmptyTreeOID), + "", + "1", + "a", + "Auto-merging", + "Auto-merging a\n", + "", + }, "\x00"), oid: gittest.DefaultObjectHash.EmptyTreeOID, expectedErr: fmt.Errorf("hex to oid: %w", git.InvalidObjectIDLengthError{ - OID: "23", + OID: "$$$", CorrectLength: gittest.DefaultObjectHash.EncodedLen(), - Length: 2, + Length: 3, }), }, { desc: "invalid stage type in conflicting file info", - output: fmt.Sprintf(`%s -100644 %s foo%sfile1 - -Auto-merging file -CONFLICT (content): Merge conflict in file1 -`, gittest.DefaultObjectHash.EmptyTreeOID, gittest.DefaultObjectHash.EmptyTreeOID, "\t"), + output: strings.Join([]string{ + gittest.DefaultObjectHash.EmptyTreeOID.String(), + fmt.Sprintf("100644 %s foo\ta", gittest.DefaultObjectHash.EmptyTreeOID), + fmt.Sprintf("100644 %s 2\ta", gittest.DefaultObjectHash.EmptyTreeOID), + fmt.Sprintf("100644 %s 3\ta", gittest.DefaultObjectHash.EmptyTreeOID), + "", + "1", + "a", + "Auto-merging", + "Auto-merging a\n", + "", + }, "\x00"), oid: gittest.DefaultObjectHash.EmptyTreeOID, expectedErr: fmt.Errorf("converting stage to int: %w", &strconv.NumError{ Func: "Atoi", @@ -395,12 +786,18 @@ CONFLICT (content): Merge conflict in file1 }, { desc: "invalid stage value in conflicting file info", - output: fmt.Sprintf(`%s -100644 %s 5%sfile1 - -Auto-merging file -CONFLICT (content): Merge conflict in file1 -`, gittest.DefaultObjectHash.EmptyTreeOID, gittest.DefaultObjectHash.EmptyTreeOID, "\t"), + output: strings.Join([]string{ + gittest.DefaultObjectHash.EmptyTreeOID.String(), + fmt.Sprintf("100644 %s 5\ta", gittest.DefaultObjectHash.EmptyTreeOID), + fmt.Sprintf("100644 %s 2\ta", gittest.DefaultObjectHash.EmptyTreeOID), + fmt.Sprintf("100644 %s 3\ta", gittest.DefaultObjectHash.EmptyTreeOID), + "", + "1", + "a", + "Auto-merging", + "Auto-merging a\n", + "", + }, "\x00"), oid: gittest.DefaultObjectHash.EmptyTreeOID, expectedErr: fmt.Errorf("invalid value for stage: 5"), }, diff --git a/internal/gitaly/service/operations/merge.go b/internal/gitaly/service/operations/merge.go index a35392b9a..4b0340eb6 100644 --- a/internal/gitaly/service/operations/merge.go +++ b/internal/gitaly/service/operations/merge.go @@ -154,7 +154,7 @@ func (s *Server) UserMergeBranch(stream gitalypb.OperationService_UserMergeBranc firstRequest.CommitId) if mergeErr != nil { - var conflictErr *localrepo.MergeTreeError + var conflictErr *localrepo.MergeTreeConflictError if errors.As(mergeErr, &conflictErr) { conflictingFiles := make([][]byte, 0, len(conflictErr.ConflictingFileInfo)) for _, conflictingFileInfo := range conflictErr.ConflictingFileInfo { |