[feat] command package - initial work (#993)

eb/code-1016-2
Enver Bisevac 2024-01-31 15:02:19 +00:00 committed by Harness
parent 87cae05747
commit 26c06b65a0
15 changed files with 1095 additions and 63 deletions

View File

@ -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{

View File

@ -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

View File

@ -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
}

128
git/adapter/cat-file.go Normal file
View File

@ -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:
// <sha> SP <type> SP <size> 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
}

View File

@ -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)
}

View File

@ -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())
}

36
git/adapter/object.go Normal file
View File

@ -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)
}

View File

@ -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(

168
git/command/command.go Normal file
View File

@ -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
}

114
git/command/command_test.go Normal file
View File

@ -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)
}
}

40
git/command/env.go Normal file
View File

@ -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
}

43
git/command/env_test.go Normal file
View File

@ -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)
}
})
}
}

54
git/command/error.go Normal file
View File

@ -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
}

280
git/command/name.go Normal file
View File

@ -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
}

130
git/command/option.go Normal file
View File

@ -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
}
}