diff --git a/git/adapter/blame.go b/git/adapter/blame.go index ca956d4ea..0009b9725 100644 --- a/git/adapter/blame.go +++ b/git/adapter/blame.go @@ -25,9 +25,8 @@ import ( "time" "github.com/harness/gitness/errors" + "github.com/harness/gitness/git/command" "github.com/harness/gitness/git/types" - - gitea "code.gitea.io/gitea/modules/git" ) var ( @@ -46,8 +45,11 @@ func (a Adapter) Blame( lineTo int, ) types.BlameReader { // prepare the git command line arguments - args := make([]string, 0, 8) - args = append(args, "blame", "--porcelain", "--encoding=UTF-8") + cmd := command.New( + "blame", + command.WithFlag("--porcelain"), + command.WithFlag("--encoding", "UTF-8"), + ) if lineFrom > 0 || lineTo > 0 { var lines string if lineFrom > 0 { @@ -57,9 +59,11 @@ func (a Adapter) Blame( lines += "," + strconv.Itoa(lineTo) } - args = append(args, "-L", lines) + cmd.Add(command.WithFlag("-L", lines)) } - args = append(args, rev, "--", file) + + cmd.Add(command.WithArg(rev)) + cmd.Add(command.WithPostSepArg(file)) pipeRead, pipeWrite := io.Pipe() stderr := &bytes.Buffer{} @@ -71,12 +75,11 @@ func (a Adapter) Blame( _ = pipeWrite.CloseWithError(err) }() - cmd := gitea.NewCommand(ctx, args...) - err = cmd.Run(&gitea.RunOpts{ - Dir: repoPath, - Stdout: pipeWrite, - Stderr: stderr, // We capture stderr output in a buffer. - }) + err = cmd.Run(ctx, + command.WithDir(repoPath), + command.WithStdout(pipeWrite), + command.WithStderr(stderr), + ) }() return &BlameReader{ diff --git a/git/adapter/blob.go b/git/adapter/blob.go index 49190eb34..e995311e2 100644 --- a/git/adapter/blob.go +++ b/git/adapter/blob.go @@ -21,8 +21,6 @@ import ( "github.com/harness/gitness/errors" "github.com/harness/gitness/git/types" - - "code.gitea.io/gitea/modules/git" ) // GetBlob returns the blob for the given object sha. @@ -32,7 +30,7 @@ func (a Adapter) GetBlob( sha string, sizeLimit int64, ) (*types.BlobReader, error) { - stdIn, stdOut, cancel := git.CatFileBatch(ctx, repoPath) + stdIn, stdOut, cancel := CatFileBatch(ctx, repoPath) _, err := stdIn.Write([]byte(sha + "\n")) if err != nil { @@ -40,7 +38,7 @@ func (a Adapter) GetBlob( return nil, fmt.Errorf("failed to write blob sha to git stdin: %w", err) } - objectSHA, objectType, objectSize, err := git.ReadBatchLine(stdOut) + objectSHA, objectType, objectSize, err := ReadBatchHeaderLine(stdOut) if err != nil { cancel() return nil, processGiteaErrorf(err, "failed to read cat-file batch line") @@ -50,10 +48,10 @@ func (a Adapter) GetBlob( cancel() return nil, fmt.Errorf("cat-file returned object sha '%s' but expected '%s'", objectSHA, sha) } - if objectType != string(git.ObjectBlob) { + if objectType != string(ObjectBlob) { cancel() return nil, errors.InvalidArgument( - "cat-file returned object type '%s' but expected '%s'", objectType, git.ObjectBlob) + "cat-file returned object type '%s' but expected '%s'", objectType, ObjectBlob) } contentSize := objectSize diff --git a/git/adapter/branch.go b/git/adapter/branch.go index 3f348b2c3..6f24d9758 100644 --- a/git/adapter/branch.go +++ b/git/adapter/branch.go @@ -15,13 +15,13 @@ package adapter import ( + "bytes" "context" "fmt" "strings" + "github.com/harness/gitness/git/command" "github.com/harness/gitness/git/types" - - gitea "code.gitea.io/gitea/modules/git" ) // GetBranch gets an existing branch. @@ -62,11 +62,14 @@ func (a Adapter) HasBranches( } // repo has branches IFF there's at least one commit that is reachable via a branch // (every existing branch points to a commit) - stdout, _, runErr := gitea.NewCommand(ctx, "rev-list", "--max-count", "1", "--branches"). - RunStdBytes(&gitea.RunOpts{Dir: repoPath}) - if runErr != nil { - return false, processGiteaErrorf(runErr, "failed to trigger rev-list command") + cmd := command.New("rev-list", + command.WithFlag("--max-count", "1"), + command.WithFlag("--branches"), + ) + output := &bytes.Buffer{} + if err := cmd.Run(ctx, command.WithDir(repoPath), command.WithStdout(output)); err != nil { + return false, processGiteaErrorf(err, "failed to trigger rev-list command") } - return strings.TrimSpace(string(stdout)) == "", nil + return strings.TrimSpace(output.String()) == "", nil } diff --git a/git/adapter/cat-file.go b/git/adapter/cat-file.go new file mode 100644 index 000000000..4a7b9bfe6 --- /dev/null +++ b/git/adapter/cat-file.go @@ -0,0 +1,128 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package adapter + +import ( + "bufio" + "bytes" + "context" + "io" + "strconv" + "strings" + + "github.com/harness/gitness/errors" + "github.com/harness/gitness/git/command" + + "github.com/djherbis/buffer" + "github.com/djherbis/nio/v3" + "github.com/rs/zerolog/log" +) + +// WriteCloserError wraps an io.WriteCloser with an additional CloseWithError function. +type WriteCloserError interface { + io.WriteCloser + CloseWithError(err error) error +} + +// CatFileBatch opens git cat-file --batch in the provided repo and returns a stdin pipe, +// a stdout reader and cancel function. +func CatFileBatch( + ctx context.Context, + repoPath string, +) (WriteCloserError, *bufio.Reader, func()) { + const bufferSize = 32 * 1024 + // We often want to feed the commits in order into cat-file --batch, + // followed by their trees and sub trees as necessary. + batchStdinReader, batchStdinWriter := io.Pipe() + batchStdoutReader, batchStdoutWriter := nio.Pipe(buffer.New(bufferSize)) + ctx, ctxCancel := context.WithCancel(ctx) + closed := make(chan struct{}) + cancel := func() { + ctxCancel() + _ = batchStdinWriter.Close() + _ = batchStdoutReader.Close() + <-closed + } + + // Ensure cancel is called as soon as the provided context is cancelled + go func() { + <-ctx.Done() + cancel() + }() + + go func() { + stderr := bytes.Buffer{} + cmd := command.New("cat-file", command.WithFlag("--batch")) + err := cmd.Run(ctx, + command.WithDir(repoPath), + command.WithStdin(batchStdinReader), + command.WithStdout(batchStdoutWriter), + command.WithStderr(&stderr), + ) + if err != nil { + _ = batchStdoutWriter.CloseWithError(command.NewError(err, stderr.Bytes())) + _ = batchStdinReader.CloseWithError(command.NewError(err, stderr.Bytes())) + } else { + _ = batchStdoutWriter.Close() + _ = batchStdinReader.Close() + } + close(closed) + }() + + // For simplicities sake we'll us a buffered reader to read from the cat-file --batch + batchReader := bufio.NewReaderSize(batchStdoutReader, bufferSize) + + return batchStdinWriter, batchReader, cancel +} + +// ReadBatchHeaderLine reads the header line from cat-file --batch +// We expect: +// SP SP LF +// sha is a 40byte not 20byte here. +func ReadBatchHeaderLine(rd *bufio.Reader) (sha []byte, objType string, size int64, err error) { + objType, err = rd.ReadString('\n') + if err != nil { + return nil, "", 0, err + } + if len(objType) == 1 { + objType, err = rd.ReadString('\n') + if err != nil { + return nil, "", 0, err + } + } + idx := strings.IndexByte(objType, ' ') + if idx < 0 { + log.Debug().Msgf("missing space type: %s", objType) + err = errors.NotFound("sha '%s' not found", sha) + return nil, "", 0, err + } + sha = []byte(objType[:idx]) + objType = objType[idx+1:] + + idx = strings.IndexByte(objType, ' ') + if idx < 0 { + err = errors.NotFound("sha '%s' not found", sha) + return nil, "", 0, err + } + + sizeStr := objType[idx+1 : len(objType)-1] + objType = objType[:idx] + + size, err = strconv.ParseInt(sizeStr, 10, 64) + if err != nil { + return nil, "", 0, err + } + return sha, objType, size, nil +} diff --git a/git/adapter/commit.go b/git/adapter/commit.go index 8582caaee..ceedf8974 100644 --- a/git/adapter/commit.go +++ b/git/adapter/commit.go @@ -23,6 +23,7 @@ import ( "time" "github.com/harness/gitness/errors" + "github.com/harness/gitness/git/command" "github.com/harness/gitness/git/types" gitea "code.gitea.io/gitea/modules/git" @@ -71,51 +72,50 @@ func (a Adapter) listCommitSHAs( limit int, filter types.CommitFilter, ) ([]string, error) { - args := make([]string, 0, 16) - args = append(args, "rev-list") + cmd := command.New("rev-list") // return commits only up to a certain reference if requested if filter.AfterRef != "" { // ^REF tells the rev-list command to return only commits that aren't reachable by SHA - args = append(args, fmt.Sprintf("^%s", filter.AfterRef)) + cmd.Add(command.WithArg(fmt.Sprintf("^%s", filter.AfterRef))) } // add refCommitSHA as starting point - args = append(args, ref) + cmd.Add(command.WithArg(ref)) if len(filter.Path) != 0 { - args = append(args, "--", filter.Path) + cmd.Add(command.WithPostSepArg(filter.Path)) } // add pagination if requested // TODO: we should add absolut limits to protect git (return error) if limit > 0 { - args = append(args, "--max-count", fmt.Sprint(limit)) + cmd.Add(command.WithFlag("--max-count", strconv.Itoa(limit))) if page > 1 { - args = append(args, "--skip", fmt.Sprint((page-1)*limit)) + cmd.Add(command.WithFlag("--skip", strconv.Itoa((page-1)*limit))) } } if filter.Since > 0 || filter.Until > 0 { - args = append(args, "--date", "unix") + cmd.Add(command.WithFlag("--date", "unix")) } if filter.Since > 0 { - args = append(args, "--since", strconv.FormatInt(filter.Since, 10)) + cmd.Add(command.WithFlag("--since", strconv.FormatInt(filter.Since, 10))) } if filter.Until > 0 { - args = append(args, "--until", strconv.FormatInt(filter.Until, 10)) + cmd.Add(command.WithFlag("--until", strconv.FormatInt(filter.Until, 10))) } if filter.Committer != "" { - args = append(args, "--committer", filter.Committer) + cmd.Add(command.WithFlag("--committer", filter.Committer)) } - - stdout, _, runErr := gitea.NewCommand(ctx, args...).RunStdBytes(&gitea.RunOpts{Dir: repoPath}) - if runErr != nil { + output := &bytes.Buffer{} + err := cmd.Run(ctx, command.WithDir(repoPath), command.WithStdout(output)) + if err != nil { // TODO: handle error in case they don't have a common merge base! - return nil, processGiteaErrorf(runErr, "failed to trigger rev-list command") + return nil, processGiteaErrorf(err, "failed to trigger rev-list command") } - return parseLinesToSlice(stdout), nil + return parseLinesToSlice(output.Bytes()), nil } // ListCommitSHAs lists the commits reachable from ref. @@ -243,13 +243,18 @@ func giteaGetRenameDetails( ref string, path string, ) (*types.PathRenameDetails, error) { - stdout, _, runErr := gitea.NewCommand(giteaRepo.Ctx, "log", ref, "--name-status", "--pretty=format:", "-1"). - RunStdBytes(&gitea.RunOpts{Dir: giteaRepo.Path}) - if runErr != nil { - return nil, fmt.Errorf("failed to trigger log command: %w", runErr) + cmd := command.New("log", + command.WithArg(ref), + command.WithFlag("--name-status"), + command.WithFlag("--pretty=format:", "-1"), + ) + output := &bytes.Buffer{} + err := cmd.Run(giteaRepo.Ctx, command.WithDir(giteaRepo.Path), command.WithStdout(output)) + if err != nil { + return nil, fmt.Errorf("failed to trigger log command: %w", err) } - lines := parseLinesToSlice(stdout) + lines := parseLinesToSlice(output.Bytes()) changeType, oldPath, newPath, err := getFileChangeTypeFromLog(lines, path) if err != nil { @@ -303,7 +308,18 @@ func (a Adapter) GetFullCommitID( if repoPath == "" { return "", ErrRepositoryPathEmpty } - return gitea.GetFullCommitID(ctx, repoPath, shortID) + cmd := command.New("rev-parse", + command.WithArg(shortID), + ) + output := &bytes.Buffer{} + err := cmd.Run(ctx, command.WithDir(repoPath), command.WithStdout(output)) + if err != nil { + if strings.Contains(err.Error(), "exit status 128") { + return "", errors.NotFound("commit not found %s", shortID) + } + return "", err + } + return strings.TrimSpace(output.String()), nil } // GetCommits returns the (latest) commits for a specific list of refs. @@ -462,19 +478,25 @@ func getCommit( fmtSubject + fmtZero + // 7 fmtBody // 8 - args := []string{"log", "--max-count=1", "--format=" + format, rev} + cmd := command.New("log", + command.WithFlag("--max-count", "1"), + command.WithFlag("--format="+format), + command.WithArg(rev), + ) if path != "" { - args = append(args, "--", path) - } - - commitLine, stderr, err := gitea.NewCommand(ctx, args...).RunStdString(&gitea.RunOpts{Dir: repoPath}) - if strings.Contains(stderr, "ambiguous argument") { - return nil, errors.NotFound("revision %q not found", rev) + cmd.Add(command.WithPostSepArg(path)) } + output := &bytes.Buffer{} + err := cmd.Run(ctx, command.WithDir(repoPath), command.WithStdout(output)) if err != nil { + if strings.Contains(err.Error(), "ambiguous argument") { + return nil, errors.NotFound("revision %q not found", rev) + } return nil, fmt.Errorf("failed to run git to get commit data: %w", err) } + commitLine := output.String() + if commitLine == "" { return nil, errors.InvalidArgument("path %q not found in %s", path, rev) } diff --git a/git/adapter/config.go b/git/adapter/config.go index 035c89c00..643cf000e 100644 --- a/git/adapter/config.go +++ b/git/adapter/config.go @@ -20,8 +20,7 @@ import ( "strings" "github.com/harness/gitness/errors" - - "code.gitea.io/gitea/modules/git" + "github.com/harness/gitness/git/command" ) // Config set local git key and value configuration. @@ -38,12 +37,15 @@ func (a Adapter) Config( return errors.InvalidArgument("key cannot be empty") } var outbuf, errbuf strings.Builder - if err := git.NewCommand(ctx, "config", "--local").AddArguments(key, value). - Run(&git.RunOpts{ - Dir: repoPath, - Stdout: &outbuf, - Stderr: &errbuf, - }); err != nil { + cmd := command.New("config", + command.WithFlag("--local"), + command.WithArg(key, value), + ) + err := cmd.Run(ctx, command.WithDir(repoPath), + command.WithStdout(&outbuf), + command.WithStderr(&errbuf), + ) + if err != nil { return fmt.Errorf("git config [%s -> <%s> ]: %w\n%s\n%s", key, value, err, outbuf.String(), errbuf.String()) } diff --git a/git/adapter/object.go b/git/adapter/object.go new file mode 100644 index 000000000..61563b52f --- /dev/null +++ b/git/adapter/object.go @@ -0,0 +1,36 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package adapter + +// ObjectType git object type. +type ObjectType string + +const ( + // ObjectCommit commit object type. + ObjectCommit ObjectType = "commit" + // ObjectTree tree object type. + ObjectTree ObjectType = "tree" + // ObjectBlob blob object type. + ObjectBlob ObjectType = "blob" + // ObjectTag tag object type. + ObjectTag ObjectType = "tag" + // ObjectBranch branch object type. + ObjectBranch ObjectType = "branch" +) + +// Bytes returns the byte array for the Object Type. +func (o ObjectType) Bytes() []byte { + return []byte(o) +} diff --git a/git/adapter/repo.go b/git/adapter/repo.go index 7e924370b..5b311e120 100644 --- a/git/adapter/repo.go +++ b/git/adapter/repo.go @@ -17,10 +17,12 @@ package adapter import ( "context" "fmt" + "os" "regexp" "strings" "time" + "github.com/harness/gitness/git/command" "github.com/harness/gitness/git/types" gitea "code.gitea.io/gitea/modules/git" @@ -44,7 +46,16 @@ func (a Adapter) InitRepository( if repoPath == "" { return ErrRepositoryPathEmpty } - return gitea.InitRepository(ctx, repoPath, bare) + err := os.MkdirAll(repoPath, os.ModePerm) + if err != nil { + return fmt.Errorf("failed to create directory '%s', err: %w", repoPath, err) + } + + cmd := command.New("init") + if bare { + cmd.Add(command.WithFlag("--bare")) + } + return cmd.Run(ctx, command.WithDir(repoPath)) } func (a Adapter) OpenRepository( diff --git a/git/command/command.go b/git/command/command.go new file mode 100644 index 000000000..3a235f54f --- /dev/null +++ b/git/command/command.go @@ -0,0 +1,168 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package command + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os/exec" + "regexp" +) + +var ( + GitExecutable = "git" + + actionRegex = regexp.MustCompile(`^[[:alnum:]]+[-[:alnum:]]*$`) +) + +// Command contains options for running a git command. +type Command struct { + // Name is the name of the Git command to run, e.g. "log", "cat-file" or "worktree". + Name string + + // Action is the action of the Git command, e.g. "set-url" in `git remote set-url` + Action string + + // Flags is the number of optional flags to pass before positional arguments, e.g. + // `--oneline` or `--format=fuller`. + Flags []string + + // Args is the arguments that shall be passed after all flags. These arguments must not be + // flags and thus cannot start with `-`. Note that it may be unsafe to use this field in the + // case where arguments are directly user-controlled. In that case it is advisable to use + // `PostSepArgs` instead. + Args []string + + // PostSepArgs is the arguments that shall be passed as positional arguments after the `--` + // separator. Git recognizes that separator as the point where it should stop expecting any + // options and treat the remaining arguments as positionals. This should be used when + // passing user-controlled input of arbitrary form like for example paths, which may start + // with a `-`. + PostSepArgs []string + + // Git environment variables + Envs Envs + + // internal counter for GIT_CONFIG_COUNT environment variable. + // more info: [link](https://git-scm.com/docs/git-config#Documentation/git-config.txt-GITCONFIGCOUNT) + configEnvCounter int +} + +// New creates new command for interacting with the git process. +func New(name string, options ...CmdOptionFunc) *Command { + c := &Command{ + Name: name, + } + + for _, opt := range options { + opt(c) + } + + return c +} + +// Add appends given options to the command. +func (c *Command) Add(options ...CmdOptionFunc) *Command { + for _, opt := range options { + opt(c) + } + return c +} + +// Run executes the git command with optional configuration using WithXxx functions. +func (c *Command) Run(ctx context.Context, opts ...RunOptionFunc) (err error) { + options := &RunOption{} + for _, f := range opts { + f(options) + } + + if options.Stdout == nil { + options.Stdout = io.Discard + } + errAsBuff := false + if options.Stderr == nil { + options.Stderr = new(bytes.Buffer) + errAsBuff = true + } + + args, err := c.makeArgs() + if err != nil { + return fmt.Errorf("failed to build argument list: %w", err) + } + cmd := exec.CommandContext(ctx, GitExecutable, args...) + if len(c.Envs) > 0 { + cmd.Env = c.Envs.Args() + } + cmd.Dir = options.Dir + cmd.Stdin = options.Stdin + cmd.Stdout = options.Stdout + cmd.Stderr = options.Stderr + if err = cmd.Start(); err != nil { + return err + } + + result := make(chan error) + go func() { + result <- cmd.Wait() + }() + + select { + case <-ctx.Done(): + <-result + if cmd.Process != nil && cmd.ProcessState != nil && !cmd.ProcessState.Exited() { + if err := cmd.Process.Kill(); err != nil && !errors.Is(ctx.Err(), context.DeadlineExceeded) { + return fmt.Errorf("kill process: %w", err) + } + } + + return ctx.Err() + case err = <-result: + if err != nil && errAsBuff { + buff, ok := options.Stderr.(*bytes.Buffer) + if ok { + return NewError(err, buff.Bytes()) + } + } + return err + } +} + +func (c *Command) makeArgs() ([]string, error) { + var safeArgs []string + + commandDescription, ok := descriptions[c.Name] + if !ok { + return nil, fmt.Errorf("invalid sub command name %q: %w", c.Name, ErrInvalidArg) + } + safeArgs = append(safeArgs, c.Name) + + if c.Action != "" { + if !actionRegex.MatchString(c.Action) { + return nil, fmt.Errorf("invalid action %q: %w", c.Action, ErrInvalidArg) + } + safeArgs = append(safeArgs, c.Action) + } + + commandArgs, err := commandDescription.args(c.Flags, c.Args, c.PostSepArgs) + if err != nil { + return nil, err + } + safeArgs = append(safeArgs, commandArgs...) + + return safeArgs, nil +} diff --git a/git/command/command_test.go b/git/command/command_test.go new file mode 100644 index 000000000..dc2f1fad0 --- /dev/null +++ b/git/command/command_test.go @@ -0,0 +1,114 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package command + +import ( + "bytes" + "context" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/harness/gitness/errors" + + "github.com/rs/zerolog/log" +) + +func TestCreateBareRepository(t *testing.T) { + cmd := New("init", WithFlag("--bare"), WithArg("samplerepo")) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + err := cmd.Run(ctx) + defer os.RemoveAll("samplerepo") + if err != nil { + t.Errorf("expected: %v error, got: %v", nil, err) + return + } + + cmd = New("rev-parse", WithFlag("--is-bare-repository")) + output := &bytes.Buffer{} + err = cmd.Run(context.Background(), WithDir("samplerepo"), WithStdout(output)) + if err != nil { + t.Errorf("expected: %v error, got: %v", nil, err) + return + } + got := strings.TrimSpace(output.String()) + exp := "true" + if got != exp { + t.Errorf("expected value: %s, got: %s", exp, got) + return + } +} + +func TestCommandContextTimeout(t *testing.T) { + cmd := New("init", WithFlag("--bare"), WithArg("samplerepo")) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := cmd.Run(ctx) + defer os.RemoveAll("samplerepo") + if err != nil { + t.Errorf("expected: %v error, got: %v", nil, err) + } + + inbuff := &bytes.Buffer{} + inbuff.WriteString("some content") + outbuffer := &bytes.Buffer{} + + cmd = New("hash-object", WithFlag("--stdin")) + err = cmd.Run(ctx, + WithDir("./samplerepo"), + WithStdin(inbuff), + WithStdout(outbuffer), + ) + if err != nil { + t.Errorf("hashing object failed: %v", err) + return + } + + log.Info().Msgf("outbuffer %s", outbuffer.String()) + + cmd = New("cat-file", WithFlag("--batch")) + + pr, pw := io.Pipe() + defer pr.Close() + + outbuffer.Reset() + + go func() { + defer pw.Close() + for i := 0; i < 3; i++ { + _, _ = pw.Write(outbuffer.Bytes()) + time.Sleep(1 * time.Second) + } + }() + + runCtx, runCancel := context.WithTimeout(context.Background(), 1*time.Second) + defer runCancel() + + err = cmd.Run(runCtx, + WithDir("./samplerepo"), + WithStdin(pr), + WithStdout(outbuffer), + ) + + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("expected: %v error, got: %v", context.DeadlineExceeded, err) + } +} diff --git a/git/command/env.go b/git/command/env.go new file mode 100644 index 000000000..e9b02914b --- /dev/null +++ b/git/command/env.go @@ -0,0 +1,40 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package command + +const ( + GitCommitterName = "GIT_COMMITTER_NAME" + GitCommitterEmail = "GIT_COMMITTER_EMAIL" + GitAuthorName = "GIT_AUTHOR_NAME" + GitAuthorEmail = "GIT_AUTHOR_EMAIL" + + GitTrace = "GIT_TRACE" + GitTracePack = "GIT_TRACE_PACK_ACCESS" + GitTracePackAccess = "GIT_TRACE_PACKET" + GitTracePerformance = "GIT_TRACE_PERFORMANCE" + GitTraceSetup = "GIT_TRACE_SETUP" + GitExecPath = "GIT_EXEC_PATH" // tells Git where to find its binaries. +) + +// Envs custom key value store for environment variables. +type Envs map[string]string + +func (e Envs) Args() []string { + slice := make([]string, 0, len(e)) + for key, val := range e { + slice = append(slice, key+"="+val) + } + return slice +} diff --git a/git/command/env_test.go b/git/command/env_test.go new file mode 100644 index 000000000..7fd1c6282 --- /dev/null +++ b/git/command/env_test.go @@ -0,0 +1,43 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package command + +import ( + "reflect" + "testing" +) + +func TestEnvs_Args(t *testing.T) { + tests := []struct { + name string + e Envs + want []string + }{ + { + name: "test envs", + e: Envs{ + "GIT_TRACE": "true", + }, + want: []string{"GIT_TRACE=true"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.e.Args(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("Args() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/git/command/error.go b/git/command/error.go new file mode 100644 index 000000000..6a9f74563 --- /dev/null +++ b/git/command/error.go @@ -0,0 +1,54 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package command + +import ( + "errors" + "fmt" +) + +var ( + // ErrInvalidArg represent family of errors to report about bad argument used to make a call. + ErrInvalidArg = errors.New("invalid argument") +) + +// Error type with optional Stderr payload. +type Error struct { + Err error + StdErr []byte +} + +// NewError creates error with source err and stderr payload. +func NewError(err error, stderr []byte) *Error { + return &Error{ + Err: err, + StdErr: stderr, + } +} + +func (e *Error) Error() string { + if len(e.StdErr) != 0 { + return fmt.Sprintf("%s: %s", e.Err.Error(), e.StdErr) + } + return e.Err.Error() +} + +// AsError unwraps Error otherwise return nil. +func AsError(err error) (e *Error) { + if errors.As(err, &e) { + return + } + return nil +} diff --git a/git/command/name.go b/git/command/name.go new file mode 100644 index 000000000..8de19b986 --- /dev/null +++ b/git/command/name.go @@ -0,0 +1,280 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package command + +import ( + "fmt" + "strings" +) + +const ( + // NoRefUpdates denotes a command which will never update refs. + NoRefUpdates = 1 << iota + // NoEndOfOptions denotes a command which doesn't know --end-of-options. + NoEndOfOptions +) + +type description struct { + flags uint + validatePositionalArgs func([]string) error +} + +// supportsEndOfOptions indicates whether a command can handle the +// `--end-of-options` option. +func (c description) supportsEndOfOptions() bool { + return c.flags&NoEndOfOptions == 0 +} + +// descriptions is a curated list of Git command descriptions. +var descriptions = map[string]description{ + "am": {}, + "apply": { + flags: NoRefUpdates, + }, + "archive": { + // git-archive(1) does not support disambiguating options from paths from revisions. + flags: NoRefUpdates | NoEndOfOptions, + }, + "blame": { + // git-blame(1) does not support disambiguating options from paths from revisions. + flags: NoRefUpdates | NoEndOfOptions, + }, + "bundle": { + flags: NoRefUpdates, + }, + "cat-file": { + flags: NoRefUpdates, + }, + "check-attr": { + flags: NoRefUpdates | NoEndOfOptions, + }, + "check-ref-format": { + // git-check-ref-format(1) uses a hand-rolled option parser which doesn't support + // `--end-of-options`. + flags: NoRefUpdates | NoEndOfOptions, + }, + "checkout": { + // git-checkout(1) does not support disambiguating options from paths from + // revisions. + flags: NoEndOfOptions, + }, + "clone": {}, + "commit": { + flags: 0, + }, + "commit-graph": { + flags: NoRefUpdates, + }, + "commit-tree": { + flags: NoRefUpdates, + }, + "config": { + flags: NoRefUpdates, + }, + "count-objects": { + flags: NoRefUpdates, + }, + "diff": { + flags: NoRefUpdates, + }, + "diff-tree": { + flags: NoRefUpdates, + }, + "fetch": { + flags: 0, + }, + "for-each-ref": { + flags: NoRefUpdates, + }, + "format-patch": { + flags: NoRefUpdates, + }, + "fsck": { + flags: NoRefUpdates, + }, + "gc": { + flags: NoRefUpdates, + }, + "grep": { + // git-grep(1) does not support disambiguating options from paths from + // revisions. + flags: NoRefUpdates | NoEndOfOptions, + }, + "hash-object": { + flags: NoRefUpdates, + }, + "index-pack": { + flags: NoRefUpdates | NoEndOfOptions, + }, + "init": { + flags: NoRefUpdates, + }, + "log": { + flags: NoRefUpdates, + }, + "ls-remote": { + flags: NoRefUpdates, + }, + "ls-tree": { + flags: NoRefUpdates, + }, + "merge-base": { + flags: NoRefUpdates, + }, + "merge-file": { + flags: NoRefUpdates, + }, + "merge-tree": { + flags: NoRefUpdates, + }, + "mktag": { + flags: NoRefUpdates, + }, + "mktree": { + flags: NoRefUpdates, + }, + "multi-pack-index": { + flags: NoRefUpdates, + }, + "pack-refs": { + flags: NoRefUpdates, + }, + "pack-objects": { + flags: NoRefUpdates, + }, + "patch-id": { + flags: NoRefUpdates | NoEndOfOptions, + }, + "prune": { + flags: NoRefUpdates, + }, + "prune-packed": { + flags: NoRefUpdates, + }, + "push": { + flags: NoRefUpdates, + }, + "receive-pack": { + flags: 0, + }, + "remote": { + // While git-remote(1)'s `add` subcommand does support `--end-of-options`, + // `remove` doesn't. + flags: NoEndOfOptions, + }, + "repack": { + flags: NoRefUpdates, + }, + "rev-list": { + // We cannot use --end-of-options here because pseudo revisions like `--all` + // and `--not` count as options. + flags: NoRefUpdates | NoEndOfOptions, + validatePositionalArgs: func(args []string) error { + for _, arg := range args { + // git-rev-list(1) supports pseudo-revision arguments which can be + // intermingled with normal positional arguments. Given that these + // pseudo-revisions have leading dashes, normal validation would + // refuse them as positional arguments. We thus override validation + // for two of these which we are using in our codebase. There are + // more, but we can add them at a later point if they're ever + // required. + if arg == "--all" || arg == "--not" { + continue + } + if err := validatePositionalArg(arg); err != nil { + return fmt.Errorf("rev-list: %w", err) + } + } + return nil + }, + }, + "rev-parse": { + // --end-of-options is echoed by git-rev-parse(1) if used without + // `--verify`. + flags: NoRefUpdates | NoEndOfOptions, + }, + "show": { + flags: NoRefUpdates, + }, + "show-ref": { + flags: NoRefUpdates, + }, + "symbolic-ref": { + flags: 0, + }, + "tag": { + flags: 0, + }, + "unpack-objects": { + flags: NoRefUpdates | NoEndOfOptions, + }, + "update-ref": { + flags: 0, + }, + "upload-archive": { + // git-upload-archive(1) has a handrolled parser which always interprets the + // first argument as directory, so we cannot use `--end-of-options`. + flags: NoRefUpdates | NoEndOfOptions, + }, + "upload-pack": { + flags: NoRefUpdates, + }, + "version": { + flags: NoRefUpdates, + }, + "worktree": { + flags: 0, + }, +} + +// args validates the given flags and arguments and, if valid, returns the complete command line. +func (c description) args(flags []string, args []string, postSepArgs []string) ([]string, error) { + var cmdArgs []string + + cmdArgs = append(cmdArgs, flags...) + + if c.supportsEndOfOptions() { + cmdArgs = append(cmdArgs, "--end-of-options") + } + + if c.validatePositionalArgs != nil { + if err := c.validatePositionalArgs(args); err != nil { + return nil, err + } + } else { + for _, a := range args { + if err := validatePositionalArg(a); err != nil { + return nil, err + } + } + } + cmdArgs = append(cmdArgs, args...) + + if len(postSepArgs) > 0 { + cmdArgs = append(cmdArgs, "--") + } + + // post separator args do not need any validation + cmdArgs = append(cmdArgs, postSepArgs...) + + return cmdArgs, nil +} + +func validatePositionalArg(arg string) error { + if strings.HasPrefix(arg, "-") { + return fmt.Errorf("positional arg %q cannot start with dash '-': %w", arg, ErrInvalidArg) + } + return nil +} diff --git a/git/command/option.go b/git/command/option.go new file mode 100644 index 000000000..d817098ca --- /dev/null +++ b/git/command/option.go @@ -0,0 +1,130 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package command + +import ( + "io" + "strconv" +) + +type CmdOptionFunc func(c *Command) + +// WithAction set the action of the Git command, e.g. "set-url" in `git remote set-url`. +func WithAction(action string) CmdOptionFunc { + return func(c *Command) { + c.Action = action + } +} + +// WithFlag set optional flags to pass before positional arguments. +func WithFlag(flags ...string) CmdOptionFunc { + return func(c *Command) { + c.Flags = append(c.Flags, flags...) + } +} + +// WithArg add arguments that shall be passed after all flags. +func WithArg(args ...string) CmdOptionFunc { + return func(c *Command) { + c.Args = append(c.Args, args...) + } +} + +// WithPostSepArg set arguments that shall be passed as positional arguments after the `--`. +func WithPostSepArg(args ...string) CmdOptionFunc { + return func(c *Command) { + c.PostSepArgs = append(c.PostSepArgs, args...) + } +} + +// WithEnv sets environment variable using key value pair +// for example: WithEnv("GIT_TRACE", "true"). +func WithEnv(keyValPairs ...string) CmdOptionFunc { + return func(c *Command) { + for i := 0; i < len(keyValPairs); i += 2 { + k, v := keyValPairs[i], keyValPairs[i+1] + c.Envs[k] = v + } + } +} + +// WithCommitter sets given committer to the command. +func WithCommitter(name, email string) CmdOptionFunc { + return func(c *Command) { + c.Envs[GitCommitterName] = name + c.Envs[GitCommitterEmail] = email + } +} + +// WithAuthor sets given author to the command. +func WithAuthor(name, email string) CmdOptionFunc { + return func(c *Command) { + c.Envs[GitAuthorName] = name + c.Envs[GitAuthorEmail] = email + } +} + +// WithConfig function sets key and value for config command. +func WithConfig(key, value string) CmdOptionFunc { + return func(c *Command) { + c.Envs["GIT_CONFIG_KEY_"+strconv.Itoa(c.configEnvCounter)] = key + c.Envs["GIT_CONFIG_VALUE_"+strconv.Itoa(c.configEnvCounter)] = value + c.configEnvCounter++ + c.Envs["GIT_CONFIG_COUNT"] = strconv.Itoa(c.configEnvCounter) + } +} + +// RunOption contains option for running a command. +type RunOption struct { + // Dir is location of repo. + Dir string + // Stdin is the input to the command. + Stdin io.Reader + // Stdout is the outputs from the command. + Stdout io.Writer + // Stderr is the error output from the command. + Stderr io.Writer +} + +type RunOptionFunc func(option *RunOption) + +// WithDir set directory RunOption.Dir, this is repository dir +// where git command should be running. +func WithDir(dir string) RunOptionFunc { + return func(option *RunOption) { + option.Dir = dir + } +} + +// WithStdin set RunOption.Stdin reader. +func WithStdin(stdin io.Reader) RunOptionFunc { + return func(option *RunOption) { + option.Stdin = stdin + } +} + +// WithStdout set RunOption.Stdout writer. +func WithStdout(stdout io.Writer) RunOptionFunc { + return func(option *RunOption) { + option.Stdout = stdout + } +} + +// WithStderr set RunOption.Stderr writer. +func WithStderr(stderr io.Writer) RunOptionFunc { + return func(option *RunOption) { + option.Stderr = stderr + } +}