mirror of
https://github.com/argoproj/argo-cd
synced 2026-05-24 09:50:08 +00:00
329 lines
10 KiB
Go
329 lines
10 KiB
Go
package git
|
|
|
|
import (
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"strings"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
"golang.org/x/crypto/ssh"
|
|
git "gopkg.in/src-d/go-git.v4"
|
|
"gopkg.in/src-d/go-git.v4/config"
|
|
"gopkg.in/src-d/go-git.v4/plumbing"
|
|
"gopkg.in/src-d/go-git.v4/plumbing/transport"
|
|
"gopkg.in/src-d/go-git.v4/plumbing/transport/http"
|
|
ssh2 "gopkg.in/src-d/go-git.v4/plumbing/transport/ssh"
|
|
"gopkg.in/src-d/go-git.v4/storage/memory"
|
|
)
|
|
|
|
// Client is a generic git client interface
|
|
type Client interface {
|
|
Root() string
|
|
Init() error
|
|
Fetch() error
|
|
Checkout(revision string) error
|
|
LsRemote(revision string) (string, error)
|
|
LsFiles(path string) ([]string, error)
|
|
CommitSHA() (string, error)
|
|
Reset() error
|
|
}
|
|
|
|
// ClientFactory is a factory of Git Clients
|
|
// Primarily used to support creation of mock git clients during unit testing
|
|
type ClientFactory interface {
|
|
NewClient(repoURL, path, username, password, sshPrivateKey string) (Client, error)
|
|
}
|
|
|
|
// nativeGitClient implements Client interface using git CLI
|
|
type nativeGitClient struct {
|
|
repoURL string
|
|
root string
|
|
username string
|
|
password string
|
|
sshPrivateKey string
|
|
auth transport.AuthMethod
|
|
}
|
|
|
|
type factory struct{}
|
|
|
|
func NewFactory() ClientFactory {
|
|
return &factory{}
|
|
}
|
|
|
|
func (f *factory) NewClient(repoURL, path, username, password, sshPrivateKey string) (Client, error) {
|
|
clnt := nativeGitClient{
|
|
repoURL: repoURL,
|
|
root: path,
|
|
username: username,
|
|
password: password,
|
|
sshPrivateKey: sshPrivateKey,
|
|
}
|
|
if sshPrivateKey != "" {
|
|
signer, err := ssh.ParsePrivateKey([]byte(sshPrivateKey))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
auth := &ssh2.PublicKeys{User: "git", Signer: signer}
|
|
auth.HostKeyCallback = ssh.InsecureIgnoreHostKey()
|
|
clnt.auth = auth
|
|
} else if username != "" || password != "" {
|
|
auth := &http.BasicAuth{Username: username, Password: password}
|
|
clnt.auth = auth
|
|
}
|
|
return &clnt, nil
|
|
}
|
|
|
|
func (m *nativeGitClient) Root() string {
|
|
return m.root
|
|
}
|
|
|
|
// Init initializes a local git repository and sets the remote origin
|
|
func (m *nativeGitClient) Init() error {
|
|
var needInit bool
|
|
if _, err := os.Stat(m.root); os.IsNotExist(err) {
|
|
needInit = true
|
|
} else {
|
|
_, err = m.runCmd("git", "status")
|
|
needInit = err != nil
|
|
}
|
|
if needInit {
|
|
log.Infof("Initializing %s to %s", m.repoURL, m.root)
|
|
_, err := exec.Command("rm", "-rf", m.root).Output()
|
|
if err != nil {
|
|
return fmt.Errorf("unable to clean repo at %s: %v", m.root, err)
|
|
}
|
|
err = os.MkdirAll(m.root, 0755)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := m.runCmd("git", "init"); err != nil {
|
|
return err
|
|
}
|
|
if _, err := m.runCmd("git", "remote", "add", "origin", m.repoURL); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// always set credentials since it can change
|
|
err := m.setCredentials()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// setCredentials sets a local credentials file to connect to a remote git repository
|
|
func (m *nativeGitClient) setCredentials() error {
|
|
if m.password != "" {
|
|
log.Debug("Setting password credentials")
|
|
gitCredentialsFile := path.Join(m.root, ".git", "credentials")
|
|
urlObj, err := url.ParseRequestURI(m.repoURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
urlObj.User = url.UserPassword(m.username, m.password)
|
|
cmdURL := urlObj.String()
|
|
err = ioutil.WriteFile(gitCredentialsFile, []byte(cmdURL), 0600)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set git credentials: %v", err)
|
|
}
|
|
_, err = m.runCmd("git", "config", "--local", "credential.helper", fmt.Sprintf("store --file=%s", gitCredentialsFile))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if IsSSHURL(m.repoURL) {
|
|
sshCmd := gitSSHCommand
|
|
if m.sshPrivateKey != "" {
|
|
log.Debug("Setting SSH credentials")
|
|
sshPrivateKeyFile := path.Join(m.root, ".git", "ssh-private-key")
|
|
err := ioutil.WriteFile(sshPrivateKeyFile, []byte(m.sshPrivateKey), 0600)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set git credentials: %v", err)
|
|
}
|
|
sshCmd += " -i " + sshPrivateKeyFile
|
|
}
|
|
_, err := m.runCmd("git", "config", "--local", "core.sshCommand", sshCmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Fetch fetches latest updates from origin
|
|
func (m *nativeGitClient) Fetch() error {
|
|
var err error
|
|
log.Debugf("Fetching repo %s at %s", m.repoURL, m.root)
|
|
if _, err = m.runCmd("git", "fetch", "origin", "--tags", "--force"); err != nil {
|
|
return err
|
|
}
|
|
// git fetch does not update the HEAD reference. The following command will update the local
|
|
// knowledge of what remote considers the “default branch”
|
|
// See: https://stackoverflow.com/questions/8839958/how-does-origin-head-get-set
|
|
if _, err := m.runCmd("git", "remote", "set-head", "origin", "-a"); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LsFiles lists the local working tree, including only files that are under source control
|
|
func (m *nativeGitClient) LsFiles(path string) ([]string, error) {
|
|
out, err := m.runCmd("git", "ls-files", "--full-name", "-z", "--", path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// remove last element, which is blank regardless of whether we're using nullbyte or newline
|
|
ss := strings.Split(out, "\000")
|
|
return ss[:len(ss)-1], nil
|
|
}
|
|
|
|
// Reset resets local changes in a repository
|
|
func (m *nativeGitClient) Reset() error {
|
|
if _, err := m.runCmd("git", "reset", "--hard", "origin/HEAD"); err != nil {
|
|
return err
|
|
}
|
|
// Delete all local branches (we must first detach so we are not checked out a branch we are about to delete)
|
|
if _, err := m.runCmd("git", "checkout", "--detach", "origin/HEAD"); err != nil {
|
|
return err
|
|
}
|
|
branchesOut, err := m.runCmd("git", "for-each-ref", "--format=%(refname:short)", "refs/heads/")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
branchesOut = strings.TrimSpace(branchesOut)
|
|
if branchesOut != "" {
|
|
branches := strings.Split(branchesOut, "\n")
|
|
args := []string{"branch", "-D"}
|
|
args = append(args, branches...)
|
|
if _, err = m.runCmd("git", args...); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := m.runCmd("git", "clean", "-fd"); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Checkout checkout specified git sha
|
|
func (m *nativeGitClient) Checkout(revision string) error {
|
|
if revision == "" || revision == "HEAD" {
|
|
revision = "origin/HEAD"
|
|
}
|
|
if _, err := m.runCmd("git", "checkout", "--force", revision); err != nil {
|
|
return err
|
|
}
|
|
if _, err := m.runCmd("git", "clean", "-fd"); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LsRemote resolves the commit SHA of a specific branch, tag, or HEAD. If the supplied revision
|
|
// does not resolve, and "looks" like a 7+ hexadecimal commit SHA, it return the revision string.
|
|
// Otherwise, it returns an error indicating that the revision could not be resolved. This method
|
|
// runs with in-memory storage and is safe to run concurrently, or to be run without a git
|
|
// repository locally cloned.
|
|
func (m *nativeGitClient) LsRemote(revision string) (string, error) {
|
|
if IsCommitSHA(revision) {
|
|
return revision, nil
|
|
}
|
|
repo, err := git.Init(memory.NewStorage(), nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
remote, err := repo.CreateRemote(&config.RemoteConfig{
|
|
Name: git.DefaultRemoteName,
|
|
URLs: []string{m.repoURL},
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
refs, err := remote.List(&git.ListOptions{})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if revision == "" {
|
|
revision = "HEAD"
|
|
}
|
|
// refToHash keeps a maps of remote refs to their hash
|
|
// (e.g. refs/heads/master -> a67038ae2e9cb9b9b16423702f98b41e36601001)
|
|
refToHash := make(map[string]string)
|
|
// refToResolve remembers ref name of the supplied revision if we determine the revision is a
|
|
// symbolic reference (like HEAD), in which case we will resolve it from the refToHash map
|
|
refToResolve := ""
|
|
for _, ref := range refs {
|
|
refName := ref.Name().String()
|
|
if refName != "HEAD" && !strings.HasPrefix(refName, "refs/heads/") && !strings.HasPrefix(refName, "refs/tags/") {
|
|
// ignore things like 'refs/pull/' 'refs/reviewable'
|
|
continue
|
|
}
|
|
hash := ref.Hash().String()
|
|
if ref.Type() == plumbing.HashReference {
|
|
refToHash[refName] = hash
|
|
}
|
|
//log.Debugf("%s\t%s", hash, refName)
|
|
if ref.Name().Short() == revision {
|
|
if ref.Type() == plumbing.HashReference {
|
|
log.Debugf("revision '%s' resolved to '%s'", revision, hash)
|
|
return hash, nil
|
|
}
|
|
if ref.Type() == plumbing.SymbolicReference {
|
|
refToResolve = ref.Target().String()
|
|
}
|
|
}
|
|
}
|
|
if refToResolve != "" {
|
|
// If refToResolve is non-empty, we are resolving symbolic reference (e.g. HEAD).
|
|
// It should exist in our refToHash map
|
|
if hash, ok := refToHash[refToResolve]; ok {
|
|
log.Debugf("symbolic reference '%s' (%s) resolved to '%s'", revision, refToResolve, hash)
|
|
return hash, nil
|
|
}
|
|
}
|
|
// We support the ability to use a truncated commit-SHA (e.g. first 7 characters of a SHA)
|
|
if IsTruncatedCommitSHA(revision) {
|
|
log.Debugf("revision '%s' assumed to be commit sha", revision)
|
|
return revision, nil
|
|
}
|
|
// If we get here, revision string had non hexadecimal characters (indicating its a branch, tag,
|
|
// or symbolic ref) and we were unable to resolve it to a commit SHA.
|
|
return "", fmt.Errorf("Unable to resolve '%s' to a commit SHA", revision)
|
|
}
|
|
|
|
// CommitSHA returns current commit sha from `git rev-parse HEAD`
|
|
func (m *nativeGitClient) CommitSHA() (string, error) {
|
|
out, err := m.runCmd("git", "rev-parse", "HEAD")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(out), nil
|
|
}
|
|
|
|
// runCmd is a convenience function to run a command in a given directory and return its output
|
|
func (m *nativeGitClient) runCmd(command string, args ...string) (string, error) {
|
|
cmd := exec.Command(command, args...)
|
|
log.Debug(strings.Join(cmd.Args, " "))
|
|
cmd.Dir = m.root
|
|
env := os.Environ()
|
|
env = append(env, "GIT_ASKPASS=")
|
|
cmd.Env = env
|
|
out, err := cmd.Output()
|
|
if len(out) > 0 {
|
|
log.Debug(string(out))
|
|
}
|
|
if err != nil {
|
|
exErr, ok := err.(*exec.ExitError)
|
|
if ok {
|
|
errOutput := strings.Split(string(exErr.Stderr), "\n")[0]
|
|
log.Debug(errOutput)
|
|
return string(out), fmt.Errorf("'%s' failed: %v", strings.Join(cmd.Args, " "), errOutput)
|
|
}
|
|
return string(out), fmt.Errorf("'%s' failed: %v", strings.Join(cmd.Args, " "), err)
|
|
}
|
|
return string(out), nil
|
|
}
|