From b42e3c04b00552a84c104e06e8f0d3bc5194f0d7 Mon Sep 17 00:00:00 2001 From: Sami Hiltunen Date: Mon, 25 Jul 2022 16:54:51 +0300 Subject: Rename gitaly-git2go-v15 to gitaly-git2go With the auxiliary binaries packed into the gitaly binary and deployed as a single unit, we no longer need to maintain backwards compatibility between them and Gitaly. This commit drops the now unnecessary major version suffix from the gitaly-git2go binary which was needed due to the major version upgrades causing the type paths to change and thus breaking the Gob protocol between the binaries. --- Makefile | 6 +- cmd/gitaly-git2go-v15/apply.go | 222 ---------- cmd/gitaly-git2go-v15/cherry_pick.go | 122 ------ cmd/gitaly-git2go-v15/cherry_pick_test.go | 272 ------------ cmd/gitaly-git2go-v15/commit.go | 19 - cmd/gitaly-git2go-v15/commit/change_file_mode.go | 30 -- cmd/gitaly-git2go-v15/commit/commit.go | 111 ----- cmd/gitaly-git2go-v15/commit/create_directory.go | 30 -- cmd/gitaly-git2go-v15/commit/create_file.go | 30 -- cmd/gitaly-git2go-v15/commit/delete_file.go | 16 - cmd/gitaly-git2go-v15/commit/move_file.go | 41 -- cmd/gitaly-git2go-v15/commit/update_file.go | 30 -- cmd/gitaly-git2go-v15/commit/validate.go | 48 -- cmd/gitaly-git2go-v15/conflicts.go | 171 -------- cmd/gitaly-git2go-v15/conflicts_test.go | 278 ------------ cmd/gitaly-git2go-v15/featureflags.go | 41 -- cmd/gitaly-git2go-v15/git2goutil/repo.go | 10 - cmd/gitaly-git2go-v15/main.go | 177 -------- cmd/gitaly-git2go-v15/merge.go | 251 ----------- cmd/gitaly-git2go-v15/merge_test.go | 536 ----------------------- cmd/gitaly-git2go-v15/rebase.go | 191 -------- cmd/gitaly-git2go-v15/rebase_test.go | 297 ------------- cmd/gitaly-git2go-v15/resolve_conflicts.go | 263 ----------- cmd/gitaly-git2go-v15/revert.go | 104 ----- cmd/gitaly-git2go-v15/revert_test.go | 231 ---------- cmd/gitaly-git2go-v15/submodule.go | 142 ------ cmd/gitaly-git2go-v15/submodule_test.go | 129 ------ cmd/gitaly-git2go-v15/testhelper_test.go | 44 -- cmd/gitaly-git2go-v15/util.go | 28 -- cmd/gitaly-git2go/apply.go | 222 ++++++++++ cmd/gitaly-git2go/cherry_pick.go | 122 ++++++ cmd/gitaly-git2go/cherry_pick_test.go | 272 ++++++++++++ cmd/gitaly-git2go/commit.go | 19 + cmd/gitaly-git2go/commit/change_file_mode.go | 30 ++ cmd/gitaly-git2go/commit/commit.go | 111 +++++ cmd/gitaly-git2go/commit/create_directory.go | 30 ++ cmd/gitaly-git2go/commit/create_file.go | 30 ++ cmd/gitaly-git2go/commit/delete_file.go | 16 + cmd/gitaly-git2go/commit/move_file.go | 41 ++ cmd/gitaly-git2go/commit/update_file.go | 30 ++ cmd/gitaly-git2go/commit/validate.go | 48 ++ cmd/gitaly-git2go/conflicts.go | 171 ++++++++ cmd/gitaly-git2go/conflicts_test.go | 278 ++++++++++++ cmd/gitaly-git2go/featureflags.go | 41 ++ cmd/gitaly-git2go/git2goutil/repo.go | 10 + cmd/gitaly-git2go/main.go | 177 ++++++++ cmd/gitaly-git2go/merge.go | 251 +++++++++++ cmd/gitaly-git2go/merge_test.go | 536 +++++++++++++++++++++++ cmd/gitaly-git2go/rebase.go | 191 ++++++++ cmd/gitaly-git2go/rebase_test.go | 297 +++++++++++++ cmd/gitaly-git2go/resolve_conflicts.go | 263 +++++++++++ cmd/gitaly-git2go/revert.go | 104 +++++ cmd/gitaly-git2go/revert_test.go | 231 ++++++++++ cmd/gitaly-git2go/submodule.go | 142 ++++++ cmd/gitaly-git2go/submodule_test.go | 129 ++++++ cmd/gitaly-git2go/testhelper_test.go | 44 ++ cmd/gitaly-git2go/util.go | 28 ++ internal/git2go/executor.go | 4 +- internal/gitaly/config/config.go | 2 +- internal/testhelper/testcfg/build.go | 2 +- packed_binaries.go | 2 +- 61 files changed, 3872 insertions(+), 3872 deletions(-) delete mode 100644 cmd/gitaly-git2go-v15/apply.go delete mode 100644 cmd/gitaly-git2go-v15/cherry_pick.go delete mode 100644 cmd/gitaly-git2go-v15/cherry_pick_test.go delete mode 100644 cmd/gitaly-git2go-v15/commit.go delete mode 100644 cmd/gitaly-git2go-v15/commit/change_file_mode.go delete mode 100644 cmd/gitaly-git2go-v15/commit/commit.go delete mode 100644 cmd/gitaly-git2go-v15/commit/create_directory.go delete mode 100644 cmd/gitaly-git2go-v15/commit/create_file.go delete mode 100644 cmd/gitaly-git2go-v15/commit/delete_file.go delete mode 100644 cmd/gitaly-git2go-v15/commit/move_file.go delete mode 100644 cmd/gitaly-git2go-v15/commit/update_file.go delete mode 100644 cmd/gitaly-git2go-v15/commit/validate.go delete mode 100644 cmd/gitaly-git2go-v15/conflicts.go delete mode 100644 cmd/gitaly-git2go-v15/conflicts_test.go delete mode 100644 cmd/gitaly-git2go-v15/featureflags.go delete mode 100644 cmd/gitaly-git2go-v15/git2goutil/repo.go delete mode 100644 cmd/gitaly-git2go-v15/main.go delete mode 100644 cmd/gitaly-git2go-v15/merge.go delete mode 100644 cmd/gitaly-git2go-v15/merge_test.go delete mode 100644 cmd/gitaly-git2go-v15/rebase.go delete mode 100644 cmd/gitaly-git2go-v15/rebase_test.go delete mode 100644 cmd/gitaly-git2go-v15/resolve_conflicts.go delete mode 100644 cmd/gitaly-git2go-v15/revert.go delete mode 100644 cmd/gitaly-git2go-v15/revert_test.go delete mode 100644 cmd/gitaly-git2go-v15/submodule.go delete mode 100644 cmd/gitaly-git2go-v15/submodule_test.go delete mode 100644 cmd/gitaly-git2go-v15/testhelper_test.go delete mode 100644 cmd/gitaly-git2go-v15/util.go create mode 100644 cmd/gitaly-git2go/apply.go create mode 100644 cmd/gitaly-git2go/cherry_pick.go create mode 100644 cmd/gitaly-git2go/cherry_pick_test.go create mode 100644 cmd/gitaly-git2go/commit.go create mode 100644 cmd/gitaly-git2go/commit/change_file_mode.go create mode 100644 cmd/gitaly-git2go/commit/commit.go create mode 100644 cmd/gitaly-git2go/commit/create_directory.go create mode 100644 cmd/gitaly-git2go/commit/create_file.go create mode 100644 cmd/gitaly-git2go/commit/delete_file.go create mode 100644 cmd/gitaly-git2go/commit/move_file.go create mode 100644 cmd/gitaly-git2go/commit/update_file.go create mode 100644 cmd/gitaly-git2go/commit/validate.go create mode 100644 cmd/gitaly-git2go/conflicts.go create mode 100644 cmd/gitaly-git2go/conflicts_test.go create mode 100644 cmd/gitaly-git2go/featureflags.go create mode 100644 cmd/gitaly-git2go/git2goutil/repo.go create mode 100644 cmd/gitaly-git2go/main.go create mode 100644 cmd/gitaly-git2go/merge.go create mode 100644 cmd/gitaly-git2go/merge_test.go create mode 100644 cmd/gitaly-git2go/rebase.go create mode 100644 cmd/gitaly-git2go/rebase_test.go create mode 100644 cmd/gitaly-git2go/resolve_conflicts.go create mode 100644 cmd/gitaly-git2go/revert.go create mode 100644 cmd/gitaly-git2go/revert_test.go create mode 100644 cmd/gitaly-git2go/submodule.go create mode 100644 cmd/gitaly-git2go/submodule_test.go create mode 100644 cmd/gitaly-git2go/testhelper_test.go create mode 100644 cmd/gitaly-git2go/util.go diff --git a/Makefile b/Makefile index 8a4e3f5d1..b2531fa4f 100644 --- a/Makefile +++ b/Makefile @@ -254,7 +254,7 @@ BENCHMARK_REPO := ${TEST_REPO_DIR}/benchmark.git # All executables provided by Gitaly. GITALY_EXECUTABLES = $(addprefix ${BUILD_DIR}/bin/,$(notdir $(shell find ${SOURCE_DIR}/cmd -mindepth 1 -maxdepth 1 -type d -print))) # All executables packed inside the Gitaly binary. -GITALY_PACKED_EXECUTABLES = $(filter %gitaly-hooks %gitaly-git2go-v15 %gitaly-ssh %gitaly-lfs-smudge, ${GITALY_EXECUTABLES}) +GITALY_PACKED_EXECUTABLES = $(filter %gitaly-hooks %gitaly-git2go %gitaly-ssh %gitaly-lfs-smudge, ${GITALY_EXECUTABLES}) # All executables that should be installed. GITALY_INSTALLED_EXECUTABLES = $(filter-out ${GITALY_PACKED_EXECUTABLES}, ${GITALY_EXECUTABLES}) # Find all Go source files. @@ -623,8 +623,8 @@ clear-go-build-cache-if-needed: ${BUILD_DIR}/intermediate/gitaly: GO_BUILD_TAGS = ${SERVER_BUILD_TAGS} ${BUILD_DIR}/intermediate/gitaly: remove-legacy-go-mod ${GITALY_PACKED_EXECUTABLES} ${BUILD_DIR}/intermediate/praefect: GO_BUILD_TAGS = ${SERVER_BUILD_TAGS} -${BUILD_DIR}/intermediate/gitaly-git2go-v15: GO_BUILD_TAGS = ${GIT2GO_BUILD_TAGS} -${BUILD_DIR}/intermediate/gitaly-git2go-v15: libgit2 +${BUILD_DIR}/intermediate/gitaly-git2go: GO_BUILD_TAGS = ${GIT2GO_BUILD_TAGS} +${BUILD_DIR}/intermediate/gitaly-git2go: libgit2 ${BUILD_DIR}/intermediate/%: clear-go-build-cache-if-needed .FORCE @ # We're building intermediate binaries first which contain a fixed build ID @ # of "TEMP_GITALY_BUILD_ID". In the final binary we replace this build ID with diff --git a/cmd/gitaly-git2go-v15/apply.go b/cmd/gitaly-git2go-v15/apply.go deleted file mode 100644 index 2bd20b631..000000000 --- a/cmd/gitaly-git2go-v15/apply.go +++ /dev/null @@ -1,222 +0,0 @@ -//go:build static && system_libgit2 - -package main - -import ( - "bytes" - "context" - "encoding/gob" - "errors" - "flag" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - - git "github.com/libgit2/git2go/v33" - "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go-v15/git2goutil" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" -) - -type patchIterator struct { - value git2go.Patch - decoder *gob.Decoder - error error -} - -func (iter *patchIterator) Next() bool { - if err := iter.decoder.Decode(&iter.value); err != nil { - if !errors.Is(err, io.EOF) { - iter.error = fmt.Errorf("decode patch: %w", err) - } - - return false - } - - return true -} - -func (iter *patchIterator) Value() git2go.Patch { return iter.value } - -func (iter *patchIterator) Err() error { return iter.error } - -type applySubcommand struct { - gitBinaryPath string -} - -func (cmd *applySubcommand) Flags() *flag.FlagSet { - fs := flag.NewFlagSet("apply", flag.ExitOnError) - fs.StringVar(&cmd.gitBinaryPath, "git-binary-path", "", "Path to the Git binary.") - return fs -} - -// Run runs the subcommand. -func (cmd *applySubcommand) Run(ctx context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error { - var params git2go.ApplyParams - if err := decoder.Decode(¶ms); err != nil { - return fmt.Errorf("decode params: %w", err) - } - - params.Patches = &patchIterator{decoder: decoder} - commitID, err := cmd.apply(ctx, params) - return encoder.Encode(git2go.Result{ - CommitID: commitID, - Err: git2go.SerializableError(err), - }) -} - -func (cmd *applySubcommand) apply(ctx context.Context, params git2go.ApplyParams) (string, error) { - repo, err := git2goutil.OpenRepository(params.Repository) - if err != nil { - return "", fmt.Errorf("open repository: %w", err) - } - - commitOID, err := git.NewOid(params.ParentCommit) - if err != nil { - return "", fmt.Errorf("parse parent commit oid: %w", err) - } - - committer := git.Signature(params.Committer) - for i := 0; params.Patches.Next(); i++ { - commitOID, err = cmd.applyPatch(ctx, repo, &committer, commitOID, params.Patches.Value()) - if err != nil { - return "", fmt.Errorf("apply patch [%d]: %w", i+1, err) - } - } - - if err := params.Patches.Err(); err != nil { - return "", fmt.Errorf("reading patches: %w", err) - } - - return commitOID.String(), nil -} - -func (cmd *applySubcommand) applyPatch( - ctx context.Context, - repo *git.Repository, - committer *git.Signature, - parentCommitOID *git.Oid, - patch git2go.Patch, -) (*git.Oid, error) { - parentCommit, err := repo.LookupCommit(parentCommitOID) - if err != nil { - return nil, fmt.Errorf("lookup commit: %w", err) - } - - parentTree, err := parentCommit.Tree() - if err != nil { - return nil, fmt.Errorf("lookup tree: %w", err) - } - - diff, err := git.DiffFromBuffer(patch.Diff, repo) - if err != nil { - return nil, fmt.Errorf("diff from buffer: %w", err) - } - - patchedIndex, err := repo.ApplyToTree(diff, parentTree, nil) - if err != nil { - if !git.IsErrorCode(err, git.ErrorCodeApplyFail) { - return nil, fmt.Errorf("apply to tree: %w", err) - } - - patchedIndex, err = cmd.threeWayMerge(ctx, repo, parentTree, diff, patch.Diff) - if err != nil { - return nil, fmt.Errorf("three way merge: %w", err) - } - } - - patchedTree, err := patchedIndex.WriteTreeTo(repo) - if err != nil { - return nil, fmt.Errorf("write patched tree: %w", err) - } - - author := git.Signature(patch.Author) - patchedCommitOID, err := repo.CreateCommitFromIds("", &author, committer, patch.Message, patchedTree, parentCommitOID) - if err != nil { - return nil, fmt.Errorf("create commit: %w", err) - } - - return patchedCommitOID, nil -} - -// threeWayMerge attempts a three-way merge as a fallback if applying the patch fails. -// Fallback three-way merge is only possible if the patch records the pre-image blobs -// and the repository contains them. It works as follows: -// -// 1. An index that contains only the pre-image blobs of the patch is built. This is done -// by calling `git apply --build-fake-ancestor`. The tree of the index is the fake -// ancestor tree. -// 2. The fake ancestor tree is patched to produce the post-image tree of the patch. -// 3. Three-way merge is performed with fake ancestor tree as the common ancestor, the -// base commit's tree as our tree and the patched fake ancestor tree as their tree. -func (cmd *applySubcommand) threeWayMerge( - ctx context.Context, - repo *git.Repository, - our *git.Tree, - diff *git.Diff, - rawDiff []byte, -) (*git.Index, error) { - ancestorTree, err := cmd.buildFakeAncestor(ctx, repo, rawDiff) - if err != nil { - return nil, fmt.Errorf("build fake ancestor: %w", err) - } - - patchedAncestorIndex, err := repo.ApplyToTree(diff, ancestorTree, nil) - if err != nil { - return nil, fmt.Errorf("patch fake ancestor: %w", err) - } - - patchedAncestorTreeOID, err := patchedAncestorIndex.WriteTreeTo(repo) - if err != nil { - return nil, fmt.Errorf("write patched fake ancestor: %w", err) - } - - patchedTree, err := repo.LookupTree(patchedAncestorTreeOID) - if err != nil { - return nil, fmt.Errorf("lookup patched tree: %w", err) - } - - patchedIndex, err := repo.MergeTrees(ancestorTree, our, patchedTree, nil) - if err != nil { - return nil, fmt.Errorf("merge trees: %w", err) - } - - if patchedIndex.HasConflicts() { - return nil, git2go.ErrMergeConflict - } - - return patchedIndex, nil -} - -func (cmd *applySubcommand) buildFakeAncestor(ctx context.Context, repo *git.Repository, diff []byte) (*git.Tree, error) { - dir, err := os.MkdirTemp("", "gitaly-git2go") - if err != nil { - return nil, fmt.Errorf("create temporary directory: %w", err) - } - defer func() { _ = os.RemoveAll(dir) }() - - file := filepath.Join(dir, "patch-merge-index") - gitCmd := exec.CommandContext(ctx, cmd.gitBinaryPath, "--git-dir", repo.Path(), "apply", "--build-fake-ancestor", file) - gitCmd.Stdin = bytes.NewReader(diff) - if _, err := gitCmd.Output(); err != nil { - var exitError *exec.ExitError - if errors.As(err, &exitError) { - err = fmt.Errorf("%w, stderr: %q", err, exitError.Stderr) - } - - return nil, fmt.Errorf("git: %w", err) - } - - fakeAncestor, err := git.OpenIndex(file) - if err != nil { - return nil, fmt.Errorf("open fake ancestor index: %w", err) - } - - ancestorTreeOID, err := fakeAncestor.WriteTreeTo(repo) - if err != nil { - return nil, fmt.Errorf("write fake ancestor tree: %w", err) - } - - return repo.LookupTree(ancestorTreeOID) -} diff --git a/cmd/gitaly-git2go-v15/cherry_pick.go b/cmd/gitaly-git2go-v15/cherry_pick.go deleted file mode 100644 index 5ebbb26b0..000000000 --- a/cmd/gitaly-git2go-v15/cherry_pick.go +++ /dev/null @@ -1,122 +0,0 @@ -//go:build static && system_libgit2 - -package main - -import ( - "context" - "encoding/gob" - "errors" - "flag" - "fmt" - - git "github.com/libgit2/git2go/v33" - "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go-v15/git2goutil" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" -) - -type cherryPickSubcommand struct{} - -func (cmd *cherryPickSubcommand) Flags() *flag.FlagSet { - return flag.NewFlagSet("cherry-pick", flag.ExitOnError) -} - -func (cmd *cherryPickSubcommand) Run(ctx context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error { - var request git2go.CherryPickCommand - if err := decoder.Decode(&request); err != nil { - return err - } - - commitID, err := cmd.cherryPick(ctx, &request) - return encoder.Encode(git2go.Result{ - CommitID: commitID, - Err: git2go.SerializableError(err), - }) -} - -func (cmd *cherryPickSubcommand) verify(ctx context.Context, r *git2go.CherryPickCommand) error { - if r.Repository == "" { - return errors.New("missing repository") - } - if r.CommitterName == "" { - return errors.New("missing committer name") - } - if r.CommitterMail == "" { - return errors.New("missing committer mail") - } - if r.CommitterDate.IsZero() { - return errors.New("missing committer date") - } - if r.Message == "" { - return errors.New("missing message") - } - if r.Ours == "" { - return errors.New("missing ours") - } - if r.Commit == "" { - return errors.New("missing commit") - } - - return nil -} - -func (cmd *cherryPickSubcommand) cherryPick(ctx context.Context, r *git2go.CherryPickCommand) (string, error) { - if err := cmd.verify(ctx, r); err != nil { - return "", err - } - - repo, err := git2goutil.OpenRepository(r.Repository) - if err != nil { - return "", fmt.Errorf("could not open repository: %w", err) - } - defer repo.Free() - - ours, err := lookupCommit(repo, r.Ours) - if err != nil { - return "", fmt.Errorf("ours commit lookup: %w", err) - } - - pick, err := lookupCommit(repo, r.Commit) - if err != nil { - return "", fmt.Errorf("commit lookup: %w", err) - } - - opts, err := git.DefaultCherrypickOptions() - if err != nil { - return "", fmt.Errorf("could not get default cherry-pick options: %w", err) - } - opts.Mainline = r.Mainline - - index, err := repo.CherrypickCommit(pick, ours, opts) - if err != nil { - return "", fmt.Errorf("could not cherry-pick commit: %w", err) - } - - if index.HasConflicts() { - conflictingFiles, err := getConflictingFiles(index) - if err != nil { - return "", fmt.Errorf("getting conflicting files: %w", err) - } - - return "", git2go.ConflictingFilesError{ - ConflictingFiles: conflictingFiles, - } - } - - tree, err := index.WriteTreeTo(repo) - if err != nil { - return "", fmt.Errorf("could not write tree: %w", err) - } - - if tree.Equal(ours.TreeId()) { - return "", git2go.EmptyError{} - } - - committer := git.Signature(git2go.NewSignature(r.CommitterName, r.CommitterMail, r.CommitterDate)) - - commit, err := repo.CreateCommitFromIds("", pick.Author(), &committer, r.Message, tree, ours.Id()) - if err != nil { - return "", fmt.Errorf("could not create cherry-pick commit: %w", err) - } - - return commit.String(), nil -} diff --git a/cmd/gitaly-git2go-v15/cherry_pick_test.go b/cmd/gitaly-git2go-v15/cherry_pick_test.go deleted file mode 100644 index 129476451..000000000 --- a/cmd/gitaly-git2go-v15/cherry_pick_test.go +++ /dev/null @@ -1,272 +0,0 @@ -//go:build static && system_libgit2 && !gitaly_test_sha256 - -package main - -import ( - "testing" - "time" - - git "github.com/libgit2/git2go/v33" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go-v15/git2goutil" - "gitlab.com/gitlab-org/gitaly/v15/internal/git/gittest" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" - "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper" - "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper/testcfg" -) - -func TestCherryPick_validation(t *testing.T) { - cfg, repo, repoPath := testcfg.BuildWithRepo(t) - testcfg.BuildGitalyGit2Go(t, cfg) - executor := buildExecutor(t, cfg) - - testcases := []struct { - desc string - request git2go.CherryPickCommand - expectedErr string - }{ - { - desc: "no arguments", - expectedErr: "cherry-pick: missing repository", - }, - { - desc: "missing repository", - request: git2go.CherryPickCommand{CommitterName: "Foo", CommitterMail: "foo@example.com", CommitterDate: time.Now(), Message: "Foo", Ours: "HEAD", Commit: "HEAD"}, - expectedErr: "cherry-pick: missing repository", - }, - { - desc: "missing committer name", - request: git2go.CherryPickCommand{Repository: repoPath, CommitterMail: "foo@example.com", CommitterDate: time.Now(), Message: "Foo", Ours: "HEAD", Commit: "HEAD"}, - expectedErr: "cherry-pick: missing committer name", - }, - { - desc: "missing committer mail", - request: git2go.CherryPickCommand{Repository: repoPath, CommitterName: "Foo", CommitterDate: time.Now(), Message: "Foo", Ours: "HEAD", Commit: "HEAD"}, - expectedErr: "cherry-pick: missing committer mail", - }, - { - desc: "missing committer date", - request: git2go.CherryPickCommand{Repository: repoPath, CommitterName: "Foo", CommitterMail: "foo@example.com", Message: "Foo", Ours: "HEAD", Commit: "HEAD"}, - expectedErr: "cherry-pick: missing committer date", - }, - { - desc: "missing message", - request: git2go.CherryPickCommand{Repository: repoPath, CommitterName: "Foo", CommitterMail: "foo@example.com", CommitterDate: time.Now(), Ours: "HEAD", Commit: "HEAD"}, - expectedErr: "cherry-pick: missing message", - }, - { - desc: "missing ours", - request: git2go.CherryPickCommand{Repository: repoPath, CommitterName: "Foo", CommitterMail: "foo@example.com", CommitterDate: time.Now(), Message: "Foo", Commit: "HEAD"}, - expectedErr: "cherry-pick: missing ours", - }, - { - desc: "missing commit", - request: git2go.CherryPickCommand{Repository: repoPath, CommitterName: "Foo", CommitterMail: "foo@example.com", CommitterDate: time.Now(), Message: "Foo", Ours: "HEAD"}, - expectedErr: "cherry-pick: missing commit", - }, - } - for _, tc := range testcases { - t.Run(tc.desc, func(t *testing.T) { - ctx := testhelper.Context(t) - - _, err := executor.CherryPick(ctx, repo, tc.request) - require.EqualError(t, err, tc.expectedErr) - }) - } -} - -func TestCherryPick(t *testing.T) { - testcases := []struct { - desc string - base []gittest.TreeEntry - ours []gittest.TreeEntry - commit []gittest.TreeEntry - expected map[string]string - expectedCommitID string - expectedErr error - expectedErrMsg string - }{ - { - desc: "trivial cherry-pick succeeds", - base: []gittest.TreeEntry{ - {Path: "file", Content: "foo", Mode: "100644"}, - }, - ours: []gittest.TreeEntry{ - {Path: "file", Content: "foo", Mode: "100644"}, - }, - commit: []gittest.TreeEntry{ - {Path: "file", Content: "foobar", Mode: "100644"}, - }, - expected: map[string]string{ - "file": "foobar", - }, - expectedCommitID: "a54ea83118c363c34cc605a6e61fd7abc4795cc4", - }, - { - desc: "conflicting cherry-pick fails", - base: []gittest.TreeEntry{ - {Path: "file", Content: "foo", Mode: "100644"}, - }, - ours: []gittest.TreeEntry{ - {Path: "file", Content: "fooqux", Mode: "100644"}, - }, - commit: []gittest.TreeEntry{ - {Path: "file", Content: "foobar", Mode: "100644"}, - }, - expectedErr: git2go.ConflictingFilesError{}, - expectedErrMsg: "cherry-pick: there are conflicting files", - }, - { - desc: "empty cherry-pick fails", - base: []gittest.TreeEntry{ - {Path: "file", Content: "foo", Mode: "100644"}, - }, - ours: []gittest.TreeEntry{ - {Path: "file", Content: "fooqux", Mode: "100644"}, - }, - commit: []gittest.TreeEntry{ - {Path: "file", Content: "fooqux", Mode: "100644"}, - }, - expectedErr: git2go.EmptyError{}, - expectedErrMsg: "cherry-pick: could not apply because the result was empty", - }, - { - desc: "fails on nonexistent ours commit", - expectedErrMsg: "cherry-pick: ours commit lookup: lookup commit \"nonexistent\": revspec 'nonexistent' not found", - }, - { - desc: "fails on nonexistent cherry-pick commit", - ours: []gittest.TreeEntry{ - {Path: "file", Content: "fooqux", Mode: "100644"}, - }, - expectedErrMsg: "cherry-pick: commit lookup: lookup commit \"nonexistent\": revspec 'nonexistent' not found", - }, - } - for _, tc := range testcases { - cfg, repo, repoPath := testcfg.BuildWithRepo(t) - testcfg.BuildGitalyGit2Go(t, cfg) - executor := buildExecutor(t, cfg) - - base := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries(tc.base...)) - - ours, commit := "nonexistent", "nonexistent" - if len(tc.ours) > 0 { - ours = gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(base), gittest.WithTreeEntries(tc.ours...)).String() - } - if len(tc.commit) > 0 { - commit = gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(base), gittest.WithTreeEntries(tc.commit...)).String() - } - - t.Run(tc.desc, func(t *testing.T) { - ctx := testhelper.Context(t) - - committer := git.Signature{ - Name: "Baz", - Email: "baz@example.com", - When: time.Date(2021, 1, 17, 14, 45, 51, 0, time.FixedZone("", +2*60*60)), - } - - response, err := executor.CherryPick(ctx, repo, git2go.CherryPickCommand{ - Repository: repoPath, - CommitterName: committer.Name, - CommitterMail: committer.Email, - CommitterDate: committer.When, - Message: "Foo", - Ours: ours, - Commit: commit, - }) - - if tc.expectedErrMsg != "" { - require.EqualError(t, err, tc.expectedErrMsg) - - if tc.expectedErr != nil { - require.ErrorAs(t, err, &tc.expectedErr) - } - return - } - - require.NoError(t, err) - assert.Equal(t, tc.expectedCommitID, response.String()) - - repo, err := git2goutil.OpenRepository(repoPath) - require.NoError(t, err) - defer repo.Free() - - commitOid, err := git.NewOid(response.String()) - require.NoError(t, err) - - commit, err := repo.LookupCommit(commitOid) - require.NoError(t, err) - require.Equal(t, &DefaultAuthor, commit.Author()) - require.Equal(t, &committer, commit.Committer()) - - tree, err := commit.Tree() - require.NoError(t, err) - require.Len(t, tc.expected, int(tree.EntryCount())) - - for name, contents := range tc.expected { - entry := tree.EntryByName(name) - require.NotNil(t, entry) - - blob, err := repo.LookupBlob(entry.Id) - require.NoError(t, err) - require.Equal(t, []byte(contents), blob.Contents()) - } - }) - } -} - -func TestCherryPickStructuredErrors(t *testing.T) { - ctx := testhelper.Context(t) - - cfg, repo, repoPath := testcfg.BuildWithRepo(t) - - testcfg.BuildGitalyGit2Go(t, cfg) - executor := buildExecutor(t, cfg) - - base := gittest.WriteCommit( - t, - cfg, - repoPath, - gittest.WithTreeEntries(gittest.TreeEntry{ - Path: "file", Content: "foo", Mode: "100644", - })) - - ours := gittest.WriteCommit( - t, - cfg, - repoPath, - gittest.WithParents(base), - gittest.WithTreeEntries( - gittest.TreeEntry{Path: "file", Content: "fooqux", Mode: "100644"}, - )).String() - - commit := gittest.WriteCommit( - t, - cfg, - repoPath, - gittest.WithParents(base), - gittest.WithTreeEntries( - gittest.TreeEntry{Path: "file", Content: "foobar", Mode: "100644"}, - )).String() - - committer := git.Signature{ - Name: "Baz", - Email: "baz@example.com", - When: time.Date(2021, 1, 17, 14, 45, 51, 0, time.FixedZone("", +2*60*60)), - } - - _, err := executor.CherryPick(ctx, repo, git2go.CherryPickCommand{ - Repository: repoPath, - CommitterName: committer.Name, - CommitterMail: committer.Email, - CommitterDate: committer.When, - Message: "Foo", - Ours: ours, - Commit: commit, - }) - - require.EqualError(t, err, "cherry-pick: there are conflicting files") - require.ErrorAs(t, err, &git2go.ConflictingFilesError{}) -} diff --git a/cmd/gitaly-git2go-v15/commit.go b/cmd/gitaly-git2go-v15/commit.go deleted file mode 100644 index e6789e424..000000000 --- a/cmd/gitaly-git2go-v15/commit.go +++ /dev/null @@ -1,19 +0,0 @@ -//go:build static && system_libgit2 - -package main - -import ( - "context" - "encoding/gob" - "flag" - - "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go-v15/commit" -) - -type commitSubcommand struct{} - -func (commitSubcommand) Flags() *flag.FlagSet { return flag.NewFlagSet("commit", flag.ExitOnError) } - -func (commitSubcommand) Run(ctx context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error { - return commit.Run(ctx, decoder, encoder) -} diff --git a/cmd/gitaly-git2go-v15/commit/change_file_mode.go b/cmd/gitaly-git2go-v15/commit/change_file_mode.go deleted file mode 100644 index e07db9ba4..000000000 --- a/cmd/gitaly-git2go-v15/commit/change_file_mode.go +++ /dev/null @@ -1,30 +0,0 @@ -//go:build static && system_libgit2 - -package commit - -import ( - git "github.com/libgit2/git2go/v33" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" -) - -func applyChangeFileMode(action git2go.ChangeFileMode, index *git.Index) error { - entry, err := index.EntryByPath(action.Path, 0) - if err != nil { - if git.IsErrorCode(err, git.ErrorCodeNotFound) { - return git2go.FileNotFoundError(action.Path) - } - - return err - } - - mode := git.FilemodeBlob - if action.ExecutableMode { - mode = git.FilemodeBlobExecutable - } - - return index.Add(&git.IndexEntry{ - Path: action.Path, - Mode: mode, - Id: entry.Id, - }) -} diff --git a/cmd/gitaly-git2go-v15/commit/commit.go b/cmd/gitaly-git2go-v15/commit/commit.go deleted file mode 100644 index 43a07d012..000000000 --- a/cmd/gitaly-git2go-v15/commit/commit.go +++ /dev/null @@ -1,111 +0,0 @@ -//go:build static && system_libgit2 - -package commit - -import ( - "context" - "encoding/gob" - "errors" - "fmt" - - git "github.com/libgit2/git2go/v33" - "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go-v15/git2goutil" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" -) - -// Run runs the commit subcommand. -func Run(ctx context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error { - var params git2go.CommitParams - if err := decoder.Decode(¶ms); err != nil { - return err - } - - commitID, err := commit(ctx, params) - return encoder.Encode(git2go.Result{ - CommitID: commitID, - Err: git2go.SerializableError(err), - }) -} - -func commit(ctx context.Context, params git2go.CommitParams) (string, error) { - repo, err := git2goutil.OpenRepository(params.Repository) - if err != nil { - return "", fmt.Errorf("open repository: %w", err) - } - - index, err := git.NewIndex() - if err != nil { - return "", fmt.Errorf("new index: %w", err) - } - - var parents []*git.Oid - if params.Parent != "" { - parentOID, err := git.NewOid(params.Parent) - if err != nil { - return "", fmt.Errorf("parse base commit oid: %w", err) - } - - parents = []*git.Oid{parentOID} - - baseCommit, err := repo.LookupCommit(parentOID) - if err != nil { - return "", fmt.Errorf("lookup commit: %w", err) - } - - baseTree, err := baseCommit.Tree() - if err != nil { - return "", fmt.Errorf("lookup tree: %w", err) - } - - if err := index.ReadTree(baseTree); err != nil { - return "", fmt.Errorf("read tree: %w", err) - } - } - - for _, action := range params.Actions { - if err := apply(action, repo, index); err != nil { - if git.IsErrorClass(err, git.ErrorClassIndex) { - err = git2go.IndexError(err.Error()) - } - - return "", fmt.Errorf("apply action %T: %w", action, err) - } - } - - treeOID, err := index.WriteTreeTo(repo) - if err != nil { - return "", fmt.Errorf("write tree: %w", err) - } - - author := git.Signature(params.Author) - committer := git.Signature(params.Committer) - commitID, err := repo.CreateCommitFromIds("", &author, &committer, params.Message, treeOID, parents...) - if err != nil { - if git.IsErrorClass(err, git.ErrorClassInvalid) { - return "", git2go.InvalidArgumentError(err.Error()) - } - - return "", fmt.Errorf("create commit: %w", err) - } - - return commitID.String(), nil -} - -func apply(action git2go.Action, repo *git.Repository, index *git.Index) error { - switch action := action.(type) { - case git2go.ChangeFileMode: - return applyChangeFileMode(action, index) - case git2go.CreateDirectory: - return applyCreateDirectory(action, repo, index) - case git2go.CreateFile: - return applyCreateFile(action, index) - case git2go.DeleteFile: - return applyDeleteFile(action, index) - case git2go.MoveFile: - return applyMoveFile(action, index) - case git2go.UpdateFile: - return applyUpdateFile(action, index) - default: - return errors.New("unsupported action") - } -} diff --git a/cmd/gitaly-git2go-v15/commit/create_directory.go b/cmd/gitaly-git2go-v15/commit/create_directory.go deleted file mode 100644 index 061666ed3..000000000 --- a/cmd/gitaly-git2go-v15/commit/create_directory.go +++ /dev/null @@ -1,30 +0,0 @@ -//go:build static && system_libgit2 - -package commit - -import ( - "fmt" - "path/filepath" - - git "github.com/libgit2/git2go/v33" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" -) - -func applyCreateDirectory(action git2go.CreateDirectory, repo *git.Repository, index *git.Index) error { - if err := validateFileDoesNotExist(index, action.Path); err != nil { - return err - } else if err := validateDirectoryDoesNotExist(index, action.Path); err != nil { - return err - } - - emptyBlobOID, err := repo.CreateBlobFromBuffer([]byte{}) - if err != nil { - return fmt.Errorf("create blob from buffer: %w", err) - } - - return index.Add(&git.IndexEntry{ - Path: filepath.Join(action.Path, ".gitkeep"), - Mode: git.FilemodeBlob, - Id: emptyBlobOID, - }) -} diff --git a/cmd/gitaly-git2go-v15/commit/create_file.go b/cmd/gitaly-git2go-v15/commit/create_file.go deleted file mode 100644 index 926916561..000000000 --- a/cmd/gitaly-git2go-v15/commit/create_file.go +++ /dev/null @@ -1,30 +0,0 @@ -//go:build static && system_libgit2 - -package commit - -import ( - git "github.com/libgit2/git2go/v33" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" -) - -func applyCreateFile(action git2go.CreateFile, index *git.Index) error { - if err := validateFileDoesNotExist(index, action.Path); err != nil { - return err - } - - oid, err := git.NewOid(action.OID) - if err != nil { - return err - } - - mode := git.FilemodeBlob - if action.ExecutableMode { - mode = git.FilemodeBlobExecutable - } - - return index.Add(&git.IndexEntry{ - Path: action.Path, - Mode: mode, - Id: oid, - }) -} diff --git a/cmd/gitaly-git2go-v15/commit/delete_file.go b/cmd/gitaly-git2go-v15/commit/delete_file.go deleted file mode 100644 index a5af77b7b..000000000 --- a/cmd/gitaly-git2go-v15/commit/delete_file.go +++ /dev/null @@ -1,16 +0,0 @@ -//go:build static && system_libgit2 - -package commit - -import ( - git "github.com/libgit2/git2go/v33" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" -) - -func applyDeleteFile(action git2go.DeleteFile, index *git.Index) error { - if err := validateFileExists(index, action.Path); err != nil { - return err - } - - return index.RemoveByPath(action.Path) -} diff --git a/cmd/gitaly-git2go-v15/commit/move_file.go b/cmd/gitaly-git2go-v15/commit/move_file.go deleted file mode 100644 index b31853c96..000000000 --- a/cmd/gitaly-git2go-v15/commit/move_file.go +++ /dev/null @@ -1,41 +0,0 @@ -//go:build static && system_libgit2 - -package commit - -import ( - git "github.com/libgit2/git2go/v33" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" -) - -func applyMoveFile(action git2go.MoveFile, index *git.Index) error { - entry, err := index.EntryByPath(action.Path, 0) - if err != nil { - if git.IsErrorCode(err, git.ErrorCodeNotFound) { - return git2go.FileNotFoundError(action.Path) - } - - return err - } - - if err := validateFileDoesNotExist(index, action.NewPath); err != nil { - return err - } - - oid := entry.Id - if action.OID != "" { - oid, err = git.NewOid(action.OID) - if err != nil { - return err - } - } - - if err := index.Add(&git.IndexEntry{ - Path: action.NewPath, - Mode: entry.Mode, - Id: oid, - }); err != nil { - return err - } - - return index.RemoveByPath(entry.Path) -} diff --git a/cmd/gitaly-git2go-v15/commit/update_file.go b/cmd/gitaly-git2go-v15/commit/update_file.go deleted file mode 100644 index cea5d629b..000000000 --- a/cmd/gitaly-git2go-v15/commit/update_file.go +++ /dev/null @@ -1,30 +0,0 @@ -//go:build static && system_libgit2 - -package commit - -import ( - git "github.com/libgit2/git2go/v33" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" -) - -func applyUpdateFile(action git2go.UpdateFile, index *git.Index) error { - entry, err := index.EntryByPath(action.Path, 0) - if err != nil { - if git.IsErrorCode(err, git.ErrorCodeNotFound) { - return git2go.FileNotFoundError(action.Path) - } - - return err - } - - oid, err := git.NewOid(action.OID) - if err != nil { - return err - } - - return index.Add(&git.IndexEntry{ - Path: action.Path, - Mode: entry.Mode, - Id: oid, - }) -} diff --git a/cmd/gitaly-git2go-v15/commit/validate.go b/cmd/gitaly-git2go-v15/commit/validate.go deleted file mode 100644 index ab3f972b1..000000000 --- a/cmd/gitaly-git2go-v15/commit/validate.go +++ /dev/null @@ -1,48 +0,0 @@ -//go:build static && system_libgit2 - -package commit - -import ( - "os" - - git "github.com/libgit2/git2go/v33" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" -) - -func validateFileExists(index *git.Index, path string) error { - if _, err := index.Find(path); err != nil { - if git.IsErrorCode(err, git.ErrorCodeNotFound) { - return git2go.FileNotFoundError(path) - } - - return err - } - - return nil -} - -func validateFileDoesNotExist(index *git.Index, path string) error { - _, err := index.Find(path) - if err == nil { - return git2go.FileExistsError(path) - } - - if !git.IsErrorCode(err, git.ErrorCodeNotFound) { - return err - } - - return nil -} - -func validateDirectoryDoesNotExist(index *git.Index, path string) error { - _, err := index.FindPrefix(path + string(os.PathSeparator)) - if err == nil { - return git2go.DirectoryExistsError(path) - } - - if !git.IsErrorCode(err, git.ErrorCodeNotFound) { - return err - } - - return nil -} diff --git a/cmd/gitaly-git2go-v15/conflicts.go b/cmd/gitaly-git2go-v15/conflicts.go deleted file mode 100644 index c29f729df..000000000 --- a/cmd/gitaly-git2go-v15/conflicts.go +++ /dev/null @@ -1,171 +0,0 @@ -//go:build static && system_libgit2 - -package main - -import ( - "context" - "encoding/gob" - "errors" - "flag" - "fmt" - - git "github.com/libgit2/git2go/v33" - "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go-v15/git2goutil" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" - "gitlab.com/gitlab-org/gitaly/v15/internal/helper" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -type conflictsSubcommand struct{} - -func (cmd *conflictsSubcommand) Flags() *flag.FlagSet { - return flag.NewFlagSet("conflicts", flag.ExitOnError) -} - -func (cmd *conflictsSubcommand) Run(_ context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error { - var request git2go.ConflictsCommand - if err := decoder.Decode(&request); err != nil { - return err - } - res := cmd.conflicts(request) - return encoder.Encode(res) -} - -func (conflictsSubcommand) conflicts(request git2go.ConflictsCommand) git2go.ConflictsResult { - repo, err := git2goutil.OpenRepository(request.Repository) - if err != nil { - return conflictError(codes.Internal, fmt.Errorf("could not open repository: %w", err).Error()) - } - - oursOid, err := git.NewOid(request.Ours) - if err != nil { - return conflictError(codes.InvalidArgument, err.Error()) - } - - ours, err := repo.LookupCommit(oursOid) - if err != nil { - return convertError(err, git.ErrorCodeNotFound, codes.InvalidArgument) - } - - theirsOid, err := git.NewOid(request.Theirs) - if err != nil { - return conflictError(codes.InvalidArgument, err.Error()) - } - - theirs, err := repo.LookupCommit(theirsOid) - if err != nil { - return convertError(err, git.ErrorCodeNotFound, codes.InvalidArgument) - } - - index, err := repo.MergeCommits(ours, theirs, nil) - if err != nil { - return conflictError(codes.FailedPrecondition, fmt.Sprintf("could not merge commits: %v", err)) - } - - iterator, err := index.ConflictIterator() - if err != nil { - return conflictError(codes.Internal, fmt.Errorf("could not get conflicts: %w", err).Error()) - } - - var result git2go.ConflictsResult - for { - conflict, err := iterator.Next() - if err != nil { - var gitError *git.GitError - if errors.As(err, &gitError) && gitError.Code == git.ErrorCodeIterOver { - break - } - return conflictError(codes.Internal, err.Error()) - } - - merge, err := Merge(repo, conflict) - if err != nil { - if s, ok := status.FromError(err); ok { - return conflictError(s.Code(), s.Message()) - } - return conflictError(codes.Internal, err.Error()) - } - - result.Conflicts = append(result.Conflicts, git2go.Conflict{ - Ancestor: conflictEntryFromIndex(conflict.Ancestor), - Our: conflictEntryFromIndex(conflict.Our), - Their: conflictEntryFromIndex(conflict.Their), - Content: merge.Contents, - }) - } - - return result -} - -// Merge will merge the given index conflict and produce a file with conflict -// markers. -func Merge(repo *git.Repository, conflict git.IndexConflict) (*git.MergeFileResult, 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 nil, helper.ErrFailedPreconditionf("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 nil, fmt.Errorf("could not compute conflicts: %w", err) - } - - // In a case of tree-based conflicts (e.g. no ancestor), fallback to `Path` - // of `their` side. If that's also blank, fallback to `Path` of `our` side. - // This is to ensure that there's always a `Path` when we try to merge - // conflicts. - if merge.Path == "" { - if their.Path != "" { - merge.Path = their.Path - } else { - merge.Path = our.Path - } - } - - return merge, nil -} - -func conflictEntryFromIndex(entry *git.IndexEntry) git2go.ConflictEntry { - if entry == nil { - return git2go.ConflictEntry{} - } - return git2go.ConflictEntry{ - Path: entry.Path, - Mode: int32(entry.Mode), - } -} - -func conflictError(code codes.Code, message string) git2go.ConflictsResult { - err := git2go.ConflictError{ - Code: code, - Message: message, - } - return git2go.ConflictsResult{ - Err: err, - } -} - -func convertError(err error, errorCode git.ErrorCode, returnCode codes.Code) git2go.ConflictsResult { - var gitError *git.GitError - if errors.As(err, &gitError) && gitError.Code == errorCode { - return conflictError(returnCode, err.Error()) - } - return conflictError(codes.Internal, err.Error()) -} diff --git a/cmd/gitaly-git2go-v15/conflicts_test.go b/cmd/gitaly-git2go-v15/conflicts_test.go deleted file mode 100644 index c8e9848eb..000000000 --- a/cmd/gitaly-git2go-v15/conflicts_test.go +++ /dev/null @@ -1,278 +0,0 @@ -//go:build static && system_libgit2 && !gitaly_test_sha256 - -package main - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/require" - glgit "gitlab.com/gitlab-org/gitaly/v15/internal/git" - "gitlab.com/gitlab-org/gitaly/v15/internal/git/gittest" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" - "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper" - "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper/testcfg" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -func TestConflicts(t *testing.T) { - testcases := []struct { - desc string - base []gittest.TreeEntry - ours []gittest.TreeEntry - theirs []gittest.TreeEntry - conflicts []git2go.Conflict - }{ - { - desc: "no conflicts", - base: []gittest.TreeEntry{ - {Path: "file", Content: "a", Mode: "100644"}, - }, - ours: []gittest.TreeEntry{ - {Path: "file", Content: "a", Mode: "100644"}, - }, - theirs: []gittest.TreeEntry{ - {Path: "file", Content: "b", Mode: "100644"}, - }, - conflicts: nil, - }, - { - desc: "single file", - base: []gittest.TreeEntry{ - {Path: "file", Content: "a", Mode: "100644"}, - }, - ours: []gittest.TreeEntry{ - {Path: "file", Content: "b", Mode: "100644"}, - }, - theirs: []gittest.TreeEntry{ - {Path: "file", Content: "c", Mode: "100644"}, - }, - conflicts: []git2go.Conflict{ - { - Ancestor: git2go.ConflictEntry{Path: "file", Mode: 0o100644}, - Our: git2go.ConflictEntry{Path: "file", Mode: 0o100644}, - Their: git2go.ConflictEntry{Path: "file", Mode: 0o100644}, - Content: []byte("<<<<<<< file\nb\n=======\nc\n>>>>>>> file\n"), - }, - }, - }, - { - desc: "multiple files with single conflict", - base: []gittest.TreeEntry{ - {Path: "file-1", Content: "a", Mode: "100644"}, - {Path: "file-2", Content: "a", Mode: "100644"}, - }, - ours: []gittest.TreeEntry{ - {Path: "file-1", Content: "b", Mode: "100644"}, - {Path: "file-2", Content: "b", Mode: "100644"}, - }, - theirs: []gittest.TreeEntry{ - {Path: "file-1", Content: "a", Mode: "100644"}, - {Path: "file-2", Content: "c", Mode: "100644"}, - }, - conflicts: []git2go.Conflict{ - { - Ancestor: git2go.ConflictEntry{Path: "file-2", Mode: 0o100644}, - Our: git2go.ConflictEntry{Path: "file-2", Mode: 0o100644}, - Their: git2go.ConflictEntry{Path: "file-2", Mode: 0o100644}, - Content: []byte("<<<<<<< file-2\nb\n=======\nc\n>>>>>>> file-2\n"), - }, - }, - }, - { - desc: "multiple conflicts", - base: []gittest.TreeEntry{ - {Path: "file-1", Content: "a", Mode: "100644"}, - {Path: "file-2", Content: "a", Mode: "100644"}, - }, - ours: []gittest.TreeEntry{ - {Path: "file-1", Content: "b", Mode: "100644"}, - {Path: "file-2", Content: "b", Mode: "100644"}, - }, - theirs: []gittest.TreeEntry{ - {Path: "file-1", Content: "c", Mode: "100644"}, - {Path: "file-2", Content: "c", Mode: "100644"}, - }, - conflicts: []git2go.Conflict{ - { - Ancestor: git2go.ConflictEntry{Path: "file-1", Mode: 0o100644}, - Our: git2go.ConflictEntry{Path: "file-1", Mode: 0o100644}, - Their: git2go.ConflictEntry{Path: "file-1", Mode: 0o100644}, - Content: []byte("<<<<<<< file-1\nb\n=======\nc\n>>>>>>> file-1\n"), - }, - { - Ancestor: git2go.ConflictEntry{Path: "file-2", Mode: 0o100644}, - Our: git2go.ConflictEntry{Path: "file-2", Mode: 0o100644}, - Their: git2go.ConflictEntry{Path: "file-2", Mode: 0o100644}, - Content: []byte("<<<<<<< file-2\nb\n=======\nc\n>>>>>>> file-2\n"), - }, - }, - }, - { - desc: "modified-delete-conflict", - base: []gittest.TreeEntry{ - {Path: "file", Content: "content", Mode: "100644"}, - }, - ours: []gittest.TreeEntry{ - {Path: "file", Content: "changed", Mode: "100644"}, - }, - theirs: []gittest.TreeEntry{ - {Path: "different-file", Content: "unrelated", Mode: "100644"}, - }, - conflicts: []git2go.Conflict{ - { - Ancestor: git2go.ConflictEntry{Path: "file", Mode: 0o100644}, - Our: git2go.ConflictEntry{Path: "file", Mode: 0o100644}, - Their: git2go.ConflictEntry{}, - Content: []byte("<<<<<<< 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: []gittest.TreeEntry{ - {Path: "file", Content: "a\nb\nc\nd\ne\nf\ng\n", Mode: "100644"}, - }, - ours: []gittest.TreeEntry{ - {Path: "renamed-1", Content: "a\nb\nc\nd\ne\nf\ng\n", Mode: "100644"}, - }, - theirs: []gittest.TreeEntry{ - {Path: "renamed-2", Content: "a\nb\nc\nd\ne\nf\ng\n", Mode: "100644"}, - }, - conflicts: []git2go.Conflict{ - { - Ancestor: git2go.ConflictEntry{Path: "file", Mode: 0o100644}, - Our: git2go.ConflictEntry{}, - Their: git2go.ConflictEntry{}, - Content: nil, - }, - { - Ancestor: git2go.ConflictEntry{}, - Our: git2go.ConflictEntry{Path: "renamed-1", Mode: 0o100644}, - Their: git2go.ConflictEntry{}, - Content: []byte("a\nb\nc\nd\ne\nf\ng\n"), - }, - { - Ancestor: git2go.ConflictEntry{}, - Our: git2go.ConflictEntry{}, - Their: git2go.ConflictEntry{Path: "renamed-2", Mode: 0o100644}, - Content: []byte("a\nb\nc\nd\ne\nf\ng\n"), - }, - }, - }, - } - - for _, tc := range testcases { - cfg, repo, repoPath := testcfg.BuildWithRepo(t) - executor := buildExecutor(t, cfg) - - testcfg.BuildGitalyGit2Go(t, cfg) - - base := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries(tc.base...)) - ours := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(base), gittest.WithTreeEntries(tc.ours...)) - theirs := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(base), gittest.WithTreeEntries(tc.theirs...)) - - t.Run(tc.desc, func(t *testing.T) { - ctx := testhelper.Context(t) - - response, err := executor.Conflicts(ctx, repo, git2go.ConflictsCommand{ - Repository: repoPath, - Ours: ours.String(), - Theirs: theirs.String(), - }) - - require.NoError(t, err) - require.Equal(t, tc.conflicts, response.Conflicts) - }) - } -} - -func TestConflicts_checkError(t *testing.T) { - cfg, repo, repoPath := testcfg.BuildWithRepo(t) - base := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries()) - validOID := glgit.ObjectID(base.String()) - executor := buildExecutor(t, cfg) - - testcfg.BuildGitalyGit2Go(t, cfg) - - testcases := []struct { - desc string - overrideRepoPath string - ours glgit.ObjectID - theirs glgit.ObjectID - expErr error - }{ - { - desc: "ours is not set", - ours: "", - theirs: validOID, - expErr: fmt.Errorf("conflicts: %w: missing ours", git2go.ErrInvalidArgument), - }, - { - desc: "theirs is not set", - ours: validOID, - theirs: "", - expErr: fmt.Errorf("conflicts: %w: missing theirs", git2go.ErrInvalidArgument), - }, - { - desc: "invalid repository", - overrideRepoPath: "not/existing/path.git", - ours: validOID, - theirs: validOID, - expErr: status.Error(codes.Internal, "could not open repository: failed to resolve path 'not/existing/path.git': No such file or directory"), - }, - { - desc: "ours is invalid", - ours: "1", - theirs: validOID, - expErr: status.Error(codes.InvalidArgument, "encoding/hex: odd length hex string"), - }, - { - desc: "theirs is invalid", - ours: validOID, - theirs: "1", - expErr: status.Error(codes.InvalidArgument, "encoding/hex: odd length hex string"), - }, - { - desc: "ours OID doesn't exist", - ours: glgit.ObjectHashSHA1.ZeroOID, - theirs: validOID, - expErr: status.Error(codes.InvalidArgument, "odb: cannot read object: null OID cannot exist"), - }, - { - desc: "invalid object type", - ours: glgit.ObjectHashSHA1.EmptyTreeOID, - theirs: validOID, - expErr: status.Error(codes.InvalidArgument, "the requested type does not match the type in the ODB"), - }, - { - desc: "theirs OID doesn't exist", - ours: validOID, - theirs: glgit.ObjectHashSHA1.ZeroOID, - expErr: status.Error(codes.InvalidArgument, "odb: cannot read object: null OID cannot exist"), - }, - } - - for _, tc := range testcases { - t.Run(tc.desc, func(t *testing.T) { - repoPath := repoPath - if tc.overrideRepoPath != "" { - repoPath = tc.overrideRepoPath - } - ctx := testhelper.Context(t) - - _, err := executor.Conflicts(ctx, repo, git2go.ConflictsCommand{ - Repository: repoPath, - Ours: tc.ours.String(), - Theirs: tc.theirs.String(), - }) - - require.Error(t, err) - require.Equal(t, tc.expErr, err) - }) - } -} diff --git a/cmd/gitaly-git2go-v15/featureflags.go b/cmd/gitaly-git2go-v15/featureflags.go deleted file mode 100644 index 720320901..000000000 --- a/cmd/gitaly-git2go-v15/featureflags.go +++ /dev/null @@ -1,41 +0,0 @@ -//go:build static && system_libgit2 && gitaly_test - -package main - -import ( - "context" - "encoding/gob" - "flag" - - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" - "gitlab.com/gitlab-org/gitaly/v15/internal/metadata/featureflag" -) - -// This subcommand is only called in tests, so we don't want to register it like -// the other subcommands but instead will do it in an init block. The gitaly_test build -// flag will guarantee that this is not built and registered in the -// gitaly-git2go binary -func init() { - subcommands["feature-flags"] = &featureFlagsSubcommand{} -} - -type featureFlagsSubcommand struct{} - -func (featureFlagsSubcommand) Flags() *flag.FlagSet { - return flag.NewFlagSet("feature-flags", flag.ExitOnError) -} - -func (featureFlagsSubcommand) Run(ctx context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error { - var flags []git2go.FeatureFlag - for flag, value := range featureflag.FromContext(ctx) { - flags = append(flags, git2go.FeatureFlag{ - Name: flag.Name, - MetadataKey: flag.MetadataKey(), - Value: value, - }) - } - - return encoder.Encode(git2go.FeatureFlags{ - Flags: flags, - }) -} diff --git a/cmd/gitaly-git2go-v15/git2goutil/repo.go b/cmd/gitaly-git2go-v15/git2goutil/repo.go deleted file mode 100644 index 259da77e8..000000000 --- a/cmd/gitaly-git2go-v15/git2goutil/repo.go +++ /dev/null @@ -1,10 +0,0 @@ -package git2goutil - -import ( - git "github.com/libgit2/git2go/v33" -) - -// OpenRepository opens the repository located at path as a Git2Go repository. -func OpenRepository(path string) (*git.Repository, error) { - return git.OpenRepositoryExtended(path, git.RepositoryOpenFromEnv, "") -} diff --git a/cmd/gitaly-git2go-v15/main.go b/cmd/gitaly-git2go-v15/main.go deleted file mode 100644 index e8a2c2c7b..000000000 --- a/cmd/gitaly-git2go-v15/main.go +++ /dev/null @@ -1,177 +0,0 @@ -//go:build static && system_libgit2 - -package main - -import ( - "context" - "encoding/gob" - "flag" - "fmt" - "os" - "strings" - - "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus/ctxlogrus" - git "github.com/libgit2/git2go/v33" - "github.com/sirupsen/logrus" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" - glog "gitlab.com/gitlab-org/gitaly/v15/internal/log" - "gitlab.com/gitlab-org/gitaly/v15/internal/metadata/featureflag" - "gitlab.com/gitlab-org/labkit/correlation" -) - -type subcmd interface { - Flags() *flag.FlagSet - Run(ctx context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error -} - -var subcommands = map[string]subcmd{ - "apply": &applySubcommand{}, - "cherry-pick": &cherryPickSubcommand{}, - "commit": commitSubcommand{}, - "conflicts": &conflictsSubcommand{}, - "merge": &mergeSubcommand{}, - "rebase": &rebaseSubcommand{}, - "revert": &revertSubcommand{}, - "resolve": &resolveSubcommand{}, - "submodule": &submoduleSubcommand{}, -} - -func fatalf(logger logrus.FieldLogger, encoder *gob.Encoder, format string, args ...interface{}) { - err := encoder.Encode(git2go.Result{ - Err: git2go.SerializableError(fmt.Errorf(format, args...)), - }) - if err != nil { - logger.WithError(err).Error("encode to gob failed") - } - // An exit code of 1 would indicate an error over stderr. Since our errors - // are encoded over gob, we need to exit cleanly - os.Exit(0) -} - -func configureLogging(format, level string) { - // Gitaly logging by default goes to stdout, which would interfere with gob - // encoding. - for _, l := range glog.Loggers { - l.Out = os.Stderr - } - glog.Configure(glog.Loggers, format, level) -} - -func main() { - decoder := gob.NewDecoder(os.Stdin) - encoder := gob.NewEncoder(os.Stdout) - - var logFormat, logLevel, correlationID string - var enabledFeatureFlags, disabledFeatureFlags featureFlagArg - - flags := flag.NewFlagSet(git2go.BinaryName, flag.PanicOnError) - flags.StringVar(&logFormat, "log-format", "", "logging format") - flags.StringVar(&logLevel, "log-level", "", "logging level") - flags.StringVar(&correlationID, "correlation-id", "", "correlation ID used for request tracing") - flags.Var( - &enabledFeatureFlags, - "enabled-feature-flags", - "comma separated list of explicitly enabled feature flags", - ) - flags.Var( - &disabledFeatureFlags, - "disabled-feature-flags", - "comma separated list of explicitly disabled feature flags", - ) - _ = flags.Parse(os.Args[1:]) - - if correlationID == "" { - correlationID = correlation.SafeRandomID() - } - - configureLogging(logFormat, logLevel) - - ctx := correlation.ContextWithCorrelation(context.Background(), correlationID) - logger := glog.Default().WithFields(logrus.Fields{ - "command.name": git2go.BinaryName, - "correlation_id": correlationID, - "enabled_feature_flags": enabledFeatureFlags, - "disabled_feature_flags": disabledFeatureFlags, - }) - - if flags.NArg() < 1 { - fatalf(logger, encoder, "missing subcommand") - } - - subcmd, ok := subcommands[flags.Arg(0)] - if !ok { - fatalf(logger, encoder, "unknown subcommand: %q", flags.Arg(0)) - } - - subcmdFlags := subcmd.Flags() - if err := subcmdFlags.Parse(flags.Args()[1:]); err != nil { - fatalf(logger, encoder, "parsing flags of %q: %s", subcmdFlags.Name(), err) - } - - if subcmdFlags.NArg() != 0 { - fatalf(logger, encoder, "%s: trailing arguments", subcmdFlags.Name()) - } - - if err := git.EnableFsyncGitDir(true); err != nil { - fatalf(logger, encoder, "enable fsync: %s", err) - } - - for _, configLevel := range []git.ConfigLevel{ - git.ConfigLevelSystem, - git.ConfigLevelXDG, - git.ConfigLevelGlobal, - } { - if err := git.SetSearchPath(configLevel, "/dev/null"); err != nil { - fatalf(logger, encoder, "setting search path: %s", err) - } - } - - subcmdLogger := logger.WithField("command.subcommand", subcmdFlags.Name()) - subcmdLogger.Infof("starting %s command", subcmdFlags.Name()) - - ctx = ctxlogrus.ToContext(ctx, subcmdLogger) - ctx = enabledFeatureFlags.ToContext(ctx, true) - ctx = disabledFeatureFlags.ToContext(ctx, false) - - if err := subcmd.Run(ctx, decoder, encoder); err != nil { - subcmdLogger.WithError(err).Errorf("%s command failed", subcmdFlags.Name()) - fatalf(logger, encoder, "%s: %s", subcmdFlags.Name(), err) - } - - subcmdLogger.Infof("%s command finished", subcmdFlags.Name()) -} - -type featureFlagArg []featureflag.FeatureFlag - -func (v *featureFlagArg) String() string { - metadataKeys := make([]string, 0, len(*v)) - for _, flag := range *v { - metadataKeys = append(metadataKeys, flag.MetadataKey()) - } - return strings.Join(metadataKeys, ",") -} - -func (v *featureFlagArg) Set(s string) error { - if s == "" { - return nil - } - - for _, metadataKey := range strings.Split(s, ",") { - flag, err := featureflag.FromMetadataKey(metadataKey) - if err != nil { - return err - } - - *v = append(*v, flag) - } - - return nil -} - -func (v featureFlagArg) ToContext(ctx context.Context, enabled bool) context.Context { - for _, flag := range v { - ctx = featureflag.IncomingCtxWithFeatureFlag(ctx, flag, enabled) - } - - return ctx -} diff --git a/cmd/gitaly-git2go-v15/merge.go b/cmd/gitaly-git2go-v15/merge.go deleted file mode 100644 index 3a1d8bfb8..000000000 --- a/cmd/gitaly-git2go-v15/merge.go +++ /dev/null @@ -1,251 +0,0 @@ -//go:build static && system_libgit2 - -package main - -import ( - "context" - "encoding/gob" - "errors" - "flag" - "fmt" - "time" - - git "github.com/libgit2/git2go/v33" - "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go-v15/git2goutil" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" -) - -type mergeSubcommand struct{} - -func (cmd *mergeSubcommand) Flags() *flag.FlagSet { - flags := flag.NewFlagSet("merge", flag.ExitOnError) - return flags -} - -func (cmd *mergeSubcommand) Run(_ context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error { - var request git2go.MergeCommand - if err := decoder.Decode(&request); err != nil { - return err - } - - if request.AuthorDate.IsZero() { - request.AuthorDate = time.Now() - } - - commitID, err := merge(request) - - return encoder.Encode(git2go.Result{ - CommitID: commitID, - Err: git2go.SerializableError(err), - }) -} - -func merge(request git2go.MergeCommand) (string, error) { - repo, err := git2goutil.OpenRepository(request.Repository) - if err != nil { - return "", fmt.Errorf("could not open repository: %w", err) - } - defer repo.Free() - - ours, err := lookupCommit(repo, request.Ours) - if err != nil { - return "", fmt.Errorf("ours commit lookup: %w", err) - } - - theirs, err := lookupCommit(repo, request.Theirs) - if err != nil { - return "", fmt.Errorf("theirs commit lookup: %w", err) - } - - mergeOpts, err := git.DefaultMergeOptions() - if err != nil { - return "", fmt.Errorf("could not create merge options: %w", err) - } - mergeOpts.RecursionLimit = git2go.MergeRecursionLimit - - index, err := repo.MergeCommits(ours, theirs, &mergeOpts) - if err != nil { - return "", fmt.Errorf("could not merge commits: %w", err) - } - defer index.Free() - - if index.HasConflicts() { - if !request.AllowConflicts { - conflictingFiles, err := getConflictingFiles(index) - if err != nil { - return "", fmt.Errorf("getting conflicting files: %w", err) - } - - return "", git2go.ConflictingFilesError{ - ConflictingFiles: conflictingFiles, - } - } - - if err := resolveConflicts(repo, index); err != nil { - return "", fmt.Errorf("could not resolve conflicts: %w", err) - } - } - - tree, err := index.WriteTreeTo(repo) - if err != nil { - return "", fmt.Errorf("could not write tree: %w", err) - } - - author := git.Signature(git2go.NewSignature(request.AuthorName, request.AuthorMail, request.AuthorDate)) - committer := author - if request.CommitterMail != "" { - committer = git.Signature(git2go.NewSignature(request.CommitterName, request.CommitterMail, request.CommitterDate)) - } - - var parents []*git.Oid - if request.Squash { - parents = []*git.Oid{ours.Id()} - } else { - parents = []*git.Oid{ours.Id(), theirs.Id()} - } - commit, err := repo.CreateCommitFromIds("", &author, &committer, request.Message, tree, parents...) - if err != nil { - return "", fmt.Errorf("could not create merge commit: %w", err) - } - - return commit.String(), nil -} - -func resolveConflicts(repo *git.Repository, index *git.Index) error { - // We need to get all conflicts up front as resolving conflicts as we - // iterate breaks the iterator. - indexConflicts, err := getConflicts(index) - if err != nil { - return err - } - - for _, conflict := range indexConflicts { - if isConflictMergeable(conflict) { - merge, err := Merge(repo, conflict) - if err != nil { - return err - } - - mergedBlob, err := repo.CreateBlobFromBuffer(merge.Contents) - if err != nil { - return err - } - - mergedIndexEntry := git.IndexEntry{ - Path: merge.Path, - Mode: git.Filemode(merge.Mode), - Id: mergedBlob, - } - - if err := index.Add(&mergedIndexEntry); err != nil { - return err - } - - if err := index.RemoveConflict(merge.Path); err != nil { - return err - } - } else { - if conflict.Their != nil { - // If a conflict has `Their` present, we add it back to the index - // as we want those changes to be part of the merge. - if err := index.Add(conflict.Their); err != nil { - return err - } - - if err := index.RemoveConflict(conflict.Their.Path); err != nil { - return err - } - } else if conflict.Our != nil { - // If a conflict has `Our` present, remove its conflict as we - // don't want to include those changes. - if err := index.RemoveConflict(conflict.Our.Path); err != nil { - return err - } - } else { - // If conflict has no `Their` and `Our`, remove the conflict to - // mark it as resolved. - if err := index.RemoveConflict(conflict.Ancestor.Path); err != nil { - return err - } - } - } - } - - if index.HasConflicts() { - conflictingFiles, err := getConflictingFiles(index) - if err != nil { - return fmt.Errorf("getting conflicting files: %w", err) - } - - return git2go.ConflictingFilesError{ - ConflictingFiles: conflictingFiles, - } - } - - return nil -} - -func getConflictingFiles(index *git.Index) ([]string, error) { - conflicts, err := getConflicts(index) - if err != nil { - return nil, fmt.Errorf("getting conflicts: %w", err) - } - - conflictingFiles := make([]string, 0, len(conflicts)) - for _, conflict := range conflicts { - switch { - case conflict.Our != nil: - conflictingFiles = append(conflictingFiles, conflict.Our.Path) - case conflict.Ancestor != nil: - conflictingFiles = append(conflictingFiles, conflict.Ancestor.Path) - case conflict.Their != nil: - conflictingFiles = append(conflictingFiles, conflict.Their.Path) - default: - return nil, errors.New("invalid conflict") - } - } - - return conflictingFiles, nil -} - -func isConflictMergeable(conflict git.IndexConflict) bool { - conflictIndexEntriesCount := 0 - - if conflict.Their != nil { - conflictIndexEntriesCount++ - } - - if conflict.Our != nil { - conflictIndexEntriesCount++ - } - - if conflict.Ancestor != nil { - conflictIndexEntriesCount++ - } - - return conflictIndexEntriesCount >= 2 -} - -func getConflicts(index *git.Index) ([]git.IndexConflict, error) { - var conflicts []git.IndexConflict - - iterator, err := index.ConflictIterator() - if err != nil { - return nil, err - } - defer iterator.Free() - - for { - conflict, err := iterator.Next() - if err != nil { - if git.IsErrorCode(err, git.ErrorCodeIterOver) { - break - } - return nil, err - } - - conflicts = append(conflicts, conflict) - } - - return conflicts, nil -} diff --git a/cmd/gitaly-git2go-v15/merge_test.go b/cmd/gitaly-git2go-v15/merge_test.go deleted file mode 100644 index 00c3b0ee3..000000000 --- a/cmd/gitaly-git2go-v15/merge_test.go +++ /dev/null @@ -1,536 +0,0 @@ -//go:build static && system_libgit2 && !gitaly_test_sha256 - -package main - -import ( - "fmt" - "testing" - "time" - - libgit2 "github.com/libgit2/git2go/v33" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go-v15/git2goutil" - "gitlab.com/gitlab-org/gitaly/v15/internal/git" - "gitlab.com/gitlab-org/gitaly/v15/internal/git/gittest" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" - "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper" - "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper/testcfg" -) - -func TestMerge_missingArguments(t *testing.T) { - t.Parallel() - ctx := testhelper.Context(t) - - cfg, repo, repoPath := testcfg.BuildWithRepo(t) - executor := buildExecutor(t, cfg) - - testcases := []struct { - desc string - request git2go.MergeCommand - expectedErr string - }{ - { - desc: "no arguments", - expectedErr: "merge: invalid parameters: missing repository", - }, - { - desc: "missing repository", - request: git2go.MergeCommand{AuthorName: "Foo", AuthorMail: "foo@example.com", Message: "Foo", Ours: "HEAD", Theirs: "HEAD"}, - expectedErr: "merge: invalid parameters: missing repository", - }, - { - desc: "missing author name", - request: git2go.MergeCommand{Repository: repoPath, AuthorMail: "foo@example.com", Message: "Foo", Ours: "HEAD", Theirs: "HEAD"}, - expectedErr: "merge: invalid parameters: missing author name", - }, - { - desc: "missing author mail", - request: git2go.MergeCommand{Repository: repoPath, AuthorName: "Foo", Message: "Foo", Ours: "HEAD", Theirs: "HEAD"}, - expectedErr: "merge: invalid parameters: missing author mail", - }, - { - desc: "missing message", - request: git2go.MergeCommand{Repository: repoPath, AuthorName: "Foo", AuthorMail: "foo@example.com", Ours: "HEAD", Theirs: "HEAD"}, - expectedErr: "merge: invalid parameters: missing message", - }, - { - desc: "missing ours", - request: git2go.MergeCommand{Repository: repoPath, AuthorName: "Foo", AuthorMail: "foo@example.com", Message: "Foo", Theirs: "HEAD"}, - expectedErr: "merge: invalid parameters: missing ours", - }, - { - desc: "missing theirs", - request: git2go.MergeCommand{Repository: repoPath, AuthorName: "Foo", AuthorMail: "foo@example.com", Message: "Foo", Ours: "HEAD"}, - expectedErr: "merge: invalid parameters: missing theirs", - }, - // Committer* arguments are required only when at least one of them is non-empty - { - desc: "missing committer mail", - request: git2go.MergeCommand{Repository: repoPath, AuthorName: "Foo", AuthorMail: "foo@example.com", CommitterName: "Bar", Message: "Foo", Theirs: "HEAD", Ours: "HEAD"}, - expectedErr: "merge: invalid parameters: missing committer mail", - }, - { - desc: "missing committer name", - request: git2go.MergeCommand{Repository: repoPath, AuthorName: "Foo", AuthorMail: "foo@example.com", CommitterMail: "bar@example.com", Message: "Foo", Theirs: "HEAD", Ours: "HEAD"}, - expectedErr: "merge: invalid parameters: missing committer name", - }, - { - desc: "missing committer date", - request: git2go.MergeCommand{Repository: repoPath, AuthorName: "Foo", AuthorMail: "foo@example.com", CommitterName: "Bar", CommitterMail: "bar@example.com", Message: "Foo", Theirs: "HEAD", Ours: "HEAD"}, - expectedErr: "merge: invalid parameters: missing committer date", - }, - } - - for _, tc := range testcases { - t.Run(tc.desc, func(t *testing.T) { - _, err := executor.Merge(ctx, repo, tc.request) - require.Error(t, err) - require.Equal(t, tc.expectedErr, err.Error()) - }) - } -} - -func TestMerge_invalidRepositoryPath(t *testing.T) { - t.Parallel() - ctx := testhelper.Context(t) - - cfg, repo, _ := testcfg.BuildWithRepo(t) - testcfg.BuildGitalyGit2Go(t, cfg) - executor := buildExecutor(t, cfg) - - _, err := executor.Merge(ctx, repo, git2go.MergeCommand{ - Repository: "/does/not/exist", AuthorName: "Foo", AuthorMail: "foo@example.com", Message: "Foo", Ours: "HEAD", Theirs: "HEAD", - }) - require.Error(t, err) - require.Contains(t, err.Error(), "merge: could not open repository") -} - -func TestMerge_trees(t *testing.T) { - t.Parallel() - ctx := testhelper.Context(t) - - testcases := []struct { - desc string - base []gittest.TreeEntry - ours []gittest.TreeEntry - theirs []gittest.TreeEntry - expected map[string]string - withCommitter bool - squash bool - expectedResponse git2go.MergeResult - expectedErr error - }{ - { - desc: "trivial merge succeeds", - base: []gittest.TreeEntry{ - {Path: "file", Content: "a", Mode: "100644"}, - }, - ours: []gittest.TreeEntry{ - {Path: "file", Content: "a", Mode: "100644"}, - }, - theirs: []gittest.TreeEntry{ - {Path: "file", Content: "a", Mode: "100644"}, - }, - expected: map[string]string{ - "file": "a", - }, - expectedResponse: git2go.MergeResult{ - CommitID: "0db317551c49eddadde2b337550d8e57d9536886", - }, - }, - { - desc: "trivial merge with different committer succeeds", - base: []gittest.TreeEntry{ - {Path: "file", Content: "a", Mode: "100644"}, - }, - ours: []gittest.TreeEntry{ - {Path: "file", Content: "a", Mode: "100644"}, - }, - theirs: []gittest.TreeEntry{ - {Path: "file", Content: "a", Mode: "100644"}, - }, - expected: map[string]string{ - "file": "a", - }, - withCommitter: true, - expectedResponse: git2go.MergeResult{ - CommitID: "38dcbe72d91ed5621286290f70df9a5dd08f5cb6", - }, - }, - { - desc: "trivial squash succeeds", - base: []gittest.TreeEntry{ - {Path: "file", Content: "a", Mode: "100644"}, - }, - ours: []gittest.TreeEntry{ - {Path: "file", Content: "a", Mode: "100644"}, - }, - theirs: []gittest.TreeEntry{ - {Path: "file", Content: "a", Mode: "100644"}, - }, - expected: map[string]string{ - "file": "a", - }, - squash: true, - expectedResponse: git2go.MergeResult{ - CommitID: "a0781480ce3cbba80440e6c112c5ee7f718ed3c2", - }, - }, - { - desc: "non-trivial merge succeeds", - base: []gittest.TreeEntry{ - {Path: "file", Content: "a\nb\nc\nd\ne\nf\n", Mode: "100644"}, - }, - ours: []gittest.TreeEntry{ - {Path: "file", Content: "0\na\nb\nc\nd\ne\nf\n", Mode: "100644"}, - }, - theirs: []gittest.TreeEntry{ - {Path: "file", Content: "a\nb\nc\nd\ne\nf\n0\n", Mode: "100644"}, - }, - expected: map[string]string{ - "file": "0\na\nb\nc\nd\ne\nf\n0\n", - }, - expectedResponse: git2go.MergeResult{ - CommitID: "3c030d1ee80bbb005666619375fa0629c86b9534", - }, - }, - { - desc: "non-trivial squash succeeds", - base: []gittest.TreeEntry{ - {Path: "file", Content: "a\nb\nc\nd\ne\nf\n", Mode: "100644"}, - }, - ours: []gittest.TreeEntry{ - {Path: "file", Content: "0\na\nb\nc\nd\ne\nf\n", Mode: "100644"}, - }, - theirs: []gittest.TreeEntry{ - {Path: "file", Content: "a\nb\nc\nd\ne\nf\n0\n", Mode: "100644"}, - }, - expected: map[string]string{ - "file": "0\na\nb\nc\nd\ne\nf\n0\n", - }, - squash: true, - expectedResponse: git2go.MergeResult{ - CommitID: "43853c4a027a67c7e39afa8e7ef0a34a1874ef26", - }, - }, - { - desc: "multiple files succeed", - base: []gittest.TreeEntry{ - {Path: "1", Content: "foo", Mode: "100644"}, - {Path: "2", Content: "bar", Mode: "100644"}, - {Path: "3", Content: "qux", Mode: "100644"}, - }, - ours: []gittest.TreeEntry{ - {Path: "1", Content: "foo", Mode: "100644"}, - {Path: "2", Content: "modified", Mode: "100644"}, - {Path: "3", Content: "qux", Mode: "100644"}, - }, - theirs: []gittest.TreeEntry{ - {Path: "1", Content: "modified", Mode: "100644"}, - {Path: "2", Content: "bar", Mode: "100644"}, - {Path: "3", Content: "qux", Mode: "100644"}, - }, - expected: map[string]string{ - "1": "modified", - "2": "modified", - "3": "qux", - }, - expectedResponse: git2go.MergeResult{ - CommitID: "6be1fdb2c4116881c7a82575be41618e8a690ff4", - }, - }, - { - desc: "multiple files squash succeed", - base: []gittest.TreeEntry{ - {Path: "1", Content: "foo", Mode: "100644"}, - {Path: "2", Content: "bar", Mode: "100644"}, - {Path: "3", Content: "qux", Mode: "100644"}, - }, - ours: []gittest.TreeEntry{ - {Path: "1", Content: "foo", Mode: "100644"}, - {Path: "2", Content: "modified", Mode: "100644"}, - {Path: "3", Content: "qux", Mode: "100644"}, - }, - theirs: []gittest.TreeEntry{ - {Path: "1", Content: "modified", Mode: "100644"}, - {Path: "2", Content: "bar", Mode: "100644"}, - {Path: "3", Content: "qux", Mode: "100644"}, - }, - expected: map[string]string{ - "1": "modified", - "2": "modified", - "3": "qux", - }, - squash: true, - expectedResponse: git2go.MergeResult{ - CommitID: "fe094a98b22ac53e1da1a9eb16118ce49f01fdbe", - }, - }, - { - desc: "conflicting merge fails", - base: []gittest.TreeEntry{ - {Path: "1", Content: "foo", Mode: "100644"}, - }, - ours: []gittest.TreeEntry{ - {Path: "1", Content: "bar", Mode: "100644"}, - }, - theirs: []gittest.TreeEntry{ - {Path: "1", Content: "qux", Mode: "100644"}, - }, - expectedErr: fmt.Errorf("merge: %w", git2go.ConflictingFilesError{ - ConflictingFiles: []string{"1"}, - }), - }, - } - - for _, tc := range testcases { - cfg, repoProto, repoPath := testcfg.BuildWithRepo(t) - testcfg.BuildGitalyGit2Go(t, cfg) - executor := buildExecutor(t, cfg) - - base := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries(tc.base...)) - ours := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(base), gittest.WithTreeEntries(tc.ours...)) - theirs := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(base), gittest.WithTreeEntries(tc.theirs...)) - - authorDate := time.Date(2020, 7, 30, 7, 45, 50, 0, time.FixedZone("UTC+2", +2*60*60)) - committerDate := time.Date(2021, 7, 30, 7, 45, 50, 0, time.FixedZone("UTC+2", +2*60*60)) - - t.Run(tc.desc, func(t *testing.T) { - mergeCommand := git2go.MergeCommand{ - Repository: repoPath, - AuthorName: "John Doe", - AuthorMail: "john.doe@example.com", - AuthorDate: authorDate, - Message: "Merge message", - Ours: ours.String(), - Theirs: theirs.String(), - Squash: tc.squash, - } - if tc.withCommitter { - mergeCommand.CommitterName = "Jane Doe" - mergeCommand.CommitterMail = "jane.doe@example.com" - mergeCommand.CommitterDate = committerDate - } - response, err := executor.Merge(ctx, repoProto, mergeCommand) - - if tc.expectedErr != nil { - require.Equal(t, tc.expectedErr, err) - return - } - - require.NoError(t, err) - assert.Equal(t, tc.expectedResponse, response) - - repo, err := git2goutil.OpenRepository(repoPath) - require.NoError(t, err) - defer repo.Free() - - commitOid, err := libgit2.NewOid(response.CommitID) - require.NoError(t, err) - - commit, err := repo.LookupCommit(commitOid) - require.NoError(t, err) - - tree, err := commit.Tree() - require.NoError(t, err) - require.EqualValues(t, len(tc.expected), tree.EntryCount()) - - for name, contents := range tc.expected { - entry := tree.EntryByName(name) - require.NotNil(t, entry) - - blob, err := repo.LookupBlob(entry.Id) - require.NoError(t, err) - require.Equal(t, []byte(contents), blob.Contents()) - } - }) - } -} - -func TestMerge_squash(t *testing.T) { - t.Parallel() - - ctx := testhelper.Context(t) - - cfg, repoProto, repoPath := testcfg.BuildWithRepo(t) - testcfg.BuildGitalyGit2Go(t, cfg) - executor := buildExecutor(t, cfg) - - baseFile := gittest.TreeEntry{Path: "file.txt", Content: "b\nc", Mode: "100644"} - ourFile := gittest.TreeEntry{Path: "file.txt", Content: "a\nb\nc", Mode: "100644"} - theirFile1 := gittest.TreeEntry{Path: "file.txt", Content: "b\nc\nd", Mode: "100644"} - theirFile2 := gittest.TreeEntry{Path: "file.txt", Content: "b\nc\nd\ne", Mode: "100644"} - - base := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries(baseFile)) - ours := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(base), gittest.WithTreeEntries(ourFile)) - theirs1 := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(base), gittest.WithTreeEntries(theirFile1)) - theirs2 := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(theirs1), gittest.WithTreeEntries(theirFile2)) - - date := time.Date(2020, 7, 30, 7, 45, 50, 0, time.FixedZone("UTC+2", +2*60*60)) - response, err := executor.Merge(ctx, repoProto, git2go.MergeCommand{ - Repository: repoPath, - AuthorName: "John Doe", - AuthorMail: "john.doe@example.com", - AuthorDate: date, - Message: "Merge message", - Ours: ours.String(), - Theirs: theirs2.String(), - Squash: true, - }) - require.NoError(t, err) - assert.Equal(t, "882b43b68d160876e3833dc6bbabf7032058e837", response.CommitID) - - repo, err := git2goutil.OpenRepository(repoPath) - require.NoError(t, err) - - commitOid, err := libgit2.NewOid(response.CommitID) - require.NoError(t, err) - - theirs2Oid, err := libgit2.NewOid(theirs2.String()) - require.NoError(t, err) - isDescendant, err := repo.DescendantOf(commitOid, theirs2Oid) - require.NoError(t, err) - require.False(t, isDescendant) - - commit, err := repo.LookupCommit(commitOid) - require.NoError(t, err) - - require.Equal(t, uint(1), commit.ParentCount()) - require.Equal(t, ours.String(), commit.ParentId(0).String()) - - tree, err := commit.Tree() - require.NoError(t, err) - - entry := tree.EntryByName("file.txt") - require.NotNil(t, entry) - - blob, err := repo.LookupBlob(entry.Id) - require.NoError(t, err) - require.Equal(t, "a\nb\nc\nd\ne", string(blob.Contents())) -} - -func TestMerge_recursive(t *testing.T) { - t.Parallel() - ctx := testhelper.Context(t) - - cfg := testcfg.Build(t) - testcfg.BuildGitalyGit2Go(t, cfg) - executor := buildExecutor(t, cfg) - - repoProto, repoPath := gittest.InitRepo(t, cfg, cfg.Storages[0]) - - base := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries( - gittest.TreeEntry{Path: "base", Content: "base\n", Mode: "100644"}, - )) - - ours := make([]git.ObjectID, git2go.MergeRecursionLimit) - ours[0] = gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(base), gittest.WithTreeEntries( - gittest.TreeEntry{Path: "base", Content: "base\n", Mode: "100644"}, - gittest.TreeEntry{Path: "ours", Content: "ours-0\n", Mode: "100644"}, - )) - - theirs := make([]git.ObjectID, git2go.MergeRecursionLimit) - theirs[0] = gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(base), gittest.WithTreeEntries( - gittest.TreeEntry{Path: "base", Content: "base\n", Mode: "100644"}, - gittest.TreeEntry{Path: "theirs", Content: "theirs-0\n", Mode: "100644"}, - )) - - // We're now creating a set of criss-cross merges which look like the following graph: - // - // o---o---o---o---o- -o---o ours - // / \ / \ / \ / \ / \ . / \ / - // base o X X X X . X - // \ / \ / \ / \ / \ / . \ / \ - // o---o---o---o---o- -o---o theirs - // - // We then merge ours with theirs. The peculiarity about this merge is that the merge base - // is not unique, and as a result the merge will generate virtual merge bases for each of - // the criss-cross merges. This operation may thus be heavily expensive to perform. - for i := 1; i < git2go.MergeRecursionLimit; i++ { - ours[i] = gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(ours[i-1], theirs[i-1]), gittest.WithTreeEntries( - gittest.TreeEntry{Path: "base", Content: "base\n", Mode: "100644"}, - gittest.TreeEntry{Path: "ours", Content: fmt.Sprintf("ours-%d\n", i), Mode: "100644"}, - gittest.TreeEntry{Path: "theirs", Content: fmt.Sprintf("theirs-%d\n", i-1), Mode: "100644"}, - )) - - theirs[i] = gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(theirs[i-1], ours[i-1]), gittest.WithTreeEntries( - gittest.TreeEntry{Path: "base", Content: "base\n", Mode: "100644"}, - gittest.TreeEntry{Path: "ours", Content: fmt.Sprintf("ours-%d\n", i-1), Mode: "100644"}, - gittest.TreeEntry{Path: "theirs", Content: fmt.Sprintf("theirs-%d\n", i), Mode: "100644"}, - )) - } - - authorDate := time.Date(2020, 7, 30, 7, 45, 50, 0, time.FixedZone("UTC+2", +2*60*60)) - - // When creating the criss-cross merges, we have been doing evil merges - // as each merge has applied changes from the other side while at the - // same time incrementing the own file contents. As we exceed the merge - // limit, git will just pick one of both possible merge bases when - // hitting that limit instead of computing another virtual merge base. - // The result is thus a merge of the following three commits: - // - // merge base ours theirs - // ---------- ---- ------ - // - // base: "base" base: "base" base: "base" - // theirs: "theirs-1" theirs: "theirs-1 theirs: "theirs-2" - // ours: "ours-0" ours: "ours-2" ours: "ours-1" - // - // This is a classical merge commit as "ours" differs in all three - // cases. We thus expect a merge conflict, which unfortunately - // demonstrates that restricting the recursion limit may cause us to - // fail resolution. - _, err := executor.Merge(ctx, repoProto, git2go.MergeCommand{ - Repository: repoPath, - AuthorName: "John Doe", - AuthorMail: "john.doe@example.com", - AuthorDate: authorDate, - Message: "Merge message", - Ours: ours[len(ours)-1].String(), - Theirs: theirs[len(theirs)-1].String(), - }) - require.Equal(t, fmt.Errorf("merge: %w", git2go.ConflictingFilesError{ - ConflictingFiles: []string{"theirs"}, - }), err) - - // Otherwise, if we're merging an earlier criss-cross merge which has - // half of the limit many criss-cross patterns, we exactly hit the - // recursion limit and thus succeed. - response, err := executor.Merge(ctx, repoProto, git2go.MergeCommand{ - Repository: repoPath, - AuthorName: "John Doe", - AuthorMail: "john.doe@example.com", - AuthorDate: authorDate, - Message: "Merge message", - Ours: ours[git2go.MergeRecursionLimit/2].String(), - Theirs: theirs[git2go.MergeRecursionLimit/2].String(), - }) - require.NoError(t, err) - - repo, err := git2goutil.OpenRepository(repoPath) - require.NoError(t, err) - - commitOid, err := libgit2.NewOid(response.CommitID) - require.NoError(t, err) - - commit, err := repo.LookupCommit(commitOid) - require.NoError(t, err) - - tree, err := commit.Tree() - require.NoError(t, err) - - require.EqualValues(t, 3, tree.EntryCount()) - for name, contents := range map[string]string{ - "base": "base\n", - "ours": "ours-10\n", - "theirs": "theirs-10\n", - } { - entry := tree.EntryByName(name) - require.NotNil(t, entry) - - blob, err := repo.LookupBlob(entry.Id) - require.NoError(t, err) - require.Equal(t, []byte(contents), blob.Contents()) - } -} diff --git a/cmd/gitaly-git2go-v15/rebase.go b/cmd/gitaly-git2go-v15/rebase.go deleted file mode 100644 index e2cc3022f..000000000 --- a/cmd/gitaly-git2go-v15/rebase.go +++ /dev/null @@ -1,191 +0,0 @@ -//go:build static && system_libgit2 - -package main - -import ( - "context" - "encoding/gob" - "errors" - "flag" - "fmt" - - git "github.com/libgit2/git2go/v33" - "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go-v15/git2goutil" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" -) - -type rebaseSubcommand struct{} - -func (cmd *rebaseSubcommand) Flags() *flag.FlagSet { - return flag.NewFlagSet("rebase", flag.ExitOnError) -} - -func (cmd *rebaseSubcommand) Run(ctx context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error { - var request git2go.RebaseCommand - if err := decoder.Decode(&request); err != nil { - return err - } - - commitID, err := cmd.rebase(ctx, &request) - return encoder.Encode(git2go.Result{ - CommitID: commitID, - Err: git2go.SerializableError(err), - }) -} - -func (cmd *rebaseSubcommand) verify(ctx context.Context, r *git2go.RebaseCommand) error { - if r.Repository == "" { - return errors.New("missing repository") - } - if r.Committer.Name == "" { - return errors.New("missing committer name") - } - if r.Committer.Email == "" { - return errors.New("missing committer email") - } - if r.BranchName == "" && r.CommitID == "" { - return errors.New("missing branch name") - } - if r.BranchName != "" && r.CommitID != "" { - return errors.New("both branch name and commit ID") - } - if r.UpstreamRevision == "" && r.UpstreamCommitID == "" { - return errors.New("missing upstream revision") - } - if r.UpstreamRevision != "" && r.UpstreamCommitID != "" { - return errors.New("both upstream revision and upstream commit ID") - } - return nil -} - -func (cmd *rebaseSubcommand) rebase(ctx context.Context, request *git2go.RebaseCommand) (string, error) { - if err := cmd.verify(ctx, request); err != nil { - return "", err - } - - repo, err := git2goutil.OpenRepository(request.Repository) - if err != nil { - return "", fmt.Errorf("open repository: %w", err) - } - - opts, err := git.DefaultRebaseOptions() - if err != nil { - return "", fmt.Errorf("get rebase options: %w", err) - } - opts.InMemory = 1 - - var commit *git.AnnotatedCommit - if request.BranchName != "" { - commit, err = repo.AnnotatedCommitFromRevspec(fmt.Sprintf("refs/heads/%s", request.BranchName)) - if err != nil { - return "", fmt.Errorf("look up branch %q: %w", request.BranchName, err) - } - } else { - commitOid, err := git.NewOid(request.CommitID.String()) - if err != nil { - return "", fmt.Errorf("parse commit %q: %w", request.CommitID, err) - } - - commit, err = repo.LookupAnnotatedCommit(commitOid) - if err != nil { - return "", fmt.Errorf("look up commit %q: %w", request.CommitID, err) - } - } - - upstreamCommitParam := request.UpstreamRevision - if upstreamCommitParam == "" { - upstreamCommitParam = request.UpstreamCommitID.String() - } - - upstreamCommitOID, err := git.NewOid(upstreamCommitParam) - if err != nil { - return "", fmt.Errorf("parse upstream revision %q: %w", upstreamCommitParam, err) - } - - upstreamCommit, err := repo.LookupAnnotatedCommit(upstreamCommitOID) - if err != nil { - return "", fmt.Errorf("look up upstream revision %q: %w", upstreamCommitParam, err) - } - - mergeBase, err := repo.MergeBase(upstreamCommit.Id(), commit.Id()) - if err != nil { - return "", fmt.Errorf("find merge base: %w", err) - } - - if mergeBase.Equal(upstreamCommit.Id()) { - // Branch is zero commits behind, so do not rebase - return commit.Id().String(), nil - } - - if mergeBase.Equal(commit.Id()) { - // Branch is merged, so fast-forward to upstream - return upstreamCommit.Id().String(), nil - } - - mergeCommit, err := repo.LookupAnnotatedCommit(mergeBase) - if err != nil { - return "", fmt.Errorf("look up merge base: %w", err) - } - - rebase, err := repo.InitRebase(commit, mergeCommit, upstreamCommit, &opts) - if err != nil { - return "", fmt.Errorf("initiate rebase: %w", err) - } - - committer := git.Signature(request.Committer) - var oid *git.Oid - for { - op, err := rebase.Next() - if git.IsErrorCode(err, git.ErrorCodeIterOver) { - break - } else if err != nil { - return "", fmt.Errorf("rebase iterate: %w", err) - } - - commit, err := repo.LookupCommit(op.Id) - if err != nil { - return "", fmt.Errorf("lookup commit: %w", err) - } - - if err := rebase.Commit(op.Id, nil, &committer, commit.Message()); err != nil { - if git.IsErrorCode(err, git.ErrorCodeUnmerged) { - index, err := rebase.InmemoryIndex() - if err != nil { - return "", fmt.Errorf("getting conflicting index: %w", err) - } - - conflictingFiles, err := getConflictingFiles(index) - if err != nil { - return "", fmt.Errorf("getting conflicting files: %w", err) - } - - return "", fmt.Errorf("commit %q: %w", op.Id.String(), git2go.ConflictingFilesError{ - ConflictingFiles: conflictingFiles, - }) - } - - // If the commit has already been applied on the target branch then we can - // skip it if we were told to. - if request.SkipEmptyCommits && git.IsErrorCode(err, git.ErrorCodeApplied) { - continue - } - - return "", fmt.Errorf("commit %q: %w", op.Id.String(), err) - } - - oid = op.Id.Copy() - } - - // When the OID is unset here, then we didn't have to rebase any commits at all. We can - // thus return the upstream commit directly: rebasing nothing onto the upstream commit is - // the same as the upstream commit itself. - if oid == nil { - return upstreamCommit.Id().String(), nil - } - - if err = rebase.Finish(); err != nil { - return "", fmt.Errorf("finish rebase: %w", err) - } - - return oid.String(), nil -} diff --git a/cmd/gitaly-git2go-v15/rebase_test.go b/cmd/gitaly-git2go-v15/rebase_test.go deleted file mode 100644 index 38de7af42..000000000 --- a/cmd/gitaly-git2go-v15/rebase_test.go +++ /dev/null @@ -1,297 +0,0 @@ -//go:build static && system_libgit2 && !gitaly_test_sha256 - -package main - -import ( - "fmt" - "testing" - "time" - - git "github.com/libgit2/git2go/v33" - "github.com/stretchr/testify/require" - "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go-v15/git2goutil" - gitalygit "gitlab.com/gitlab-org/gitaly/v15/internal/git" - "gitlab.com/gitlab-org/gitaly/v15/internal/git/gittest" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" - "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper" - "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper/testcfg" -) - -var masterRevision = "1e292f8fedd741b75372e19097c76d327140c312" - -func TestRebase_validation(t *testing.T) { - cfg, repo, repoPath := testcfg.BuildWithRepo(t) - testcfg.BuildGitalyGit2Go(t, cfg) - committer := git2go.NewSignature("Foo", "foo@example.com", time.Now()) - executor := buildExecutor(t, cfg) - - testcases := []struct { - desc string - request git2go.RebaseCommand - expectedErr string - }{ - { - desc: "no arguments", - expectedErr: "rebase: missing repository", - }, - { - desc: "missing repository", - request: git2go.RebaseCommand{Committer: committer, BranchName: "feature", UpstreamRevision: masterRevision}, - expectedErr: "rebase: missing repository", - }, - { - desc: "missing committer name", - request: git2go.RebaseCommand{Repository: repoPath, Committer: git2go.Signature{Email: "foo@example.com"}, BranchName: "feature", UpstreamRevision: masterRevision}, - expectedErr: "rebase: missing committer name", - }, - { - desc: "missing committer email", - request: git2go.RebaseCommand{Repository: repoPath, Committer: git2go.Signature{Name: "Foo"}, BranchName: "feature", UpstreamRevision: masterRevision}, - expectedErr: "rebase: missing committer email", - }, - { - desc: "missing branch name", - request: git2go.RebaseCommand{Repository: repoPath, Committer: committer, UpstreamRevision: masterRevision}, - expectedErr: "rebase: missing branch name", - }, - { - desc: "missing upstream branch", - request: git2go.RebaseCommand{Repository: repoPath, Committer: committer, BranchName: "feature"}, - expectedErr: "rebase: missing upstream revision", - }, - { - desc: "both branch name and commit ID", - request: git2go.RebaseCommand{Repository: repoPath, Committer: committer, BranchName: "feature", CommitID: "a"}, - expectedErr: "rebase: both branch name and commit ID", - }, - { - desc: "both upstream revision and upstream commit ID", - request: git2go.RebaseCommand{Repository: repoPath, Committer: committer, BranchName: "feature", UpstreamRevision: "a", UpstreamCommitID: "a"}, - expectedErr: "rebase: both upstream revision and upstream commit ID", - }, - } - for _, tc := range testcases { - t.Run(tc.desc, func(t *testing.T) { - ctx := testhelper.Context(t) - - _, err := executor.Rebase(ctx, repo, tc.request) - require.EqualError(t, err, tc.expectedErr) - }) - } -} - -func TestRebase_rebase(t *testing.T) { - testcases := []struct { - desc string - branch string - commitsAhead int - setupRepo func(testing.TB, *git.Repository) - expected string - expectedErr string - }{ - { - desc: "Single commit rebase", - branch: "gitaly-rename-test", - commitsAhead: 1, - expected: "a08ed4bc45f9e686db93c5d0519f63d7b537270c", - }, - { - desc: "Multiple commits", - branch: "csv", - commitsAhead: 5, - expected: "2f8365edc69d3683e22c4209ae9641642d84dd4a", - }, - { - desc: "Branch zero commits behind", - branch: "sha-starting-with-large-number", - commitsAhead: 1, - expected: "842616594688d2351480dfebd67b3d8d15571e6d", - }, - { - desc: "Merged branch", - branch: "branch-merged", - expected: masterRevision, - }, - { - desc: "Partially merged branch", - branch: "branch-merged-plus-one", - setupRepo: func(t testing.TB, repo *git.Repository) { - head, err := lookupCommit(repo, "branch-merged") - require.NoError(t, err) - - other, err := lookupCommit(repo, "gitaly-rename-test") - require.NoError(t, err) - tree, err := other.Tree() - require.NoError(t, err) - newOid, err := repo.CreateCommitFromIds("refs/heads/branch-merged-plus-one", &DefaultAuthor, &DefaultAuthor, "Message", tree.Object.Id(), head.Object.Id()) - require.NoError(t, err) - require.Equal(t, "8665d9b4b56f6b8ab8c4128a5549d1820bf68bf5", newOid.String()) - }, - commitsAhead: 1, - expected: "56bafb70922008232d171b78930be6cdb722bb39", - }, - { - desc: "With upstream merged into", - branch: "csv-plus-merge", - setupRepo: func(t testing.TB, repo *git.Repository) { - ours, err := lookupCommit(repo, "csv") - require.NoError(t, err) - theirs, err := lookupCommit(repo, "b83d6e391c22777fca1ed3012fce84f633d7fed0") - require.NoError(t, err) - - index, err := repo.MergeCommits(ours, theirs, nil) - require.NoError(t, err) - tree, err := index.WriteTreeTo(repo) - require.NoError(t, err) - - newOid, err := repo.CreateCommitFromIds("refs/heads/csv-plus-merge", &DefaultAuthor, &DefaultAuthor, "Message", tree, ours.Object.Id(), theirs.Object.Id()) - require.NoError(t, err) - require.Equal(t, "5b2d6bd7be0b1b9f7e46b64d02fe9882c133a128", newOid.String()) - }, - commitsAhead: 5, // Same as "Multiple commits" - expected: "2f8365edc69d3683e22c4209ae9641642d84dd4a", - }, - { - desc: "Rebase with conflict", - branch: "rebase-encoding-failure-trigger", - expectedErr: "rebase: commit \"eb8f5fb9523b868cef583e09d4bf70b99d2dd404\": there are conflicting files", - }, - { - desc: "Orphaned branch", - branch: "orphaned-branch", - expectedErr: "rebase: find merge base: no merge base found", - }, - } - - for _, tc := range testcases { - t.Run(tc.desc, func(t *testing.T) { - ctx := testhelper.Context(t) - - committer := git2go.NewSignature(string(gittest.TestUser.Name), - string(gittest.TestUser.Email), - time.Date(2021, 3, 1, 13, 45, 50, 0, time.FixedZone("", +2*60*60))) - - cfg, repoProto, repoPath := testcfg.BuildWithRepo(t) - testcfg.BuildGitalyGit2Go(t, cfg) - executor := buildExecutor(t, cfg) - - repo, err := git2goutil.OpenRepository(repoPath) - require.NoError(t, err) - - if tc.setupRepo != nil { - tc.setupRepo(t, repo) - } - - branchCommit, err := lookupCommit(repo, tc.branch) - require.NoError(t, err) - - for desc, request := range map[string]git2go.RebaseCommand{ - "with branch and upstream": { - Repository: repoPath, - Committer: committer, - BranchName: tc.branch, - UpstreamRevision: masterRevision, - }, - "with branch and upstream commit ID": { - Repository: repoPath, - Committer: committer, - BranchName: tc.branch, - UpstreamCommitID: gitalygit.ObjectID(masterRevision), - }, - "with commit ID and upstream": { - Repository: repoPath, - Committer: committer, - BranchName: tc.branch, - UpstreamRevision: masterRevision, - }, - "with commit ID and upstream commit ID": { - Repository: repoPath, - Committer: committer, - CommitID: gitalygit.ObjectID(branchCommit.Id().String()), - UpstreamCommitID: gitalygit.ObjectID(masterRevision), - }, - } { - t.Run(desc, func(t *testing.T) { - response, err := executor.Rebase(ctx, repoProto, request) - if tc.expectedErr != "" { - require.EqualError(t, err, tc.expectedErr) - } else { - require.NoError(t, err) - - result := response.String() - require.Equal(t, tc.expected, result) - - commit, err := lookupCommit(repo, result) - require.NoError(t, err) - - for i := tc.commitsAhead; i > 0; i-- { - commit = commit.Parent(0) - } - masterCommit, err := lookupCommit(repo, masterRevision) - require.NoError(t, err) - require.Equal(t, masterCommit, commit) - } - }) - } - }) - } -} - -func TestRebase_skipEmptyCommit(t *testing.T) { - ctx := testhelper.Context(t) - - cfg, repoProto, repoPath := testcfg.BuildWithRepo(t) - testcfg.BuildGitalyGit2Go(t, cfg) - - // Set up history with two diverging lines of branches, where both sides have implemented - // the same changes. During rebase, the diff will thus become empty. - base := gittest.WriteCommit(t, cfg, repoPath, - gittest.WithTreeEntries(gittest.TreeEntry{ - Path: "a", Content: "base", Mode: "100644", - }), - ) - theirs := gittest.WriteCommit(t, cfg, repoPath, gittest.WithMessage("theirs"), - gittest.WithParents(base), gittest.WithTreeEntries(gittest.TreeEntry{ - Path: "a", Content: "changed", Mode: "100644", - }), - ) - ours := gittest.WriteCommit(t, cfg, repoPath, gittest.WithMessage("ours"), - gittest.WithParents(base), gittest.WithTreeEntries(gittest.TreeEntry{ - Path: "a", Content: "changed", Mode: "100644", - }), - ) - - for _, tc := range []struct { - desc string - skipEmptyCommits bool - expectedErr string - expectedResponse gitalygit.ObjectID - }{ - { - desc: "do not skip empty commit", - skipEmptyCommits: false, - expectedErr: fmt.Sprintf("rebase: commit %q: this patch has already been applied", ours), - }, - { - desc: "skip empty commit", - skipEmptyCommits: true, - expectedResponse: theirs, - }, - } { - t.Run(tc.desc, func(t *testing.T) { - response, err := buildExecutor(t, cfg).Rebase(ctx, repoProto, git2go.RebaseCommand{ - Repository: repoPath, - Committer: git2go.NewSignature("Foo", "foo@example.com", time.Now()), - CommitID: ours, - UpstreamCommitID: theirs, - SkipEmptyCommits: tc.skipEmptyCommits, - }) - if tc.expectedErr == "" { - require.NoError(t, err) - } else { - require.EqualError(t, err, tc.expectedErr) - } - require.Equal(t, tc.expectedResponse, response) - }) - } -} diff --git a/cmd/gitaly-git2go-v15/resolve_conflicts.go b/cmd/gitaly-git2go-v15/resolve_conflicts.go deleted file mode 100644 index aa6159262..000000000 --- a/cmd/gitaly-git2go-v15/resolve_conflicts.go +++ /dev/null @@ -1,263 +0,0 @@ -//go:build static && system_libgit2 - -package main - -import ( - "bytes" - "context" - "encoding/gob" - "errors" - "flag" - "fmt" - "strings" - "time" - - git "github.com/libgit2/git2go/v33" - "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go-v15/git2goutil" - "gitlab.com/gitlab-org/gitaly/v15/internal/git/conflict" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" -) - -type resolveSubcommand struct{} - -func (cmd *resolveSubcommand) Flags() *flag.FlagSet { - return flag.NewFlagSet("resolve", flag.ExitOnError) -} - -func (cmd resolveSubcommand) Run(_ context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error { - var request git2go.ResolveCommand - if err := decoder.Decode(&request); err != nil { - return err - } - - if request.AuthorDate.IsZero() { - request.AuthorDate = time.Now() - } - - repo, err := git2goutil.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("ours commit lookup: %w", err) - } - - theirs, err := lookupCommit(repo, request.Theirs) - if err != nil { - return fmt.Errorf("theirs commit lookup: %w", err) - } - - index, err := repo.MergeCommits(ours, theirs, nil) - if err != nil { - return fmt.Errorf("could not merge commits: %w", err) - } - - ci, err := index.ConflictIterator() - if err != nil { - return err - } - - type paths struct { - theirs, ours string - } - conflicts := map[paths]git.IndexConflict{} - - for { - c, err := ci.Next() - if git.IsErrorCode(err, git.ErrorCodeIterOver) { - break - } - if err != nil { - return err - } - - if c.Our.Path == "" || c.Their.Path == "" { - return errors.New("conflict side missing") - } - - k := paths{ - theirs: c.Their.Path, - ours: c.Our.Path, - } - conflicts[k] = c - } - - odb, err := repo.Odb() - if err != nil { - return err - } - - for _, r := range request.Resolutions { - c, ok := conflicts[paths{ - theirs: r.OldPath, - ours: r.NewPath, - }] - if !ok { - // Note: this emulates the Ruby error that occurs when - // there are no conflicts for a resolution - return errors.New("NoMethodError: undefined method `resolve_lines' for nil:NilClass") - } - - switch { - case c.Our == nil: - return fmt.Errorf("missing our-part of merge file input for new path %q", r.NewPath) - case c.Their == nil: - return fmt.Errorf("missing their-part of merge file input for new path %q", r.NewPath) - } - - ancestor, our, their, err := readConflictEntries(odb, c) - if err != nil { - return fmt.Errorf("read conflict entries: %w", err) - } - - mfr, err := mergeFileResult(ancestor, our, their) - if err != nil { - return fmt.Errorf("merge file result for %q: %w", r.NewPath, err) - } - - if r.Content != "" && bytes.Equal([]byte(r.Content), mfr.Contents) { - return fmt.Errorf("Resolved content has no changes for file %s", r.NewPath) //nolint - } - - conflictFile, err := conflict.Parse( - bytes.NewReader(mfr.Contents), - ancestor, - our, - their, - ) - if err != nil { - return fmt.Errorf("parse conflict for %q: %w", c.Ancestor.Path, err) - } - - resolvedBlob, err := conflictFile.Resolve(r) - if err != nil { - return err // do not decorate this error to satisfy old test - } - - resolvedBlobOID, err := odb.Write(resolvedBlob, git.ObjectBlob) - if err != nil { - return fmt.Errorf("write object for %q: %w", c.Ancestor.Path, err) - } - - ourResolvedEntry := *c.Our // copy by value - ourResolvedEntry.Id = resolvedBlobOID - if err := index.Add(&ourResolvedEntry); err != nil { - return fmt.Errorf("add index for %q: %w", c.Ancestor.Path, err) - } - - if err := index.RemoveConflict(ourResolvedEntry.Path); err != nil { - return fmt.Errorf("remove conflict from index for %q: %w", c.Ancestor.Path, err) - } - } - - if index.HasConflicts() { - ci, err := index.ConflictIterator() - if err != nil { - return fmt.Errorf("iterating unresolved conflicts: %w", err) - } - - var conflictPaths []string - for { - c, err := ci.Next() - if git.IsErrorCode(err, git.ErrorCodeIterOver) { - break - } - if err != nil { - return fmt.Errorf("next unresolved conflict: %w", err) - } - var conflictingPath string - if c.Ancestor != nil { - conflictingPath = c.Ancestor.Path - } else { - conflictingPath = c.Our.Path - } - - conflictPaths = append(conflictPaths, conflictingPath) - } - - return fmt.Errorf("Missing resolutions for the following files: %s", strings.Join(conflictPaths, ", ")) //nolint - } - - tree, err := index.WriteTreeTo(repo) - if err != nil { - return fmt.Errorf("write tree to repo: %w", err) - } - - signature := git2go.NewSignature(request.AuthorName, request.AuthorMail, request.AuthorDate) - committer := &git.Signature{ - Name: signature.Name, - Email: signature.Email, - When: request.AuthorDate, - } - - commit, err := repo.CreateCommitFromIds("", committer, committer, request.Message, tree, ours.Id(), theirs.Id()) - if err != nil { - return fmt.Errorf("could not create resolve conflict commit: %w", err) - } - - response := git2go.ResolveResult{ - MergeResult: git2go.MergeResult{ - CommitID: commit.String(), - }, - } - - return encoder.Encode(response) -} - -func readConflictEntries(odb *git.Odb, c git.IndexConflict) (*conflict.Entry, *conflict.Entry, *conflict.Entry, error) { - var ancestor, our, their *conflict.Entry - - for _, part := range []struct { - entry *git.IndexEntry - result **conflict.Entry - }{ - {entry: c.Ancestor, result: &ancestor}, - {entry: c.Our, result: &our}, - {entry: c.Their, result: &their}, - } { - if part.entry == nil { - continue - } - - blob, err := odb.Read(part.entry.Id) - if err != nil { - return nil, nil, nil, err - } - - *part.result = &conflict.Entry{ - Path: part.entry.Path, - Mode: uint(part.entry.Mode), - Contents: blob.Data(), - } - } - - return ancestor, our, their, nil -} - -func mergeFileResult(ancestor, our, their *conflict.Entry) (*git.MergeFileResult, error) { - mfr, err := git.MergeFile( - conflictEntryToMergeFileInput(ancestor), - conflictEntryToMergeFileInput(our), - conflictEntryToMergeFileInput(their), - nil, - ) - if err != nil { - return nil, err - } - - return mfr, nil -} - -func conflictEntryToMergeFileInput(e *conflict.Entry) git.MergeFileInput { - if e == nil { - return git.MergeFileInput{} - } - - return git.MergeFileInput{ - Path: e.Path, - Mode: e.Mode, - Contents: e.Contents, - } -} diff --git a/cmd/gitaly-git2go-v15/revert.go b/cmd/gitaly-git2go-v15/revert.go deleted file mode 100644 index 9c66c4865..000000000 --- a/cmd/gitaly-git2go-v15/revert.go +++ /dev/null @@ -1,104 +0,0 @@ -//go:build static && system_libgit2 - -package main - -import ( - "context" - "encoding/gob" - "errors" - "flag" - "fmt" - - git "github.com/libgit2/git2go/v33" - "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go-v15/git2goutil" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" -) - -type revertSubcommand struct{} - -func (cmd *revertSubcommand) Flags() *flag.FlagSet { - return flag.NewFlagSet("revert", flag.ExitOnError) -} - -func (cmd *revertSubcommand) Run(ctx context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error { - var request git2go.RevertCommand - if err := decoder.Decode(&request); err != nil { - return err - } - - commitID, err := cmd.revert(ctx, &request) - return encoder.Encode(git2go.Result{ - CommitID: commitID, - Err: git2go.SerializableError(err), - }) -} - -func (cmd *revertSubcommand) verify(ctx context.Context, r *git2go.RevertCommand) error { - if r.Repository == "" { - return errors.New("missing repository") - } - if r.AuthorName == "" { - return errors.New("missing author name") - } - if r.AuthorMail == "" { - return errors.New("missing author mail") - } - if r.Message == "" { - return errors.New("missing message") - } - if r.Ours == "" { - return errors.New("missing ours") - } - if r.Revert == "" { - return errors.New("missing revert") - } - return nil -} - -func (cmd *revertSubcommand) revert(ctx context.Context, request *git2go.RevertCommand) (string, error) { - if err := cmd.verify(ctx, request); err != nil { - return "", err - } - repo, err := git2goutil.OpenRepository(request.Repository) - if err != nil { - return "", fmt.Errorf("open repository: %w", err) - } - defer repo.Free() - - ours, err := lookupCommit(repo, request.Ours) - if err != nil { - return "", fmt.Errorf("ours commit lookup: %w", err) - } - - revert, err := lookupCommit(repo, request.Revert) - if err != nil { - return "", fmt.Errorf("revert commit lookup: %w", err) - } - - index, err := repo.RevertCommit(revert, ours, request.Mainline, nil) - if err != nil { - return "", fmt.Errorf("revert: %w", err) - } - defer index.Free() - - if index.HasConflicts() { - return "", git2go.HasConflictsError{} - } - - tree, err := index.WriteTreeTo(repo) - if err != nil { - return "", fmt.Errorf("write tree: %w", err) - } - - if tree.Equal(ours.TreeId()) { - return "", git2go.EmptyError{} - } - - committer := git.Signature(git2go.NewSignature(request.AuthorName, request.AuthorMail, request.AuthorDate)) - commit, err := repo.CreateCommitFromIds("", &committer, &committer, request.Message, tree, ours.Id()) - if err != nil { - return "", fmt.Errorf("create revert commit: %w", err) - } - - return commit.String(), nil -} diff --git a/cmd/gitaly-git2go-v15/revert_test.go b/cmd/gitaly-git2go-v15/revert_test.go deleted file mode 100644 index 35a603ba9..000000000 --- a/cmd/gitaly-git2go-v15/revert_test.go +++ /dev/null @@ -1,231 +0,0 @@ -//go:build static && system_libgit2 && !gitaly_test_sha256 - -package main - -import ( - "errors" - "testing" - "time" - - git "github.com/libgit2/git2go/v33" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go-v15/git2goutil" - "gitlab.com/gitlab-org/gitaly/v15/internal/git/gittest" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" - "gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/config" - "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper" - "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper/testcfg" -) - -func TestRevert_validation(t *testing.T) { - cfg, repo, repoPath := testcfg.BuildWithRepo(t) - testcfg.BuildGitalyGit2Go(t, cfg) - executor := buildExecutor(t, cfg) - - testcases := []struct { - desc string - request git2go.RevertCommand - expectedErr string - }{ - { - desc: "no arguments", - expectedErr: "revert: missing repository", - }, - { - desc: "missing repository", - request: git2go.RevertCommand{AuthorName: "Foo", AuthorMail: "foo@example.com", Message: "Foo", Ours: "HEAD", Revert: "HEAD"}, - expectedErr: "revert: missing repository", - }, - { - desc: "missing author name", - request: git2go.RevertCommand{Repository: repoPath, AuthorMail: "foo@example.com", Message: "Foo", Ours: "HEAD", Revert: "HEAD"}, - expectedErr: "revert: missing author name", - }, - { - desc: "missing author mail", - request: git2go.RevertCommand{Repository: repoPath, AuthorName: "Foo", Message: "Foo", Ours: "HEAD", Revert: "HEAD"}, - expectedErr: "revert: missing author mail", - }, - { - desc: "missing message", - request: git2go.RevertCommand{Repository: repoPath, AuthorName: "Foo", AuthorMail: "foo@example.com", Ours: "HEAD", Revert: "HEAD"}, - expectedErr: "revert: missing message", - }, - { - desc: "missing ours", - request: git2go.RevertCommand{Repository: repoPath, AuthorName: "Foo", AuthorMail: "foo@example.com", Message: "Foo", Revert: "HEAD"}, - expectedErr: "revert: missing ours", - }, - { - desc: "missing revert", - request: git2go.RevertCommand{Repository: repoPath, AuthorName: "Foo", AuthorMail: "foo@example.com", Message: "Foo", Ours: "HEAD"}, - expectedErr: "revert: missing revert", - }, - } - for _, tc := range testcases { - t.Run(tc.desc, func(t *testing.T) { - ctx := testhelper.Context(t) - - _, err := executor.Revert(ctx, repo, tc.request) - require.Error(t, err) - require.EqualError(t, err, tc.expectedErr) - }) - } -} - -func TestRevert_trees(t *testing.T) { - testcases := []struct { - desc string - setupRepo func(t testing.TB, cfg config.Cfg, repoPath string) (ours, revert string) - expected map[string]string - expectedCommitID string - expectedErr string - expectedErrAs interface{} - }{ - { - desc: "trivial revert succeeds", - setupRepo: func(t testing.TB, cfg config.Cfg, repoPath string) (ours, revert string) { - baseOid := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries( - gittest.TreeEntry{Path: "a", Content: "apple", Mode: "100644"}, - gittest.TreeEntry{Path: "b", Content: "banana", Mode: "100644"}, - )) - revertOid := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(baseOid), gittest.WithTreeEntries( - gittest.TreeEntry{Path: "a", Content: "apple", Mode: "100644"}, - gittest.TreeEntry{Path: "b", Content: "pineapple", Mode: "100644"}, - )) - oursOid := gittest.WriteCommit(t, cfg, repoPath, - gittest.WithParents(revertOid), gittest.WithTreeEntries( - gittest.TreeEntry{Path: "a", Content: "apple", Mode: "100644"}, - gittest.TreeEntry{Path: "b", Content: "pineapple", Mode: "100644"}, - gittest.TreeEntry{Path: "c", Content: "carrot", Mode: "100644"}, - )) - - return oursOid.String(), revertOid.String() - }, - expected: map[string]string{ - "a": "apple", - "b": "banana", - "c": "carrot", - }, - expectedCommitID: "c9a58d2273b265cb229f02a5a88037bbdc96ad26", - }, - { - desc: "conflicting revert fails", - setupRepo: func(t testing.TB, cfg config.Cfg, repoPath string) (ours, revert string) { - baseOid := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries( - gittest.TreeEntry{Path: "a", Content: "apple", Mode: "100644"}, - )) - revertOid := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(baseOid), gittest.WithTreeEntries( - gittest.TreeEntry{Path: "a", Content: "pineapple", Mode: "100644"}, - )) - oursOid := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(revertOid), gittest.WithTreeEntries( - gittest.TreeEntry{Path: "a", Content: "carrot", Mode: "100644"}, - )) - - return oursOid.String(), revertOid.String() - }, - expectedErr: "revert: could not apply due to conflicts", - expectedErrAs: &git2go.HasConflictsError{}, - }, - { - desc: "empty revert fails", - setupRepo: func(t testing.TB, cfg config.Cfg, repoPath string) (ours, revert string) { - baseOid := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries( - gittest.TreeEntry{Path: "a", Content: "apple", Mode: "100644"}, - )) - revertOid := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(baseOid), gittest.WithTreeEntries( - gittest.TreeEntry{Path: "a", Content: "banana", Mode: "100644"}, - )) - oursOid := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(revertOid), gittest.WithTreeEntries( - gittest.TreeEntry{Path: "a", Content: "apple", Mode: "100644"}, - )) - - return oursOid.String(), revertOid.String() - }, - expectedErr: "revert: could not apply because the result was empty", - expectedErrAs: &git2go.EmptyError{}, - }, - { - desc: "nonexistent ours fails", - setupRepo: func(t testing.TB, cfg config.Cfg, repoPath string) (ours, revert string) { - revertOid := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries( - gittest.TreeEntry{Path: "a", Content: "apple", Mode: "100644"}, - )) - - return "nonexistent", revertOid.String() - }, - expectedErr: "revert: ours commit lookup: lookup commit \"nonexistent\": revspec 'nonexistent' not found", - }, - { - desc: "nonexistent revert fails", - setupRepo: func(t testing.TB, cfg config.Cfg, repoPath string) (ours, revert string) { - oursOid := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries( - gittest.TreeEntry{Path: "a", Content: "apple", Mode: "100644"}, - )) - - return oursOid.String(), "nonexistent" - }, - expectedErr: "revert: revert commit lookup: lookup commit \"nonexistent\": revspec 'nonexistent' not found", - }, - } - for _, tc := range testcases { - t.Run(tc.desc, func(t *testing.T) { - cfg, repoProto, repoPath := testcfg.BuildWithRepo(t) - testcfg.BuildGitalyGit2Go(t, cfg) - executor := buildExecutor(t, cfg) - - ours, revert := tc.setupRepo(t, cfg, repoPath) - ctx := testhelper.Context(t) - - authorDate := time.Date(2020, 7, 30, 7, 45, 50, 0, time.FixedZone("UTC+2", +2*60*60)) - - request := git2go.RevertCommand{ - Repository: repoPath, - AuthorName: "Foo", - AuthorMail: "foo@example.com", - AuthorDate: authorDate, - Message: "Foo", - Ours: ours, - Revert: revert, - } - - response, err := executor.Revert(ctx, repoProto, request) - - if tc.expectedErr != "" { - require.EqualError(t, err, tc.expectedErr) - - if tc.expectedErrAs != nil { - require.True(t, errors.As(err, tc.expectedErrAs)) - } - return - } - - require.NoError(t, err) - assert.Equal(t, tc.expectedCommitID, response.String()) - - repo, err := git2goutil.OpenRepository(repoPath) - require.NoError(t, err) - defer repo.Free() - - commitOid, err := git.NewOid(response.String()) - require.NoError(t, err) - - commit, err := repo.LookupCommit(commitOid) - require.NoError(t, err) - - tree, err := commit.Tree() - require.NoError(t, err) - require.EqualValues(t, len(tc.expected), tree.EntryCount()) - - for name, contents := range tc.expected { - entry := tree.EntryByName(name) - require.NotNil(t, entry) - - blob, err := repo.LookupBlob(entry.Id) - require.NoError(t, err) - require.Equal(t, []byte(contents), blob.Contents()) - } - }) - } -} diff --git a/cmd/gitaly-git2go-v15/submodule.go b/cmd/gitaly-git2go-v15/submodule.go deleted file mode 100644 index 3c2f2e0eb..000000000 --- a/cmd/gitaly-git2go-v15/submodule.go +++ /dev/null @@ -1,142 +0,0 @@ -//go:build static && system_libgit2 - -package main - -import ( - "context" - "encoding/gob" - "flag" - "fmt" - "time" - - git "github.com/libgit2/git2go/v33" - "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go-v15/git2goutil" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" -) - -type submoduleSubcommand struct{} - -func (cmd *submoduleSubcommand) Flags() *flag.FlagSet { - return flag.NewFlagSet("submodule", flag.ExitOnError) -} - -func (cmd *submoduleSubcommand) Run(_ context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error { - var request git2go.SubmoduleCommand - - if err := decoder.Decode(&request); err != nil { - return fmt.Errorf("deserializing submodule command request: %w", err) - } - - res, err := cmd.run(request) - if err != nil { - return err - } - - return encoder.Encode(res) -} - -func (cmd *submoduleSubcommand) run(request git2go.SubmoduleCommand) (*git2go.SubmoduleResult, error) { - if request.AuthorDate.IsZero() { - request.AuthorDate = time.Now() - } - - smCommitOID, err := git.NewOid(request.CommitSHA) - if err != nil { - return nil, fmt.Errorf("converting %s to OID: %w", request.CommitSHA, err) - } - - repo, err := git2goutil.OpenRepository(request.Repository) - if err != nil { - return nil, fmt.Errorf("open repository: %w", err) - } - - fullBranchRefName := "refs/heads/" + request.Branch - o, err := repo.RevparseSingle(fullBranchRefName) - if err != nil { - return nil, fmt.Errorf("%s: %w", git2go.LegacyErrPrefixInvalidBranch, err) - } - - startCommit, err := o.AsCommit() - if err != nil { - return nil, fmt.Errorf("peeling %s as a commit: %w", o.Id(), err) - } - - rootTree, err := startCommit.Tree() - if err != nil { - return nil, fmt.Errorf("root tree from starting commit: %w", err) - } - - index, err := git.NewIndex() - if err != nil { - return nil, fmt.Errorf("creating new index: %w", err) - } - - if err := index.ReadTree(rootTree); err != nil { - return nil, fmt.Errorf("reading root tree into index: %w", err) - } - - smEntry, err := index.EntryByPath(request.Submodule, 0) - if err != nil { - return nil, fmt.Errorf( - "%s: %w", - git2go.LegacyErrPrefixInvalidSubmodulePath, err, - ) - } - - if smEntry.Id.Cmp(smCommitOID) == 0 { - //nolint - return nil, fmt.Errorf( - "The submodule %s is already at %s", - request.Submodule, request.CommitSHA, - ) - } - - if smEntry.Mode != git.FilemodeCommit { - return nil, fmt.Errorf( - "%s: %w", - git2go.LegacyErrPrefixInvalidSubmodulePath, err, - ) - } - - newEntry := *smEntry // copy by value - newEntry.Id = smCommitOID // assign new commit SHA - if err := index.Add(&newEntry); err != nil { - return nil, fmt.Errorf("add new submodule entry to index: %w", err) - } - - newRootTreeOID, err := index.WriteTreeTo(repo) - if err != nil { - return nil, fmt.Errorf("write index to repo: %w", err) - } - - newTree, err := repo.LookupTree(newRootTreeOID) - if err != nil { - return nil, fmt.Errorf("looking up new submodule entry root tree: %w", err) - } - - committer := git.Signature( - git2go.NewSignature( - request.AuthorName, - request.AuthorMail, - request.AuthorDate, - ), - ) - newCommitOID, err := repo.CreateCommit( - "", // caller should update branch with hooks - &committer, - &committer, - request.Message, - newTree, - startCommit, - ) - if err != nil { - return nil, fmt.Errorf( - "%s: %w", - git2go.LegacyErrPrefixFailedCommit, err, - ) - } - - return &git2go.SubmoduleResult{ - CommitID: newCommitOID.String(), - }, nil -} diff --git a/cmd/gitaly-git2go-v15/submodule_test.go b/cmd/gitaly-git2go-v15/submodule_test.go deleted file mode 100644 index 7e6c2a5bd..000000000 --- a/cmd/gitaly-git2go-v15/submodule_test.go +++ /dev/null @@ -1,129 +0,0 @@ -//go:build static && system_libgit2 && !gitaly_test_sha256 - -package main - -import ( - "bytes" - "fmt" - "testing" - - "github.com/stretchr/testify/require" - "gitlab.com/gitlab-org/gitaly/v15/internal/git" - "gitlab.com/gitlab-org/gitaly/v15/internal/git/gittest" - "gitlab.com/gitlab-org/gitaly/v15/internal/git/localrepo" - "gitlab.com/gitlab-org/gitaly/v15/internal/git/lstree" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" - "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper" - "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper/testcfg" -) - -func TestSubmodule(t *testing.T) { - commitMessage := []byte("Update Submodule message") - - testCases := []struct { - desc string - command git2go.SubmoduleCommand - expectedStderr string - }{ - { - desc: "Update submodule", - command: git2go.SubmoduleCommand{ - AuthorName: string(gittest.TestUser.Name), - AuthorMail: string(gittest.TestUser.Email), - Message: string(commitMessage), - CommitSHA: "41fa1bc9e0f0630ced6a8a211d60c2af425ecc2d", - Submodule: "gitlab-grack", - Branch: "master", - }, - }, - { - desc: "Update submodule inside folder", - command: git2go.SubmoduleCommand{ - AuthorName: string(gittest.TestUser.Name), - AuthorMail: string(gittest.TestUser.Email), - Message: string(commitMessage), - CommitSHA: "e25eda1fece24ac7a03624ed1320f82396f35bd8", - Submodule: "test_inside_folder/another_folder/six", - Branch: "submodule_inside_folder", - }, - }, - { - desc: "Invalid branch", - command: git2go.SubmoduleCommand{ - AuthorName: string(gittest.TestUser.Name), - AuthorMail: string(gittest.TestUser.Email), - Message: string(commitMessage), - CommitSHA: "e25eda1fece24ac7a03624ed1320f82396f35bd8", - Submodule: "test_inside_folder/another_folder/six", - Branch: "non/existent", - }, - expectedStderr: "Invalid branch", - }, - { - desc: "Invalid submodule", - command: git2go.SubmoduleCommand{ - AuthorName: string(gittest.TestUser.Name), - AuthorMail: string(gittest.TestUser.Email), - Message: string(commitMessage), - CommitSHA: "e25eda1fece24ac7a03624ed1320f82396f35bd8", - Submodule: "non-existent-submodule", - Branch: "master", - }, - expectedStderr: "Invalid submodule path", - }, - { - desc: "Duplicate reference", - command: git2go.SubmoduleCommand{ - AuthorName: string(gittest.TestUser.Name), - AuthorMail: string(gittest.TestUser.Email), - Message: string(commitMessage), - CommitSHA: "409f37c4f05865e4fb208c771485f211a22c4c2d", - Submodule: "six", - Branch: "master", - }, - expectedStderr: "The submodule six is already at 409f37c4f", - }, - } - - for _, tc := range testCases { - t.Run(tc.desc, func(t *testing.T) { - cfg, repoProto, repoPath := testcfg.BuildWithRepo(t) - testcfg.BuildGitalyGit2Go(t, cfg) - repo := localrepo.NewTestRepo(t, cfg, repoProto) - executor := buildExecutor(t, cfg) - - tc.command.Repository = repoPath - ctx := testhelper.Context(t) - - response, err := executor.Submodule(ctx, repo, tc.command) - if tc.expectedStderr != "" { - require.Error(t, err) - require.Contains(t, err.Error(), tc.expectedStderr) - return - } - require.NoError(t, err) - - commit, err := repo.ReadCommit(ctx, git.Revision(response.CommitID)) - require.NoError(t, err) - require.Equal(t, commit.Author.Email, gittest.TestUser.Email) - require.Equal(t, commit.Committer.Email, gittest.TestUser.Email) - require.Equal(t, commit.Subject, commitMessage) - - entry := gittest.Exec( - t, - cfg, - "-C", - repoPath, - "ls-tree", - "-z", - fmt.Sprintf("%s^{tree}:", response.CommitID), - tc.command.Submodule, - ) - parser := lstree.NewParser(bytes.NewReader(entry)) - parsedEntry, err := parser.NextEntry() - require.NoError(t, err) - require.Equal(t, tc.command.Submodule, parsedEntry.Path) - require.Equal(t, tc.command.CommitSHA, parsedEntry.ObjectID.String()) - }) - } -} diff --git a/cmd/gitaly-git2go-v15/testhelper_test.go b/cmd/gitaly-git2go-v15/testhelper_test.go deleted file mode 100644 index 274687cb6..000000000 --- a/cmd/gitaly-git2go-v15/testhelper_test.go +++ /dev/null @@ -1,44 +0,0 @@ -//go:build static && system_libgit2 && !gitaly_test_sha256 - -package main - -import ( - "fmt" - "testing" - - git "github.com/libgit2/git2go/v33" - "gitlab.com/gitlab-org/gitaly/v15/internal/git/gittest" - "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" - "gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/config" - "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper" -) - -// DefaultAuthor is the author used by BuildCommit -var DefaultAuthor = git.Signature{ - Name: gittest.DefaultCommitterName, - Email: gittest.DefaultCommitterMail, - When: gittest.DefaultCommitTime, -} - -func TestMain(m *testing.M) { - testhelper.Run(m, testhelper.WithSetup(func() error { - // We use Git2go to access repositories in our tests, so we must tell it to ignore - // any configuration files that happen to exist. We do the same in `main()`, so - // this is not only specific to tests. - for _, configLevel := range []git.ConfigLevel{ - git.ConfigLevelSystem, - git.ConfigLevelXDG, - git.ConfigLevelGlobal, - } { - if err := git.SetSearchPath(configLevel, "/dev/null"); err != nil { - return fmt.Errorf("setting Git2go search path: %s", err) - } - } - - return nil - })) -} - -func buildExecutor(tb testing.TB, cfg config.Cfg) *git2go.Executor { - return git2go.NewExecutor(cfg, gittest.NewCommandFactory(tb, cfg), config.NewLocator(cfg)) -} diff --git a/cmd/gitaly-git2go-v15/util.go b/cmd/gitaly-git2go-v15/util.go deleted file mode 100644 index 7a94371c3..000000000 --- a/cmd/gitaly-git2go-v15/util.go +++ /dev/null @@ -1,28 +0,0 @@ -//go:build static && system_libgit2 - -package main - -import ( - "fmt" - - git "github.com/libgit2/git2go/v33" -) - -func lookupCommit(repo *git.Repository, ref string) (*git.Commit, error) { - object, err := repo.RevparseSingle(ref) - if err != nil { - return nil, fmt.Errorf("lookup commit %q: %w", ref, err) - } - - peeled, err := object.Peel(git.ObjectCommit) - if err != nil { - return nil, fmt.Errorf("lookup commit %q: peel: %w", ref, err) - } - - commit, err := peeled.AsCommit() - if err != nil { - return nil, fmt.Errorf("lookup commit %q: as commit: %w", ref, err) - } - - return commit, nil -} diff --git a/cmd/gitaly-git2go/apply.go b/cmd/gitaly-git2go/apply.go new file mode 100644 index 000000000..3a7f65b79 --- /dev/null +++ b/cmd/gitaly-git2go/apply.go @@ -0,0 +1,222 @@ +//go:build static && system_libgit2 + +package main + +import ( + "bytes" + "context" + "encoding/gob" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + + git "github.com/libgit2/git2go/v33" + "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go/git2goutil" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" +) + +type patchIterator struct { + value git2go.Patch + decoder *gob.Decoder + error error +} + +func (iter *patchIterator) Next() bool { + if err := iter.decoder.Decode(&iter.value); err != nil { + if !errors.Is(err, io.EOF) { + iter.error = fmt.Errorf("decode patch: %w", err) + } + + return false + } + + return true +} + +func (iter *patchIterator) Value() git2go.Patch { return iter.value } + +func (iter *patchIterator) Err() error { return iter.error } + +type applySubcommand struct { + gitBinaryPath string +} + +func (cmd *applySubcommand) Flags() *flag.FlagSet { + fs := flag.NewFlagSet("apply", flag.ExitOnError) + fs.StringVar(&cmd.gitBinaryPath, "git-binary-path", "", "Path to the Git binary.") + return fs +} + +// Run runs the subcommand. +func (cmd *applySubcommand) Run(ctx context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error { + var params git2go.ApplyParams + if err := decoder.Decode(¶ms); err != nil { + return fmt.Errorf("decode params: %w", err) + } + + params.Patches = &patchIterator{decoder: decoder} + commitID, err := cmd.apply(ctx, params) + return encoder.Encode(git2go.Result{ + CommitID: commitID, + Err: git2go.SerializableError(err), + }) +} + +func (cmd *applySubcommand) apply(ctx context.Context, params git2go.ApplyParams) (string, error) { + repo, err := git2goutil.OpenRepository(params.Repository) + if err != nil { + return "", fmt.Errorf("open repository: %w", err) + } + + commitOID, err := git.NewOid(params.ParentCommit) + if err != nil { + return "", fmt.Errorf("parse parent commit oid: %w", err) + } + + committer := git.Signature(params.Committer) + for i := 0; params.Patches.Next(); i++ { + commitOID, err = cmd.applyPatch(ctx, repo, &committer, commitOID, params.Patches.Value()) + if err != nil { + return "", fmt.Errorf("apply patch [%d]: %w", i+1, err) + } + } + + if err := params.Patches.Err(); err != nil { + return "", fmt.Errorf("reading patches: %w", err) + } + + return commitOID.String(), nil +} + +func (cmd *applySubcommand) applyPatch( + ctx context.Context, + repo *git.Repository, + committer *git.Signature, + parentCommitOID *git.Oid, + patch git2go.Patch, +) (*git.Oid, error) { + parentCommit, err := repo.LookupCommit(parentCommitOID) + if err != nil { + return nil, fmt.Errorf("lookup commit: %w", err) + } + + parentTree, err := parentCommit.Tree() + if err != nil { + return nil, fmt.Errorf("lookup tree: %w", err) + } + + diff, err := git.DiffFromBuffer(patch.Diff, repo) + if err != nil { + return nil, fmt.Errorf("diff from buffer: %w", err) + } + + patchedIndex, err := repo.ApplyToTree(diff, parentTree, nil) + if err != nil { + if !git.IsErrorCode(err, git.ErrorCodeApplyFail) { + return nil, fmt.Errorf("apply to tree: %w", err) + } + + patchedIndex, err = cmd.threeWayMerge(ctx, repo, parentTree, diff, patch.Diff) + if err != nil { + return nil, fmt.Errorf("three way merge: %w", err) + } + } + + patchedTree, err := patchedIndex.WriteTreeTo(repo) + if err != nil { + return nil, fmt.Errorf("write patched tree: %w", err) + } + + author := git.Signature(patch.Author) + patchedCommitOID, err := repo.CreateCommitFromIds("", &author, committer, patch.Message, patchedTree, parentCommitOID) + if err != nil { + return nil, fmt.Errorf("create commit: %w", err) + } + + return patchedCommitOID, nil +} + +// threeWayMerge attempts a three-way merge as a fallback if applying the patch fails. +// Fallback three-way merge is only possible if the patch records the pre-image blobs +// and the repository contains them. It works as follows: +// +// 1. An index that contains only the pre-image blobs of the patch is built. This is done +// by calling `git apply --build-fake-ancestor`. The tree of the index is the fake +// ancestor tree. +// 2. The fake ancestor tree is patched to produce the post-image tree of the patch. +// 3. Three-way merge is performed with fake ancestor tree as the common ancestor, the +// base commit's tree as our tree and the patched fake ancestor tree as their tree. +func (cmd *applySubcommand) threeWayMerge( + ctx context.Context, + repo *git.Repository, + our *git.Tree, + diff *git.Diff, + rawDiff []byte, +) (*git.Index, error) { + ancestorTree, err := cmd.buildFakeAncestor(ctx, repo, rawDiff) + if err != nil { + return nil, fmt.Errorf("build fake ancestor: %w", err) + } + + patchedAncestorIndex, err := repo.ApplyToTree(diff, ancestorTree, nil) + if err != nil { + return nil, fmt.Errorf("patch fake ancestor: %w", err) + } + + patchedAncestorTreeOID, err := patchedAncestorIndex.WriteTreeTo(repo) + if err != nil { + return nil, fmt.Errorf("write patched fake ancestor: %w", err) + } + + patchedTree, err := repo.LookupTree(patchedAncestorTreeOID) + if err != nil { + return nil, fmt.Errorf("lookup patched tree: %w", err) + } + + patchedIndex, err := repo.MergeTrees(ancestorTree, our, patchedTree, nil) + if err != nil { + return nil, fmt.Errorf("merge trees: %w", err) + } + + if patchedIndex.HasConflicts() { + return nil, git2go.ErrMergeConflict + } + + return patchedIndex, nil +} + +func (cmd *applySubcommand) buildFakeAncestor(ctx context.Context, repo *git.Repository, diff []byte) (*git.Tree, error) { + dir, err := os.MkdirTemp("", "gitaly-git2go") + if err != nil { + return nil, fmt.Errorf("create temporary directory: %w", err) + } + defer func() { _ = os.RemoveAll(dir) }() + + file := filepath.Join(dir, "patch-merge-index") + gitCmd := exec.CommandContext(ctx, cmd.gitBinaryPath, "--git-dir", repo.Path(), "apply", "--build-fake-ancestor", file) + gitCmd.Stdin = bytes.NewReader(diff) + if _, err := gitCmd.Output(); err != nil { + var exitError *exec.ExitError + if errors.As(err, &exitError) { + err = fmt.Errorf("%w, stderr: %q", err, exitError.Stderr) + } + + return nil, fmt.Errorf("git: %w", err) + } + + fakeAncestor, err := git.OpenIndex(file) + if err != nil { + return nil, fmt.Errorf("open fake ancestor index: %w", err) + } + + ancestorTreeOID, err := fakeAncestor.WriteTreeTo(repo) + if err != nil { + return nil, fmt.Errorf("write fake ancestor tree: %w", err) + } + + return repo.LookupTree(ancestorTreeOID) +} diff --git a/cmd/gitaly-git2go/cherry_pick.go b/cmd/gitaly-git2go/cherry_pick.go new file mode 100644 index 000000000..ebf29759f --- /dev/null +++ b/cmd/gitaly-git2go/cherry_pick.go @@ -0,0 +1,122 @@ +//go:build static && system_libgit2 + +package main + +import ( + "context" + "encoding/gob" + "errors" + "flag" + "fmt" + + git "github.com/libgit2/git2go/v33" + "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go/git2goutil" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" +) + +type cherryPickSubcommand struct{} + +func (cmd *cherryPickSubcommand) Flags() *flag.FlagSet { + return flag.NewFlagSet("cherry-pick", flag.ExitOnError) +} + +func (cmd *cherryPickSubcommand) Run(ctx context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error { + var request git2go.CherryPickCommand + if err := decoder.Decode(&request); err != nil { + return err + } + + commitID, err := cmd.cherryPick(ctx, &request) + return encoder.Encode(git2go.Result{ + CommitID: commitID, + Err: git2go.SerializableError(err), + }) +} + +func (cmd *cherryPickSubcommand) verify(ctx context.Context, r *git2go.CherryPickCommand) error { + if r.Repository == "" { + return errors.New("missing repository") + } + if r.CommitterName == "" { + return errors.New("missing committer name") + } + if r.CommitterMail == "" { + return errors.New("missing committer mail") + } + if r.CommitterDate.IsZero() { + return errors.New("missing committer date") + } + if r.Message == "" { + return errors.New("missing message") + } + if r.Ours == "" { + return errors.New("missing ours") + } + if r.Commit == "" { + return errors.New("missing commit") + } + + return nil +} + +func (cmd *cherryPickSubcommand) cherryPick(ctx context.Context, r *git2go.CherryPickCommand) (string, error) { + if err := cmd.verify(ctx, r); err != nil { + return "", err + } + + repo, err := git2goutil.OpenRepository(r.Repository) + if err != nil { + return "", fmt.Errorf("could not open repository: %w", err) + } + defer repo.Free() + + ours, err := lookupCommit(repo, r.Ours) + if err != nil { + return "", fmt.Errorf("ours commit lookup: %w", err) + } + + pick, err := lookupCommit(repo, r.Commit) + if err != nil { + return "", fmt.Errorf("commit lookup: %w", err) + } + + opts, err := git.DefaultCherrypickOptions() + if err != nil { + return "", fmt.Errorf("could not get default cherry-pick options: %w", err) + } + opts.Mainline = r.Mainline + + index, err := repo.CherrypickCommit(pick, ours, opts) + if err != nil { + return "", fmt.Errorf("could not cherry-pick commit: %w", err) + } + + if index.HasConflicts() { + conflictingFiles, err := getConflictingFiles(index) + if err != nil { + return "", fmt.Errorf("getting conflicting files: %w", err) + } + + return "", git2go.ConflictingFilesError{ + ConflictingFiles: conflictingFiles, + } + } + + tree, err := index.WriteTreeTo(repo) + if err != nil { + return "", fmt.Errorf("could not write tree: %w", err) + } + + if tree.Equal(ours.TreeId()) { + return "", git2go.EmptyError{} + } + + committer := git.Signature(git2go.NewSignature(r.CommitterName, r.CommitterMail, r.CommitterDate)) + + commit, err := repo.CreateCommitFromIds("", pick.Author(), &committer, r.Message, tree, ours.Id()) + if err != nil { + return "", fmt.Errorf("could not create cherry-pick commit: %w", err) + } + + return commit.String(), nil +} diff --git a/cmd/gitaly-git2go/cherry_pick_test.go b/cmd/gitaly-git2go/cherry_pick_test.go new file mode 100644 index 000000000..4522f3d94 --- /dev/null +++ b/cmd/gitaly-git2go/cherry_pick_test.go @@ -0,0 +1,272 @@ +//go:build static && system_libgit2 && !gitaly_test_sha256 + +package main + +import ( + "testing" + "time" + + git "github.com/libgit2/git2go/v33" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go/git2goutil" + "gitlab.com/gitlab-org/gitaly/v15/internal/git/gittest" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" + "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper" + "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper/testcfg" +) + +func TestCherryPick_validation(t *testing.T) { + cfg, repo, repoPath := testcfg.BuildWithRepo(t) + testcfg.BuildGitalyGit2Go(t, cfg) + executor := buildExecutor(t, cfg) + + testcases := []struct { + desc string + request git2go.CherryPickCommand + expectedErr string + }{ + { + desc: "no arguments", + expectedErr: "cherry-pick: missing repository", + }, + { + desc: "missing repository", + request: git2go.CherryPickCommand{CommitterName: "Foo", CommitterMail: "foo@example.com", CommitterDate: time.Now(), Message: "Foo", Ours: "HEAD", Commit: "HEAD"}, + expectedErr: "cherry-pick: missing repository", + }, + { + desc: "missing committer name", + request: git2go.CherryPickCommand{Repository: repoPath, CommitterMail: "foo@example.com", CommitterDate: time.Now(), Message: "Foo", Ours: "HEAD", Commit: "HEAD"}, + expectedErr: "cherry-pick: missing committer name", + }, + { + desc: "missing committer mail", + request: git2go.CherryPickCommand{Repository: repoPath, CommitterName: "Foo", CommitterDate: time.Now(), Message: "Foo", Ours: "HEAD", Commit: "HEAD"}, + expectedErr: "cherry-pick: missing committer mail", + }, + { + desc: "missing committer date", + request: git2go.CherryPickCommand{Repository: repoPath, CommitterName: "Foo", CommitterMail: "foo@example.com", Message: "Foo", Ours: "HEAD", Commit: "HEAD"}, + expectedErr: "cherry-pick: missing committer date", + }, + { + desc: "missing message", + request: git2go.CherryPickCommand{Repository: repoPath, CommitterName: "Foo", CommitterMail: "foo@example.com", CommitterDate: time.Now(), Ours: "HEAD", Commit: "HEAD"}, + expectedErr: "cherry-pick: missing message", + }, + { + desc: "missing ours", + request: git2go.CherryPickCommand{Repository: repoPath, CommitterName: "Foo", CommitterMail: "foo@example.com", CommitterDate: time.Now(), Message: "Foo", Commit: "HEAD"}, + expectedErr: "cherry-pick: missing ours", + }, + { + desc: "missing commit", + request: git2go.CherryPickCommand{Repository: repoPath, CommitterName: "Foo", CommitterMail: "foo@example.com", CommitterDate: time.Now(), Message: "Foo", Ours: "HEAD"}, + expectedErr: "cherry-pick: missing commit", + }, + } + for _, tc := range testcases { + t.Run(tc.desc, func(t *testing.T) { + ctx := testhelper.Context(t) + + _, err := executor.CherryPick(ctx, repo, tc.request) + require.EqualError(t, err, tc.expectedErr) + }) + } +} + +func TestCherryPick(t *testing.T) { + testcases := []struct { + desc string + base []gittest.TreeEntry + ours []gittest.TreeEntry + commit []gittest.TreeEntry + expected map[string]string + expectedCommitID string + expectedErr error + expectedErrMsg string + }{ + { + desc: "trivial cherry-pick succeeds", + base: []gittest.TreeEntry{ + {Path: "file", Content: "foo", Mode: "100644"}, + }, + ours: []gittest.TreeEntry{ + {Path: "file", Content: "foo", Mode: "100644"}, + }, + commit: []gittest.TreeEntry{ + {Path: "file", Content: "foobar", Mode: "100644"}, + }, + expected: map[string]string{ + "file": "foobar", + }, + expectedCommitID: "a54ea83118c363c34cc605a6e61fd7abc4795cc4", + }, + { + desc: "conflicting cherry-pick fails", + base: []gittest.TreeEntry{ + {Path: "file", Content: "foo", Mode: "100644"}, + }, + ours: []gittest.TreeEntry{ + {Path: "file", Content: "fooqux", Mode: "100644"}, + }, + commit: []gittest.TreeEntry{ + {Path: "file", Content: "foobar", Mode: "100644"}, + }, + expectedErr: git2go.ConflictingFilesError{}, + expectedErrMsg: "cherry-pick: there are conflicting files", + }, + { + desc: "empty cherry-pick fails", + base: []gittest.TreeEntry{ + {Path: "file", Content: "foo", Mode: "100644"}, + }, + ours: []gittest.TreeEntry{ + {Path: "file", Content: "fooqux", Mode: "100644"}, + }, + commit: []gittest.TreeEntry{ + {Path: "file", Content: "fooqux", Mode: "100644"}, + }, + expectedErr: git2go.EmptyError{}, + expectedErrMsg: "cherry-pick: could not apply because the result was empty", + }, + { + desc: "fails on nonexistent ours commit", + expectedErrMsg: "cherry-pick: ours commit lookup: lookup commit \"nonexistent\": revspec 'nonexistent' not found", + }, + { + desc: "fails on nonexistent cherry-pick commit", + ours: []gittest.TreeEntry{ + {Path: "file", Content: "fooqux", Mode: "100644"}, + }, + expectedErrMsg: "cherry-pick: commit lookup: lookup commit \"nonexistent\": revspec 'nonexistent' not found", + }, + } + for _, tc := range testcases { + cfg, repo, repoPath := testcfg.BuildWithRepo(t) + testcfg.BuildGitalyGit2Go(t, cfg) + executor := buildExecutor(t, cfg) + + base := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries(tc.base...)) + + ours, commit := "nonexistent", "nonexistent" + if len(tc.ours) > 0 { + ours = gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(base), gittest.WithTreeEntries(tc.ours...)).String() + } + if len(tc.commit) > 0 { + commit = gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(base), gittest.WithTreeEntries(tc.commit...)).String() + } + + t.Run(tc.desc, func(t *testing.T) { + ctx := testhelper.Context(t) + + committer := git.Signature{ + Name: "Baz", + Email: "baz@example.com", + When: time.Date(2021, 1, 17, 14, 45, 51, 0, time.FixedZone("", +2*60*60)), + } + + response, err := executor.CherryPick(ctx, repo, git2go.CherryPickCommand{ + Repository: repoPath, + CommitterName: committer.Name, + CommitterMail: committer.Email, + CommitterDate: committer.When, + Message: "Foo", + Ours: ours, + Commit: commit, + }) + + if tc.expectedErrMsg != "" { + require.EqualError(t, err, tc.expectedErrMsg) + + if tc.expectedErr != nil { + require.ErrorAs(t, err, &tc.expectedErr) + } + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expectedCommitID, response.String()) + + repo, err := git2goutil.OpenRepository(repoPath) + require.NoError(t, err) + defer repo.Free() + + commitOid, err := git.NewOid(response.String()) + require.NoError(t, err) + + commit, err := repo.LookupCommit(commitOid) + require.NoError(t, err) + require.Equal(t, &DefaultAuthor, commit.Author()) + require.Equal(t, &committer, commit.Committer()) + + tree, err := commit.Tree() + require.NoError(t, err) + require.Len(t, tc.expected, int(tree.EntryCount())) + + for name, contents := range tc.expected { + entry := tree.EntryByName(name) + require.NotNil(t, entry) + + blob, err := repo.LookupBlob(entry.Id) + require.NoError(t, err) + require.Equal(t, []byte(contents), blob.Contents()) + } + }) + } +} + +func TestCherryPickStructuredErrors(t *testing.T) { + ctx := testhelper.Context(t) + + cfg, repo, repoPath := testcfg.BuildWithRepo(t) + + testcfg.BuildGitalyGit2Go(t, cfg) + executor := buildExecutor(t, cfg) + + base := gittest.WriteCommit( + t, + cfg, + repoPath, + gittest.WithTreeEntries(gittest.TreeEntry{ + Path: "file", Content: "foo", Mode: "100644", + })) + + ours := gittest.WriteCommit( + t, + cfg, + repoPath, + gittest.WithParents(base), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "file", Content: "fooqux", Mode: "100644"}, + )).String() + + commit := gittest.WriteCommit( + t, + cfg, + repoPath, + gittest.WithParents(base), + gittest.WithTreeEntries( + gittest.TreeEntry{Path: "file", Content: "foobar", Mode: "100644"}, + )).String() + + committer := git.Signature{ + Name: "Baz", + Email: "baz@example.com", + When: time.Date(2021, 1, 17, 14, 45, 51, 0, time.FixedZone("", +2*60*60)), + } + + _, err := executor.CherryPick(ctx, repo, git2go.CherryPickCommand{ + Repository: repoPath, + CommitterName: committer.Name, + CommitterMail: committer.Email, + CommitterDate: committer.When, + Message: "Foo", + Ours: ours, + Commit: commit, + }) + + require.EqualError(t, err, "cherry-pick: there are conflicting files") + require.ErrorAs(t, err, &git2go.ConflictingFilesError{}) +} diff --git a/cmd/gitaly-git2go/commit.go b/cmd/gitaly-git2go/commit.go new file mode 100644 index 000000000..cd0a1a31d --- /dev/null +++ b/cmd/gitaly-git2go/commit.go @@ -0,0 +1,19 @@ +//go:build static && system_libgit2 + +package main + +import ( + "context" + "encoding/gob" + "flag" + + "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go/commit" +) + +type commitSubcommand struct{} + +func (commitSubcommand) Flags() *flag.FlagSet { return flag.NewFlagSet("commit", flag.ExitOnError) } + +func (commitSubcommand) Run(ctx context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error { + return commit.Run(ctx, decoder, encoder) +} diff --git a/cmd/gitaly-git2go/commit/change_file_mode.go b/cmd/gitaly-git2go/commit/change_file_mode.go new file mode 100644 index 000000000..e07db9ba4 --- /dev/null +++ b/cmd/gitaly-git2go/commit/change_file_mode.go @@ -0,0 +1,30 @@ +//go:build static && system_libgit2 + +package commit + +import ( + git "github.com/libgit2/git2go/v33" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" +) + +func applyChangeFileMode(action git2go.ChangeFileMode, index *git.Index) error { + entry, err := index.EntryByPath(action.Path, 0) + if err != nil { + if git.IsErrorCode(err, git.ErrorCodeNotFound) { + return git2go.FileNotFoundError(action.Path) + } + + return err + } + + mode := git.FilemodeBlob + if action.ExecutableMode { + mode = git.FilemodeBlobExecutable + } + + return index.Add(&git.IndexEntry{ + Path: action.Path, + Mode: mode, + Id: entry.Id, + }) +} diff --git a/cmd/gitaly-git2go/commit/commit.go b/cmd/gitaly-git2go/commit/commit.go new file mode 100644 index 000000000..57ca1f586 --- /dev/null +++ b/cmd/gitaly-git2go/commit/commit.go @@ -0,0 +1,111 @@ +//go:build static && system_libgit2 + +package commit + +import ( + "context" + "encoding/gob" + "errors" + "fmt" + + git "github.com/libgit2/git2go/v33" + "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go/git2goutil" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" +) + +// Run runs the commit subcommand. +func Run(ctx context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error { + var params git2go.CommitParams + if err := decoder.Decode(¶ms); err != nil { + return err + } + + commitID, err := commit(ctx, params) + return encoder.Encode(git2go.Result{ + CommitID: commitID, + Err: git2go.SerializableError(err), + }) +} + +func commit(ctx context.Context, params git2go.CommitParams) (string, error) { + repo, err := git2goutil.OpenRepository(params.Repository) + if err != nil { + return "", fmt.Errorf("open repository: %w", err) + } + + index, err := git.NewIndex() + if err != nil { + return "", fmt.Errorf("new index: %w", err) + } + + var parents []*git.Oid + if params.Parent != "" { + parentOID, err := git.NewOid(params.Parent) + if err != nil { + return "", fmt.Errorf("parse base commit oid: %w", err) + } + + parents = []*git.Oid{parentOID} + + baseCommit, err := repo.LookupCommit(parentOID) + if err != nil { + return "", fmt.Errorf("lookup commit: %w", err) + } + + baseTree, err := baseCommit.Tree() + if err != nil { + return "", fmt.Errorf("lookup tree: %w", err) + } + + if err := index.ReadTree(baseTree); err != nil { + return "", fmt.Errorf("read tree: %w", err) + } + } + + for _, action := range params.Actions { + if err := apply(action, repo, index); err != nil { + if git.IsErrorClass(err, git.ErrorClassIndex) { + err = git2go.IndexError(err.Error()) + } + + return "", fmt.Errorf("apply action %T: %w", action, err) + } + } + + treeOID, err := index.WriteTreeTo(repo) + if err != nil { + return "", fmt.Errorf("write tree: %w", err) + } + + author := git.Signature(params.Author) + committer := git.Signature(params.Committer) + commitID, err := repo.CreateCommitFromIds("", &author, &committer, params.Message, treeOID, parents...) + if err != nil { + if git.IsErrorClass(err, git.ErrorClassInvalid) { + return "", git2go.InvalidArgumentError(err.Error()) + } + + return "", fmt.Errorf("create commit: %w", err) + } + + return commitID.String(), nil +} + +func apply(action git2go.Action, repo *git.Repository, index *git.Index) error { + switch action := action.(type) { + case git2go.ChangeFileMode: + return applyChangeFileMode(action, index) + case git2go.CreateDirectory: + return applyCreateDirectory(action, repo, index) + case git2go.CreateFile: + return applyCreateFile(action, index) + case git2go.DeleteFile: + return applyDeleteFile(action, index) + case git2go.MoveFile: + return applyMoveFile(action, index) + case git2go.UpdateFile: + return applyUpdateFile(action, index) + default: + return errors.New("unsupported action") + } +} diff --git a/cmd/gitaly-git2go/commit/create_directory.go b/cmd/gitaly-git2go/commit/create_directory.go new file mode 100644 index 000000000..061666ed3 --- /dev/null +++ b/cmd/gitaly-git2go/commit/create_directory.go @@ -0,0 +1,30 @@ +//go:build static && system_libgit2 + +package commit + +import ( + "fmt" + "path/filepath" + + git "github.com/libgit2/git2go/v33" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" +) + +func applyCreateDirectory(action git2go.CreateDirectory, repo *git.Repository, index *git.Index) error { + if err := validateFileDoesNotExist(index, action.Path); err != nil { + return err + } else if err := validateDirectoryDoesNotExist(index, action.Path); err != nil { + return err + } + + emptyBlobOID, err := repo.CreateBlobFromBuffer([]byte{}) + if err != nil { + return fmt.Errorf("create blob from buffer: %w", err) + } + + return index.Add(&git.IndexEntry{ + Path: filepath.Join(action.Path, ".gitkeep"), + Mode: git.FilemodeBlob, + Id: emptyBlobOID, + }) +} diff --git a/cmd/gitaly-git2go/commit/create_file.go b/cmd/gitaly-git2go/commit/create_file.go new file mode 100644 index 000000000..926916561 --- /dev/null +++ b/cmd/gitaly-git2go/commit/create_file.go @@ -0,0 +1,30 @@ +//go:build static && system_libgit2 + +package commit + +import ( + git "github.com/libgit2/git2go/v33" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" +) + +func applyCreateFile(action git2go.CreateFile, index *git.Index) error { + if err := validateFileDoesNotExist(index, action.Path); err != nil { + return err + } + + oid, err := git.NewOid(action.OID) + if err != nil { + return err + } + + mode := git.FilemodeBlob + if action.ExecutableMode { + mode = git.FilemodeBlobExecutable + } + + return index.Add(&git.IndexEntry{ + Path: action.Path, + Mode: mode, + Id: oid, + }) +} diff --git a/cmd/gitaly-git2go/commit/delete_file.go b/cmd/gitaly-git2go/commit/delete_file.go new file mode 100644 index 000000000..a5af77b7b --- /dev/null +++ b/cmd/gitaly-git2go/commit/delete_file.go @@ -0,0 +1,16 @@ +//go:build static && system_libgit2 + +package commit + +import ( + git "github.com/libgit2/git2go/v33" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" +) + +func applyDeleteFile(action git2go.DeleteFile, index *git.Index) error { + if err := validateFileExists(index, action.Path); err != nil { + return err + } + + return index.RemoveByPath(action.Path) +} diff --git a/cmd/gitaly-git2go/commit/move_file.go b/cmd/gitaly-git2go/commit/move_file.go new file mode 100644 index 000000000..b31853c96 --- /dev/null +++ b/cmd/gitaly-git2go/commit/move_file.go @@ -0,0 +1,41 @@ +//go:build static && system_libgit2 + +package commit + +import ( + git "github.com/libgit2/git2go/v33" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" +) + +func applyMoveFile(action git2go.MoveFile, index *git.Index) error { + entry, err := index.EntryByPath(action.Path, 0) + if err != nil { + if git.IsErrorCode(err, git.ErrorCodeNotFound) { + return git2go.FileNotFoundError(action.Path) + } + + return err + } + + if err := validateFileDoesNotExist(index, action.NewPath); err != nil { + return err + } + + oid := entry.Id + if action.OID != "" { + oid, err = git.NewOid(action.OID) + if err != nil { + return err + } + } + + if err := index.Add(&git.IndexEntry{ + Path: action.NewPath, + Mode: entry.Mode, + Id: oid, + }); err != nil { + return err + } + + return index.RemoveByPath(entry.Path) +} diff --git a/cmd/gitaly-git2go/commit/update_file.go b/cmd/gitaly-git2go/commit/update_file.go new file mode 100644 index 000000000..cea5d629b --- /dev/null +++ b/cmd/gitaly-git2go/commit/update_file.go @@ -0,0 +1,30 @@ +//go:build static && system_libgit2 + +package commit + +import ( + git "github.com/libgit2/git2go/v33" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" +) + +func applyUpdateFile(action git2go.UpdateFile, index *git.Index) error { + entry, err := index.EntryByPath(action.Path, 0) + if err != nil { + if git.IsErrorCode(err, git.ErrorCodeNotFound) { + return git2go.FileNotFoundError(action.Path) + } + + return err + } + + oid, err := git.NewOid(action.OID) + if err != nil { + return err + } + + return index.Add(&git.IndexEntry{ + Path: action.Path, + Mode: entry.Mode, + Id: oid, + }) +} diff --git a/cmd/gitaly-git2go/commit/validate.go b/cmd/gitaly-git2go/commit/validate.go new file mode 100644 index 000000000..ab3f972b1 --- /dev/null +++ b/cmd/gitaly-git2go/commit/validate.go @@ -0,0 +1,48 @@ +//go:build static && system_libgit2 + +package commit + +import ( + "os" + + git "github.com/libgit2/git2go/v33" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" +) + +func validateFileExists(index *git.Index, path string) error { + if _, err := index.Find(path); err != nil { + if git.IsErrorCode(err, git.ErrorCodeNotFound) { + return git2go.FileNotFoundError(path) + } + + return err + } + + return nil +} + +func validateFileDoesNotExist(index *git.Index, path string) error { + _, err := index.Find(path) + if err == nil { + return git2go.FileExistsError(path) + } + + if !git.IsErrorCode(err, git.ErrorCodeNotFound) { + return err + } + + return nil +} + +func validateDirectoryDoesNotExist(index *git.Index, path string) error { + _, err := index.FindPrefix(path + string(os.PathSeparator)) + if err == nil { + return git2go.DirectoryExistsError(path) + } + + if !git.IsErrorCode(err, git.ErrorCodeNotFound) { + return err + } + + return nil +} diff --git a/cmd/gitaly-git2go/conflicts.go b/cmd/gitaly-git2go/conflicts.go new file mode 100644 index 000000000..1e2fbb4e0 --- /dev/null +++ b/cmd/gitaly-git2go/conflicts.go @@ -0,0 +1,171 @@ +//go:build static && system_libgit2 + +package main + +import ( + "context" + "encoding/gob" + "errors" + "flag" + "fmt" + + git "github.com/libgit2/git2go/v33" + "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go/git2goutil" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" + "gitlab.com/gitlab-org/gitaly/v15/internal/helper" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type conflictsSubcommand struct{} + +func (cmd *conflictsSubcommand) Flags() *flag.FlagSet { + return flag.NewFlagSet("conflicts", flag.ExitOnError) +} + +func (cmd *conflictsSubcommand) Run(_ context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error { + var request git2go.ConflictsCommand + if err := decoder.Decode(&request); err != nil { + return err + } + res := cmd.conflicts(request) + return encoder.Encode(res) +} + +func (conflictsSubcommand) conflicts(request git2go.ConflictsCommand) git2go.ConflictsResult { + repo, err := git2goutil.OpenRepository(request.Repository) + if err != nil { + return conflictError(codes.Internal, fmt.Errorf("could not open repository: %w", err).Error()) + } + + oursOid, err := git.NewOid(request.Ours) + if err != nil { + return conflictError(codes.InvalidArgument, err.Error()) + } + + ours, err := repo.LookupCommit(oursOid) + if err != nil { + return convertError(err, git.ErrorCodeNotFound, codes.InvalidArgument) + } + + theirsOid, err := git.NewOid(request.Theirs) + if err != nil { + return conflictError(codes.InvalidArgument, err.Error()) + } + + theirs, err := repo.LookupCommit(theirsOid) + if err != nil { + return convertError(err, git.ErrorCodeNotFound, codes.InvalidArgument) + } + + index, err := repo.MergeCommits(ours, theirs, nil) + if err != nil { + return conflictError(codes.FailedPrecondition, fmt.Sprintf("could not merge commits: %v", err)) + } + + iterator, err := index.ConflictIterator() + if err != nil { + return conflictError(codes.Internal, fmt.Errorf("could not get conflicts: %w", err).Error()) + } + + var result git2go.ConflictsResult + for { + conflict, err := iterator.Next() + if err != nil { + var gitError *git.GitError + if errors.As(err, &gitError) && gitError.Code == git.ErrorCodeIterOver { + break + } + return conflictError(codes.Internal, err.Error()) + } + + merge, err := Merge(repo, conflict) + if err != nil { + if s, ok := status.FromError(err); ok { + return conflictError(s.Code(), s.Message()) + } + return conflictError(codes.Internal, err.Error()) + } + + result.Conflicts = append(result.Conflicts, git2go.Conflict{ + Ancestor: conflictEntryFromIndex(conflict.Ancestor), + Our: conflictEntryFromIndex(conflict.Our), + Their: conflictEntryFromIndex(conflict.Their), + Content: merge.Contents, + }) + } + + return result +} + +// Merge will merge the given index conflict and produce a file with conflict +// markers. +func Merge(repo *git.Repository, conflict git.IndexConflict) (*git.MergeFileResult, 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 nil, helper.ErrFailedPreconditionf("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 nil, fmt.Errorf("could not compute conflicts: %w", err) + } + + // In a case of tree-based conflicts (e.g. no ancestor), fallback to `Path` + // of `their` side. If that's also blank, fallback to `Path` of `our` side. + // This is to ensure that there's always a `Path` when we try to merge + // conflicts. + if merge.Path == "" { + if their.Path != "" { + merge.Path = their.Path + } else { + merge.Path = our.Path + } + } + + return merge, nil +} + +func conflictEntryFromIndex(entry *git.IndexEntry) git2go.ConflictEntry { + if entry == nil { + return git2go.ConflictEntry{} + } + return git2go.ConflictEntry{ + Path: entry.Path, + Mode: int32(entry.Mode), + } +} + +func conflictError(code codes.Code, message string) git2go.ConflictsResult { + err := git2go.ConflictError{ + Code: code, + Message: message, + } + return git2go.ConflictsResult{ + Err: err, + } +} + +func convertError(err error, errorCode git.ErrorCode, returnCode codes.Code) git2go.ConflictsResult { + var gitError *git.GitError + if errors.As(err, &gitError) && gitError.Code == errorCode { + return conflictError(returnCode, err.Error()) + } + return conflictError(codes.Internal, err.Error()) +} diff --git a/cmd/gitaly-git2go/conflicts_test.go b/cmd/gitaly-git2go/conflicts_test.go new file mode 100644 index 000000000..c8e9848eb --- /dev/null +++ b/cmd/gitaly-git2go/conflicts_test.go @@ -0,0 +1,278 @@ +//go:build static && system_libgit2 && !gitaly_test_sha256 + +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + glgit "gitlab.com/gitlab-org/gitaly/v15/internal/git" + "gitlab.com/gitlab-org/gitaly/v15/internal/git/gittest" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" + "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper" + "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper/testcfg" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestConflicts(t *testing.T) { + testcases := []struct { + desc string + base []gittest.TreeEntry + ours []gittest.TreeEntry + theirs []gittest.TreeEntry + conflicts []git2go.Conflict + }{ + { + desc: "no conflicts", + base: []gittest.TreeEntry{ + {Path: "file", Content: "a", Mode: "100644"}, + }, + ours: []gittest.TreeEntry{ + {Path: "file", Content: "a", Mode: "100644"}, + }, + theirs: []gittest.TreeEntry{ + {Path: "file", Content: "b", Mode: "100644"}, + }, + conflicts: nil, + }, + { + desc: "single file", + base: []gittest.TreeEntry{ + {Path: "file", Content: "a", Mode: "100644"}, + }, + ours: []gittest.TreeEntry{ + {Path: "file", Content: "b", Mode: "100644"}, + }, + theirs: []gittest.TreeEntry{ + {Path: "file", Content: "c", Mode: "100644"}, + }, + conflicts: []git2go.Conflict{ + { + Ancestor: git2go.ConflictEntry{Path: "file", Mode: 0o100644}, + Our: git2go.ConflictEntry{Path: "file", Mode: 0o100644}, + Their: git2go.ConflictEntry{Path: "file", Mode: 0o100644}, + Content: []byte("<<<<<<< file\nb\n=======\nc\n>>>>>>> file\n"), + }, + }, + }, + { + desc: "multiple files with single conflict", + base: []gittest.TreeEntry{ + {Path: "file-1", Content: "a", Mode: "100644"}, + {Path: "file-2", Content: "a", Mode: "100644"}, + }, + ours: []gittest.TreeEntry{ + {Path: "file-1", Content: "b", Mode: "100644"}, + {Path: "file-2", Content: "b", Mode: "100644"}, + }, + theirs: []gittest.TreeEntry{ + {Path: "file-1", Content: "a", Mode: "100644"}, + {Path: "file-2", Content: "c", Mode: "100644"}, + }, + conflicts: []git2go.Conflict{ + { + Ancestor: git2go.ConflictEntry{Path: "file-2", Mode: 0o100644}, + Our: git2go.ConflictEntry{Path: "file-2", Mode: 0o100644}, + Their: git2go.ConflictEntry{Path: "file-2", Mode: 0o100644}, + Content: []byte("<<<<<<< file-2\nb\n=======\nc\n>>>>>>> file-2\n"), + }, + }, + }, + { + desc: "multiple conflicts", + base: []gittest.TreeEntry{ + {Path: "file-1", Content: "a", Mode: "100644"}, + {Path: "file-2", Content: "a", Mode: "100644"}, + }, + ours: []gittest.TreeEntry{ + {Path: "file-1", Content: "b", Mode: "100644"}, + {Path: "file-2", Content: "b", Mode: "100644"}, + }, + theirs: []gittest.TreeEntry{ + {Path: "file-1", Content: "c", Mode: "100644"}, + {Path: "file-2", Content: "c", Mode: "100644"}, + }, + conflicts: []git2go.Conflict{ + { + Ancestor: git2go.ConflictEntry{Path: "file-1", Mode: 0o100644}, + Our: git2go.ConflictEntry{Path: "file-1", Mode: 0o100644}, + Their: git2go.ConflictEntry{Path: "file-1", Mode: 0o100644}, + Content: []byte("<<<<<<< file-1\nb\n=======\nc\n>>>>>>> file-1\n"), + }, + { + Ancestor: git2go.ConflictEntry{Path: "file-2", Mode: 0o100644}, + Our: git2go.ConflictEntry{Path: "file-2", Mode: 0o100644}, + Their: git2go.ConflictEntry{Path: "file-2", Mode: 0o100644}, + Content: []byte("<<<<<<< file-2\nb\n=======\nc\n>>>>>>> file-2\n"), + }, + }, + }, + { + desc: "modified-delete-conflict", + base: []gittest.TreeEntry{ + {Path: "file", Content: "content", Mode: "100644"}, + }, + ours: []gittest.TreeEntry{ + {Path: "file", Content: "changed", Mode: "100644"}, + }, + theirs: []gittest.TreeEntry{ + {Path: "different-file", Content: "unrelated", Mode: "100644"}, + }, + conflicts: []git2go.Conflict{ + { + Ancestor: git2go.ConflictEntry{Path: "file", Mode: 0o100644}, + Our: git2go.ConflictEntry{Path: "file", Mode: 0o100644}, + Their: git2go.ConflictEntry{}, + Content: []byte("<<<<<<< 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: []gittest.TreeEntry{ + {Path: "file", Content: "a\nb\nc\nd\ne\nf\ng\n", Mode: "100644"}, + }, + ours: []gittest.TreeEntry{ + {Path: "renamed-1", Content: "a\nb\nc\nd\ne\nf\ng\n", Mode: "100644"}, + }, + theirs: []gittest.TreeEntry{ + {Path: "renamed-2", Content: "a\nb\nc\nd\ne\nf\ng\n", Mode: "100644"}, + }, + conflicts: []git2go.Conflict{ + { + Ancestor: git2go.ConflictEntry{Path: "file", Mode: 0o100644}, + Our: git2go.ConflictEntry{}, + Their: git2go.ConflictEntry{}, + Content: nil, + }, + { + Ancestor: git2go.ConflictEntry{}, + Our: git2go.ConflictEntry{Path: "renamed-1", Mode: 0o100644}, + Their: git2go.ConflictEntry{}, + Content: []byte("a\nb\nc\nd\ne\nf\ng\n"), + }, + { + Ancestor: git2go.ConflictEntry{}, + Our: git2go.ConflictEntry{}, + Their: git2go.ConflictEntry{Path: "renamed-2", Mode: 0o100644}, + Content: []byte("a\nb\nc\nd\ne\nf\ng\n"), + }, + }, + }, + } + + for _, tc := range testcases { + cfg, repo, repoPath := testcfg.BuildWithRepo(t) + executor := buildExecutor(t, cfg) + + testcfg.BuildGitalyGit2Go(t, cfg) + + base := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries(tc.base...)) + ours := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(base), gittest.WithTreeEntries(tc.ours...)) + theirs := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(base), gittest.WithTreeEntries(tc.theirs...)) + + t.Run(tc.desc, func(t *testing.T) { + ctx := testhelper.Context(t) + + response, err := executor.Conflicts(ctx, repo, git2go.ConflictsCommand{ + Repository: repoPath, + Ours: ours.String(), + Theirs: theirs.String(), + }) + + require.NoError(t, err) + require.Equal(t, tc.conflicts, response.Conflicts) + }) + } +} + +func TestConflicts_checkError(t *testing.T) { + cfg, repo, repoPath := testcfg.BuildWithRepo(t) + base := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries()) + validOID := glgit.ObjectID(base.String()) + executor := buildExecutor(t, cfg) + + testcfg.BuildGitalyGit2Go(t, cfg) + + testcases := []struct { + desc string + overrideRepoPath string + ours glgit.ObjectID + theirs glgit.ObjectID + expErr error + }{ + { + desc: "ours is not set", + ours: "", + theirs: validOID, + expErr: fmt.Errorf("conflicts: %w: missing ours", git2go.ErrInvalidArgument), + }, + { + desc: "theirs is not set", + ours: validOID, + theirs: "", + expErr: fmt.Errorf("conflicts: %w: missing theirs", git2go.ErrInvalidArgument), + }, + { + desc: "invalid repository", + overrideRepoPath: "not/existing/path.git", + ours: validOID, + theirs: validOID, + expErr: status.Error(codes.Internal, "could not open repository: failed to resolve path 'not/existing/path.git': No such file or directory"), + }, + { + desc: "ours is invalid", + ours: "1", + theirs: validOID, + expErr: status.Error(codes.InvalidArgument, "encoding/hex: odd length hex string"), + }, + { + desc: "theirs is invalid", + ours: validOID, + theirs: "1", + expErr: status.Error(codes.InvalidArgument, "encoding/hex: odd length hex string"), + }, + { + desc: "ours OID doesn't exist", + ours: glgit.ObjectHashSHA1.ZeroOID, + theirs: validOID, + expErr: status.Error(codes.InvalidArgument, "odb: cannot read object: null OID cannot exist"), + }, + { + desc: "invalid object type", + ours: glgit.ObjectHashSHA1.EmptyTreeOID, + theirs: validOID, + expErr: status.Error(codes.InvalidArgument, "the requested type does not match the type in the ODB"), + }, + { + desc: "theirs OID doesn't exist", + ours: validOID, + theirs: glgit.ObjectHashSHA1.ZeroOID, + expErr: status.Error(codes.InvalidArgument, "odb: cannot read object: null OID cannot exist"), + }, + } + + for _, tc := range testcases { + t.Run(tc.desc, func(t *testing.T) { + repoPath := repoPath + if tc.overrideRepoPath != "" { + repoPath = tc.overrideRepoPath + } + ctx := testhelper.Context(t) + + _, err := executor.Conflicts(ctx, repo, git2go.ConflictsCommand{ + Repository: repoPath, + Ours: tc.ours.String(), + Theirs: tc.theirs.String(), + }) + + require.Error(t, err) + require.Equal(t, tc.expErr, err) + }) + } +} diff --git a/cmd/gitaly-git2go/featureflags.go b/cmd/gitaly-git2go/featureflags.go new file mode 100644 index 000000000..720320901 --- /dev/null +++ b/cmd/gitaly-git2go/featureflags.go @@ -0,0 +1,41 @@ +//go:build static && system_libgit2 && gitaly_test + +package main + +import ( + "context" + "encoding/gob" + "flag" + + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" + "gitlab.com/gitlab-org/gitaly/v15/internal/metadata/featureflag" +) + +// This subcommand is only called in tests, so we don't want to register it like +// the other subcommands but instead will do it in an init block. The gitaly_test build +// flag will guarantee that this is not built and registered in the +// gitaly-git2go binary +func init() { + subcommands["feature-flags"] = &featureFlagsSubcommand{} +} + +type featureFlagsSubcommand struct{} + +func (featureFlagsSubcommand) Flags() *flag.FlagSet { + return flag.NewFlagSet("feature-flags", flag.ExitOnError) +} + +func (featureFlagsSubcommand) Run(ctx context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error { + var flags []git2go.FeatureFlag + for flag, value := range featureflag.FromContext(ctx) { + flags = append(flags, git2go.FeatureFlag{ + Name: flag.Name, + MetadataKey: flag.MetadataKey(), + Value: value, + }) + } + + return encoder.Encode(git2go.FeatureFlags{ + Flags: flags, + }) +} diff --git a/cmd/gitaly-git2go/git2goutil/repo.go b/cmd/gitaly-git2go/git2goutil/repo.go new file mode 100644 index 000000000..259da77e8 --- /dev/null +++ b/cmd/gitaly-git2go/git2goutil/repo.go @@ -0,0 +1,10 @@ +package git2goutil + +import ( + git "github.com/libgit2/git2go/v33" +) + +// OpenRepository opens the repository located at path as a Git2Go repository. +func OpenRepository(path string) (*git.Repository, error) { + return git.OpenRepositoryExtended(path, git.RepositoryOpenFromEnv, "") +} diff --git a/cmd/gitaly-git2go/main.go b/cmd/gitaly-git2go/main.go new file mode 100644 index 000000000..e8a2c2c7b --- /dev/null +++ b/cmd/gitaly-git2go/main.go @@ -0,0 +1,177 @@ +//go:build static && system_libgit2 + +package main + +import ( + "context" + "encoding/gob" + "flag" + "fmt" + "os" + "strings" + + "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus/ctxlogrus" + git "github.com/libgit2/git2go/v33" + "github.com/sirupsen/logrus" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" + glog "gitlab.com/gitlab-org/gitaly/v15/internal/log" + "gitlab.com/gitlab-org/gitaly/v15/internal/metadata/featureflag" + "gitlab.com/gitlab-org/labkit/correlation" +) + +type subcmd interface { + Flags() *flag.FlagSet + Run(ctx context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error +} + +var subcommands = map[string]subcmd{ + "apply": &applySubcommand{}, + "cherry-pick": &cherryPickSubcommand{}, + "commit": commitSubcommand{}, + "conflicts": &conflictsSubcommand{}, + "merge": &mergeSubcommand{}, + "rebase": &rebaseSubcommand{}, + "revert": &revertSubcommand{}, + "resolve": &resolveSubcommand{}, + "submodule": &submoduleSubcommand{}, +} + +func fatalf(logger logrus.FieldLogger, encoder *gob.Encoder, format string, args ...interface{}) { + err := encoder.Encode(git2go.Result{ + Err: git2go.SerializableError(fmt.Errorf(format, args...)), + }) + if err != nil { + logger.WithError(err).Error("encode to gob failed") + } + // An exit code of 1 would indicate an error over stderr. Since our errors + // are encoded over gob, we need to exit cleanly + os.Exit(0) +} + +func configureLogging(format, level string) { + // Gitaly logging by default goes to stdout, which would interfere with gob + // encoding. + for _, l := range glog.Loggers { + l.Out = os.Stderr + } + glog.Configure(glog.Loggers, format, level) +} + +func main() { + decoder := gob.NewDecoder(os.Stdin) + encoder := gob.NewEncoder(os.Stdout) + + var logFormat, logLevel, correlationID string + var enabledFeatureFlags, disabledFeatureFlags featureFlagArg + + flags := flag.NewFlagSet(git2go.BinaryName, flag.PanicOnError) + flags.StringVar(&logFormat, "log-format", "", "logging format") + flags.StringVar(&logLevel, "log-level", "", "logging level") + flags.StringVar(&correlationID, "correlation-id", "", "correlation ID used for request tracing") + flags.Var( + &enabledFeatureFlags, + "enabled-feature-flags", + "comma separated list of explicitly enabled feature flags", + ) + flags.Var( + &disabledFeatureFlags, + "disabled-feature-flags", + "comma separated list of explicitly disabled feature flags", + ) + _ = flags.Parse(os.Args[1:]) + + if correlationID == "" { + correlationID = correlation.SafeRandomID() + } + + configureLogging(logFormat, logLevel) + + ctx := correlation.ContextWithCorrelation(context.Background(), correlationID) + logger := glog.Default().WithFields(logrus.Fields{ + "command.name": git2go.BinaryName, + "correlation_id": correlationID, + "enabled_feature_flags": enabledFeatureFlags, + "disabled_feature_flags": disabledFeatureFlags, + }) + + if flags.NArg() < 1 { + fatalf(logger, encoder, "missing subcommand") + } + + subcmd, ok := subcommands[flags.Arg(0)] + if !ok { + fatalf(logger, encoder, "unknown subcommand: %q", flags.Arg(0)) + } + + subcmdFlags := subcmd.Flags() + if err := subcmdFlags.Parse(flags.Args()[1:]); err != nil { + fatalf(logger, encoder, "parsing flags of %q: %s", subcmdFlags.Name(), err) + } + + if subcmdFlags.NArg() != 0 { + fatalf(logger, encoder, "%s: trailing arguments", subcmdFlags.Name()) + } + + if err := git.EnableFsyncGitDir(true); err != nil { + fatalf(logger, encoder, "enable fsync: %s", err) + } + + for _, configLevel := range []git.ConfigLevel{ + git.ConfigLevelSystem, + git.ConfigLevelXDG, + git.ConfigLevelGlobal, + } { + if err := git.SetSearchPath(configLevel, "/dev/null"); err != nil { + fatalf(logger, encoder, "setting search path: %s", err) + } + } + + subcmdLogger := logger.WithField("command.subcommand", subcmdFlags.Name()) + subcmdLogger.Infof("starting %s command", subcmdFlags.Name()) + + ctx = ctxlogrus.ToContext(ctx, subcmdLogger) + ctx = enabledFeatureFlags.ToContext(ctx, true) + ctx = disabledFeatureFlags.ToContext(ctx, false) + + if err := subcmd.Run(ctx, decoder, encoder); err != nil { + subcmdLogger.WithError(err).Errorf("%s command failed", subcmdFlags.Name()) + fatalf(logger, encoder, "%s: %s", subcmdFlags.Name(), err) + } + + subcmdLogger.Infof("%s command finished", subcmdFlags.Name()) +} + +type featureFlagArg []featureflag.FeatureFlag + +func (v *featureFlagArg) String() string { + metadataKeys := make([]string, 0, len(*v)) + for _, flag := range *v { + metadataKeys = append(metadataKeys, flag.MetadataKey()) + } + return strings.Join(metadataKeys, ",") +} + +func (v *featureFlagArg) Set(s string) error { + if s == "" { + return nil + } + + for _, metadataKey := range strings.Split(s, ",") { + flag, err := featureflag.FromMetadataKey(metadataKey) + if err != nil { + return err + } + + *v = append(*v, flag) + } + + return nil +} + +func (v featureFlagArg) ToContext(ctx context.Context, enabled bool) context.Context { + for _, flag := range v { + ctx = featureflag.IncomingCtxWithFeatureFlag(ctx, flag, enabled) + } + + return ctx +} diff --git a/cmd/gitaly-git2go/merge.go b/cmd/gitaly-git2go/merge.go new file mode 100644 index 000000000..4ab3ef9fd --- /dev/null +++ b/cmd/gitaly-git2go/merge.go @@ -0,0 +1,251 @@ +//go:build static && system_libgit2 + +package main + +import ( + "context" + "encoding/gob" + "errors" + "flag" + "fmt" + "time" + + git "github.com/libgit2/git2go/v33" + "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go/git2goutil" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" +) + +type mergeSubcommand struct{} + +func (cmd *mergeSubcommand) Flags() *flag.FlagSet { + flags := flag.NewFlagSet("merge", flag.ExitOnError) + return flags +} + +func (cmd *mergeSubcommand) Run(_ context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error { + var request git2go.MergeCommand + if err := decoder.Decode(&request); err != nil { + return err + } + + if request.AuthorDate.IsZero() { + request.AuthorDate = time.Now() + } + + commitID, err := merge(request) + + return encoder.Encode(git2go.Result{ + CommitID: commitID, + Err: git2go.SerializableError(err), + }) +} + +func merge(request git2go.MergeCommand) (string, error) { + repo, err := git2goutil.OpenRepository(request.Repository) + if err != nil { + return "", fmt.Errorf("could not open repository: %w", err) + } + defer repo.Free() + + ours, err := lookupCommit(repo, request.Ours) + if err != nil { + return "", fmt.Errorf("ours commit lookup: %w", err) + } + + theirs, err := lookupCommit(repo, request.Theirs) + if err != nil { + return "", fmt.Errorf("theirs commit lookup: %w", err) + } + + mergeOpts, err := git.DefaultMergeOptions() + if err != nil { + return "", fmt.Errorf("could not create merge options: %w", err) + } + mergeOpts.RecursionLimit = git2go.MergeRecursionLimit + + index, err := repo.MergeCommits(ours, theirs, &mergeOpts) + if err != nil { + return "", fmt.Errorf("could not merge commits: %w", err) + } + defer index.Free() + + if index.HasConflicts() { + if !request.AllowConflicts { + conflictingFiles, err := getConflictingFiles(index) + if err != nil { + return "", fmt.Errorf("getting conflicting files: %w", err) + } + + return "", git2go.ConflictingFilesError{ + ConflictingFiles: conflictingFiles, + } + } + + if err := resolveConflicts(repo, index); err != nil { + return "", fmt.Errorf("could not resolve conflicts: %w", err) + } + } + + tree, err := index.WriteTreeTo(repo) + if err != nil { + return "", fmt.Errorf("could not write tree: %w", err) + } + + author := git.Signature(git2go.NewSignature(request.AuthorName, request.AuthorMail, request.AuthorDate)) + committer := author + if request.CommitterMail != "" { + committer = git.Signature(git2go.NewSignature(request.CommitterName, request.CommitterMail, request.CommitterDate)) + } + + var parents []*git.Oid + if request.Squash { + parents = []*git.Oid{ours.Id()} + } else { + parents = []*git.Oid{ours.Id(), theirs.Id()} + } + commit, err := repo.CreateCommitFromIds("", &author, &committer, request.Message, tree, parents...) + if err != nil { + return "", fmt.Errorf("could not create merge commit: %w", err) + } + + return commit.String(), nil +} + +func resolveConflicts(repo *git.Repository, index *git.Index) error { + // We need to get all conflicts up front as resolving conflicts as we + // iterate breaks the iterator. + indexConflicts, err := getConflicts(index) + if err != nil { + return err + } + + for _, conflict := range indexConflicts { + if isConflictMergeable(conflict) { + merge, err := Merge(repo, conflict) + if err != nil { + return err + } + + mergedBlob, err := repo.CreateBlobFromBuffer(merge.Contents) + if err != nil { + return err + } + + mergedIndexEntry := git.IndexEntry{ + Path: merge.Path, + Mode: git.Filemode(merge.Mode), + Id: mergedBlob, + } + + if err := index.Add(&mergedIndexEntry); err != nil { + return err + } + + if err := index.RemoveConflict(merge.Path); err != nil { + return err + } + } else { + if conflict.Their != nil { + // If a conflict has `Their` present, we add it back to the index + // as we want those changes to be part of the merge. + if err := index.Add(conflict.Their); err != nil { + return err + } + + if err := index.RemoveConflict(conflict.Their.Path); err != nil { + return err + } + } else if conflict.Our != nil { + // If a conflict has `Our` present, remove its conflict as we + // don't want to include those changes. + if err := index.RemoveConflict(conflict.Our.Path); err != nil { + return err + } + } else { + // If conflict has no `Their` and `Our`, remove the conflict to + // mark it as resolved. + if err := index.RemoveConflict(conflict.Ancestor.Path); err != nil { + return err + } + } + } + } + + if index.HasConflicts() { + conflictingFiles, err := getConflictingFiles(index) + if err != nil { + return fmt.Errorf("getting conflicting files: %w", err) + } + + return git2go.ConflictingFilesError{ + ConflictingFiles: conflictingFiles, + } + } + + return nil +} + +func getConflictingFiles(index *git.Index) ([]string, error) { + conflicts, err := getConflicts(index) + if err != nil { + return nil, fmt.Errorf("getting conflicts: %w", err) + } + + conflictingFiles := make([]string, 0, len(conflicts)) + for _, conflict := range conflicts { + switch { + case conflict.Our != nil: + conflictingFiles = append(conflictingFiles, conflict.Our.Path) + case conflict.Ancestor != nil: + conflictingFiles = append(conflictingFiles, conflict.Ancestor.Path) + case conflict.Their != nil: + conflictingFiles = append(conflictingFiles, conflict.Their.Path) + default: + return nil, errors.New("invalid conflict") + } + } + + return conflictingFiles, nil +} + +func isConflictMergeable(conflict git.IndexConflict) bool { + conflictIndexEntriesCount := 0 + + if conflict.Their != nil { + conflictIndexEntriesCount++ + } + + if conflict.Our != nil { + conflictIndexEntriesCount++ + } + + if conflict.Ancestor != nil { + conflictIndexEntriesCount++ + } + + return conflictIndexEntriesCount >= 2 +} + +func getConflicts(index *git.Index) ([]git.IndexConflict, error) { + var conflicts []git.IndexConflict + + iterator, err := index.ConflictIterator() + if err != nil { + return nil, err + } + defer iterator.Free() + + for { + conflict, err := iterator.Next() + if err != nil { + if git.IsErrorCode(err, git.ErrorCodeIterOver) { + break + } + return nil, err + } + + conflicts = append(conflicts, conflict) + } + + return conflicts, nil +} diff --git a/cmd/gitaly-git2go/merge_test.go b/cmd/gitaly-git2go/merge_test.go new file mode 100644 index 000000000..aedc6e9bf --- /dev/null +++ b/cmd/gitaly-git2go/merge_test.go @@ -0,0 +1,536 @@ +//go:build static && system_libgit2 && !gitaly_test_sha256 + +package main + +import ( + "fmt" + "testing" + "time" + + libgit2 "github.com/libgit2/git2go/v33" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go/git2goutil" + "gitlab.com/gitlab-org/gitaly/v15/internal/git" + "gitlab.com/gitlab-org/gitaly/v15/internal/git/gittest" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" + "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper" + "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper/testcfg" +) + +func TestMerge_missingArguments(t *testing.T) { + t.Parallel() + ctx := testhelper.Context(t) + + cfg, repo, repoPath := testcfg.BuildWithRepo(t) + executor := buildExecutor(t, cfg) + + testcases := []struct { + desc string + request git2go.MergeCommand + expectedErr string + }{ + { + desc: "no arguments", + expectedErr: "merge: invalid parameters: missing repository", + }, + { + desc: "missing repository", + request: git2go.MergeCommand{AuthorName: "Foo", AuthorMail: "foo@example.com", Message: "Foo", Ours: "HEAD", Theirs: "HEAD"}, + expectedErr: "merge: invalid parameters: missing repository", + }, + { + desc: "missing author name", + request: git2go.MergeCommand{Repository: repoPath, AuthorMail: "foo@example.com", Message: "Foo", Ours: "HEAD", Theirs: "HEAD"}, + expectedErr: "merge: invalid parameters: missing author name", + }, + { + desc: "missing author mail", + request: git2go.MergeCommand{Repository: repoPath, AuthorName: "Foo", Message: "Foo", Ours: "HEAD", Theirs: "HEAD"}, + expectedErr: "merge: invalid parameters: missing author mail", + }, + { + desc: "missing message", + request: git2go.MergeCommand{Repository: repoPath, AuthorName: "Foo", AuthorMail: "foo@example.com", Ours: "HEAD", Theirs: "HEAD"}, + expectedErr: "merge: invalid parameters: missing message", + }, + { + desc: "missing ours", + request: git2go.MergeCommand{Repository: repoPath, AuthorName: "Foo", AuthorMail: "foo@example.com", Message: "Foo", Theirs: "HEAD"}, + expectedErr: "merge: invalid parameters: missing ours", + }, + { + desc: "missing theirs", + request: git2go.MergeCommand{Repository: repoPath, AuthorName: "Foo", AuthorMail: "foo@example.com", Message: "Foo", Ours: "HEAD"}, + expectedErr: "merge: invalid parameters: missing theirs", + }, + // Committer* arguments are required only when at least one of them is non-empty + { + desc: "missing committer mail", + request: git2go.MergeCommand{Repository: repoPath, AuthorName: "Foo", AuthorMail: "foo@example.com", CommitterName: "Bar", Message: "Foo", Theirs: "HEAD", Ours: "HEAD"}, + expectedErr: "merge: invalid parameters: missing committer mail", + }, + { + desc: "missing committer name", + request: git2go.MergeCommand{Repository: repoPath, AuthorName: "Foo", AuthorMail: "foo@example.com", CommitterMail: "bar@example.com", Message: "Foo", Theirs: "HEAD", Ours: "HEAD"}, + expectedErr: "merge: invalid parameters: missing committer name", + }, + { + desc: "missing committer date", + request: git2go.MergeCommand{Repository: repoPath, AuthorName: "Foo", AuthorMail: "foo@example.com", CommitterName: "Bar", CommitterMail: "bar@example.com", Message: "Foo", Theirs: "HEAD", Ours: "HEAD"}, + expectedErr: "merge: invalid parameters: missing committer date", + }, + } + + for _, tc := range testcases { + t.Run(tc.desc, func(t *testing.T) { + _, err := executor.Merge(ctx, repo, tc.request) + require.Error(t, err) + require.Equal(t, tc.expectedErr, err.Error()) + }) + } +} + +func TestMerge_invalidRepositoryPath(t *testing.T) { + t.Parallel() + ctx := testhelper.Context(t) + + cfg, repo, _ := testcfg.BuildWithRepo(t) + testcfg.BuildGitalyGit2Go(t, cfg) + executor := buildExecutor(t, cfg) + + _, err := executor.Merge(ctx, repo, git2go.MergeCommand{ + Repository: "/does/not/exist", AuthorName: "Foo", AuthorMail: "foo@example.com", Message: "Foo", Ours: "HEAD", Theirs: "HEAD", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "merge: could not open repository") +} + +func TestMerge_trees(t *testing.T) { + t.Parallel() + ctx := testhelper.Context(t) + + testcases := []struct { + desc string + base []gittest.TreeEntry + ours []gittest.TreeEntry + theirs []gittest.TreeEntry + expected map[string]string + withCommitter bool + squash bool + expectedResponse git2go.MergeResult + expectedErr error + }{ + { + desc: "trivial merge succeeds", + base: []gittest.TreeEntry{ + {Path: "file", Content: "a", Mode: "100644"}, + }, + ours: []gittest.TreeEntry{ + {Path: "file", Content: "a", Mode: "100644"}, + }, + theirs: []gittest.TreeEntry{ + {Path: "file", Content: "a", Mode: "100644"}, + }, + expected: map[string]string{ + "file": "a", + }, + expectedResponse: git2go.MergeResult{ + CommitID: "0db317551c49eddadde2b337550d8e57d9536886", + }, + }, + { + desc: "trivial merge with different committer succeeds", + base: []gittest.TreeEntry{ + {Path: "file", Content: "a", Mode: "100644"}, + }, + ours: []gittest.TreeEntry{ + {Path: "file", Content: "a", Mode: "100644"}, + }, + theirs: []gittest.TreeEntry{ + {Path: "file", Content: "a", Mode: "100644"}, + }, + expected: map[string]string{ + "file": "a", + }, + withCommitter: true, + expectedResponse: git2go.MergeResult{ + CommitID: "38dcbe72d91ed5621286290f70df9a5dd08f5cb6", + }, + }, + { + desc: "trivial squash succeeds", + base: []gittest.TreeEntry{ + {Path: "file", Content: "a", Mode: "100644"}, + }, + ours: []gittest.TreeEntry{ + {Path: "file", Content: "a", Mode: "100644"}, + }, + theirs: []gittest.TreeEntry{ + {Path: "file", Content: "a", Mode: "100644"}, + }, + expected: map[string]string{ + "file": "a", + }, + squash: true, + expectedResponse: git2go.MergeResult{ + CommitID: "a0781480ce3cbba80440e6c112c5ee7f718ed3c2", + }, + }, + { + desc: "non-trivial merge succeeds", + base: []gittest.TreeEntry{ + {Path: "file", Content: "a\nb\nc\nd\ne\nf\n", Mode: "100644"}, + }, + ours: []gittest.TreeEntry{ + {Path: "file", Content: "0\na\nb\nc\nd\ne\nf\n", Mode: "100644"}, + }, + theirs: []gittest.TreeEntry{ + {Path: "file", Content: "a\nb\nc\nd\ne\nf\n0\n", Mode: "100644"}, + }, + expected: map[string]string{ + "file": "0\na\nb\nc\nd\ne\nf\n0\n", + }, + expectedResponse: git2go.MergeResult{ + CommitID: "3c030d1ee80bbb005666619375fa0629c86b9534", + }, + }, + { + desc: "non-trivial squash succeeds", + base: []gittest.TreeEntry{ + {Path: "file", Content: "a\nb\nc\nd\ne\nf\n", Mode: "100644"}, + }, + ours: []gittest.TreeEntry{ + {Path: "file", Content: "0\na\nb\nc\nd\ne\nf\n", Mode: "100644"}, + }, + theirs: []gittest.TreeEntry{ + {Path: "file", Content: "a\nb\nc\nd\ne\nf\n0\n", Mode: "100644"}, + }, + expected: map[string]string{ + "file": "0\na\nb\nc\nd\ne\nf\n0\n", + }, + squash: true, + expectedResponse: git2go.MergeResult{ + CommitID: "43853c4a027a67c7e39afa8e7ef0a34a1874ef26", + }, + }, + { + desc: "multiple files succeed", + base: []gittest.TreeEntry{ + {Path: "1", Content: "foo", Mode: "100644"}, + {Path: "2", Content: "bar", Mode: "100644"}, + {Path: "3", Content: "qux", Mode: "100644"}, + }, + ours: []gittest.TreeEntry{ + {Path: "1", Content: "foo", Mode: "100644"}, + {Path: "2", Content: "modified", Mode: "100644"}, + {Path: "3", Content: "qux", Mode: "100644"}, + }, + theirs: []gittest.TreeEntry{ + {Path: "1", Content: "modified", Mode: "100644"}, + {Path: "2", Content: "bar", Mode: "100644"}, + {Path: "3", Content: "qux", Mode: "100644"}, + }, + expected: map[string]string{ + "1": "modified", + "2": "modified", + "3": "qux", + }, + expectedResponse: git2go.MergeResult{ + CommitID: "6be1fdb2c4116881c7a82575be41618e8a690ff4", + }, + }, + { + desc: "multiple files squash succeed", + base: []gittest.TreeEntry{ + {Path: "1", Content: "foo", Mode: "100644"}, + {Path: "2", Content: "bar", Mode: "100644"}, + {Path: "3", Content: "qux", Mode: "100644"}, + }, + ours: []gittest.TreeEntry{ + {Path: "1", Content: "foo", Mode: "100644"}, + {Path: "2", Content: "modified", Mode: "100644"}, + {Path: "3", Content: "qux", Mode: "100644"}, + }, + theirs: []gittest.TreeEntry{ + {Path: "1", Content: "modified", Mode: "100644"}, + {Path: "2", Content: "bar", Mode: "100644"}, + {Path: "3", Content: "qux", Mode: "100644"}, + }, + expected: map[string]string{ + "1": "modified", + "2": "modified", + "3": "qux", + }, + squash: true, + expectedResponse: git2go.MergeResult{ + CommitID: "fe094a98b22ac53e1da1a9eb16118ce49f01fdbe", + }, + }, + { + desc: "conflicting merge fails", + base: []gittest.TreeEntry{ + {Path: "1", Content: "foo", Mode: "100644"}, + }, + ours: []gittest.TreeEntry{ + {Path: "1", Content: "bar", Mode: "100644"}, + }, + theirs: []gittest.TreeEntry{ + {Path: "1", Content: "qux", Mode: "100644"}, + }, + expectedErr: fmt.Errorf("merge: %w", git2go.ConflictingFilesError{ + ConflictingFiles: []string{"1"}, + }), + }, + } + + for _, tc := range testcases { + cfg, repoProto, repoPath := testcfg.BuildWithRepo(t) + testcfg.BuildGitalyGit2Go(t, cfg) + executor := buildExecutor(t, cfg) + + base := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries(tc.base...)) + ours := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(base), gittest.WithTreeEntries(tc.ours...)) + theirs := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(base), gittest.WithTreeEntries(tc.theirs...)) + + authorDate := time.Date(2020, 7, 30, 7, 45, 50, 0, time.FixedZone("UTC+2", +2*60*60)) + committerDate := time.Date(2021, 7, 30, 7, 45, 50, 0, time.FixedZone("UTC+2", +2*60*60)) + + t.Run(tc.desc, func(t *testing.T) { + mergeCommand := git2go.MergeCommand{ + Repository: repoPath, + AuthorName: "John Doe", + AuthorMail: "john.doe@example.com", + AuthorDate: authorDate, + Message: "Merge message", + Ours: ours.String(), + Theirs: theirs.String(), + Squash: tc.squash, + } + if tc.withCommitter { + mergeCommand.CommitterName = "Jane Doe" + mergeCommand.CommitterMail = "jane.doe@example.com" + mergeCommand.CommitterDate = committerDate + } + response, err := executor.Merge(ctx, repoProto, mergeCommand) + + if tc.expectedErr != nil { + require.Equal(t, tc.expectedErr, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expectedResponse, response) + + repo, err := git2goutil.OpenRepository(repoPath) + require.NoError(t, err) + defer repo.Free() + + commitOid, err := libgit2.NewOid(response.CommitID) + require.NoError(t, err) + + commit, err := repo.LookupCommit(commitOid) + require.NoError(t, err) + + tree, err := commit.Tree() + require.NoError(t, err) + require.EqualValues(t, len(tc.expected), tree.EntryCount()) + + for name, contents := range tc.expected { + entry := tree.EntryByName(name) + require.NotNil(t, entry) + + blob, err := repo.LookupBlob(entry.Id) + require.NoError(t, err) + require.Equal(t, []byte(contents), blob.Contents()) + } + }) + } +} + +func TestMerge_squash(t *testing.T) { + t.Parallel() + + ctx := testhelper.Context(t) + + cfg, repoProto, repoPath := testcfg.BuildWithRepo(t) + testcfg.BuildGitalyGit2Go(t, cfg) + executor := buildExecutor(t, cfg) + + baseFile := gittest.TreeEntry{Path: "file.txt", Content: "b\nc", Mode: "100644"} + ourFile := gittest.TreeEntry{Path: "file.txt", Content: "a\nb\nc", Mode: "100644"} + theirFile1 := gittest.TreeEntry{Path: "file.txt", Content: "b\nc\nd", Mode: "100644"} + theirFile2 := gittest.TreeEntry{Path: "file.txt", Content: "b\nc\nd\ne", Mode: "100644"} + + base := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries(baseFile)) + ours := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(base), gittest.WithTreeEntries(ourFile)) + theirs1 := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(base), gittest.WithTreeEntries(theirFile1)) + theirs2 := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(theirs1), gittest.WithTreeEntries(theirFile2)) + + date := time.Date(2020, 7, 30, 7, 45, 50, 0, time.FixedZone("UTC+2", +2*60*60)) + response, err := executor.Merge(ctx, repoProto, git2go.MergeCommand{ + Repository: repoPath, + AuthorName: "John Doe", + AuthorMail: "john.doe@example.com", + AuthorDate: date, + Message: "Merge message", + Ours: ours.String(), + Theirs: theirs2.String(), + Squash: true, + }) + require.NoError(t, err) + assert.Equal(t, "882b43b68d160876e3833dc6bbabf7032058e837", response.CommitID) + + repo, err := git2goutil.OpenRepository(repoPath) + require.NoError(t, err) + + commitOid, err := libgit2.NewOid(response.CommitID) + require.NoError(t, err) + + theirs2Oid, err := libgit2.NewOid(theirs2.String()) + require.NoError(t, err) + isDescendant, err := repo.DescendantOf(commitOid, theirs2Oid) + require.NoError(t, err) + require.False(t, isDescendant) + + commit, err := repo.LookupCommit(commitOid) + require.NoError(t, err) + + require.Equal(t, uint(1), commit.ParentCount()) + require.Equal(t, ours.String(), commit.ParentId(0).String()) + + tree, err := commit.Tree() + require.NoError(t, err) + + entry := tree.EntryByName("file.txt") + require.NotNil(t, entry) + + blob, err := repo.LookupBlob(entry.Id) + require.NoError(t, err) + require.Equal(t, "a\nb\nc\nd\ne", string(blob.Contents())) +} + +func TestMerge_recursive(t *testing.T) { + t.Parallel() + ctx := testhelper.Context(t) + + cfg := testcfg.Build(t) + testcfg.BuildGitalyGit2Go(t, cfg) + executor := buildExecutor(t, cfg) + + repoProto, repoPath := gittest.InitRepo(t, cfg, cfg.Storages[0]) + + base := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries( + gittest.TreeEntry{Path: "base", Content: "base\n", Mode: "100644"}, + )) + + ours := make([]git.ObjectID, git2go.MergeRecursionLimit) + ours[0] = gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(base), gittest.WithTreeEntries( + gittest.TreeEntry{Path: "base", Content: "base\n", Mode: "100644"}, + gittest.TreeEntry{Path: "ours", Content: "ours-0\n", Mode: "100644"}, + )) + + theirs := make([]git.ObjectID, git2go.MergeRecursionLimit) + theirs[0] = gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(base), gittest.WithTreeEntries( + gittest.TreeEntry{Path: "base", Content: "base\n", Mode: "100644"}, + gittest.TreeEntry{Path: "theirs", Content: "theirs-0\n", Mode: "100644"}, + )) + + // We're now creating a set of criss-cross merges which look like the following graph: + // + // o---o---o---o---o- -o---o ours + // / \ / \ / \ / \ / \ . / \ / + // base o X X X X . X + // \ / \ / \ / \ / \ / . \ / \ + // o---o---o---o---o- -o---o theirs + // + // We then merge ours with theirs. The peculiarity about this merge is that the merge base + // is not unique, and as a result the merge will generate virtual merge bases for each of + // the criss-cross merges. This operation may thus be heavily expensive to perform. + for i := 1; i < git2go.MergeRecursionLimit; i++ { + ours[i] = gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(ours[i-1], theirs[i-1]), gittest.WithTreeEntries( + gittest.TreeEntry{Path: "base", Content: "base\n", Mode: "100644"}, + gittest.TreeEntry{Path: "ours", Content: fmt.Sprintf("ours-%d\n", i), Mode: "100644"}, + gittest.TreeEntry{Path: "theirs", Content: fmt.Sprintf("theirs-%d\n", i-1), Mode: "100644"}, + )) + + theirs[i] = gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(theirs[i-1], ours[i-1]), gittest.WithTreeEntries( + gittest.TreeEntry{Path: "base", Content: "base\n", Mode: "100644"}, + gittest.TreeEntry{Path: "ours", Content: fmt.Sprintf("ours-%d\n", i-1), Mode: "100644"}, + gittest.TreeEntry{Path: "theirs", Content: fmt.Sprintf("theirs-%d\n", i), Mode: "100644"}, + )) + } + + authorDate := time.Date(2020, 7, 30, 7, 45, 50, 0, time.FixedZone("UTC+2", +2*60*60)) + + // When creating the criss-cross merges, we have been doing evil merges + // as each merge has applied changes from the other side while at the + // same time incrementing the own file contents. As we exceed the merge + // limit, git will just pick one of both possible merge bases when + // hitting that limit instead of computing another virtual merge base. + // The result is thus a merge of the following three commits: + // + // merge base ours theirs + // ---------- ---- ------ + // + // base: "base" base: "base" base: "base" + // theirs: "theirs-1" theirs: "theirs-1 theirs: "theirs-2" + // ours: "ours-0" ours: "ours-2" ours: "ours-1" + // + // This is a classical merge commit as "ours" differs in all three + // cases. We thus expect a merge conflict, which unfortunately + // demonstrates that restricting the recursion limit may cause us to + // fail resolution. + _, err := executor.Merge(ctx, repoProto, git2go.MergeCommand{ + Repository: repoPath, + AuthorName: "John Doe", + AuthorMail: "john.doe@example.com", + AuthorDate: authorDate, + Message: "Merge message", + Ours: ours[len(ours)-1].String(), + Theirs: theirs[len(theirs)-1].String(), + }) + require.Equal(t, fmt.Errorf("merge: %w", git2go.ConflictingFilesError{ + ConflictingFiles: []string{"theirs"}, + }), err) + + // Otherwise, if we're merging an earlier criss-cross merge which has + // half of the limit many criss-cross patterns, we exactly hit the + // recursion limit and thus succeed. + response, err := executor.Merge(ctx, repoProto, git2go.MergeCommand{ + Repository: repoPath, + AuthorName: "John Doe", + AuthorMail: "john.doe@example.com", + AuthorDate: authorDate, + Message: "Merge message", + Ours: ours[git2go.MergeRecursionLimit/2].String(), + Theirs: theirs[git2go.MergeRecursionLimit/2].String(), + }) + require.NoError(t, err) + + repo, err := git2goutil.OpenRepository(repoPath) + require.NoError(t, err) + + commitOid, err := libgit2.NewOid(response.CommitID) + require.NoError(t, err) + + commit, err := repo.LookupCommit(commitOid) + require.NoError(t, err) + + tree, err := commit.Tree() + require.NoError(t, err) + + require.EqualValues(t, 3, tree.EntryCount()) + for name, contents := range map[string]string{ + "base": "base\n", + "ours": "ours-10\n", + "theirs": "theirs-10\n", + } { + entry := tree.EntryByName(name) + require.NotNil(t, entry) + + blob, err := repo.LookupBlob(entry.Id) + require.NoError(t, err) + require.Equal(t, []byte(contents), blob.Contents()) + } +} diff --git a/cmd/gitaly-git2go/rebase.go b/cmd/gitaly-git2go/rebase.go new file mode 100644 index 000000000..b0638c72e --- /dev/null +++ b/cmd/gitaly-git2go/rebase.go @@ -0,0 +1,191 @@ +//go:build static && system_libgit2 + +package main + +import ( + "context" + "encoding/gob" + "errors" + "flag" + "fmt" + + git "github.com/libgit2/git2go/v33" + "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go/git2goutil" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" +) + +type rebaseSubcommand struct{} + +func (cmd *rebaseSubcommand) Flags() *flag.FlagSet { + return flag.NewFlagSet("rebase", flag.ExitOnError) +} + +func (cmd *rebaseSubcommand) Run(ctx context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error { + var request git2go.RebaseCommand + if err := decoder.Decode(&request); err != nil { + return err + } + + commitID, err := cmd.rebase(ctx, &request) + return encoder.Encode(git2go.Result{ + CommitID: commitID, + Err: git2go.SerializableError(err), + }) +} + +func (cmd *rebaseSubcommand) verify(ctx context.Context, r *git2go.RebaseCommand) error { + if r.Repository == "" { + return errors.New("missing repository") + } + if r.Committer.Name == "" { + return errors.New("missing committer name") + } + if r.Committer.Email == "" { + return errors.New("missing committer email") + } + if r.BranchName == "" && r.CommitID == "" { + return errors.New("missing branch name") + } + if r.BranchName != "" && r.CommitID != "" { + return errors.New("both branch name and commit ID") + } + if r.UpstreamRevision == "" && r.UpstreamCommitID == "" { + return errors.New("missing upstream revision") + } + if r.UpstreamRevision != "" && r.UpstreamCommitID != "" { + return errors.New("both upstream revision and upstream commit ID") + } + return nil +} + +func (cmd *rebaseSubcommand) rebase(ctx context.Context, request *git2go.RebaseCommand) (string, error) { + if err := cmd.verify(ctx, request); err != nil { + return "", err + } + + repo, err := git2goutil.OpenRepository(request.Repository) + if err != nil { + return "", fmt.Errorf("open repository: %w", err) + } + + opts, err := git.DefaultRebaseOptions() + if err != nil { + return "", fmt.Errorf("get rebase options: %w", err) + } + opts.InMemory = 1 + + var commit *git.AnnotatedCommit + if request.BranchName != "" { + commit, err = repo.AnnotatedCommitFromRevspec(fmt.Sprintf("refs/heads/%s", request.BranchName)) + if err != nil { + return "", fmt.Errorf("look up branch %q: %w", request.BranchName, err) + } + } else { + commitOid, err := git.NewOid(request.CommitID.String()) + if err != nil { + return "", fmt.Errorf("parse commit %q: %w", request.CommitID, err) + } + + commit, err = repo.LookupAnnotatedCommit(commitOid) + if err != nil { + return "", fmt.Errorf("look up commit %q: %w", request.CommitID, err) + } + } + + upstreamCommitParam := request.UpstreamRevision + if upstreamCommitParam == "" { + upstreamCommitParam = request.UpstreamCommitID.String() + } + + upstreamCommitOID, err := git.NewOid(upstreamCommitParam) + if err != nil { + return "", fmt.Errorf("parse upstream revision %q: %w", upstreamCommitParam, err) + } + + upstreamCommit, err := repo.LookupAnnotatedCommit(upstreamCommitOID) + if err != nil { + return "", fmt.Errorf("look up upstream revision %q: %w", upstreamCommitParam, err) + } + + mergeBase, err := repo.MergeBase(upstreamCommit.Id(), commit.Id()) + if err != nil { + return "", fmt.Errorf("find merge base: %w", err) + } + + if mergeBase.Equal(upstreamCommit.Id()) { + // Branch is zero commits behind, so do not rebase + return commit.Id().String(), nil + } + + if mergeBase.Equal(commit.Id()) { + // Branch is merged, so fast-forward to upstream + return upstreamCommit.Id().String(), nil + } + + mergeCommit, err := repo.LookupAnnotatedCommit(mergeBase) + if err != nil { + return "", fmt.Errorf("look up merge base: %w", err) + } + + rebase, err := repo.InitRebase(commit, mergeCommit, upstreamCommit, &opts) + if err != nil { + return "", fmt.Errorf("initiate rebase: %w", err) + } + + committer := git.Signature(request.Committer) + var oid *git.Oid + for { + op, err := rebase.Next() + if git.IsErrorCode(err, git.ErrorCodeIterOver) { + break + } else if err != nil { + return "", fmt.Errorf("rebase iterate: %w", err) + } + + commit, err := repo.LookupCommit(op.Id) + if err != nil { + return "", fmt.Errorf("lookup commit: %w", err) + } + + if err := rebase.Commit(op.Id, nil, &committer, commit.Message()); err != nil { + if git.IsErrorCode(err, git.ErrorCodeUnmerged) { + index, err := rebase.InmemoryIndex() + if err != nil { + return "", fmt.Errorf("getting conflicting index: %w", err) + } + + conflictingFiles, err := getConflictingFiles(index) + if err != nil { + return "", fmt.Errorf("getting conflicting files: %w", err) + } + + return "", fmt.Errorf("commit %q: %w", op.Id.String(), git2go.ConflictingFilesError{ + ConflictingFiles: conflictingFiles, + }) + } + + // If the commit has already been applied on the target branch then we can + // skip it if we were told to. + if request.SkipEmptyCommits && git.IsErrorCode(err, git.ErrorCodeApplied) { + continue + } + + return "", fmt.Errorf("commit %q: %w", op.Id.String(), err) + } + + oid = op.Id.Copy() + } + + // When the OID is unset here, then we didn't have to rebase any commits at all. We can + // thus return the upstream commit directly: rebasing nothing onto the upstream commit is + // the same as the upstream commit itself. + if oid == nil { + return upstreamCommit.Id().String(), nil + } + + if err = rebase.Finish(); err != nil { + return "", fmt.Errorf("finish rebase: %w", err) + } + + return oid.String(), nil +} diff --git a/cmd/gitaly-git2go/rebase_test.go b/cmd/gitaly-git2go/rebase_test.go new file mode 100644 index 000000000..54ca7b137 --- /dev/null +++ b/cmd/gitaly-git2go/rebase_test.go @@ -0,0 +1,297 @@ +//go:build static && system_libgit2 && !gitaly_test_sha256 + +package main + +import ( + "fmt" + "testing" + "time" + + git "github.com/libgit2/git2go/v33" + "github.com/stretchr/testify/require" + "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go/git2goutil" + gitalygit "gitlab.com/gitlab-org/gitaly/v15/internal/git" + "gitlab.com/gitlab-org/gitaly/v15/internal/git/gittest" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" + "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper" + "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper/testcfg" +) + +var masterRevision = "1e292f8fedd741b75372e19097c76d327140c312" + +func TestRebase_validation(t *testing.T) { + cfg, repo, repoPath := testcfg.BuildWithRepo(t) + testcfg.BuildGitalyGit2Go(t, cfg) + committer := git2go.NewSignature("Foo", "foo@example.com", time.Now()) + executor := buildExecutor(t, cfg) + + testcases := []struct { + desc string + request git2go.RebaseCommand + expectedErr string + }{ + { + desc: "no arguments", + expectedErr: "rebase: missing repository", + }, + { + desc: "missing repository", + request: git2go.RebaseCommand{Committer: committer, BranchName: "feature", UpstreamRevision: masterRevision}, + expectedErr: "rebase: missing repository", + }, + { + desc: "missing committer name", + request: git2go.RebaseCommand{Repository: repoPath, Committer: git2go.Signature{Email: "foo@example.com"}, BranchName: "feature", UpstreamRevision: masterRevision}, + expectedErr: "rebase: missing committer name", + }, + { + desc: "missing committer email", + request: git2go.RebaseCommand{Repository: repoPath, Committer: git2go.Signature{Name: "Foo"}, BranchName: "feature", UpstreamRevision: masterRevision}, + expectedErr: "rebase: missing committer email", + }, + { + desc: "missing branch name", + request: git2go.RebaseCommand{Repository: repoPath, Committer: committer, UpstreamRevision: masterRevision}, + expectedErr: "rebase: missing branch name", + }, + { + desc: "missing upstream branch", + request: git2go.RebaseCommand{Repository: repoPath, Committer: committer, BranchName: "feature"}, + expectedErr: "rebase: missing upstream revision", + }, + { + desc: "both branch name and commit ID", + request: git2go.RebaseCommand{Repository: repoPath, Committer: committer, BranchName: "feature", CommitID: "a"}, + expectedErr: "rebase: both branch name and commit ID", + }, + { + desc: "both upstream revision and upstream commit ID", + request: git2go.RebaseCommand{Repository: repoPath, Committer: committer, BranchName: "feature", UpstreamRevision: "a", UpstreamCommitID: "a"}, + expectedErr: "rebase: both upstream revision and upstream commit ID", + }, + } + for _, tc := range testcases { + t.Run(tc.desc, func(t *testing.T) { + ctx := testhelper.Context(t) + + _, err := executor.Rebase(ctx, repo, tc.request) + require.EqualError(t, err, tc.expectedErr) + }) + } +} + +func TestRebase_rebase(t *testing.T) { + testcases := []struct { + desc string + branch string + commitsAhead int + setupRepo func(testing.TB, *git.Repository) + expected string + expectedErr string + }{ + { + desc: "Single commit rebase", + branch: "gitaly-rename-test", + commitsAhead: 1, + expected: "a08ed4bc45f9e686db93c5d0519f63d7b537270c", + }, + { + desc: "Multiple commits", + branch: "csv", + commitsAhead: 5, + expected: "2f8365edc69d3683e22c4209ae9641642d84dd4a", + }, + { + desc: "Branch zero commits behind", + branch: "sha-starting-with-large-number", + commitsAhead: 1, + expected: "842616594688d2351480dfebd67b3d8d15571e6d", + }, + { + desc: "Merged branch", + branch: "branch-merged", + expected: masterRevision, + }, + { + desc: "Partially merged branch", + branch: "branch-merged-plus-one", + setupRepo: func(t testing.TB, repo *git.Repository) { + head, err := lookupCommit(repo, "branch-merged") + require.NoError(t, err) + + other, err := lookupCommit(repo, "gitaly-rename-test") + require.NoError(t, err) + tree, err := other.Tree() + require.NoError(t, err) + newOid, err := repo.CreateCommitFromIds("refs/heads/branch-merged-plus-one", &DefaultAuthor, &DefaultAuthor, "Message", tree.Object.Id(), head.Object.Id()) + require.NoError(t, err) + require.Equal(t, "8665d9b4b56f6b8ab8c4128a5549d1820bf68bf5", newOid.String()) + }, + commitsAhead: 1, + expected: "56bafb70922008232d171b78930be6cdb722bb39", + }, + { + desc: "With upstream merged into", + branch: "csv-plus-merge", + setupRepo: func(t testing.TB, repo *git.Repository) { + ours, err := lookupCommit(repo, "csv") + require.NoError(t, err) + theirs, err := lookupCommit(repo, "b83d6e391c22777fca1ed3012fce84f633d7fed0") + require.NoError(t, err) + + index, err := repo.MergeCommits(ours, theirs, nil) + require.NoError(t, err) + tree, err := index.WriteTreeTo(repo) + require.NoError(t, err) + + newOid, err := repo.CreateCommitFromIds("refs/heads/csv-plus-merge", &DefaultAuthor, &DefaultAuthor, "Message", tree, ours.Object.Id(), theirs.Object.Id()) + require.NoError(t, err) + require.Equal(t, "5b2d6bd7be0b1b9f7e46b64d02fe9882c133a128", newOid.String()) + }, + commitsAhead: 5, // Same as "Multiple commits" + expected: "2f8365edc69d3683e22c4209ae9641642d84dd4a", + }, + { + desc: "Rebase with conflict", + branch: "rebase-encoding-failure-trigger", + expectedErr: "rebase: commit \"eb8f5fb9523b868cef583e09d4bf70b99d2dd404\": there are conflicting files", + }, + { + desc: "Orphaned branch", + branch: "orphaned-branch", + expectedErr: "rebase: find merge base: no merge base found", + }, + } + + for _, tc := range testcases { + t.Run(tc.desc, func(t *testing.T) { + ctx := testhelper.Context(t) + + committer := git2go.NewSignature(string(gittest.TestUser.Name), + string(gittest.TestUser.Email), + time.Date(2021, 3, 1, 13, 45, 50, 0, time.FixedZone("", +2*60*60))) + + cfg, repoProto, repoPath := testcfg.BuildWithRepo(t) + testcfg.BuildGitalyGit2Go(t, cfg) + executor := buildExecutor(t, cfg) + + repo, err := git2goutil.OpenRepository(repoPath) + require.NoError(t, err) + + if tc.setupRepo != nil { + tc.setupRepo(t, repo) + } + + branchCommit, err := lookupCommit(repo, tc.branch) + require.NoError(t, err) + + for desc, request := range map[string]git2go.RebaseCommand{ + "with branch and upstream": { + Repository: repoPath, + Committer: committer, + BranchName: tc.branch, + UpstreamRevision: masterRevision, + }, + "with branch and upstream commit ID": { + Repository: repoPath, + Committer: committer, + BranchName: tc.branch, + UpstreamCommitID: gitalygit.ObjectID(masterRevision), + }, + "with commit ID and upstream": { + Repository: repoPath, + Committer: committer, + BranchName: tc.branch, + UpstreamRevision: masterRevision, + }, + "with commit ID and upstream commit ID": { + Repository: repoPath, + Committer: committer, + CommitID: gitalygit.ObjectID(branchCommit.Id().String()), + UpstreamCommitID: gitalygit.ObjectID(masterRevision), + }, + } { + t.Run(desc, func(t *testing.T) { + response, err := executor.Rebase(ctx, repoProto, request) + if tc.expectedErr != "" { + require.EqualError(t, err, tc.expectedErr) + } else { + require.NoError(t, err) + + result := response.String() + require.Equal(t, tc.expected, result) + + commit, err := lookupCommit(repo, result) + require.NoError(t, err) + + for i := tc.commitsAhead; i > 0; i-- { + commit = commit.Parent(0) + } + masterCommit, err := lookupCommit(repo, masterRevision) + require.NoError(t, err) + require.Equal(t, masterCommit, commit) + } + }) + } + }) + } +} + +func TestRebase_skipEmptyCommit(t *testing.T) { + ctx := testhelper.Context(t) + + cfg, repoProto, repoPath := testcfg.BuildWithRepo(t) + testcfg.BuildGitalyGit2Go(t, cfg) + + // Set up history with two diverging lines of branches, where both sides have implemented + // the same changes. During rebase, the diff will thus become empty. + base := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithTreeEntries(gittest.TreeEntry{ + Path: "a", Content: "base", Mode: "100644", + }), + ) + theirs := gittest.WriteCommit(t, cfg, repoPath, gittest.WithMessage("theirs"), + gittest.WithParents(base), gittest.WithTreeEntries(gittest.TreeEntry{ + Path: "a", Content: "changed", Mode: "100644", + }), + ) + ours := gittest.WriteCommit(t, cfg, repoPath, gittest.WithMessage("ours"), + gittest.WithParents(base), gittest.WithTreeEntries(gittest.TreeEntry{ + Path: "a", Content: "changed", Mode: "100644", + }), + ) + + for _, tc := range []struct { + desc string + skipEmptyCommits bool + expectedErr string + expectedResponse gitalygit.ObjectID + }{ + { + desc: "do not skip empty commit", + skipEmptyCommits: false, + expectedErr: fmt.Sprintf("rebase: commit %q: this patch has already been applied", ours), + }, + { + desc: "skip empty commit", + skipEmptyCommits: true, + expectedResponse: theirs, + }, + } { + t.Run(tc.desc, func(t *testing.T) { + response, err := buildExecutor(t, cfg).Rebase(ctx, repoProto, git2go.RebaseCommand{ + Repository: repoPath, + Committer: git2go.NewSignature("Foo", "foo@example.com", time.Now()), + CommitID: ours, + UpstreamCommitID: theirs, + SkipEmptyCommits: tc.skipEmptyCommits, + }) + if tc.expectedErr == "" { + require.NoError(t, err) + } else { + require.EqualError(t, err, tc.expectedErr) + } + require.Equal(t, tc.expectedResponse, response) + }) + } +} diff --git a/cmd/gitaly-git2go/resolve_conflicts.go b/cmd/gitaly-git2go/resolve_conflicts.go new file mode 100644 index 000000000..73062eb17 --- /dev/null +++ b/cmd/gitaly-git2go/resolve_conflicts.go @@ -0,0 +1,263 @@ +//go:build static && system_libgit2 + +package main + +import ( + "bytes" + "context" + "encoding/gob" + "errors" + "flag" + "fmt" + "strings" + "time" + + git "github.com/libgit2/git2go/v33" + "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go/git2goutil" + "gitlab.com/gitlab-org/gitaly/v15/internal/git/conflict" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" +) + +type resolveSubcommand struct{} + +func (cmd *resolveSubcommand) Flags() *flag.FlagSet { + return flag.NewFlagSet("resolve", flag.ExitOnError) +} + +func (cmd resolveSubcommand) Run(_ context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error { + var request git2go.ResolveCommand + if err := decoder.Decode(&request); err != nil { + return err + } + + if request.AuthorDate.IsZero() { + request.AuthorDate = time.Now() + } + + repo, err := git2goutil.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("ours commit lookup: %w", err) + } + + theirs, err := lookupCommit(repo, request.Theirs) + if err != nil { + return fmt.Errorf("theirs commit lookup: %w", err) + } + + index, err := repo.MergeCommits(ours, theirs, nil) + if err != nil { + return fmt.Errorf("could not merge commits: %w", err) + } + + ci, err := index.ConflictIterator() + if err != nil { + return err + } + + type paths struct { + theirs, ours string + } + conflicts := map[paths]git.IndexConflict{} + + for { + c, err := ci.Next() + if git.IsErrorCode(err, git.ErrorCodeIterOver) { + break + } + if err != nil { + return err + } + + if c.Our.Path == "" || c.Their.Path == "" { + return errors.New("conflict side missing") + } + + k := paths{ + theirs: c.Their.Path, + ours: c.Our.Path, + } + conflicts[k] = c + } + + odb, err := repo.Odb() + if err != nil { + return err + } + + for _, r := range request.Resolutions { + c, ok := conflicts[paths{ + theirs: r.OldPath, + ours: r.NewPath, + }] + if !ok { + // Note: this emulates the Ruby error that occurs when + // there are no conflicts for a resolution + return errors.New("NoMethodError: undefined method `resolve_lines' for nil:NilClass") + } + + switch { + case c.Our == nil: + return fmt.Errorf("missing our-part of merge file input for new path %q", r.NewPath) + case c.Their == nil: + return fmt.Errorf("missing their-part of merge file input for new path %q", r.NewPath) + } + + ancestor, our, their, err := readConflictEntries(odb, c) + if err != nil { + return fmt.Errorf("read conflict entries: %w", err) + } + + mfr, err := mergeFileResult(ancestor, our, their) + if err != nil { + return fmt.Errorf("merge file result for %q: %w", r.NewPath, err) + } + + if r.Content != "" && bytes.Equal([]byte(r.Content), mfr.Contents) { + return fmt.Errorf("Resolved content has no changes for file %s", r.NewPath) //nolint + } + + conflictFile, err := conflict.Parse( + bytes.NewReader(mfr.Contents), + ancestor, + our, + their, + ) + if err != nil { + return fmt.Errorf("parse conflict for %q: %w", c.Ancestor.Path, err) + } + + resolvedBlob, err := conflictFile.Resolve(r) + if err != nil { + return err // do not decorate this error to satisfy old test + } + + resolvedBlobOID, err := odb.Write(resolvedBlob, git.ObjectBlob) + if err != nil { + return fmt.Errorf("write object for %q: %w", c.Ancestor.Path, err) + } + + ourResolvedEntry := *c.Our // copy by value + ourResolvedEntry.Id = resolvedBlobOID + if err := index.Add(&ourResolvedEntry); err != nil { + return fmt.Errorf("add index for %q: %w", c.Ancestor.Path, err) + } + + if err := index.RemoveConflict(ourResolvedEntry.Path); err != nil { + return fmt.Errorf("remove conflict from index for %q: %w", c.Ancestor.Path, err) + } + } + + if index.HasConflicts() { + ci, err := index.ConflictIterator() + if err != nil { + return fmt.Errorf("iterating unresolved conflicts: %w", err) + } + + var conflictPaths []string + for { + c, err := ci.Next() + if git.IsErrorCode(err, git.ErrorCodeIterOver) { + break + } + if err != nil { + return fmt.Errorf("next unresolved conflict: %w", err) + } + var conflictingPath string + if c.Ancestor != nil { + conflictingPath = c.Ancestor.Path + } else { + conflictingPath = c.Our.Path + } + + conflictPaths = append(conflictPaths, conflictingPath) + } + + return fmt.Errorf("Missing resolutions for the following files: %s", strings.Join(conflictPaths, ", ")) //nolint + } + + tree, err := index.WriteTreeTo(repo) + if err != nil { + return fmt.Errorf("write tree to repo: %w", err) + } + + signature := git2go.NewSignature(request.AuthorName, request.AuthorMail, request.AuthorDate) + committer := &git.Signature{ + Name: signature.Name, + Email: signature.Email, + When: request.AuthorDate, + } + + commit, err := repo.CreateCommitFromIds("", committer, committer, request.Message, tree, ours.Id(), theirs.Id()) + if err != nil { + return fmt.Errorf("could not create resolve conflict commit: %w", err) + } + + response := git2go.ResolveResult{ + MergeResult: git2go.MergeResult{ + CommitID: commit.String(), + }, + } + + return encoder.Encode(response) +} + +func readConflictEntries(odb *git.Odb, c git.IndexConflict) (*conflict.Entry, *conflict.Entry, *conflict.Entry, error) { + var ancestor, our, their *conflict.Entry + + for _, part := range []struct { + entry *git.IndexEntry + result **conflict.Entry + }{ + {entry: c.Ancestor, result: &ancestor}, + {entry: c.Our, result: &our}, + {entry: c.Their, result: &their}, + } { + if part.entry == nil { + continue + } + + blob, err := odb.Read(part.entry.Id) + if err != nil { + return nil, nil, nil, err + } + + *part.result = &conflict.Entry{ + Path: part.entry.Path, + Mode: uint(part.entry.Mode), + Contents: blob.Data(), + } + } + + return ancestor, our, their, nil +} + +func mergeFileResult(ancestor, our, their *conflict.Entry) (*git.MergeFileResult, error) { + mfr, err := git.MergeFile( + conflictEntryToMergeFileInput(ancestor), + conflictEntryToMergeFileInput(our), + conflictEntryToMergeFileInput(their), + nil, + ) + if err != nil { + return nil, err + } + + return mfr, nil +} + +func conflictEntryToMergeFileInput(e *conflict.Entry) git.MergeFileInput { + if e == nil { + return git.MergeFileInput{} + } + + return git.MergeFileInput{ + Path: e.Path, + Mode: e.Mode, + Contents: e.Contents, + } +} diff --git a/cmd/gitaly-git2go/revert.go b/cmd/gitaly-git2go/revert.go new file mode 100644 index 000000000..aaeeb00ac --- /dev/null +++ b/cmd/gitaly-git2go/revert.go @@ -0,0 +1,104 @@ +//go:build static && system_libgit2 + +package main + +import ( + "context" + "encoding/gob" + "errors" + "flag" + "fmt" + + git "github.com/libgit2/git2go/v33" + "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go/git2goutil" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" +) + +type revertSubcommand struct{} + +func (cmd *revertSubcommand) Flags() *flag.FlagSet { + return flag.NewFlagSet("revert", flag.ExitOnError) +} + +func (cmd *revertSubcommand) Run(ctx context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error { + var request git2go.RevertCommand + if err := decoder.Decode(&request); err != nil { + return err + } + + commitID, err := cmd.revert(ctx, &request) + return encoder.Encode(git2go.Result{ + CommitID: commitID, + Err: git2go.SerializableError(err), + }) +} + +func (cmd *revertSubcommand) verify(ctx context.Context, r *git2go.RevertCommand) error { + if r.Repository == "" { + return errors.New("missing repository") + } + if r.AuthorName == "" { + return errors.New("missing author name") + } + if r.AuthorMail == "" { + return errors.New("missing author mail") + } + if r.Message == "" { + return errors.New("missing message") + } + if r.Ours == "" { + return errors.New("missing ours") + } + if r.Revert == "" { + return errors.New("missing revert") + } + return nil +} + +func (cmd *revertSubcommand) revert(ctx context.Context, request *git2go.RevertCommand) (string, error) { + if err := cmd.verify(ctx, request); err != nil { + return "", err + } + repo, err := git2goutil.OpenRepository(request.Repository) + if err != nil { + return "", fmt.Errorf("open repository: %w", err) + } + defer repo.Free() + + ours, err := lookupCommit(repo, request.Ours) + if err != nil { + return "", fmt.Errorf("ours commit lookup: %w", err) + } + + revert, err := lookupCommit(repo, request.Revert) + if err != nil { + return "", fmt.Errorf("revert commit lookup: %w", err) + } + + index, err := repo.RevertCommit(revert, ours, request.Mainline, nil) + if err != nil { + return "", fmt.Errorf("revert: %w", err) + } + defer index.Free() + + if index.HasConflicts() { + return "", git2go.HasConflictsError{} + } + + tree, err := index.WriteTreeTo(repo) + if err != nil { + return "", fmt.Errorf("write tree: %w", err) + } + + if tree.Equal(ours.TreeId()) { + return "", git2go.EmptyError{} + } + + committer := git.Signature(git2go.NewSignature(request.AuthorName, request.AuthorMail, request.AuthorDate)) + commit, err := repo.CreateCommitFromIds("", &committer, &committer, request.Message, tree, ours.Id()) + if err != nil { + return "", fmt.Errorf("create revert commit: %w", err) + } + + return commit.String(), nil +} diff --git a/cmd/gitaly-git2go/revert_test.go b/cmd/gitaly-git2go/revert_test.go new file mode 100644 index 000000000..cdd8bd6f9 --- /dev/null +++ b/cmd/gitaly-git2go/revert_test.go @@ -0,0 +1,231 @@ +//go:build static && system_libgit2 && !gitaly_test_sha256 + +package main + +import ( + "errors" + "testing" + "time" + + git "github.com/libgit2/git2go/v33" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go/git2goutil" + "gitlab.com/gitlab-org/gitaly/v15/internal/git/gittest" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" + "gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/config" + "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper" + "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper/testcfg" +) + +func TestRevert_validation(t *testing.T) { + cfg, repo, repoPath := testcfg.BuildWithRepo(t) + testcfg.BuildGitalyGit2Go(t, cfg) + executor := buildExecutor(t, cfg) + + testcases := []struct { + desc string + request git2go.RevertCommand + expectedErr string + }{ + { + desc: "no arguments", + expectedErr: "revert: missing repository", + }, + { + desc: "missing repository", + request: git2go.RevertCommand{AuthorName: "Foo", AuthorMail: "foo@example.com", Message: "Foo", Ours: "HEAD", Revert: "HEAD"}, + expectedErr: "revert: missing repository", + }, + { + desc: "missing author name", + request: git2go.RevertCommand{Repository: repoPath, AuthorMail: "foo@example.com", Message: "Foo", Ours: "HEAD", Revert: "HEAD"}, + expectedErr: "revert: missing author name", + }, + { + desc: "missing author mail", + request: git2go.RevertCommand{Repository: repoPath, AuthorName: "Foo", Message: "Foo", Ours: "HEAD", Revert: "HEAD"}, + expectedErr: "revert: missing author mail", + }, + { + desc: "missing message", + request: git2go.RevertCommand{Repository: repoPath, AuthorName: "Foo", AuthorMail: "foo@example.com", Ours: "HEAD", Revert: "HEAD"}, + expectedErr: "revert: missing message", + }, + { + desc: "missing ours", + request: git2go.RevertCommand{Repository: repoPath, AuthorName: "Foo", AuthorMail: "foo@example.com", Message: "Foo", Revert: "HEAD"}, + expectedErr: "revert: missing ours", + }, + { + desc: "missing revert", + request: git2go.RevertCommand{Repository: repoPath, AuthorName: "Foo", AuthorMail: "foo@example.com", Message: "Foo", Ours: "HEAD"}, + expectedErr: "revert: missing revert", + }, + } + for _, tc := range testcases { + t.Run(tc.desc, func(t *testing.T) { + ctx := testhelper.Context(t) + + _, err := executor.Revert(ctx, repo, tc.request) + require.Error(t, err) + require.EqualError(t, err, tc.expectedErr) + }) + } +} + +func TestRevert_trees(t *testing.T) { + testcases := []struct { + desc string + setupRepo func(t testing.TB, cfg config.Cfg, repoPath string) (ours, revert string) + expected map[string]string + expectedCommitID string + expectedErr string + expectedErrAs interface{} + }{ + { + desc: "trivial revert succeeds", + setupRepo: func(t testing.TB, cfg config.Cfg, repoPath string) (ours, revert string) { + baseOid := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries( + gittest.TreeEntry{Path: "a", Content: "apple", Mode: "100644"}, + gittest.TreeEntry{Path: "b", Content: "banana", Mode: "100644"}, + )) + revertOid := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(baseOid), gittest.WithTreeEntries( + gittest.TreeEntry{Path: "a", Content: "apple", Mode: "100644"}, + gittest.TreeEntry{Path: "b", Content: "pineapple", Mode: "100644"}, + )) + oursOid := gittest.WriteCommit(t, cfg, repoPath, + gittest.WithParents(revertOid), gittest.WithTreeEntries( + gittest.TreeEntry{Path: "a", Content: "apple", Mode: "100644"}, + gittest.TreeEntry{Path: "b", Content: "pineapple", Mode: "100644"}, + gittest.TreeEntry{Path: "c", Content: "carrot", Mode: "100644"}, + )) + + return oursOid.String(), revertOid.String() + }, + expected: map[string]string{ + "a": "apple", + "b": "banana", + "c": "carrot", + }, + expectedCommitID: "c9a58d2273b265cb229f02a5a88037bbdc96ad26", + }, + { + desc: "conflicting revert fails", + setupRepo: func(t testing.TB, cfg config.Cfg, repoPath string) (ours, revert string) { + baseOid := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries( + gittest.TreeEntry{Path: "a", Content: "apple", Mode: "100644"}, + )) + revertOid := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(baseOid), gittest.WithTreeEntries( + gittest.TreeEntry{Path: "a", Content: "pineapple", Mode: "100644"}, + )) + oursOid := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(revertOid), gittest.WithTreeEntries( + gittest.TreeEntry{Path: "a", Content: "carrot", Mode: "100644"}, + )) + + return oursOid.String(), revertOid.String() + }, + expectedErr: "revert: could not apply due to conflicts", + expectedErrAs: &git2go.HasConflictsError{}, + }, + { + desc: "empty revert fails", + setupRepo: func(t testing.TB, cfg config.Cfg, repoPath string) (ours, revert string) { + baseOid := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries( + gittest.TreeEntry{Path: "a", Content: "apple", Mode: "100644"}, + )) + revertOid := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(baseOid), gittest.WithTreeEntries( + gittest.TreeEntry{Path: "a", Content: "banana", Mode: "100644"}, + )) + oursOid := gittest.WriteCommit(t, cfg, repoPath, gittest.WithParents(revertOid), gittest.WithTreeEntries( + gittest.TreeEntry{Path: "a", Content: "apple", Mode: "100644"}, + )) + + return oursOid.String(), revertOid.String() + }, + expectedErr: "revert: could not apply because the result was empty", + expectedErrAs: &git2go.EmptyError{}, + }, + { + desc: "nonexistent ours fails", + setupRepo: func(t testing.TB, cfg config.Cfg, repoPath string) (ours, revert string) { + revertOid := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries( + gittest.TreeEntry{Path: "a", Content: "apple", Mode: "100644"}, + )) + + return "nonexistent", revertOid.String() + }, + expectedErr: "revert: ours commit lookup: lookup commit \"nonexistent\": revspec 'nonexistent' not found", + }, + { + desc: "nonexistent revert fails", + setupRepo: func(t testing.TB, cfg config.Cfg, repoPath string) (ours, revert string) { + oursOid := gittest.WriteCommit(t, cfg, repoPath, gittest.WithTreeEntries( + gittest.TreeEntry{Path: "a", Content: "apple", Mode: "100644"}, + )) + + return oursOid.String(), "nonexistent" + }, + expectedErr: "revert: revert commit lookup: lookup commit \"nonexistent\": revspec 'nonexistent' not found", + }, + } + for _, tc := range testcases { + t.Run(tc.desc, func(t *testing.T) { + cfg, repoProto, repoPath := testcfg.BuildWithRepo(t) + testcfg.BuildGitalyGit2Go(t, cfg) + executor := buildExecutor(t, cfg) + + ours, revert := tc.setupRepo(t, cfg, repoPath) + ctx := testhelper.Context(t) + + authorDate := time.Date(2020, 7, 30, 7, 45, 50, 0, time.FixedZone("UTC+2", +2*60*60)) + + request := git2go.RevertCommand{ + Repository: repoPath, + AuthorName: "Foo", + AuthorMail: "foo@example.com", + AuthorDate: authorDate, + Message: "Foo", + Ours: ours, + Revert: revert, + } + + response, err := executor.Revert(ctx, repoProto, request) + + if tc.expectedErr != "" { + require.EqualError(t, err, tc.expectedErr) + + if tc.expectedErrAs != nil { + require.True(t, errors.As(err, tc.expectedErrAs)) + } + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expectedCommitID, response.String()) + + repo, err := git2goutil.OpenRepository(repoPath) + require.NoError(t, err) + defer repo.Free() + + commitOid, err := git.NewOid(response.String()) + require.NoError(t, err) + + commit, err := repo.LookupCommit(commitOid) + require.NoError(t, err) + + tree, err := commit.Tree() + require.NoError(t, err) + require.EqualValues(t, len(tc.expected), tree.EntryCount()) + + for name, contents := range tc.expected { + entry := tree.EntryByName(name) + require.NotNil(t, entry) + + blob, err := repo.LookupBlob(entry.Id) + require.NoError(t, err) + require.Equal(t, []byte(contents), blob.Contents()) + } + }) + } +} diff --git a/cmd/gitaly-git2go/submodule.go b/cmd/gitaly-git2go/submodule.go new file mode 100644 index 000000000..0e52ee3f4 --- /dev/null +++ b/cmd/gitaly-git2go/submodule.go @@ -0,0 +1,142 @@ +//go:build static && system_libgit2 + +package main + +import ( + "context" + "encoding/gob" + "flag" + "fmt" + "time" + + git "github.com/libgit2/git2go/v33" + "gitlab.com/gitlab-org/gitaly/v15/cmd/gitaly-git2go/git2goutil" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" +) + +type submoduleSubcommand struct{} + +func (cmd *submoduleSubcommand) Flags() *flag.FlagSet { + return flag.NewFlagSet("submodule", flag.ExitOnError) +} + +func (cmd *submoduleSubcommand) Run(_ context.Context, decoder *gob.Decoder, encoder *gob.Encoder) error { + var request git2go.SubmoduleCommand + + if err := decoder.Decode(&request); err != nil { + return fmt.Errorf("deserializing submodule command request: %w", err) + } + + res, err := cmd.run(request) + if err != nil { + return err + } + + return encoder.Encode(res) +} + +func (cmd *submoduleSubcommand) run(request git2go.SubmoduleCommand) (*git2go.SubmoduleResult, error) { + if request.AuthorDate.IsZero() { + request.AuthorDate = time.Now() + } + + smCommitOID, err := git.NewOid(request.CommitSHA) + if err != nil { + return nil, fmt.Errorf("converting %s to OID: %w", request.CommitSHA, err) + } + + repo, err := git2goutil.OpenRepository(request.Repository) + if err != nil { + return nil, fmt.Errorf("open repository: %w", err) + } + + fullBranchRefName := "refs/heads/" + request.Branch + o, err := repo.RevparseSingle(fullBranchRefName) + if err != nil { + return nil, fmt.Errorf("%s: %w", git2go.LegacyErrPrefixInvalidBranch, err) + } + + startCommit, err := o.AsCommit() + if err != nil { + return nil, fmt.Errorf("peeling %s as a commit: %w", o.Id(), err) + } + + rootTree, err := startCommit.Tree() + if err != nil { + return nil, fmt.Errorf("root tree from starting commit: %w", err) + } + + index, err := git.NewIndex() + if err != nil { + return nil, fmt.Errorf("creating new index: %w", err) + } + + if err := index.ReadTree(rootTree); err != nil { + return nil, fmt.Errorf("reading root tree into index: %w", err) + } + + smEntry, err := index.EntryByPath(request.Submodule, 0) + if err != nil { + return nil, fmt.Errorf( + "%s: %w", + git2go.LegacyErrPrefixInvalidSubmodulePath, err, + ) + } + + if smEntry.Id.Cmp(smCommitOID) == 0 { + //nolint + return nil, fmt.Errorf( + "The submodule %s is already at %s", + request.Submodule, request.CommitSHA, + ) + } + + if smEntry.Mode != git.FilemodeCommit { + return nil, fmt.Errorf( + "%s: %w", + git2go.LegacyErrPrefixInvalidSubmodulePath, err, + ) + } + + newEntry := *smEntry // copy by value + newEntry.Id = smCommitOID // assign new commit SHA + if err := index.Add(&newEntry); err != nil { + return nil, fmt.Errorf("add new submodule entry to index: %w", err) + } + + newRootTreeOID, err := index.WriteTreeTo(repo) + if err != nil { + return nil, fmt.Errorf("write index to repo: %w", err) + } + + newTree, err := repo.LookupTree(newRootTreeOID) + if err != nil { + return nil, fmt.Errorf("looking up new submodule entry root tree: %w", err) + } + + committer := git.Signature( + git2go.NewSignature( + request.AuthorName, + request.AuthorMail, + request.AuthorDate, + ), + ) + newCommitOID, err := repo.CreateCommit( + "", // caller should update branch with hooks + &committer, + &committer, + request.Message, + newTree, + startCommit, + ) + if err != nil { + return nil, fmt.Errorf( + "%s: %w", + git2go.LegacyErrPrefixFailedCommit, err, + ) + } + + return &git2go.SubmoduleResult{ + CommitID: newCommitOID.String(), + }, nil +} diff --git a/cmd/gitaly-git2go/submodule_test.go b/cmd/gitaly-git2go/submodule_test.go new file mode 100644 index 000000000..7e6c2a5bd --- /dev/null +++ b/cmd/gitaly-git2go/submodule_test.go @@ -0,0 +1,129 @@ +//go:build static && system_libgit2 && !gitaly_test_sha256 + +package main + +import ( + "bytes" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "gitlab.com/gitlab-org/gitaly/v15/internal/git" + "gitlab.com/gitlab-org/gitaly/v15/internal/git/gittest" + "gitlab.com/gitlab-org/gitaly/v15/internal/git/localrepo" + "gitlab.com/gitlab-org/gitaly/v15/internal/git/lstree" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" + "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper" + "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper/testcfg" +) + +func TestSubmodule(t *testing.T) { + commitMessage := []byte("Update Submodule message") + + testCases := []struct { + desc string + command git2go.SubmoduleCommand + expectedStderr string + }{ + { + desc: "Update submodule", + command: git2go.SubmoduleCommand{ + AuthorName: string(gittest.TestUser.Name), + AuthorMail: string(gittest.TestUser.Email), + Message: string(commitMessage), + CommitSHA: "41fa1bc9e0f0630ced6a8a211d60c2af425ecc2d", + Submodule: "gitlab-grack", + Branch: "master", + }, + }, + { + desc: "Update submodule inside folder", + command: git2go.SubmoduleCommand{ + AuthorName: string(gittest.TestUser.Name), + AuthorMail: string(gittest.TestUser.Email), + Message: string(commitMessage), + CommitSHA: "e25eda1fece24ac7a03624ed1320f82396f35bd8", + Submodule: "test_inside_folder/another_folder/six", + Branch: "submodule_inside_folder", + }, + }, + { + desc: "Invalid branch", + command: git2go.SubmoduleCommand{ + AuthorName: string(gittest.TestUser.Name), + AuthorMail: string(gittest.TestUser.Email), + Message: string(commitMessage), + CommitSHA: "e25eda1fece24ac7a03624ed1320f82396f35bd8", + Submodule: "test_inside_folder/another_folder/six", + Branch: "non/existent", + }, + expectedStderr: "Invalid branch", + }, + { + desc: "Invalid submodule", + command: git2go.SubmoduleCommand{ + AuthorName: string(gittest.TestUser.Name), + AuthorMail: string(gittest.TestUser.Email), + Message: string(commitMessage), + CommitSHA: "e25eda1fece24ac7a03624ed1320f82396f35bd8", + Submodule: "non-existent-submodule", + Branch: "master", + }, + expectedStderr: "Invalid submodule path", + }, + { + desc: "Duplicate reference", + command: git2go.SubmoduleCommand{ + AuthorName: string(gittest.TestUser.Name), + AuthorMail: string(gittest.TestUser.Email), + Message: string(commitMessage), + CommitSHA: "409f37c4f05865e4fb208c771485f211a22c4c2d", + Submodule: "six", + Branch: "master", + }, + expectedStderr: "The submodule six is already at 409f37c4f", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + cfg, repoProto, repoPath := testcfg.BuildWithRepo(t) + testcfg.BuildGitalyGit2Go(t, cfg) + repo := localrepo.NewTestRepo(t, cfg, repoProto) + executor := buildExecutor(t, cfg) + + tc.command.Repository = repoPath + ctx := testhelper.Context(t) + + response, err := executor.Submodule(ctx, repo, tc.command) + if tc.expectedStderr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedStderr) + return + } + require.NoError(t, err) + + commit, err := repo.ReadCommit(ctx, git.Revision(response.CommitID)) + require.NoError(t, err) + require.Equal(t, commit.Author.Email, gittest.TestUser.Email) + require.Equal(t, commit.Committer.Email, gittest.TestUser.Email) + require.Equal(t, commit.Subject, commitMessage) + + entry := gittest.Exec( + t, + cfg, + "-C", + repoPath, + "ls-tree", + "-z", + fmt.Sprintf("%s^{tree}:", response.CommitID), + tc.command.Submodule, + ) + parser := lstree.NewParser(bytes.NewReader(entry)) + parsedEntry, err := parser.NextEntry() + require.NoError(t, err) + require.Equal(t, tc.command.Submodule, parsedEntry.Path) + require.Equal(t, tc.command.CommitSHA, parsedEntry.ObjectID.String()) + }) + } +} diff --git a/cmd/gitaly-git2go/testhelper_test.go b/cmd/gitaly-git2go/testhelper_test.go new file mode 100644 index 000000000..274687cb6 --- /dev/null +++ b/cmd/gitaly-git2go/testhelper_test.go @@ -0,0 +1,44 @@ +//go:build static && system_libgit2 && !gitaly_test_sha256 + +package main + +import ( + "fmt" + "testing" + + git "github.com/libgit2/git2go/v33" + "gitlab.com/gitlab-org/gitaly/v15/internal/git/gittest" + "gitlab.com/gitlab-org/gitaly/v15/internal/git2go" + "gitlab.com/gitlab-org/gitaly/v15/internal/gitaly/config" + "gitlab.com/gitlab-org/gitaly/v15/internal/testhelper" +) + +// DefaultAuthor is the author used by BuildCommit +var DefaultAuthor = git.Signature{ + Name: gittest.DefaultCommitterName, + Email: gittest.DefaultCommitterMail, + When: gittest.DefaultCommitTime, +} + +func TestMain(m *testing.M) { + testhelper.Run(m, testhelper.WithSetup(func() error { + // We use Git2go to access repositories in our tests, so we must tell it to ignore + // any configuration files that happen to exist. We do the same in `main()`, so + // this is not only specific to tests. + for _, configLevel := range []git.ConfigLevel{ + git.ConfigLevelSystem, + git.ConfigLevelXDG, + git.ConfigLevelGlobal, + } { + if err := git.SetSearchPath(configLevel, "/dev/null"); err != nil { + return fmt.Errorf("setting Git2go search path: %s", err) + } + } + + return nil + })) +} + +func buildExecutor(tb testing.TB, cfg config.Cfg) *git2go.Executor { + return git2go.NewExecutor(cfg, gittest.NewCommandFactory(tb, cfg), config.NewLocator(cfg)) +} diff --git a/cmd/gitaly-git2go/util.go b/cmd/gitaly-git2go/util.go new file mode 100644 index 000000000..7a94371c3 --- /dev/null +++ b/cmd/gitaly-git2go/util.go @@ -0,0 +1,28 @@ +//go:build static && system_libgit2 + +package main + +import ( + "fmt" + + git "github.com/libgit2/git2go/v33" +) + +func lookupCommit(repo *git.Repository, ref string) (*git.Commit, error) { + object, err := repo.RevparseSingle(ref) + if err != nil { + return nil, fmt.Errorf("lookup commit %q: %w", ref, err) + } + + peeled, err := object.Peel(git.ObjectCommit) + if err != nil { + return nil, fmt.Errorf("lookup commit %q: peel: %w", ref, err) + } + + commit, err := peeled.AsCommit() + if err != nil { + return nil, fmt.Errorf("lookup commit %q: as commit: %w", ref, err) + } + + return commit, nil +} diff --git a/internal/git2go/executor.go b/internal/git2go/executor.go index 9524f03b1..30c8ff0a3 100644 --- a/internal/git2go/executor.go +++ b/internal/git2go/executor.go @@ -24,8 +24,8 @@ var ( // ErrInvalidArgument is returned in case the merge arguments are invalid. ErrInvalidArgument = errors.New("invalid parameters") - // BinaryName is a binary name with version suffix . - BinaryName = "gitaly-git2go-v15" + // BinaryName is the name of the gitaly-git2go binary. + BinaryName = "gitaly-git2go" ) // Executor executes gitaly-git2go. diff --git a/internal/gitaly/config/config.go b/internal/gitaly/config/config.go index 0e458b0ba..bba1e7d40 100644 --- a/internal/gitaly/config/config.go +++ b/internal/gitaly/config/config.go @@ -290,7 +290,7 @@ func validateIsDirectory(path, name string) error { var packedBinaries = map[string]struct{}{ "gitaly-hooks": {}, "gitaly-ssh": {}, - "gitaly-git2go-v15": {}, + "gitaly-git2go": {}, "gitaly-lfs-smudge": {}, } diff --git a/internal/testhelper/testcfg/build.go b/internal/testhelper/testcfg/build.go index ffacd17cb..d1d9e7a8b 100644 --- a/internal/testhelper/testcfg/build.go +++ b/internal/testhelper/testcfg/build.go @@ -21,7 +21,7 @@ var buildOnceByName sync.Map // BuildGitalyGit2Go builds the gitaly-git2go command and installs it into the binary directory. func BuildGitalyGit2Go(t testing.TB, cfg config.Cfg) string { - return buildGitalyCommand(t, cfg, "gitaly-git2go-v15") + return buildGitalyCommand(t, cfg, "gitaly-git2go") } // BuildGitalyWrapper builds the gitaly-wrapper command and installs it into the binary directory. diff --git a/packed_binaries.go b/packed_binaries.go index 16c02f2e4..fe3e435e0 100644 --- a/packed_binaries.go +++ b/packed_binaries.go @@ -11,7 +11,7 @@ import ( // buildDir is the directory path where our build target places the built binaries. const buildDir = "_build/bin" -//go:embed _build/bin/gitaly-hooks _build/bin/gitaly-ssh _build/bin/gitaly-git2go-v15 _build/bin/gitaly-lfs-smudge +//go:embed _build/bin/gitaly-hooks _build/bin/gitaly-ssh _build/bin/gitaly-git2go _build/bin/gitaly-lfs-smudge // // packedBinariesFS contains embedded binaries. If you modify the above embeddings, you must also update // GITALY_PACKED_EXECUTABLES in Makefile and packedBinaries in internal/gitaly/config/config.go. -- cgit v1.2.3