Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitaly.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSami Hiltunen <shiltunen@gitlab.com>2020-10-15 13:40:26 +0300
committerSami Hiltunen <shiltunen@gitlab.com>2020-10-21 14:21:12 +0300
commit1dfda33b2ec81399307a0f062d14061c429a72ab (patch)
treeb2b7461d550d3370280a06edb4d06d1f06521154
parent57424d40acf18995bc2facce7b75f8e6e3f14e75 (diff)
add commit subcommand to gitaly-git2go
Adds 'commit' subcommand to gitaly-git2go to allow for building commits without worktrees. Commit is built from a set of actions and written in to the object database.
-rw-r--r--cmd/gitaly-git2go/commit.go19
-rw-r--r--cmd/gitaly-git2go/commit/change_file_mode.go30
-rw-r--r--cmd/gitaly-git2go/commit/commit.go106
-rw-r--r--cmd/gitaly-git2go/commit/create_directory.go30
-rw-r--r--cmd/gitaly-git2go/commit/create_file.go30
-rw-r--r--cmd/gitaly-git2go/commit/delete_file.go16
-rw-r--r--cmd/gitaly-git2go/commit/move_file.go41
-rw-r--r--cmd/gitaly-git2go/commit/update_file.go30
-rw-r--r--cmd/gitaly-git2go/commit/validate.go48
-rw-r--r--cmd/gitaly-git2go/conflicts.go4
-rw-r--r--cmd/gitaly-git2go/main.go7
-rw-r--r--cmd/gitaly-git2go/merge.go23
-rw-r--r--cmd/gitaly-git2go/revert.go11
-rw-r--r--internal/git2go/command.go18
-rw-r--r--internal/git2go/commit.go69
-rw-r--r--internal/git2go/commit_actions.go74
-rw-r--r--internal/git2go/commit_test.go546
-rw-r--r--internal/git2go/conflicts.go4
-rw-r--r--internal/git2go/executor.go11
-rw-r--r--internal/git2go/gob.go70
-rw-r--r--internal/git2go/gob_test.go66
-rw-r--r--internal/git2go/merge.go4
-rw-r--r--internal/git2go/revert.go4
-rw-r--r--internal/git2go/signature.go27
24 files changed, 1245 insertions, 43 deletions
diff --git a/cmd/gitaly-git2go/commit.go b/cmd/gitaly-git2go/commit.go
new file mode 100644
index 000000000..3d8ac4c7f
--- /dev/null
+++ b/cmd/gitaly-git2go/commit.go
@@ -0,0 +1,19 @@
+// +build static,system_libgit2
+
+package main
+
+import (
+ "context"
+ "flag"
+ "io"
+
+ "gitlab.com/gitlab-org/gitaly/cmd/gitaly-git2go/commit"
+)
+
+type commitSubcommand struct{}
+
+func (commitSubcommand) Flags() *flag.FlagSet { return flag.NewFlagSet("commit", flag.ExitOnError) }
+
+func (commitSubcommand) Run(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
+ return commit.Run(ctx, stdin, stdout)
+}
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..17200ee37
--- /dev/null
+++ b/cmd/gitaly-git2go/commit/change_file_mode.go
@@ -0,0 +1,30 @@
+// +build static,system_libgit2
+
+package commit
+
+import (
+ git "github.com/libgit2/git2go/v30"
+ "gitlab.com/gitlab-org/gitaly/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.ErrNotFound) {
+ 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..90862e1a7
--- /dev/null
+++ b/cmd/gitaly-git2go/commit/commit.go
@@ -0,0 +1,106 @@
+// +build static,system_libgit2
+
+package commit
+
+import (
+ "context"
+ "encoding/gob"
+ "errors"
+ "fmt"
+ "io"
+
+ git "github.com/libgit2/git2go/v30"
+ "gitlab.com/gitlab-org/gitaly/internal/git2go"
+)
+
+// Run runs the commit subcommand.
+func Run(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
+ var params git2go.CommitParams
+ if err := gob.NewDecoder(stdin).Decode(&params); err != nil {
+ return err
+ }
+
+ commitID, err := commit(ctx, params)
+ return gob.NewEncoder(stdout).Encode(git2go.Result{
+ CommitID: commitID,
+ Error: git2go.SerializableError(err),
+ })
+}
+
+func commit(ctx context.Context, params git2go.CommitParams) (string, error) {
+ repo, err := git.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 {
+ return "", fmt.Errorf("apply action %T: %w", action, err)
+ }
+ }
+
+ treeOID, err := index.WriteTreeTo(repo)
+ if err != nil {
+ return "", fmt.Errorf("write tree: %w", err)
+ }
+
+ signature := git.Signature(params.Author)
+ commitID, err := repo.CreateCommitFromIds("", &signature, &signature, params.Message, treeOID, parents...)
+ if err != nil {
+ if git.IsErrorClass(err, git.ErrClassInvalid) {
+ 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..c7059c0a3
--- /dev/null
+++ b/cmd/gitaly-git2go/commit/create_directory.go
@@ -0,0 +1,30 @@
+// +build static,system_libgit2
+
+package commit
+
+import (
+ "fmt"
+ "path/filepath"
+
+ git "github.com/libgit2/git2go/v30"
+ "gitlab.com/gitlab-org/gitaly/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..62bc6f61b
--- /dev/null
+++ b/cmd/gitaly-git2go/commit/create_file.go
@@ -0,0 +1,30 @@
+// +build static,system_libgit2
+
+package commit
+
+import (
+ git "github.com/libgit2/git2go/v30"
+ "gitlab.com/gitlab-org/gitaly/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..0c9df3be6
--- /dev/null
+++ b/cmd/gitaly-git2go/commit/delete_file.go
@@ -0,0 +1,16 @@
+// +build static,system_libgit2
+
+package commit
+
+import (
+ git "github.com/libgit2/git2go/v30"
+ "gitlab.com/gitlab-org/gitaly/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..e9c115083
--- /dev/null
+++ b/cmd/gitaly-git2go/commit/move_file.go
@@ -0,0 +1,41 @@
+// +build static,system_libgit2
+
+package commit
+
+import (
+ git "github.com/libgit2/git2go/v30"
+ "gitlab.com/gitlab-org/gitaly/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.ErrNotFound) {
+ 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..0bf6e7514
--- /dev/null
+++ b/cmd/gitaly-git2go/commit/update_file.go
@@ -0,0 +1,30 @@
+// +build static,system_libgit2
+
+package commit
+
+import (
+ git "github.com/libgit2/git2go/v30"
+ "gitlab.com/gitlab-org/gitaly/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.ErrNotFound) {
+ 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..8854cdbbe
--- /dev/null
+++ b/cmd/gitaly-git2go/commit/validate.go
@@ -0,0 +1,48 @@
+// +build static,system_libgit2
+
+package commit
+
+import (
+ "os"
+
+ git "github.com/libgit2/git2go/v30"
+ "gitlab.com/gitlab-org/gitaly/internal/git2go"
+)
+
+func validateFileExists(index *git.Index, path string) error {
+ if _, err := index.Find(path); err != nil {
+ if git.IsErrorCode(err, git.ErrNotFound) {
+ 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.ErrNotFound) {
+ 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.ErrNotFound) {
+ return err
+ }
+
+ return nil
+}
diff --git a/cmd/gitaly-git2go/conflicts.go b/cmd/gitaly-git2go/conflicts.go
index f2ba28abd..85f3c5a9c 100644
--- a/cmd/gitaly-git2go/conflicts.go
+++ b/cmd/gitaly-git2go/conflicts.go
@@ -3,9 +3,11 @@
package main
import (
+ "context"
"errors"
"flag"
"fmt"
+ "io"
"os"
git "github.com/libgit2/git2go/v30"
@@ -81,7 +83,7 @@ func conflictError(code codes.Code, message string) error {
}
// Run performs a merge and prints resulting conflicts to stdout.
-func (cmd *conflictsSubcommand) Run() error {
+func (cmd *conflictsSubcommand) Run(context.Context, io.Reader, io.Writer) error {
request, err := git2go.ConflictsCommandFromSerialized(cmd.request)
if err != nil {
return err
diff --git a/cmd/gitaly-git2go/main.go b/cmd/gitaly-git2go/main.go
index c9a46a13f..cfe5e5cde 100644
--- a/cmd/gitaly-git2go/main.go
+++ b/cmd/gitaly-git2go/main.go
@@ -3,18 +3,21 @@
package main
import (
+ "context"
"flag"
"fmt"
+ "io"
"os"
)
type subcmd interface {
Flags() *flag.FlagSet
- Run() error
+ Run(ctx context.Context, stdin io.Reader, stdout io.Writer) error
}
var subcommands = map[string]subcmd{
"conflicts": &conflictsSubcommand{},
+ "commit": commitSubcommand{},
"merge": &mergeSubcommand{},
"revert": &revertSubcommand{},
}
@@ -46,7 +49,7 @@ func main() {
fatalf("%s: trailing arguments", subcmdFlags.Name())
}
- if err := subcmd.Run(); err != nil {
+ if err := subcmd.Run(context.Background(), os.Stdin, os.Stdout); err != nil {
fatalf("%s: %s", subcmdFlags.Name(), err)
}
}
diff --git a/cmd/gitaly-git2go/merge.go b/cmd/gitaly-git2go/merge.go
index 3abf34e83..5a9f10a73 100644
--- a/cmd/gitaly-git2go/merge.go
+++ b/cmd/gitaly-git2go/merge.go
@@ -3,11 +3,12 @@
package main
import (
+ "context"
"errors"
"flag"
"fmt"
+ "io"
"os"
- "strings"
"time"
git "github.com/libgit2/git2go/v30"
@@ -24,18 +25,7 @@ func (cmd *mergeSubcommand) Flags() *flag.FlagSet {
return flags
}
-func sanitizeSignatureInfo(info string) string {
- return strings.Map(func(r rune) rune {
- switch r {
- case '<', '>', '\n':
- return -1
- default:
- return r
- }
- }, info)
-}
-
-func (cmd *mergeSubcommand) Run() error {
+func (cmd *mergeSubcommand) Run(context.Context, io.Reader, io.Writer) error {
request, err := git2go.MergeCommandFromSerialized(cmd.request)
if err != nil {
return err
@@ -76,12 +66,7 @@ func (cmd *mergeSubcommand) Run() error {
return fmt.Errorf("could not write tree: %w", err)
}
- committer := git.Signature{
- Name: sanitizeSignatureInfo(request.AuthorName),
- Email: sanitizeSignatureInfo(request.AuthorMail),
- When: request.AuthorDate,
- }
-
+ committer := git.Signature(git2go.NewSignature(request.AuthorName, request.AuthorMail, request.AuthorDate))
commit, err := repo.CreateCommitFromIds("", &committer, &committer, request.Message, tree, ours.Id(), theirs.Id())
if err != nil {
return fmt.Errorf("could not create merge commit: %w", err)
diff --git a/cmd/gitaly-git2go/revert.go b/cmd/gitaly-git2go/revert.go
index c1a4d4920..110d50309 100644
--- a/cmd/gitaly-git2go/revert.go
+++ b/cmd/gitaly-git2go/revert.go
@@ -3,9 +3,11 @@
package main
import (
+ "context"
"errors"
"flag"
"fmt"
+ "io"
"os"
git "github.com/libgit2/git2go/v30"
@@ -22,7 +24,7 @@ func (cmd *revertSubcommand) Flags() *flag.FlagSet {
return flags
}
-func (cmd *revertSubcommand) Run() error {
+func (cmd *revertSubcommand) Run(context.Context, io.Reader, io.Writer) error {
request, err := git2go.RevertCommandFromSerialized(cmd.request)
if err != nil {
return err
@@ -59,12 +61,7 @@ func (cmd *revertSubcommand) Run() error {
return fmt.Errorf("write tree: %w", err)
}
- committer := git.Signature{
- Name: sanitizeSignatureInfo(request.AuthorName),
- Email: sanitizeSignatureInfo(request.AuthorMail),
- When: request.AuthorDate,
- }
-
+ 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)
diff --git a/internal/git2go/command.go b/internal/git2go/command.go
index 2409b847c..c65665de1 100644
--- a/internal/git2go/command.go
+++ b/internal/git2go/command.go
@@ -8,30 +8,32 @@ import (
"fmt"
"io"
"os/exec"
- "path"
+ "path/filepath"
"strings"
"gitlab.com/gitlab-org/gitaly/internal/command"
"gitlab.com/gitlab-org/gitaly/internal/gitaly/config"
)
-func run(ctx context.Context, cfg config.Cfg, subcommand string, arg string) (string, error) {
- binary := path.Join(cfg.BinDir, "gitaly-git2go")
+func binaryPathFromCfg(cfg config.Cfg) string {
+ return filepath.Join(cfg.BinDir, "gitaly-git2go")
+}
+func run(ctx context.Context, binaryPath string, stdin io.Reader, args ...string) (*bytes.Buffer, error) {
var stderr, stdout bytes.Buffer
- cmd, err := command.New(ctx, exec.Command(binary, subcommand, "-request", arg), nil, &stdout, &stderr)
+ cmd, err := command.New(ctx, exec.Command(binaryPath, args...), stdin, &stdout, &stderr)
if err != nil {
- return "", err
+ return nil, err
}
if err := cmd.Wait(); err != nil {
if _, ok := err.(*exec.ExitError); ok {
- return "", fmt.Errorf("%s", stderr.String())
+ return nil, fmt.Errorf("%s", stderr.String())
}
- return "", err
+ return nil, err
}
- return stdout.String(), nil
+ return &stdout, nil
}
func serialize(v interface{}) (string, error) {
diff --git a/internal/git2go/commit.go b/internal/git2go/commit.go
new file mode 100644
index 000000000..ee24862d9
--- /dev/null
+++ b/internal/git2go/commit.go
@@ -0,0 +1,69 @@
+package git2go
+
+import (
+ "bytes"
+ "context"
+ "encoding/gob"
+ "fmt"
+)
+
+// InvalidArgumentError is returned when an invalid argument is provided.
+type InvalidArgumentError string
+
+func (err InvalidArgumentError) Error() string { return string(err) }
+
+// FileNotFoundError is returned when an action attempts to operate on a non-existing file.
+type FileNotFoundError string
+
+func (err FileNotFoundError) Error() string {
+ return fmt.Sprintf("file not found: %q", string(err))
+}
+
+// FileExistsError is returned when an action attempts to overwrite an existing file.
+type FileExistsError string
+
+func (err FileExistsError) Error() string {
+ return fmt.Sprintf("file exists: %q", string(err))
+}
+
+// DirectoryExistsError is returned when an action attempts to overwrite a directory.
+type DirectoryExistsError string
+
+func (err DirectoryExistsError) Error() string {
+ return fmt.Sprintf("directory exists: %q", string(err))
+}
+
+// CommitParams contains the information and the steps to build a commit.
+type CommitParams struct {
+ // Repository is the path of the repository to operate on.
+ Repository string
+ // Author is the author of the commit.
+ Author Signature
+ // Message is message of the commit.
+ Message string
+ // Parent is the OID of the commit to use as the parent of this commit.
+ Parent string
+ // Actions are the steps to build the commit.
+ Actions []Action
+}
+
+// Commit builds a commit from the actions, writes it to the object database and
+// returns its object id.
+func (b Executor) Commit(ctx context.Context, params CommitParams) (string, error) {
+ input := &bytes.Buffer{}
+ if err := gob.NewEncoder(input).Encode(params); err != nil {
+ return "", err
+ }
+
+ output, err := run(ctx, b.binaryPath, input, "commit")
+ if err != nil {
+ return "", err
+ }
+
+ var result Result
+ if err := gob.NewDecoder(output).Decode(&result); err != nil {
+ return "", err
+ }
+
+ return result.CommitID, result.Error
+}
diff --git a/internal/git2go/commit_actions.go b/internal/git2go/commit_actions.go
new file mode 100644
index 000000000..ced8c7464
--- /dev/null
+++ b/internal/git2go/commit_actions.go
@@ -0,0 +1,74 @@
+package git2go
+
+// Action represents an action taken to build a commit.
+type Action interface{ action() }
+
+// isAction is used ensuring type safety for actions.
+type isAction struct{}
+
+func (isAction) action() {}
+
+// ChangeFileMode sets a file's mode to either regular or executable file.
+// FileNotFoundError is returned when attempting to change a non-existent
+// file's mode.
+type ChangeFileMode struct {
+ isAction
+ // Path is the path of the whose mode to change.
+ Path string
+ // ExecutableMode indicates whether the file mode should be changed to executable or not.
+ ExecutableMode bool
+}
+
+// CreateDirectory creates a directory in the given path with a '.gitkeep' file inside.
+// FileExistsError is returned if a file already exists at the provided path.
+// DirectoryExistsError is returned if a directory already exists at the provided
+// path.
+type CreateDirectory struct {
+ isAction
+ // Path is the path of the directory to create.
+ Path string
+}
+
+// CreateFile creates a file using the provided path, mode and oid as the blob.
+// FileExistsError is returned if a file exists at the given path.
+type CreateFile struct {
+ isAction
+ // Path is the path of the file to create.
+ Path string
+ // ExecutableMode indicates whether the file mode should be executable or not.
+ ExecutableMode bool
+ // OID is the id of the object that contains the content of the file.
+ OID string
+}
+
+// DeleteFile deletes a file or a directory from the provided path.
+// FileNotFoundError is returned if the file does not exist.
+type DeleteFile struct {
+ isAction
+ // Path is the path of the file to delete.
+ Path string
+}
+
+// MoveFile moves a file or a directory to the new path.
+// FileNotFoundError is returned if the file does not exist.
+type MoveFile struct {
+ isAction
+ // Path is the path of the file to move.
+ Path string
+ // NewPath is the new path of the file.
+ NewPath string
+ // OID is the id of the object that contains the content of the file. If set,
+ // the file contents are updated to match the object, otherwise the file keeps
+ // the existing content.
+ OID string
+}
+
+// UpdateFile updates a file at the given path to point to the provided
+// OID. FileNotFoundError is returned if the file does not exist.
+type UpdateFile struct {
+ isAction
+ // Path is the path of the file to update.
+ Path string
+ // OID is the id of the object that contains the new content of the file.
+ OID string
+}
diff --git a/internal/git2go/commit_test.go b/internal/git2go/commit_test.go
new file mode 100644
index 000000000..6a1eea7a0
--- /dev/null
+++ b/internal/git2go/commit_test.go
@@ -0,0 +1,546 @@
+package git2go
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "gitlab.com/gitlab-org/gitaly/internal/git"
+ "gitlab.com/gitlab-org/gitaly/internal/gitaly/config"
+ "gitlab.com/gitlab-org/gitaly/internal/testhelper"
+)
+
+func TestMain(m *testing.M) {
+ testhelper.Configure()
+ testhelper.ConfigureGitalyGit2Go()
+ os.Exit(m.Run())
+}
+
+type commit struct {
+ Parent string
+ Author Signature
+ Committer Signature
+ Message string
+}
+
+func TestExecutor_Commit(t *testing.T) {
+ const (
+ DefaultMode = "100644"
+ ExecutableMode = "100755"
+ )
+
+ type step struct {
+ actions []Action
+ error error
+ treeEntries []testhelper.TreeEntry
+ }
+
+ ctx, cancel := testhelper.Context()
+ defer cancel()
+
+ pbRepo, repoPath, clean := testhelper.InitBareRepo(t)
+ defer clean()
+
+ repo := git.NewRepository(pbRepo)
+
+ originalFile, err := repo.WriteBlob(ctx, "file", bytes.NewBufferString("original"))
+ require.NoError(t, err)
+
+ updatedFile, err := repo.WriteBlob(ctx, "file", bytes.NewBufferString("updated"))
+ require.NoError(t, err)
+
+ executor := New(filepath.Join(config.Config.BinDir, "gitaly-git2go"))
+
+ for _, tc := range []struct {
+ desc string
+ steps []step
+ }{
+ {
+ desc: "create directory",
+ steps: []step{
+ {
+ actions: []Action{
+ CreateDirectory{Path: "directory"},
+ },
+ treeEntries: []testhelper.TreeEntry{
+ {Mode: DefaultMode, Path: "directory/.gitkeep"},
+ },
+ },
+ },
+ },
+ {
+ desc: "create directory created duplicate",
+ steps: []step{
+ {
+ actions: []Action{
+ CreateDirectory{Path: "directory"},
+ CreateDirectory{Path: "directory"},
+ },
+ error: DirectoryExistsError("directory"),
+ },
+ },
+ },
+ {
+ desc: "create directory existing duplicate",
+ steps: []step{
+ {
+ actions: []Action{
+ CreateDirectory{Path: "directory"},
+ },
+ treeEntries: []testhelper.TreeEntry{
+ {Mode: DefaultMode, Path: "directory/.gitkeep"},
+ },
+ },
+ {
+ actions: []Action{
+ CreateDirectory{Path: "directory"},
+ },
+ error: DirectoryExistsError("directory"),
+ },
+ },
+ },
+ {
+ desc: "create directory with a files name",
+ steps: []step{
+ {
+ actions: []Action{
+ CreateFile{Path: "file", OID: originalFile},
+ },
+ treeEntries: []testhelper.TreeEntry{
+ {Mode: DefaultMode, Path: "file", Content: "original"},
+ },
+ },
+ {
+ actions: []Action{
+ CreateDirectory{Path: "file"},
+ },
+ error: FileExistsError("file"),
+ },
+ },
+ },
+ {
+ desc: "create file",
+ steps: []step{
+ {
+ actions: []Action{
+ CreateFile{Path: "file", OID: originalFile},
+ },
+ treeEntries: []testhelper.TreeEntry{
+ {Mode: DefaultMode, Path: "file", Content: "original"},
+ },
+ },
+ },
+ },
+ {
+ desc: "create duplicate file",
+ steps: []step{
+ {
+ actions: []Action{
+ CreateFile{Path: "file", OID: originalFile},
+ CreateFile{Path: "file", OID: updatedFile},
+ },
+ error: FileExistsError("file"),
+ },
+ },
+ },
+ {
+ desc: "create file overwrites directory",
+ steps: []step{
+ {
+ actions: []Action{
+ CreateDirectory{Path: "directory"},
+ CreateFile{Path: "directory", OID: originalFile},
+ },
+ treeEntries: []testhelper.TreeEntry{
+ {Mode: DefaultMode, Path: "directory", Content: "original"},
+ },
+ },
+ },
+ },
+ {
+ desc: "update created file",
+ steps: []step{
+ {
+ actions: []Action{
+ CreateFile{Path: "file", OID: originalFile},
+ UpdateFile{Path: "file", OID: updatedFile},
+ },
+ treeEntries: []testhelper.TreeEntry{
+ {Mode: DefaultMode, Path: "file", Content: "updated"},
+ },
+ },
+ },
+ },
+ {
+ desc: "update existing file",
+ steps: []step{
+ {
+ actions: []Action{
+ CreateFile{Path: "file", OID: originalFile},
+ },
+ treeEntries: []testhelper.TreeEntry{
+ {Mode: DefaultMode, Path: "file", Content: "original"},
+ },
+ },
+ {
+ actions: []Action{
+ UpdateFile{Path: "file", OID: updatedFile},
+ },
+ treeEntries: []testhelper.TreeEntry{
+ {Mode: DefaultMode, Path: "file", Content: "updated"},
+ },
+ },
+ },
+ },
+ {
+ desc: "update non-existing file",
+ steps: []step{
+ {
+ actions: []Action{
+ UpdateFile{Path: "non-existing", OID: updatedFile},
+ },
+ error: FileNotFoundError("non-existing"),
+ },
+ },
+ },
+ {
+ desc: "move created file",
+ steps: []step{
+ {
+ actions: []Action{
+ CreateFile{Path: "original-file", OID: originalFile},
+ MoveFile{Path: "original-file", NewPath: "moved-file", OID: originalFile},
+ },
+ treeEntries: []testhelper.TreeEntry{
+ {Mode: DefaultMode, Path: "moved-file", Content: "original"},
+ },
+ },
+ },
+ },
+ {
+ desc: "moving directory fails",
+ steps: []step{
+ {
+ actions: []Action{
+ CreateDirectory{Path: "directory"},
+ MoveFile{Path: "directory", NewPath: "moved-directory"},
+ },
+ error: FileNotFoundError("directory"),
+ },
+ },
+ },
+ {
+ desc: "move file inferring content",
+ steps: []step{
+ {
+ actions: []Action{
+ CreateFile{Path: "original-file", OID: originalFile},
+ },
+ treeEntries: []testhelper.TreeEntry{
+ {Mode: DefaultMode, Path: "original-file", Content: "original"},
+ },
+ },
+ {
+ actions: []Action{
+ MoveFile{Path: "original-file", NewPath: "moved-file"},
+ },
+ treeEntries: []testhelper.TreeEntry{
+ {Mode: DefaultMode, Path: "moved-file", Content: "original"},
+ },
+ },
+ },
+ },
+ {
+ desc: "move file with non-existing source",
+ steps: []step{
+ {
+ actions: []Action{
+ MoveFile{Path: "non-existing", NewPath: "destination-file"},
+ },
+ error: FileNotFoundError("non-existing"),
+ },
+ },
+ },
+ {
+ desc: "move file with already existing destination file",
+ steps: []step{
+ {
+ actions: []Action{
+ CreateFile{Path: "source-file", OID: originalFile},
+ CreateFile{Path: "already-existing", OID: updatedFile},
+ MoveFile{Path: "source-file", NewPath: "already-existing"},
+ },
+ error: FileExistsError("already-existing"),
+ },
+ },
+ },
+ {
+ // seems like a bug in the original implementation to allow overwriting a
+ // directory
+ desc: "move file with already existing destination directory",
+ steps: []step{
+ {
+ actions: []Action{
+ CreateFile{Path: "file", OID: originalFile},
+ CreateDirectory{Path: "already-existing"},
+ MoveFile{Path: "file", NewPath: "already-existing"},
+ },
+ treeEntries: []testhelper.TreeEntry{
+ {Mode: DefaultMode, Path: "already-existing", Content: "original"},
+ },
+ },
+ },
+ },
+ {
+ desc: "move file providing content",
+ steps: []step{
+ {
+ actions: []Action{
+ CreateFile{Path: "original-file", OID: originalFile},
+ },
+ treeEntries: []testhelper.TreeEntry{
+ {Mode: DefaultMode, Path: "original-file", Content: "original"},
+ },
+ },
+ {
+ actions: []Action{
+ MoveFile{Path: "original-file", NewPath: "moved-file", OID: updatedFile},
+ },
+ treeEntries: []testhelper.TreeEntry{
+ {Mode: DefaultMode, Path: "moved-file", Content: "updated"},
+ },
+ },
+ },
+ },
+ {
+ desc: "mark non-existing file executable",
+ steps: []step{
+ {
+ actions: []Action{
+ ChangeFileMode{Path: "non-existing"},
+ },
+ error: FileNotFoundError("non-existing"),
+ },
+ },
+ },
+ {
+ desc: "mark executable file executable",
+ steps: []step{
+ {
+ actions: []Action{
+ CreateFile{Path: "file-1", OID: originalFile},
+ ChangeFileMode{Path: "file-1", ExecutableMode: true},
+ },
+ treeEntries: []testhelper.TreeEntry{
+ {Mode: ExecutableMode, Path: "file-1", Content: "original"},
+ },
+ },
+ {
+ actions: []Action{
+ ChangeFileMode{Path: "file-1", ExecutableMode: true},
+ },
+ treeEntries: []testhelper.TreeEntry{
+ {Mode: ExecutableMode, Path: "file-1", Content: "original"},
+ },
+ },
+ },
+ },
+ {
+ desc: "mark created file executable",
+ steps: []step{
+ {
+ actions: []Action{
+ CreateFile{Path: "file-1", OID: originalFile},
+ ChangeFileMode{Path: "file-1", ExecutableMode: true},
+ },
+ treeEntries: []testhelper.TreeEntry{
+ {Mode: ExecutableMode, Path: "file-1", Content: "original"},
+ },
+ },
+ },
+ },
+ {
+ desc: "mark existing file executable",
+ steps: []step{
+ {
+ actions: []Action{
+ CreateFile{Path: "file-1", OID: originalFile},
+ },
+ treeEntries: []testhelper.TreeEntry{
+ {Mode: DefaultMode, Path: "file-1", Content: "original"},
+ },
+ },
+ {
+ actions: []Action{
+ ChangeFileMode{Path: "file-1", ExecutableMode: true},
+ },
+ treeEntries: []testhelper.TreeEntry{
+ {Mode: ExecutableMode, Path: "file-1", Content: "original"},
+ },
+ },
+ },
+ },
+ {
+ desc: "move non-existing file",
+ steps: []step{
+ {
+ actions: []Action{
+ MoveFile{Path: "non-existing", NewPath: "destination"},
+ },
+ error: FileNotFoundError("non-existing"),
+ },
+ },
+ },
+ {
+ desc: "move doesn't overwrite a file",
+ steps: []step{
+ {
+ actions: []Action{
+ CreateFile{Path: "file-1", OID: originalFile},
+ CreateFile{Path: "file-2", OID: updatedFile},
+ MoveFile{Path: "file-1", NewPath: "file-2"},
+ },
+ error: FileExistsError("file-2"),
+ },
+ },
+ },
+ {
+ desc: "delete non-existing file",
+ steps: []step{
+ {
+ actions: []Action{
+ DeleteFile{Path: "non-existing"},
+ },
+ error: FileNotFoundError("non-existing"),
+ },
+ },
+ },
+ {
+ desc: "delete created file",
+ steps: []step{
+ {
+ actions: []Action{
+ CreateFile{Path: "file-1", OID: originalFile},
+ DeleteFile{Path: "file-1"},
+ },
+ },
+ },
+ },
+ {
+ desc: "delete existing file",
+ steps: []step{
+ {
+ actions: []Action{
+ CreateFile{Path: "file-1", OID: originalFile},
+ },
+ treeEntries: []testhelper.TreeEntry{
+ {Mode: DefaultMode, Path: "file-1", Content: "original"},
+ },
+ },
+ {
+ actions: []Action{
+ DeleteFile{Path: "file-1"},
+ },
+ },
+ },
+ },
+ } {
+ t.Run(tc.desc, func(t *testing.T) {
+ author := NewSignature("Author Name", "author.email@example.com", time.Now())
+ var parentCommit string
+ for i, step := range tc.steps {
+ message := fmt.Sprintf("commit %d", i+1)
+ commitID, err := executor.Commit(ctx, CommitParams{
+ Repository: repoPath,
+ Author: author,
+ Message: message,
+ Parent: parentCommit,
+ Actions: step.actions,
+ })
+
+ if step.error != nil {
+ require.True(t, errors.Is(err, step.error), "expected: %q, actual: %q", step.error, err)
+ continue
+ } else {
+ require.NoError(t, err)
+ }
+
+ require.Equal(t, commit{
+ Parent: parentCommit,
+ Author: author,
+ Committer: author,
+ Message: message,
+ }, getCommit(t, ctx, repo, commitID))
+
+ testhelper.RequireTree(t, repoPath, commitID, step.treeEntries)
+ parentCommit = commitID
+ }
+ })
+ }
+}
+
+func getCommit(t testing.TB, ctx context.Context, repo git.Repository, oid string) commit {
+ t.Helper()
+
+ data, err := repo.ReadObject(ctx, oid)
+ require.NoError(t, err)
+
+ var commit commit
+ lines := strings.Split(string(data), "\n")
+ for i, line := range lines {
+ if line == "" {
+ commit.Message = strings.Join(lines[i+1:], "\n")
+ break
+ }
+
+ split := strings.SplitN(line, " ", 2)
+ require.Len(t, split, 2, "invalid commit: %q", data)
+
+ field, value := split[0], split[1]
+ switch field {
+ case "parent":
+ require.Empty(t, commit.Parent, "multi parent parsing not implemented")
+ commit.Parent = value
+ case "author":
+ require.Empty(t, commit.Author, "commit contained multiple authors")
+ commit.Author = unmarshalSignature(t, value)
+ case "committer":
+ require.Empty(t, commit.Committer, "commit contained multiple committers")
+ commit.Committer = unmarshalSignature(t, value)
+ default:
+ }
+ }
+
+ return commit
+}
+
+func unmarshalSignature(t testing.TB, data string) Signature {
+ t.Helper()
+
+ // Format: NAME <EMAIL> DATE_UNIX DATE_TIMEZONE
+ split1 := strings.Split(data, " <")
+ require.Len(t, split1, 2, "invalid signature: %q", data)
+
+ split2 := strings.Split(split1[1], "> ")
+ require.Len(t, split2, 2, "invalid signature: %q", data)
+
+ split3 := strings.Split(split2[1], " ")
+ require.Len(t, split3, 2, "invalid signature: %q", data)
+
+ timestamp, err := strconv.ParseInt(split3[0], 10, 64)
+ require.NoError(t, err)
+
+ return Signature{
+ Name: split1[0],
+ Email: split2[0],
+ When: time.Unix(timestamp, 0),
+ }
+}
diff --git a/internal/git2go/conflicts.go b/internal/git2go/conflicts.go
index c5848c618..67f91808a 100644
--- a/internal/git2go/conflicts.go
+++ b/internal/git2go/conflicts.go
@@ -86,13 +86,13 @@ func (c ConflictsCommand) Run(ctx context.Context, cfg config.Cfg) (ConflictsRes
return ConflictsResult{}, err
}
- stdout, err := run(ctx, cfg, "conflicts", serialized)
+ stdout, err := run(ctx, binaryPathFromCfg(cfg), nil, "conflicts", "-request", serialized)
if err != nil {
return ConflictsResult{}, err
}
var response ConflictsResult
- if err := deserialize(stdout, &response); err != nil {
+ if err := deserialize(stdout.String(), &response); err != nil {
return ConflictsResult{}, err
}
diff --git a/internal/git2go/executor.go b/internal/git2go/executor.go
new file mode 100644
index 000000000..0007ab7e5
--- /dev/null
+++ b/internal/git2go/executor.go
@@ -0,0 +1,11 @@
+package git2go
+
+// Executor executes gitaly-git2go.
+type Executor struct {
+ binaryPath string
+}
+
+// New returns a new gitaly-git2go executor using the provided binary.
+func New(binaryPath string) Executor {
+ return Executor{binaryPath: binaryPath}
+}
diff --git a/internal/git2go/gob.go b/internal/git2go/gob.go
new file mode 100644
index 000000000..5f87b95e0
--- /dev/null
+++ b/internal/git2go/gob.go
@@ -0,0 +1,70 @@
+package git2go
+
+import (
+ "encoding/gob"
+ "errors"
+ "reflect"
+)
+
+func init() {
+ for typee := range registeredTypes {
+ gob.Register(typee)
+ }
+}
+
+var registeredTypes = map[interface{}]struct{}{
+ ChangeFileMode{}: {},
+ CreateDirectory{}: {},
+ CreateFile{}: {},
+ DeleteFile{}: {},
+ MoveFile{}: {},
+ UpdateFile{}: {},
+ wrapError{}: {},
+ DirectoryExistsError(""): {},
+ FileExistsError(""): {},
+ FileNotFoundError(""): {},
+ InvalidArgumentError(""): {},
+}
+
+// Result is the serialized result.
+type Result struct {
+ // CommitID is the result of the call.
+ CommitID string
+ // Error is set if the call errord.
+ Error error
+}
+
+// wrapError is used to serialize wrapped errors as fmt.wrapError type only has
+// private fields and can't be serialized via gob. It's also used to serialize unregistered
+// error types by serializing only their error message.
+type wrapError struct {
+ Message string
+ Err error
+}
+
+func (err wrapError) Error() string { return err.Message }
+
+func (err wrapError) Unwrap() error { return err.Err }
+
+// SerializableError returns an error that is Gob serializable.
+// Registered types are serialized directly. Unregistered types
+// are transformed in to an opaque error using their error message.
+// Wrapped errors remain unwrappable.
+func SerializableError(err error) error {
+ if err == nil {
+ return nil
+ }
+
+ if unwrappedErr := errors.Unwrap(err); unwrappedErr != nil {
+ return wrapError{
+ Message: err.Error(),
+ Err: SerializableError(unwrappedErr),
+ }
+ }
+
+ if _, ok := registeredTypes[reflect.Zero(reflect.TypeOf(err)).Interface()]; !ok {
+ return wrapError{Message: err.Error()}
+ }
+
+ return err
+}
diff --git a/internal/git2go/gob_test.go b/internal/git2go/gob_test.go
new file mode 100644
index 000000000..e18a10961
--- /dev/null
+++ b/internal/git2go/gob_test.go
@@ -0,0 +1,66 @@
+package git2go
+
+import (
+ "bytes"
+ "encoding/gob"
+ "errors"
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestSerializableError(t *testing.T) {
+ for _, tc := range []struct {
+ desc string
+ input error
+ output error
+ containsTyped bool
+ }{
+ {
+ desc: "plain error",
+ input: errors.New("plain error"),
+ output: wrapError{Message: "plain error"},
+ },
+ {
+ desc: "wrapped plain error",
+ input: fmt.Errorf("error wrapper: %w", errors.New("plain error")),
+ output: wrapError{Message: "error wrapper: plain error", Err: wrapError{Message: "plain error"}},
+ },
+ {
+ desc: "wrapped typed error",
+ containsTyped: true,
+ input: fmt.Errorf("error wrapper: %w", InvalidArgumentError("typed error")),
+ output: wrapError{Message: "error wrapper: typed error", Err: InvalidArgumentError("typed error")},
+ },
+ {
+ desc: "typed wrapper",
+ containsTyped: true,
+ input: wrapError{
+ Message: "error wrapper: typed error 1: typed error 2",
+ Err: wrapError{
+ Message: "typed error 1: typed error 2",
+ Err: InvalidArgumentError("typed error 2"),
+ },
+ },
+ output: wrapError{
+ Message: "error wrapper: typed error 1: typed error 2",
+ Err: wrapError{
+ Message: "typed error 1: typed error 2",
+ Err: InvalidArgumentError("typed error 2"),
+ },
+ },
+ },
+ } {
+ t.Run(tc.desc, func(t *testing.T) {
+ encoded := &bytes.Buffer{}
+ require.NoError(t, gob.NewEncoder(encoded).Encode(SerializableError(tc.input)))
+ var err wrapError
+ require.NoError(t, gob.NewDecoder(encoded).Decode(&err))
+ require.Equal(t, tc.output, err)
+
+ var typedErr InvalidArgumentError
+ require.Equal(t, tc.containsTyped, errors.As(err, &typedErr))
+ })
+ }
+}
diff --git a/internal/git2go/merge.go b/internal/git2go/merge.go
index a60ff51c8..90a2db549 100644
--- a/internal/git2go/merge.go
+++ b/internal/git2go/merge.go
@@ -69,13 +69,13 @@ func (m MergeCommand) Run(ctx context.Context, cfg config.Cfg) (MergeResult, err
return MergeResult{}, err
}
- stdout, err := run(ctx, cfg, "merge", serialized)
+ stdout, err := run(ctx, binaryPathFromCfg(cfg), nil, "merge", "-request", serialized)
if err != nil {
return MergeResult{}, err
}
var response MergeResult
- if err := deserialize(stdout, &response); err != nil {
+ if err := deserialize(stdout.String(), &response); err != nil {
return MergeResult{}, err
}
diff --git a/internal/git2go/revert.go b/internal/git2go/revert.go
index ca11169bf..8e6169abc 100644
--- a/internal/git2go/revert.go
+++ b/internal/git2go/revert.go
@@ -50,13 +50,13 @@ func (r RevertCommand) Run(ctx context.Context, cfg config.Cfg) (RevertResult, e
return RevertResult{}, err
}
- stdout, err := run(ctx, cfg, "revert", serialized)
+ stdout, err := run(ctx, binaryPathFromCfg(cfg), nil, "revert", "-request", serialized)
if err != nil {
return RevertResult{}, err
}
var response RevertResult
- if err := deserialize(stdout, &response); err != nil {
+ if err := deserialize(stdout.String(), &response); err != nil {
return RevertResult{}, err
}
diff --git a/internal/git2go/signature.go b/internal/git2go/signature.go
new file mode 100644
index 000000000..79306c051
--- /dev/null
+++ b/internal/git2go/signature.go
@@ -0,0 +1,27 @@
+package git2go
+
+import (
+ "strings"
+ "time"
+)
+
+var signatureSanitizer = strings.NewReplacer("\n", "", "<", "", ">", "")
+
+// Signature represents a commits signature.
+type Signature struct {
+ // Name of the author or the committer.
+ Name string
+ // Email of the author or the committer.
+ Email string
+ // When is the time of the commit.
+ When time.Time
+}
+
+// NewSignature creates a new sanitized signature.
+func NewSignature(name, email string, when time.Time) Signature {
+ return Signature{
+ Name: signatureSanitizer.Replace(name),
+ Email: signatureSanitizer.Replace(email),
+ When: when.Truncate(time.Second),
+ }
+}