pulumi/sdk/go/auto/git.go

195 lines
6.9 KiB
Go

// Copyright 2016-2020, Pulumi Corporation.
//
// 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 auto
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"sync"
git "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/protocol/packp/capability"
"github.com/go-git/go-git/v5/plumbing/transport"
"github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/go-git/go-git/v5/plumbing/transport/ssh"
)
var transportMutex sync.Mutex
func setupGitRepo(ctx context.Context, workDir string, repoArgs *GitRepo) (string, error) {
cloneOptions := &git.CloneOptions{
RemoteName: "origin", // be explicit so we can require it in remote refs
URL: repoArgs.URL,
}
if repoArgs.Shallow {
cloneOptions.Depth = 1
cloneOptions.SingleBranch = true
}
if repoArgs.Auth != nil {
authDetails := repoArgs.Auth
// Each of the authentication options are mutually exclusive so let's check that only 1 is specified
if authDetails.SSHPrivateKeyPath != "" && authDetails.Username != "" ||
authDetails.PersonalAccessToken != "" && authDetails.Username != "" ||
authDetails.PersonalAccessToken != "" && authDetails.SSHPrivateKeyPath != "" ||
authDetails.Username != "" && authDetails.SSHPrivateKey != "" {
return "", errors.New("please specify one authentication option of `Personal Access Token`, " +
"`Username\\Password`, `SSH Private Key Path` or `SSH Private Key`")
}
// Firstly we will try to check that an SSH Private Key Path has been specified
if authDetails.SSHPrivateKeyPath != "" {
publicKeys, err := ssh.NewPublicKeysFromFile("git", repoArgs.Auth.SSHPrivateKeyPath, repoArgs.Auth.Password)
if err != nil {
return "", fmt.Errorf("unable to use SSH Private Key Path: %w", err)
}
cloneOptions.Auth = publicKeys
}
// Then we check if the details of a SSH Private Key as passed
if authDetails.SSHPrivateKey != "" {
publicKeys, err := ssh.NewPublicKeys("git", []byte(repoArgs.Auth.SSHPrivateKey), repoArgs.Auth.Password)
if err != nil {
return "", fmt.Errorf("unable to use SSH Private Key: %w", err)
}
cloneOptions.Auth = publicKeys
}
// Then we check to see if a Personal Access Token has been specified
// the username for use with a PAT can be *anything* but an empty string
// so we are setting this to `git`
if authDetails.PersonalAccessToken != "" {
cloneOptions.Auth = &http.BasicAuth{
Username: "git",
Password: repoArgs.Auth.PersonalAccessToken,
}
}
// then we check to see if a username and a password has been specified
if authDetails.Password != "" && authDetails.Username != "" {
cloneOptions.Auth = &http.BasicAuth{
Username: repoArgs.Auth.Username,
Password: repoArgs.Auth.Password,
}
}
}
// *Repository.Clone() will do appropriate fetching given a branch name. We must deal with
// different varieties, since people have been advised to use these as a workaround while only
// "refs/heads/<default>" worked.
//
// If a reference name is not supplied, then .Clone will fetch all refs (and all objects
// referenced by those), and checking out a commit later will work as expected.
if repoArgs.Branch != "" {
refName := plumbing.ReferenceName(repoArgs.Branch)
switch {
case refName.IsRemote(): // e.g., refs/remotes/origin/branch
shorter := refName.Short() // this gives "origin/branch"
parts := strings.SplitN(shorter, "/", 2)
if len(parts) == 2 && parts[0] == "origin" {
refName = plumbing.NewBranchReferenceName(parts[1])
} else {
return "", fmt.Errorf("a remote ref must begin with 'refs/remote/origin/', but got %q", repoArgs.Branch)
}
case refName.IsTag(): // looks like `refs/tags/v1.0.0` -- respect this even though the field is `.Branch`
// nothing to do
case !refName.IsBranch(): // not a remote, not refs/heads/branch; treat as a simple branch name
refName = plumbing.NewBranchReferenceName(repoArgs.Branch)
default:
// already looks like a full branch name, so use as is
}
cloneOptions.ReferenceName = refName
}
// Azure DevOps requires multi_ack and multi_ack_detailed capabilities, which go-git doesn't implement.
// But: it's possible to do a full clone by saying it's _not_ _un_supported, in which case the library
// happily functions so long as it doesn't _actually_ get a multi_ack packet. See
// https://github.com/go-git/go-git/blob/v5.5.1/_examples/azure_devops/main.go.
repo, err := func() (*git.Repository, error) {
// Because transport.UnsupportedCapabilities is a global variable, we need a global lock around the
// use of this.
transportMutex.Lock()
defer transportMutex.Unlock()
oldUnsupportedCaps := transport.UnsupportedCapabilities
// This check is crude, but avoids having another dependency to parse the git URL.
if strings.Contains(repoArgs.URL, "dev.azure.com") {
transport.UnsupportedCapabilities = []capability.Capability{
capability.ThinPack,
}
}
// clone
repo, err := git.PlainCloneContext(ctx, workDir, false, cloneOptions)
// Regardless of error we need to restore the UnsupportedCapabilities
transport.UnsupportedCapabilities = oldUnsupportedCaps
return repo, err
}()
if err != nil {
return "", fmt.Errorf("unable to clone repo: %w", err)
}
if repoArgs.CommitHash != "" {
// ensure that the commit has been fetched
err := func() error {
// repo.FetchContext ends up looking at the global transport.UnsupportedCapabilities, so we need a
// global lock around the use of this.
transportMutex.Lock()
defer transportMutex.Unlock()
return repo.FetchContext(ctx, &git.FetchOptions{
RemoteName: "origin",
Auth: cloneOptions.Auth,
Depth: cloneOptions.Depth,
RefSpecs: []config.RefSpec{config.RefSpec(repoArgs.CommitHash + ":" + repoArgs.CommitHash)},
})
}()
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) && !errors.Is(err, git.ErrExactSHA1NotSupported) {
return "", fmt.Errorf("fetching commit: %w", err)
}
// checkout commit if specified
w, err := repo.Worktree()
if err != nil {
return "", err
}
hash := repoArgs.CommitHash
err = w.Checkout(&git.CheckoutOptions{
Hash: plumbing.NewHash(hash),
Force: true,
})
if err != nil {
return "", fmt.Errorf("unable to checkout commit: %w", err)
}
}
var relPath string
if repoArgs.ProjectPath != "" {
relPath = repoArgs.ProjectPath
}
workDir = filepath.Join(workDir, relPath)
return workDir, nil
}