Path: blob/main/components/content-service/pkg/git/git.go
2500 views
// Copyright (c) 2020 Gitpod GmbH. All rights reserved.1// Licensed under the GNU Affero General Public License (AGPL).2// See License.AGPL.txt in the project root for license information.34package git56import (7"bytes"8"context"9"fmt"10"io"11"os"12"os/exec"13"path/filepath"14"strings"15"time"1617"github.com/opentracing/opentracing-go"18"golang.org/x/xerrors"1920"github.com/gitpod-io/gitpod/common-go/log"21"github.com/gitpod-io/gitpod/common-go/tracing"22csapi "github.com/gitpod-io/gitpod/content-service/api"23)2425var (26// errNoCommitsYet is a substring of a Git error if we have no commits yet in a working copy27errNoCommitsYet = "does not have any commits yet"28)2930// IsWorkingCopy determines whether a path is a valid Git working copy/repo31func IsWorkingCopy(location string) bool {32gitFolder := filepath.Join(location, ".git")33if stat, err := os.Stat(gitFolder); err == nil {34return stat.IsDir()35}3637return false38}3940// AuthMethod is the means of authentication used during clone41type AuthMethod string4243// AuthProvider provides authentication to access a Git repository44type AuthProvider func() (username string, password string, err error)4546const (47// NoAuth disables authentication during clone48NoAuth AuthMethod = ""4950// BasicAuth uses HTTP basic auth during clone (fails if repo is cloned through http)51BasicAuth AuthMethod = "basic-auth"52)5354// CachingAuthProvider caches the first non-erroneous response of the delegate auth provider55func CachingAuthProvider(d AuthProvider) AuthProvider {56var (57cu, cpwd string58cached bool59)60return func() (username string, password string, err error) {61if cached {62return cu, cpwd, nil63}6465username, password, err = d()66if err != nil {67return68}6970cu = username71cpwd = password72cached = true73return74}75}7677// Client is a Git configuration based on which we can execute git78type Client struct {79// AuthProvider provides authentication to access a Git repository80AuthProvider AuthProvider81// AuthMethod is the method by which we authenticate82AuthMethod AuthMethod8384// Location is the path in the filesystem where we'll work in (the CWD of the Git executable)85Location string8687// Config values to be set on clone provided through `.gitpod.yml`88Config map[string]string8990// RemoteURI is the Git WS remote origin91RemoteURI string9293// UpstreamCloneURI is the fork upstream of a repository94UpstreamRemoteURI string9596// if true will run git command as gitpod user (should be executed as root that has access to sudo in this case)97RunAsGitpodUser bool9899// FullClone indicates whether we should do a full checkout or a shallow clone100FullClone bool101}102103// Status describes the status of a Git repo/working copy akin to "git status"104type Status struct {105porcelainStatus106UnpushedCommits []string107LatestCommit string108}109110const (111// maxPendingChanges is the limit beyond which we no longer report pending changes.112// For example, if a workspace has then 150 untracked files, we'll report the first113// 100 followed by "... and 50 more".114//115// We do this to keep the load on our infrastructure light and because beyond this number116// the changes are irrelevant anyways.117maxPendingChanges = 100118)119120// ToAPI produces an API response from the Git status121func (s *Status) ToAPI() *csapi.GitStatus {122limit := func(entries []string) []string {123if len(entries) > maxPendingChanges {124return append(entries[0:maxPendingChanges], fmt.Sprintf("... and %d more", len(entries)-maxPendingChanges))125}126127return entries128}129return &csapi.GitStatus{130Branch: s.BranchHead,131LatestCommit: s.LatestCommit,132UncommitedFiles: limit(s.UncommitedFiles),133TotalUncommitedFiles: int64(len(s.UncommitedFiles)),134UntrackedFiles: limit(s.UntrackedFiles),135TotalUntrackedFiles: int64(len(s.UntrackedFiles)),136UnpushedCommits: limit(s.UnpushedCommits),137TotalUnpushedCommits: int64(len(s.UnpushedCommits)),138}139}140141// OpFailedError is returned by GitWithOutput if the operation fails142// e.g. returns with a non-zero exit code.143type OpFailedError struct {144Subcommand string145Args []string146ExecErr error147Output string148}149150func (e OpFailedError) Error() string {151return fmt.Sprintf("git %s %s failed (%v): %v", e.Subcommand, strings.Join(e.Args, " "), e.ExecErr, e.Output)152}153154// GitWithOutput starts git and returns the stdout of the process. This function returns once git is started,155// not after it finishd. Once the returned reader returned io.EOF, the command is finished.156func (c *Client) GitWithOutput(ctx context.Context, ignoreErr *string, subcommand string, args ...string) (out []byte, err error) {157//nolint:staticcheck,ineffassign158span, ctx := opentracing.StartSpanFromContext(ctx, fmt.Sprintf("git.%s", subcommand))159defer func() {160if err != nil && ignoreErr != nil && strings.Contains(err.Error(), *ignoreErr) {161tracing.FinishSpan(span, nil)162} else {163tracing.FinishSpan(span, &err)164}165}()166167fullArgs := make([]string, 0)168env := make([]string, 0)169if c.AuthMethod == BasicAuth {170if c.AuthProvider == nil {171return nil, xerrors.Errorf("basic-auth method requires an auth provider")172}173174fullArgs = append(fullArgs, "-c", "credential.helper=/bin/sh -c \"echo username=$GIT_AUTH_USER; echo password=$GIT_AUTH_PASSWORD\"")175176user, pwd, err := c.AuthProvider()177if err != nil {178return nil, err179}180env = append(env, fmt.Sprintf("GIT_AUTH_USER=%s", user))181env = append(env, fmt.Sprintf("GIT_AUTH_PASSWORD=%s", pwd))182}183184env = append(env, "HOME=/home/gitpod")185186fullArgs = append(fullArgs, subcommand)187fullArgs = append(fullArgs, args...)188189env = append(env, fmt.Sprintf("PATH=%s", os.Getenv("PATH")))190if os.Getenv("http_proxy") != "" {191env = append(env, fmt.Sprintf("http_proxy=%s", os.Getenv("http_proxy")))192}193if os.Getenv("https_proxy") != "" {194env = append(env, fmt.Sprintf("https_proxy=%s", os.Getenv("https_proxy")))195}196if v := os.Getenv("GIT_SSL_CAPATH"); v != "" {197env = append(env, fmt.Sprintf("GIT_SSL_CAPATH=%s", v))198}199200if v := os.Getenv("GIT_SSL_CAINFO"); v != "" {201env = append(env, fmt.Sprintf("GIT_SSL_CAINFO=%s", v))202}203204span.LogKV("args", fullArgs)205206cmdName := "git"207if c.RunAsGitpodUser {208cmdName = "sudo"209fullArgs = append([]string{"-u", "gitpod", "git"}, fullArgs...)210}211cmd := exec.Command(cmdName, fullArgs...)212cmd.Dir = c.Location213cmd.Env = env214215res, err := cmd.CombinedOutput()216if err != nil {217if strings.Contains(err.Error(), "no child process") {218return res, nil219}220221return nil, OpFailedError{222Args: args,223ExecErr: err,224Output: string(res),225Subcommand: subcommand,226}227}228229return res, nil230}231232// Git executes git using the client configuration233func (c *Client) Git(ctx context.Context, subcommand string, args ...string) (err error) {234_, err = c.GitWithOutput(ctx, nil, subcommand, args...)235if err != nil {236return err237}238return nil239}240241// GitStatusFromFiles same as Status but reads git output from preexisting files that were generated by prestop hook242func GitStatusFromFiles(ctx context.Context, loc string) (res *Status, err error) {243gitout, err := os.ReadFile(filepath.Join(loc, "git_status.txt"))244if err != nil {245return nil, err246}247porcelain, err := parsePorcelain(bytes.NewReader(gitout))248if err != nil {249return nil, err250}251252unpushedCommits := make([]string, 0)253gitout, err = os.ReadFile(filepath.Join(loc, "git_log_1.txt"))254if err != nil && !strings.Contains(err.Error(), errNoCommitsYet) {255return nil, err256}257if gitout != nil {258out, err := io.ReadAll(bytes.NewReader(gitout))259if err != nil {260return nil, xerrors.Errorf("cannot determine unpushed commits: %w", err)261}262for _, l := range strings.Split(string(out), "\n") {263tl := strings.TrimSpace(l)264if tl != "" {265unpushedCommits = append(unpushedCommits, tl)266}267}268}269if len(unpushedCommits) == 0 {270unpushedCommits = nil271}272273latestCommit := ""274gitout, err = os.ReadFile(filepath.Join(loc, "git_log_2.txt"))275if err != nil && !strings.Contains(err.Error(), errNoCommitsYet) {276return nil, err277}278if len(gitout) > 0 {279latestCommit = strings.TrimSpace(string(gitout))280}281282return &Status{283porcelainStatus: *porcelain,284UnpushedCommits: unpushedCommits,285LatestCommit: latestCommit,286}, nil287}288289// StatusOption configures the behavior of git status290type StatusOption func(*statusOptions)291292type statusOptions struct {293disableOptionalLocks bool294}295296// WithDisableOptionalLocks disables optional locks during git status297func WithDisableOptionalLocks(disable bool) StatusOption {298return func(o *statusOptions) {299o.disableOptionalLocks = disable300}301}302303// Status runs git status304func (c *Client) Status(ctx context.Context, opts ...StatusOption) (res *Status, err error) {305options := &statusOptions{}306for _, opt := range opts {307opt(options)308}309310args := []string{"status", "--porcelain=v2", "--branch", "-uall"}311if options.disableOptionalLocks {312args = append([]string{"--no-optional-locks"}, args...)313}314gitout, err := c.GitWithOutput(ctx, nil, args[0], args[1:]...)315if err != nil {316return nil, err317}318porcelain, err := parsePorcelain(bytes.NewReader(gitout))319if err != nil {320return nil, err321}322323unpushedCommits := make([]string, 0)324gitout, err = c.GitWithOutput(ctx, &errNoCommitsYet, "log", "--pretty=%h: %s", "--branches", "--not", "--remotes")325if err != nil && !strings.Contains(err.Error(), errNoCommitsYet) {326return nil, err327}328if gitout != nil {329out, err := io.ReadAll(bytes.NewReader(gitout))330if err != nil {331return nil, xerrors.Errorf("cannot determine unpushed commits: %w", err)332}333for _, l := range strings.Split(string(out), "\n") {334tl := strings.TrimSpace(l)335if tl != "" {336unpushedCommits = append(unpushedCommits, tl)337}338}339}340if len(unpushedCommits) == 0 {341unpushedCommits = nil342}343344latestCommit := ""345gitout, err = c.GitWithOutput(ctx, &errNoCommitsYet, "log", "--pretty=%H", "-n", "1")346if err != nil && !strings.Contains(err.Error(), errNoCommitsYet) {347return nil, err348}349if len(gitout) > 0 {350latestCommit = strings.TrimSpace(string(gitout))351}352353return &Status{354porcelainStatus: *porcelain,355UnpushedCommits: unpushedCommits,356LatestCommit: latestCommit,357}, nil358}359360// Clone runs git clone361func (c *Client) Clone(ctx context.Context) (err error) {362err = os.MkdirAll(c.Location, 0775)363if err != nil {364log.WithError(err).Error("cannot create clone location")365}366367now := time.Now()368369defer func() {370log.WithField("duration", time.Since(now).String()).WithField("FullClone", c.FullClone).Info("clone repository took")371}()372373args := []string{"--depth=1", "--shallow-submodules", c.RemoteURI}374375if c.FullClone {376args = []string{c.RemoteURI}377}378379for key, value := range c.Config {380args = append(args, "--config")381args = append(args, strings.TrimSpace(key)+"="+strings.TrimSpace(value))382}383384// TODO: remove workaround once https://gitlab.com/gitlab-org/gitaly/-/issues/4248 is fixed385if strings.Contains(c.RemoteURI, "gitlab.com") {386args = append(args, "--config")387args = append(args, "http.version=HTTP/1.1")388}389390args = append(args, ".")391392return c.Git(ctx, "clone", args...)393}394395// UpdateRemote performs a git fetch on the upstream remote URI396func (c *Client) UpdateRemote(ctx context.Context) (err error) {397//nolint:staticcheck,ineffassign398span, ctx := opentracing.StartSpanFromContext(ctx, "updateRemote")399span.SetTag("upstreamRemoteURI", c.UpstreamRemoteURI)400defer tracing.FinishSpan(span, &err)401402// fetch upstream403if c.UpstreamRemoteURI != "" {404if err := c.Git(ctx, "remote", "add", "upstream", c.UpstreamRemoteURI); err != nil {405return err406}407// fetch408if err := c.Git(ctx, "fetch", "upstream"); err != nil {409return err410}411}412413return nil414}415416// UpdateSubmodules updates a repositories submodules417func (c *Client) UpdateSubmodules(ctx context.Context) (err error) {418//nolint:staticcheck,ineffassign419span, ctx := opentracing.StartSpanFromContext(ctx, "updateSubmodules")420defer tracing.FinishSpan(span, &err)421422// checkout submodules423// git submodule update --init --recursive424if err := c.Git(ctx, "submodule", "update", "--init", "--recursive"); err != nil {425return err426}427return nil428}429430431