diff options
author | Patrick Steinhardt <psteinhardt@gitlab.com> | 2020-10-12 11:16:50 +0300 |
---|---|---|
committer | Patrick Steinhardt <psteinhardt@gitlab.com> | 2020-10-12 11:16:50 +0300 |
commit | 5ad541682a84f8fb9561f28c37f29f3f5a764c35 (patch) | |
tree | 5af04767a28e3702cb25c90d01c74787153cd936 | |
parent | 99c7de6455d846a5fb98622b23afe151abc50bac (diff) | |
parent | 6d9126524f23fe27fafc1d9a5c0b24a6f4a97de5 (diff) |
Merge branch 'pks-git2go-merge-conflicts' into 'master'
git2go: Make merge conflicts available
See merge request gitlab-org/gitaly!2587
-rw-r--r-- | changelogs/unreleased/pks-git2go-merge-conflicts.yml | 5 | ||||
-rw-r--r-- | cmd/gitaly-git2go/conflicts.go | 123 | ||||
-rw-r--r-- | cmd/gitaly-git2go/conflicts_test.go | 185 | ||||
-rw-r--r-- | cmd/gitaly-git2go/main.go | 3 | ||||
-rw-r--r-- | cmd/gitaly-git2go/merge.go | 25 | ||||
-rw-r--r-- | cmd/gitaly-git2go/util.go | 28 | ||||
-rw-r--r-- | internal/git2go/command.go | 56 | ||||
-rw-r--r-- | internal/git2go/command_test.go | 28 | ||||
-rw-r--r-- | internal/git2go/conflicts.go | 94 | ||||
-rw-r--r-- | internal/git2go/conflicts_test.go | 128 | ||||
-rw-r--r-- | internal/git2go/merge.go | 47 | ||||
-rw-r--r-- | internal/git2go/merge_test.go | 6 |
12 files changed, 659 insertions, 69 deletions
diff --git a/changelogs/unreleased/pks-git2go-merge-conflicts.yml b/changelogs/unreleased/pks-git2go-merge-conflicts.yml new file mode 100644 index 000000000..56f72c881 --- /dev/null +++ b/changelogs/unreleased/pks-git2go-merge-conflicts.yml @@ -0,0 +1,5 @@ +--- +title: 'git2go: Implement new command to list merge conflicts' +merge_request: 2587 +author: +type: added diff --git a/cmd/gitaly-git2go/conflicts.go b/cmd/gitaly-git2go/conflicts.go new file mode 100644 index 000000000..2a96c34a7 --- /dev/null +++ b/cmd/gitaly-git2go/conflicts.go @@ -0,0 +1,123 @@ +// +build static,system_libgit2 + +package main + +import ( + "errors" + "flag" + "fmt" + "os" + + git "github.com/libgit2/git2go/v30" + "gitlab.com/gitlab-org/gitaly/internal/git2go" +) + +type conflictsSubcommand struct { + request string +} + +func (cmd *conflictsSubcommand) Flags() *flag.FlagSet { + flags := flag.NewFlagSet("conflicts", flag.ExitOnError) + flags.StringVar(&cmd.request, "request", "", "git2go.ConflictsCommand") + return flags +} + +func indexEntryPath(entry *git.IndexEntry) string { + if entry == nil { + return "" + } + return entry.Path +} + +func conflictContent(repo *git.Repository, conflict git.IndexConflict) (string, error) { + var ancestor, our, their git.MergeFileInput + + for entry, input := range map[*git.IndexEntry]*git.MergeFileInput{ + conflict.Ancestor: &ancestor, + conflict.Our: &our, + conflict.Their: &their, + } { + if entry == nil { + continue + } + + blob, err := repo.LookupBlob(entry.Id) + if err != nil { + return "", fmt.Errorf("could not get conflicting blob: %w", err) + } + + input.Path = entry.Path + input.Mode = uint(entry.Mode) + input.Contents = blob.Contents() + } + + merge, err := git.MergeFile(ancestor, our, their, nil) + if err != nil { + return "", fmt.Errorf("could not compute conflicts: %w", err) + } + + return string(merge.Contents), nil +} + +// Run performs a merge and prints resulting conflicts to stdout. +func (cmd *conflictsSubcommand) Run() error { + request, err := git2go.ConflictsCommandFromSerialized(cmd.request) + if err != nil { + return err + } + + repo, err := git.OpenRepository(request.Repository) + if err != nil { + return fmt.Errorf("could not open repository: %w", err) + } + + ours, err := lookupCommit(repo, request.Ours) + if err != nil { + return fmt.Errorf("could not lookup commit %q: %w", request.Ours, err) + } + + theirs, err := lookupCommit(repo, request.Theirs) + if err != nil { + return fmt.Errorf("could not lookup commit %q: %w", request.Theirs, err) + } + + index, err := repo.MergeCommits(ours, theirs, nil) + if err != nil { + return fmt.Errorf("could not merge commits: %w", err) + } + + conflicts, err := index.ConflictIterator() + if err != nil { + return fmt.Errorf("could not get conflicts: %w", err) + } + + var result git2go.ConflictsResult + for { + conflict, err := conflicts.Next() + if err != nil { + var gitError git.GitError + if errors.As(err, &gitError) && gitError.Code != git.ErrIterOver { + return err + } + break + } + + content, err := conflictContent(repo, conflict) + if err != nil { + return err + } + + result.Conflicts = append(result.Conflicts, git2go.Conflict{ + AncestorPath: indexEntryPath(conflict.Ancestor), + OurPath: indexEntryPath(conflict.Our), + TheirPath: indexEntryPath(conflict.Their), + Content: content, + }) + } + + if err := result.SerializeTo(os.Stdout); err != nil { + return err + } + + return nil +} diff --git a/cmd/gitaly-git2go/conflicts_test.go b/cmd/gitaly-git2go/conflicts_test.go new file mode 100644 index 000000000..e1310bc1e --- /dev/null +++ b/cmd/gitaly-git2go/conflicts_test.go @@ -0,0 +1,185 @@ +// +build static,system_libgit2 + +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" + "gitlab.com/gitlab-org/gitaly/internal/git2go" + "gitlab.com/gitlab-org/gitaly/internal/gitaly/config" + "gitlab.com/gitlab-org/gitaly/internal/testhelper" +) + +func TestConflicts(t *testing.T) { + testcases := []struct { + desc string + base map[string]string + ours map[string]string + theirs map[string]string + conflicts []git2go.Conflict + }{ + { + desc: "no conflicts", + base: map[string]string{ + "file": "a", + }, + ours: map[string]string{ + "file": "a", + }, + theirs: map[string]string{ + "file": "b", + }, + conflicts: nil, + }, + { + desc: "single file", + base: map[string]string{ + "file": "a", + }, + ours: map[string]string{ + "file": "b", + }, + theirs: map[string]string{ + "file": "c", + }, + conflicts: []git2go.Conflict{ + { + AncestorPath: "file", + OurPath: "file", + TheirPath: "file", + Content: "<<<<<<< file\nb\n=======\nc\n>>>>>>> file\n", + }, + }, + }, + { + desc: "multiple files with single conflict", + base: map[string]string{ + "file-1": "a", + "file-2": "a", + }, + ours: map[string]string{ + "file-1": "b", + "file-2": "b", + }, + theirs: map[string]string{ + "file-1": "a", + "file-2": "c", + }, + conflicts: []git2go.Conflict{ + { + AncestorPath: "file-2", + OurPath: "file-2", + TheirPath: "file-2", + Content: "<<<<<<< file-2\nb\n=======\nc\n>>>>>>> file-2\n", + }, + }, + }, + { + desc: "multiple conflicts", + base: map[string]string{ + "file-1": "a", + "file-2": "a", + }, + ours: map[string]string{ + "file-1": "b", + "file-2": "b", + }, + theirs: map[string]string{ + "file-1": "c", + "file-2": "c", + }, + conflicts: []git2go.Conflict{ + { + AncestorPath: "file-1", + OurPath: "file-1", + TheirPath: "file-1", + Content: "<<<<<<< file-1\nb\n=======\nc\n>>>>>>> file-1\n", + }, + { + AncestorPath: "file-2", + OurPath: "file-2", + TheirPath: "file-2", + Content: "<<<<<<< file-2\nb\n=======\nc\n>>>>>>> file-2\n", + }, + }, + }, + { + desc: "modified-delete-conflict", + base: map[string]string{ + "file": "content", + }, + ours: map[string]string{ + "file": "changed", + }, + theirs: map[string]string{ + "different-file": "unrelated", + }, + conflicts: []git2go.Conflict{ + { + AncestorPath: "file", + OurPath: "file", + TheirPath: "", + Content: "<<<<<<< file\nchanged\n=======\n>>>>>>> \n", + }, + }, + }, + { + // Ruby code doesn't call `merge_commits` with rename + // detection and so don't we. The rename conflict is + // thus split up into three conflicts. + desc: "rename-rename-conflict", + base: map[string]string{ + "file": "a\nb\nc\nd\ne\nf\ng\n", + }, + ours: map[string]string{ + "renamed-1": "a\nb\nc\nd\ne\nf\ng\n", + }, + theirs: map[string]string{ + "renamed-2": "a\nb\nc\nd\ne\nf\ng\n", + }, + conflicts: []git2go.Conflict{ + { + AncestorPath: "file", + OurPath: "", + TheirPath: "", + }, + { + AncestorPath: "", + OurPath: "renamed-1", + TheirPath: "", + Content: "a\nb\nc\nd\ne\nf\ng\n", + }, + { + AncestorPath: "", + OurPath: "", + TheirPath: "renamed-2", + Content: "a\nb\nc\nd\ne\nf\ng\n", + }, + }, + }, + } + + for _, tc := range testcases { + _, repoPath, cleanup := testhelper.NewTestRepo(t) + defer cleanup() + + base := buildCommit(t, repoPath, nil, tc.base) + ours := buildCommit(t, repoPath, base, tc.ours) + theirs := buildCommit(t, repoPath, base, tc.theirs) + + t.Run(tc.desc, func(t *testing.T) { + ctx, cancel := testhelper.Context() + defer cancel() + + response, err := git2go.ConflictsCommand{ + Repository: repoPath, + Ours: ours.String(), + Theirs: theirs.String(), + }.Run(ctx, config.Config) + + require.NoError(t, err) + require.Equal(t, tc.conflicts, response.Conflicts) + }) + } +} diff --git a/cmd/gitaly-git2go/main.go b/cmd/gitaly-git2go/main.go index cdd168482..2d2d1626a 100644 --- a/cmd/gitaly-git2go/main.go +++ b/cmd/gitaly-git2go/main.go @@ -14,7 +14,8 @@ type subcmd interface { } var subcommands = map[string]subcmd{ - "merge": &mergeSubcommand{}, + "conflicts": &conflictsSubcommand{}, + "merge": &mergeSubcommand{}, } const programName = "gitaly-git2go" diff --git a/cmd/gitaly-git2go/merge.go b/cmd/gitaly-git2go/merge.go index 3f07a51ab..97ea6f49a 100644 --- a/cmd/gitaly-git2go/merge.go +++ b/cmd/gitaly-git2go/merge.go @@ -6,6 +6,7 @@ import ( "errors" "flag" "fmt" + "os" "strings" "time" @@ -23,25 +24,6 @@ func (cmd *mergeSubcommand) Flags() *flag.FlagSet { return flags } -func lookupCommit(repo *git.Repository, ref string) (*git.Commit, error) { - object, err := repo.RevparseSingle(ref) - if err != nil { - return nil, fmt.Errorf("could not lookup reference: %w", err) - } - - peeled, err := object.Peel(git.ObjectCommit) - if err != nil { - return nil, fmt.Errorf("could not peel reference: %w", err) - } - - commit, err := peeled.AsCommit() - if err != nil { - return nil, fmt.Errorf("could not cast to commit: %w", err) - } - - return commit, nil -} - func sanitizeSignatureInfo(info string) string { return strings.Map(func(r rune) rune { switch r { @@ -107,12 +89,9 @@ func (cmd *mergeSubcommand) Run() error { CommitID: commit.String(), } - serialized, err := response.Serialize() - if err != nil { + if err := response.SerializeTo(os.Stdout); err != nil { return err } - fmt.Println(serialized) - return nil } diff --git a/cmd/gitaly-git2go/util.go b/cmd/gitaly-git2go/util.go new file mode 100644 index 000000000..d580582cd --- /dev/null +++ b/cmd/gitaly-git2go/util.go @@ -0,0 +1,28 @@ +// +build static,system_libgit2 + +package main + +import ( + "fmt" + + git "github.com/libgit2/git2go/v30" +) + +func lookupCommit(repo *git.Repository, ref string) (*git.Commit, error) { + object, err := repo.RevparseSingle(ref) + if err != nil { + return nil, fmt.Errorf("could not lookup reference: %w", err) + } + + peeled, err := object.Peel(git.ObjectCommit) + if err != nil { + return nil, fmt.Errorf("could not peel reference: %w", err) + } + + commit, err := peeled.AsCommit() + if err != nil { + return nil, fmt.Errorf("could not cast to commit: %w", err) + } + + return commit, nil +} diff --git a/internal/git2go/command.go b/internal/git2go/command.go new file mode 100644 index 000000000..2409b847c --- /dev/null +++ b/internal/git2go/command.go @@ -0,0 +1,56 @@ +package git2go + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "os/exec" + "path" + "strings" + + "gitlab.com/gitlab-org/gitaly/internal/command" + "gitlab.com/gitlab-org/gitaly/internal/gitaly/config" +) + +func run(ctx context.Context, cfg config.Cfg, subcommand string, arg string) (string, error) { + binary := path.Join(cfg.BinDir, "gitaly-git2go") + + var stderr, stdout bytes.Buffer + cmd, err := command.New(ctx, exec.Command(binary, subcommand, "-request", arg), nil, &stdout, &stderr) + if err != nil { + return "", err + } + + if err := cmd.Wait(); err != nil { + if _, ok := err.(*exec.ExitError); ok { + return "", fmt.Errorf("%s", stderr.String()) + } + return "", err + } + + return stdout.String(), nil +} + +func serialize(v interface{}) (string, error) { + marshalled, err := json.Marshal(v) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(marshalled), nil +} + +func deserialize(serialized string, v interface{}) error { + base64Decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(serialized)) + jsonDecoder := json.NewDecoder(base64Decoder) + return jsonDecoder.Decode(v) +} + +func serializeTo(writer io.Writer, v interface{}) error { + base64Encoder := base64.NewEncoder(base64.StdEncoding, writer) + defer base64Encoder.Close() + jsonEncoder := json.NewEncoder(base64Encoder) + return jsonEncoder.Encode(v) +} diff --git a/internal/git2go/command_test.go b/internal/git2go/command_test.go new file mode 100644 index 000000000..14f1c0a3a --- /dev/null +++ b/internal/git2go/command_test.go @@ -0,0 +1,28 @@ +package git2go + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSerialization_SerializeTo(t *testing.T) { + type testStruct struct { + Contents string `json:"contents"` + } + + var buf bytes.Buffer + + input := testStruct{ + Contents: "foobar", + } + err := serializeTo(&buf, &input) + require.NoError(t, err) + require.NotZero(t, buf.Len()) + + var output testStruct + err = deserialize(buf.String(), &output) + require.NoError(t, err) + require.Equal(t, input, output) +} diff --git a/internal/git2go/conflicts.go b/internal/git2go/conflicts.go new file mode 100644 index 000000000..101c260ae --- /dev/null +++ b/internal/git2go/conflicts.go @@ -0,0 +1,94 @@ +package git2go + +import ( + "context" + "errors" + "fmt" + "io" + + "gitlab.com/gitlab-org/gitaly/internal/gitaly/config" +) + +// ConflictsCommand contains parameters to perform a merge and return its conflicts. +type ConflictsCommand struct { + // Repository is the path to execute merge in. + Repository string `json:"repository"` + // Ours is the commit that is to be merged into theirs. + Ours string `json:"ours"` + // Theirs is the commit into which ours is to be merged. + Theirs string `json:"theirs"` +} + +// Conflict represents a merge conflict for a single file. +type Conflict struct { + // AncestorPath is the path of the ancestor. + AncestorPath string `json:"ancestor_path"` + // OurPath is the path of ours. + OurPath string `json:"our_path"` + // TheirPath is the path of theirs. + TheirPath string `json:"their_path"` + // Content contains the conflicting merge results. + Content string `json:"content"` +} + +// ConflictsResult contains all conflicts resulting from a merge. +type ConflictsResult struct { + // Conflicts + Conflicts []Conflict `json:"conflicts"` +} + +// ConflictsCommandFromSerialized constructs a ConflictsCommand from its serialized representation. +func ConflictsCommandFromSerialized(serialized string) (ConflictsCommand, error) { + var request ConflictsCommand + if err := deserialize(serialized, &request); err != nil { + return ConflictsCommand{}, err + } + + if err := request.verify(); err != nil { + return ConflictsCommand{}, fmt.Errorf("conflicts: %w: %s", ErrInvalidArgument, err.Error()) + } + + return request, nil +} + +// Serialize serializes the conflicts result. +func (m ConflictsResult) SerializeTo(writer io.Writer) error { + return serializeTo(writer, m) +} + +// Run performs a merge via gitaly-git2go and returns all resulting conflicts. +func (c ConflictsCommand) Run(ctx context.Context, cfg config.Cfg) (ConflictsResult, error) { + if err := c.verify(); err != nil { + return ConflictsResult{}, fmt.Errorf("conflicts: %w: %s", ErrInvalidArgument, err.Error()) + } + + serialized, err := serialize(c) + if err != nil { + return ConflictsResult{}, err + } + + stdout, err := run(ctx, cfg, "conflicts", serialized) + if err != nil { + return ConflictsResult{}, err + } + + var response ConflictsResult + if err := deserialize(stdout, &response); err != nil { + return ConflictsResult{}, err + } + + return response, nil +} + +func (c ConflictsCommand) verify() error { + if c.Repository == "" { + return errors.New("missing repository") + } + if c.Ours == "" { + return errors.New("missing ours") + } + if c.Theirs == "" { + return errors.New("missing theirs") + } + return nil +} diff --git a/internal/git2go/conflicts_test.go b/internal/git2go/conflicts_test.go new file mode 100644 index 000000000..6aeeb2903 --- /dev/null +++ b/internal/git2go/conflicts_test.go @@ -0,0 +1,128 @@ +package git2go + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestConflictsCommand_Serialization(t *testing.T) { + testcases := []struct { + desc string + cmd ConflictsCommand + err string + }{ + { + desc: "missing repository", + cmd: ConflictsCommand{}, + err: "missing repository", + }, + { + desc: "missing theirs", + cmd: ConflictsCommand{ + Repository: "foo", + Ours: "refs/heads/master", + }, + err: "missing theirs", + }, + { + desc: "missing ours", + cmd: ConflictsCommand{ + Repository: "foo", + Theirs: "refs/heads/master", + }, + err: "missing ours", + }, + { + desc: "valid command", + cmd: ConflictsCommand{ + Repository: "foo", + Ours: "refs/heads/master", + Theirs: "refs/heads/foo", + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.desc, func(t *testing.T) { + serialized, err := serialize(tc.cmd) + require.NoError(t, err) + + deserialized, err := ConflictsCommandFromSerialized(serialized) + + if tc.err != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.err) + } else { + require.NoError(t, err) + require.Equal(t, deserialized, tc.cmd) + } + }) + } +} + +func TestConflictsResult_Serialization(t *testing.T) { + serializeResult := func(t *testing.T, result ConflictsResult) string { + var buf bytes.Buffer + err := result.SerializeTo(&buf) + require.NoError(t, err) + return buf.String() + } + + testcases := []struct { + desc string + serialized string + expected ConflictsResult + err string + }{ + { + desc: "empty merge result", + serialized: serializeResult(t, ConflictsResult{}), + expected: ConflictsResult{}, + }, + { + desc: "merge result with commit", + serialized: serializeResult(t, ConflictsResult{ + Conflicts: []Conflict{ + { + AncestorPath: "dir/ancestor", + OurPath: "dir/our", + TheirPath: "dir/their", + Content: "content", + }, + }, + }), + expected: ConflictsResult{ + Conflicts: []Conflict{ + { + AncestorPath: "dir/ancestor", + OurPath: "dir/our", + TheirPath: "dir/their", + Content: "content", + }, + }, + }, + }, + { + desc: "invalid serialized representation", + serialized: "xvlc", + err: "invalid character", + }, + } + + for _, tc := range testcases { + t.Run(tc.desc, func(t *testing.T) { + var deserialized ConflictsResult + err := deserialize(tc.serialized, &deserialized) + + if tc.err != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.err) + } else { + require.NoError(t, err) + require.Equal(t, deserialized, tc.expected) + } + }) + } +} diff --git a/internal/git2go/merge.go b/internal/git2go/merge.go index b901353af..a60ff51c8 100644 --- a/internal/git2go/merge.go +++ b/internal/git2go/merge.go @@ -1,18 +1,12 @@ package git2go import ( - "bytes" "context" - "encoding/base64" - "encoding/json" "errors" "fmt" - "os/exec" - "path" - "strings" + "io" "time" - "gitlab.com/gitlab-org/gitaly/internal/command" "gitlab.com/gitlab-org/gitaly/internal/gitaly/config" ) @@ -45,20 +39,6 @@ type MergeResult struct { CommitID string `json:"commit_id"` } -func serialize(v interface{}) (string, error) { - marshalled, err := json.Marshal(v) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(marshalled), nil -} - -func deserialize(serialized string, v interface{}) error { - base64Decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(serialized)) - jsonDecoder := json.NewDecoder(base64Decoder) - return jsonDecoder.Decode(v) -} - // MergeCommandFromSerialized deserializes the merge request from its JSON representation encoded with base64. func MergeCommandFromSerialized(serialized string) (MergeCommand, error) { var request MergeCommand @@ -73,9 +53,9 @@ func MergeCommandFromSerialized(serialized string) (MergeCommand, error) { return request, nil } -// Serialize serializes the merge response into its JSON representation and encodes it with base64. -func (m MergeResult) Serialize() (string, error) { - return serialize(m) +// SerializeTo serializes the merge result and writes it into the writer. +func (m MergeResult) SerializeTo(w io.Writer) error { + return serializeTo(w, m) } // Merge performs a merge via gitaly-git2go. @@ -123,22 +103,3 @@ func (m MergeCommand) verify() error { } return nil } - -func run(ctx context.Context, cfg config.Cfg, subcommand string, arg string) (string, error) { - binary := path.Join(cfg.BinDir, "gitaly-git2go") - - var stderr, stdout bytes.Buffer - cmd, err := command.New(ctx, exec.Command(binary, subcommand, "-request", arg), nil, &stdout, &stderr) - if err != nil { - return "", err - } - - if err := cmd.Wait(); err != nil { - if _, ok := err.(*exec.ExitError); ok { - return "", fmt.Errorf("%s", stderr.String()) - } - return "", err - } - - return stdout.String(), nil -} diff --git a/internal/git2go/merge_test.go b/internal/git2go/merge_test.go index 1de4f3cab..5f0bc60c4 100644 --- a/internal/git2go/merge_test.go +++ b/internal/git2go/merge_test.go @@ -1,6 +1,7 @@ package git2go import ( + "bytes" "testing" "time" @@ -108,9 +109,10 @@ func TestGit2Go_MergeCommandSerialization(t *testing.T) { func TestGit2Go_MergeResultSerialization(t *testing.T) { serializeResult := func(t *testing.T, result MergeResult) string { - serialized, err := result.Serialize() + var buf bytes.Buffer + err := result.SerializeTo(&buf) require.NoError(t, err) - return serialized + return buf.String() } testcases := []struct { |