diff options
author | Sami Hiltunen <shiltunen@gitlab.com> | 2020-10-15 13:40:26 +0300 |
---|---|---|
committer | Sami Hiltunen <shiltunen@gitlab.com> | 2020-10-21 14:21:12 +0300 |
commit | 1dfda33b2ec81399307a0f062d14061c429a72ab (patch) | |
tree | b2b7461d550d3370280a06edb4d06d1f06521154 | |
parent | 57424d40acf18995bc2facce7b75f8e6e3f14e75 (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.
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(¶ms); 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), + } +} |