drone/git/api/repo.go

461 lines
11 KiB
Go

// 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 api
import (
"bytes"
"context"
"fmt"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/harness/gitness/git/command"
"github.com/rs/zerolog/log"
)
type CloneRepoOptions struct {
Timeout time.Duration
Mirror bool
Bare bool
Quiet bool
Branch string
Shared bool
NoCheckout bool
Depth int
Filter string
SkipTLSVerify bool
}
type PushOptions struct {
Remote string
Branch string
Force bool
ForceWithLease string
Env []string
Timeout time.Duration
Mirror bool
}
// ObjectCount represents the parsed information from the `git count-objects -v` command.
// For field meanings, see https://git-scm.com/docs/git-count-objects#_options.
type ObjectCount struct {
Count int
Size int64
InPack int
Packs int
SizePack int64
PrunePackable int
Garbage int
SizeGarbage int64
}
const (
gitReferenceNamePrefixBranch = "refs/heads/"
gitReferenceNamePrefixTag = "refs/tags/"
)
var lsRemoteHeadRegexp = regexp.MustCompile(`ref: refs/heads/([^\s]+)\s+HEAD`)
// InitRepository initializes a new Git repository.
func (g *Git) InitRepository(
ctx context.Context,
repoPath string,
bare bool,
) error {
if repoPath == "" {
return ErrRepositoryPathEmpty
}
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))
}
// SetDefaultBranch sets the default branch of a repo.
func (g *Git) SetDefaultBranch(
ctx context.Context,
repoPath string,
defaultBranch string,
allowEmpty bool,
) error {
if repoPath == "" {
return ErrRepositoryPathEmpty
}
// if requested, error out if branch doesn't exist. Otherwise, blindly set it.
exist, err := g.IsBranchExist(ctx, repoPath, defaultBranch)
if err != nil {
log.Ctx(ctx).Err(err).Msgf("failed to set default branch")
}
if !allowEmpty && !exist {
// TODO: ensure this returns not found error to caller
return fmt.Errorf("branch '%s' does not exist", defaultBranch)
}
// change default branch
cmd := command.New("symbolic-ref",
command.WithArg("HEAD", gitReferenceNamePrefixBranch+defaultBranch),
)
err = cmd.Run(ctx, command.WithDir(repoPath))
if err != nil {
return processGitErrorf(err, "failed to set new default branch")
}
return nil
}
// GetDefaultBranch gets the default branch of a repo.
func (g *Git) GetDefaultBranch(
ctx context.Context,
repoPath string,
) (string, error) {
if repoPath == "" {
return "", ErrRepositoryPathEmpty
}
// get default branch
cmd := command.New("symbolic-ref",
command.WithArg("HEAD"),
)
output := &bytes.Buffer{}
err := cmd.Run(ctx,
command.WithDir(repoPath),
command.WithStdout(output))
if err != nil {
return "", processGitErrorf(err, "failed to get default branch")
}
return output.String(), nil
}
// GetRemoteDefaultBranch retrieves the default branch of a remote repository.
// If the repo doesn't have a default branch, types.ErrNoDefaultBranch is returned.
func (g *Git) GetRemoteDefaultBranch(
ctx context.Context,
remoteURL string,
) (string, error) {
cmd := command.New("ls-remote",
command.WithConfig("credential.helper", ""),
command.WithFlag("--symref"),
command.WithFlag("-q"),
command.WithArg(remoteURL),
command.WithArg("HEAD"),
)
output := &bytes.Buffer{}
if err := cmd.Run(ctx, command.WithStdout(output)); err != nil {
return "", processGitErrorf(err, "failed to ls remote repo")
}
// git output looks as follows, and we are looking for the ref that HEAD points to
// ref: refs/heads/main HEAD
// 46963bc7f0b5e8c5f039d50ac9e6e51933c78cdf HEAD
match := lsRemoteHeadRegexp.FindStringSubmatch(strings.TrimSpace(output.String()))
if match == nil {
return "", ErrNoDefaultBranch
}
return match[1], nil
}
func (g *Git) Clone(
ctx context.Context,
from string,
to string,
opts CloneRepoOptions,
) error {
if err := os.MkdirAll(to, os.ModePerm); err != nil {
return err
}
cmd := command.New("clone")
if opts.SkipTLSVerify {
cmd.Add(command.WithConfig("http.sslVerify", "false"))
}
if opts.Mirror {
cmd.Add(command.WithFlag("--mirror"))
}
if opts.Bare {
cmd.Add(command.WithFlag("--bare"))
}
if opts.Quiet {
cmd.Add(command.WithFlag("--quiet"))
}
if opts.Shared {
cmd.Add(command.WithFlag("-s"))
}
if opts.NoCheckout {
cmd.Add(command.WithFlag("--no-checkout"))
}
if opts.Depth > 0 {
cmd.Add(command.WithFlag("--depth", strconv.Itoa(opts.Depth)))
}
if opts.Filter != "" {
cmd.Add(command.WithFlag("--filter", opts.Filter))
}
if len(opts.Branch) > 0 {
cmd.Add(command.WithFlag("-b", opts.Branch))
}
cmd.Add(command.WithPostSepArg(from, to))
if err := cmd.Run(ctx); err != nil {
return fmt.Errorf("failed to clone repository: %w", err)
}
return nil
}
// Sync synchronizes the repository to match the provided source.
// NOTE: This is a read operation and doesn't trigger any server side hooks.
func (g *Git) Sync(
ctx context.Context,
repoPath string,
source string,
refSpecs []string,
) error {
if repoPath == "" {
return ErrRepositoryPathEmpty
}
if len(refSpecs) == 0 {
refSpecs = []string{"+refs/*:refs/*"}
}
cmd := command.New("fetch",
command.WithConfig("advice.fetchShowForcedUpdates", "false"),
command.WithConfig("credential.helper", ""),
command.WithFlag(
"--quiet",
"--prune",
"--atomic",
"--force",
"--no-write-fetch-head",
"--no-show-forced-updates",
),
command.WithArg(source),
command.WithArg(refSpecs...),
)
err := cmd.Run(ctx, command.WithDir(repoPath))
if err != nil {
return processGitErrorf(err, "failed to sync repo")
}
return nil
}
func (g *Git) AddFiles(
ctx context.Context,
repoPath string,
all bool,
files ...string,
) error {
if repoPath == "" {
return ErrRepositoryPathEmpty
}
cmd := command.New("add")
if all {
cmd.Add(command.WithFlag("--all"))
}
cmd.Add(command.WithPostSepArg(files...))
err := cmd.Run(ctx, command.WithDir(repoPath))
if err != nil {
return processGitErrorf(err, "failed to add changes")
}
return nil
}
// Commit commits the changes of the repository.
func (g *Git) Commit(
ctx context.Context,
repoPath string,
opts CommitChangesOptions,
) error {
if repoPath == "" {
return ErrRepositoryPathEmpty
}
cmd := command.New("commit",
command.WithFlag("-m", opts.Message),
command.WithAuthorAndDate(
opts.Author.Identity.Name,
opts.Author.Identity.Email,
opts.Author.When,
),
command.WithCommitterAndDate(
opts.Committer.Identity.Name,
opts.Committer.Identity.Email,
opts.Committer.When,
),
)
err := cmd.Run(ctx, command.WithDir(repoPath))
// No stderr but exit status 1 means nothing to commit (see gitea CommitChanges)
if err != nil && err.Error() != "exit status 1" {
return processGitErrorf(err, "failed to commit changes")
}
return nil
}
// Push pushs local commits to given remote branch.
// TODOD: return our own error types and move to above api.Push method
func (g *Git) Push(
ctx context.Context,
repoPath string,
opts PushOptions,
) error {
if repoPath == "" {
return ErrRepositoryPathEmpty
}
cmd := command.New("push",
command.WithConfig("credential.helper", ""),
)
if opts.Force {
cmd.Add(command.WithFlag("-f"))
}
if opts.ForceWithLease != "" {
cmd.Add(command.WithFlag("--force-with-lease=" + opts.ForceWithLease))
}
if opts.Mirror {
cmd.Add(command.WithFlag("--mirror"))
}
cmd.Add(command.WithPostSepArg(opts.Remote))
if len(opts.Branch) > 0 {
cmd.Add(command.WithPostSepArg(opts.Branch))
}
if g.traceGit {
cmd.Add(command.WithEnv(command.GitTrace, "true"))
}
// remove credentials if there are any
if strings.Contains(opts.Remote, "://") && strings.Contains(opts.Remote, "@") {
opts.Remote = SanitizeCredentialURLs(opts.Remote)
}
var outbuf, errbuf strings.Builder
err := cmd.Run(ctx,
command.WithDir(repoPath),
command.WithStdout(&outbuf),
command.WithStderr(&errbuf),
command.WithEnvs(opts.Env...),
)
if g.traceGit {
log.Ctx(ctx).Trace().
Str("git", "push").
Err(err).
Msgf("IN:\n%#v\n\nSTDOUT:\n%s\n\nSTDERR:\n%s", opts, outbuf.String(), errbuf.String())
}
if err != nil {
switch {
case strings.Contains(errbuf.String(), "non-fast-forward"):
return &PushOutOfDateError{
StdOut: outbuf.String(),
StdErr: errbuf.String(),
Err: err,
}
case strings.Contains(errbuf.String(), "! [remote rejected]"):
err := &PushRejectedError{
StdOut: outbuf.String(),
StdErr: errbuf.String(),
Err: err,
}
err.GenerateMessage()
return err
case strings.Contains(errbuf.String(), "matches more than one"):
err := &MoreThanOneError{
StdOut: outbuf.String(),
StdErr: errbuf.String(),
Err: err,
}
return err
default:
// fall through to normal error handling
}
}
if err != nil {
// add commandline error output to error
if errbuf.Len() > 0 {
err = fmt.Errorf("%w\ncmd error output: %s", err, errbuf.String())
}
return processGitErrorf(err, "failed to push changes")
}
return nil
}
func (g *Git) CountObjects(ctx context.Context, repoPath string) (ObjectCount, error) {
var outbuf strings.Builder
cmd := command.New("count-objects", command.WithFlag("-v"))
err := cmd.Run(ctx,
command.WithDir(repoPath),
command.WithStdout(&outbuf),
)
if err != nil {
return ObjectCount{}, fmt.Errorf("error running git count-objects: %w", err)
}
objectCount := parseGitCountObjectsOutput(ctx, outbuf.String())
return objectCount, nil
}
func parseGitCountObjectsOutput(ctx context.Context, output string) ObjectCount {
info := ObjectCount{}
output = strings.TrimSpace(output)
lines := strings.Split(output, "\n")
for _, line := range lines {
fields := strings.Fields(line)
switch fields[0] {
case "count:":
fmt.Sscanf(fields[1], "%d", &info.Count)
case "size:":
fmt.Sscanf(fields[1], "%d", &info.Size)
case "in-pack:":
fmt.Sscanf(fields[1], "%d", &info.InPack)
case "packs:":
fmt.Sscanf(fields[1], "%d", &info.Packs)
case "size-pack:":
fmt.Sscanf(fields[1], "%d", &info.SizePack)
case "prune-packable:":
fmt.Sscanf(fields[1], "%d", &info.PrunePackable)
case "garbage:":
fmt.Sscanf(fields[1], "%d", &info.Garbage)
case "size-garbage:":
fmt.Sscanf(fields[1], "%d", &info.SizeGarbage)
default:
log.Ctx(ctx).Warn().Msgf("line '%s: %s' not processed", fields[0], fields[1])
}
}
return info
}