252 lines
6.4 KiB
Go
252 lines
6.4 KiB
Go
package git // import "github.com/docker/docker/builder/remotecontext/git"
|
|
|
|
import (
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/moby/sys/symlink"
|
|
"github.com/pkg/errors"
|
|
exec "golang.org/x/sys/execabs"
|
|
)
|
|
|
|
type gitRepo struct {
|
|
remote string
|
|
ref string
|
|
subdir string
|
|
|
|
isolateConfig bool
|
|
}
|
|
|
|
// CloneOption changes the behaviour of Clone().
|
|
type CloneOption func(*gitRepo)
|
|
|
|
// WithIsolatedConfig disables reading the user or system gitconfig files when
|
|
// performing Git operations.
|
|
func WithIsolatedConfig(v bool) CloneOption {
|
|
return func(gr *gitRepo) {
|
|
gr.isolateConfig = v
|
|
}
|
|
}
|
|
|
|
// Clone clones a repository into a newly created directory which
|
|
// will be under "docker-build-git"
|
|
func Clone(remoteURL string, opts ...CloneOption) (string, error) {
|
|
repo, err := parseRemoteURL(remoteURL)
|
|
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
opt(&repo)
|
|
}
|
|
|
|
return repo.clone()
|
|
}
|
|
|
|
func (repo gitRepo) clone() (checkoutDir string, err error) {
|
|
fetch := fetchArgs(repo.remote, repo.ref)
|
|
|
|
root, err := os.MkdirTemp("", "docker-build-git")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
defer func() {
|
|
if err != nil {
|
|
os.RemoveAll(root)
|
|
}
|
|
}()
|
|
|
|
if out, err := repo.gitWithinDir(root, "init"); err != nil {
|
|
return "", errors.Wrapf(err, "failed to init repo at %s: %s", root, out)
|
|
}
|
|
|
|
// Add origin remote for compatibility with previous implementation that
|
|
// used "git clone" and also to make sure local refs are created for branches
|
|
if out, err := repo.gitWithinDir(root, "remote", "add", "origin", repo.remote); err != nil {
|
|
return "", errors.Wrapf(err, "failed add origin repo at %s: %s", repo.remote, out)
|
|
}
|
|
|
|
if output, err := repo.gitWithinDir(root, fetch...); err != nil {
|
|
return "", errors.Wrapf(err, "error fetching: %s", output)
|
|
}
|
|
|
|
checkoutDir, err = repo.checkout(root)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
cmd := exec.Command("git", "submodule", "update", "--init", "--recursive", "--depth=1")
|
|
cmd.Dir = root
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return "", errors.Wrapf(err, "error initializing submodules: %s", output)
|
|
}
|
|
|
|
return checkoutDir, nil
|
|
}
|
|
|
|
func parseRemoteURL(remoteURL string) (gitRepo, error) {
|
|
repo := gitRepo{}
|
|
|
|
if !isGitTransport(remoteURL) {
|
|
remoteURL = "https://" + remoteURL
|
|
}
|
|
|
|
var fragment string
|
|
if strings.HasPrefix(remoteURL, "git@") {
|
|
// git@.. is not an URL, so cannot be parsed as URL
|
|
parts := strings.SplitN(remoteURL, "#", 2)
|
|
|
|
repo.remote = parts[0]
|
|
if len(parts) == 2 {
|
|
fragment = parts[1]
|
|
}
|
|
repo.ref, repo.subdir = getRefAndSubdir(fragment)
|
|
} else {
|
|
u, err := url.Parse(remoteURL)
|
|
if err != nil {
|
|
return repo, err
|
|
}
|
|
|
|
repo.ref, repo.subdir = getRefAndSubdir(u.Fragment)
|
|
u.Fragment = ""
|
|
repo.remote = u.String()
|
|
}
|
|
|
|
if strings.HasPrefix(repo.ref, "-") {
|
|
return gitRepo{}, errors.Errorf("invalid refspec: %s", repo.ref)
|
|
}
|
|
|
|
return repo, nil
|
|
}
|
|
|
|
func getRefAndSubdir(fragment string) (ref string, subdir string) {
|
|
refAndDir := strings.SplitN(fragment, ":", 2)
|
|
ref = "master"
|
|
if len(refAndDir[0]) != 0 {
|
|
ref = refAndDir[0]
|
|
}
|
|
if len(refAndDir) > 1 && len(refAndDir[1]) != 0 {
|
|
subdir = refAndDir[1]
|
|
}
|
|
return
|
|
}
|
|
|
|
func fetchArgs(remoteURL string, ref string) []string {
|
|
args := []string{"fetch"}
|
|
|
|
if supportsShallowClone(remoteURL) {
|
|
args = append(args, "--depth", "1")
|
|
}
|
|
|
|
return append(args, "origin", "--", ref)
|
|
}
|
|
|
|
// Check if a given git URL supports a shallow git clone,
|
|
// i.e. it is a non-HTTP server or a smart HTTP server.
|
|
func supportsShallowClone(remoteURL string) bool {
|
|
if scheme := getScheme(remoteURL); scheme == "http" || scheme == "https" {
|
|
// Check if the HTTP server is smart
|
|
|
|
// Smart servers must correctly respond to a query for the git-upload-pack service
|
|
serviceURL := remoteURL + "/info/refs?service=git-upload-pack"
|
|
|
|
// Try a HEAD request and fallback to a Get request on error
|
|
res, err := http.Head(serviceURL) // #nosec G107
|
|
if err != nil || res.StatusCode != http.StatusOK {
|
|
res, err = http.Get(serviceURL) // #nosec G107
|
|
if err == nil {
|
|
res.Body.Close()
|
|
}
|
|
if err != nil || res.StatusCode != http.StatusOK {
|
|
// request failed
|
|
return false
|
|
}
|
|
}
|
|
|
|
if res.Header.Get("Content-Type") != "application/x-git-upload-pack-advertisement" {
|
|
// Fallback, not a smart server
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
// Non-HTTP protocols always support shallow clones
|
|
return true
|
|
}
|
|
|
|
func (repo gitRepo) checkout(root string) (string, error) {
|
|
// Try checking out by ref name first. This will work on branches and sets
|
|
// .git/HEAD to the current branch name
|
|
if output, err := repo.gitWithinDir(root, "checkout", repo.ref); err != nil {
|
|
// If checking out by branch name fails check out the last fetched ref
|
|
if _, err2 := repo.gitWithinDir(root, "checkout", "FETCH_HEAD"); err2 != nil {
|
|
return "", errors.Wrapf(err, "error checking out %s: %s", repo.ref, output)
|
|
}
|
|
}
|
|
|
|
if repo.subdir != "" {
|
|
newCtx, err := symlink.FollowSymlinkInScope(filepath.Join(root, repo.subdir), root)
|
|
if err != nil {
|
|
return "", errors.Wrapf(err, "error setting git context, %q not within git root", repo.subdir)
|
|
}
|
|
|
|
fi, err := os.Stat(newCtx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !fi.IsDir() {
|
|
return "", errors.Errorf("error setting git context, not a directory: %s", newCtx)
|
|
}
|
|
root = newCtx
|
|
}
|
|
|
|
return root, nil
|
|
}
|
|
|
|
func (repo gitRepo) gitWithinDir(dir string, args ...string) ([]byte, error) {
|
|
args = append([]string{"-c", "protocol.file.allow=never"}, args...) // Block sneaky repositories from using repos from the filesystem as submodules.
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
// Disable unsafe remote protocols.
|
|
cmd.Env = append(os.Environ(), "GIT_PROTOCOL_FROM_USER=0")
|
|
|
|
if repo.isolateConfig {
|
|
cmd.Env = append(cmd.Env,
|
|
"GIT_CONFIG_NOSYSTEM=1", // Disable reading from system gitconfig.
|
|
"HOME=/dev/null", // Disable reading from user gitconfig.
|
|
)
|
|
}
|
|
|
|
return cmd.CombinedOutput()
|
|
}
|
|
|
|
// isGitTransport returns true if the provided str is a git transport by inspecting
|
|
// the prefix of the string for known protocols used in git.
|
|
func isGitTransport(str string) bool {
|
|
if strings.HasPrefix(str, "git@") {
|
|
return true
|
|
}
|
|
|
|
switch getScheme(str) {
|
|
case "git", "http", "https", "ssh":
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// getScheme returns addresses' scheme in lowercase, or an empty
|
|
// string in case address is an invalid URL.
|
|
func getScheme(address string) string {
|
|
u, err := url.Parse(address)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return u.Scheme
|
|
}
|