blob: 58d8cda97a93564336e7b46107258d85aa79edef [file] [log] [blame]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 downloader
import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
log "github.com/Sirupsen/logrus"
"mynewt.apache.org/newt/newt/settings"
"mynewt.apache.org/newt/util"
)
type DownloaderCommitType int
const (
COMMIT_TYPE_BRANCH DownloaderCommitType = iota
COMMIT_TYPE_TAG
COMMIT_TYPE_HASH
)
type Downloader interface {
// Fetches all remotes and downloads the specified file.
FetchFile(commit string, path string, filename string, dstDir string) error
// Clones the repo and checks out the specified commit.
Clone(commit string, dstPath string) error
// Determines the equivalent commit hash for the specified commit string.
HashFor(path string, commit string) (string, error)
// Collects all commits that are equivalent to the specified commit string
// (i.e., 1 hash, n tags, and n branches).
CommitsFor(path string, commit string) ([]string, error)
// Fetches all remotes and merges the specified branch into the local repo.
Pull(path string, branchName string) error
// Indicates whether the repo is in a clean or dirty state.
DirtyState(path string) (string, error)
// Determines the type of the specified commit.
CommitType(path string, commit string) (DownloaderCommitType, error)
// Configures the `origin` remote with the correct URL, according the the
// user's `project.yml` file and / or the repo dependency lists.
FixupOrigin(path string) error
// Retrieves the name of the currently checked out branch, or "" if no
// branch is checked out.
CurrentBranch(path string) (string, error)
// Retrieves the name of the remote branch being tracked by the specified
// local branch, or "" if there is no tracked remote branch.
UpstreamFor(repoDir string, branch string) (string, error)
}
type GenericDownloader struct {
// Whether 'origin' has been fetched during this run.
fetched bool
}
type GithubDownloader struct {
GenericDownloader
Server string
User string
Repo string
// Login for private repos.
Login string
// Password for private repos.
Password string
// Name of environment variable containing the password for private repos.
// Only used if the Password field is empty.
PasswordEnv string
}
type GitDownloader struct {
GenericDownloader
Url string
}
type LocalDownloader struct {
GenericDownloader
// Path to parent directory of repository.yml file.
Path string
}
func gitPath() (string, error) {
gitPath, err := exec.LookPath("git")
if err != nil {
return "", util.NewNewtError(fmt.Sprintf("Can't find git binary: %s\n",
err.Error()))
}
return filepath.ToSlash(gitPath), nil
}
func executeGitCommand(dir string, cmd []string, logCmd bool) ([]byte, error) {
wd, err := os.Getwd()
if err != nil {
return nil, util.NewNewtError(err.Error())
}
gp, err := gitPath()
if err != nil {
return nil, err
}
if err := os.Chdir(dir); err != nil {
return nil, util.ChildNewtError(err)
}
defer os.Chdir(wd)
gitCmd := []string{gp}
gitCmd = append(gitCmd, cmd...)
output, err := util.ShellCommandLimitDbgOutput(gitCmd, nil, logCmd, -1)
if err != nil {
return nil, err
}
return output, nil
}
func commitExists(repoDir string, commit string) bool {
cmd := []string{
"show-ref",
"--verify",
"--quiet",
"refs/heads/" + commit,
}
_, err := executeGitCommand(repoDir, cmd, true)
return err == nil
}
func initSubmodules(path string) error {
cmd := []string{
"submodule",
"init",
}
_, err := executeGitCommand(path, cmd, true)
if err != nil {
return err
}
return nil
}
func updateSubmodules(path string) error {
cmd := []string{
"submodule",
"update",
}
_, err := executeGitCommand(path, cmd, true)
if err != nil {
return err
}
return nil
}
// checkout does checkout a branch, or create a new branch from a tag name
// if the commit supplied is a tag. sha1 based commits have no special
// handling and result in dettached from HEAD state.
func checkout(repoDir string, commit string) error {
var cmd []string
ct, err := commitType(repoDir, commit)
if err != nil {
return err
}
full, err := remoteCommitName(repoDir, commit)
if err != nil {
return err
}
if ct == COMMIT_TYPE_TAG {
util.StatusMessage(util.VERBOSITY_VERBOSE, "Will create new branch %s"+
" from %s\n", commit, full)
cmd = []string{
"checkout",
full,
"-b",
commit,
}
} else {
util.StatusMessage(util.VERBOSITY_VERBOSE, "Will checkout %s\n", full)
cmd = []string{
"checkout",
commit,
}
}
if _, err := executeGitCommand(repoDir, cmd, true); err != nil {
return err
}
// Always initialize and update submodules on checkout. This prevents the
// repo from being in a modified "(new commits)" state immediately after
// switching commits. If the submodules have already been updated, this
// does not generate any network activity.
if err := initSubmodules(repoDir); err != nil {
return err
}
if err := updateSubmodules(repoDir); err != nil {
return err
}
return nil
}
// rebase applies upstream changes to the local copy and must be
// preceeded by a "fetch" to achieve any meaningful result.
func rebase(repoDir string, commit string) error {
if err := checkout(repoDir, commit); err != nil {
return err
}
// We want to rebase the remote version of this branch.
full, err := remoteCommitName(repoDir, commit)
if err != nil {
return err
}
cmd := []string{
"rebase",
full}
if _, err := executeGitCommand(repoDir, cmd, true); err != nil {
util.StatusMessage(util.VERBOSITY_VERBOSE,
"Merging changes from %s: %s\n", full, err)
return err
}
util.StatusMessage(util.VERBOSITY_VERBOSE,
"Merging changes from %s\n", full)
return nil
}
func mergeBase(repoDir string, commit string) (string, error) {
cmd := []string{
"merge-base",
commit,
commit,
}
o, err := executeGitCommand(repoDir, cmd, true)
if err != nil {
return "", err
}
return strings.TrimSpace(string(o)), nil
}
func branchExists(repoDir string, branchName string) bool {
cmd := []string{
"show-ref",
"--verify",
"--quiet",
"refs/heads/" + branchName,
}
_, err := executeGitCommand(repoDir, cmd, true)
return err == nil
}
func commitType(repoDir string, commit string) (DownloaderCommitType, error) {
if commit == "HEAD" {
return COMMIT_TYPE_HASH, nil
}
if _, err := mergeBase(repoDir, commit); err == nil {
// Distinguish local branch from hash.
if branchExists(repoDir, commit) {
return COMMIT_TYPE_BRANCH, nil
} else {
return COMMIT_TYPE_HASH, nil
}
}
if _, err := mergeBase(repoDir, "tags/"+commit); err == nil {
return COMMIT_TYPE_TAG, nil
}
return DownloaderCommitType(-1), util.FmtNewtError(
"Cannot determine commit type of \"%s\"", commit)
}
func upstreamFor(path string, commit string) (string, error) {
cmd := []string{
"rev-parse",
"--abbrev-ref",
"--symbolic-full-name",
commit + "@{u}",
}
up, err := executeGitCommand(path, cmd, true)
if err != nil {
if !util.IsExit(err) {
return "", err
} else {
return "", nil
}
}
return strings.TrimSpace(string(up)), nil
}
func remoteCommitName(path string, commit string) (string, error) {
ct, err := commitType(path, commit)
if err != nil {
return "", err
}
switch ct {
case COMMIT_TYPE_BRANCH:
rmt, err := upstreamFor(path, commit)
if err != nil {
return "", err
}
if rmt == "" {
return "",
util.FmtNewtError("No remote upstream for branch \"%s\"",
commit)
}
return rmt, nil
case COMMIT_TYPE_TAG:
return "tags/" + commit, nil
case COMMIT_TYPE_HASH:
return commit, nil
default:
return "", util.FmtNewtError("unknown commit type: %d", int(ct))
}
}
func showFile(
path string, branch string, filename string, dstDir string) error {
if err := os.MkdirAll(dstDir, os.ModePerm); err != nil {
return util.ChildNewtError(err)
}
full, err := remoteCommitName(path, branch)
if err != nil {
return err
}
cmd := []string{
"show",
fmt.Sprintf("%s:%s", full, filename),
}
dstPath := fmt.Sprintf("%s/%s", dstDir, filename)
log.Debugf("Fetching file %s to %s", filename, dstPath)
data, err := executeGitCommand(path, cmd, true)
if err != nil {
return err
}
if err := ioutil.WriteFile(dstPath, data, os.ModePerm); err != nil {
return util.ChildNewtError(err)
}
return nil
}
func getRemoteUrl(path string, remote string) (string, error) {
cmd := []string{
"remote",
"get-url",
remote,
}
o, err := executeGitCommand(path, cmd, true)
if err != nil {
return "", err
}
return strings.TrimSpace(string(o)), nil
}
func setRemoteUrlCmd(remote string, url string) []string {
return []string{
"remote",
"set-url",
remote,
url,
}
}
func setRemoteUrl(path string, remote string, url string, logCmd bool) error {
cmd := setRemoteUrlCmd(remote, url)
_, err := executeGitCommand(path, cmd, logCmd)
return err
}
func warnWrongOriginUrl(path string, curUrl string, goodUrl string) {
util.StatusMessage(util.VERBOSITY_QUIET,
"WARNING: Repo's \"origin\" remote points to unexpected URL: "+
"%s; correcting it to %s. Repo contents may be incorrect.\n",
curUrl, goodUrl)
}
func (gd *GenericDownloader) CommitType(
path string, commit string) (DownloaderCommitType, error) {
return commitType(path, commit)
}
func (gd *GenericDownloader) HashFor(path string, commit string) (string, error) {
full, err := remoteCommitName(path, commit)
if err != nil {
return "", err
}
cmd := []string{"rev-parse", full}
o, err := executeGitCommand(path, cmd, true)
if err != nil {
return "", err
}
return strings.TrimSpace(string(o)), nil
}
func (gd *GenericDownloader) CommitsFor(
path string, commit string) ([]string, error) {
// Hash.
hash, err := gd.HashFor(path, commit)
if err != nil {
return nil, err
}
// Branches and tags.
cmd := []string{
"for-each-ref",
"--format=%(refname:short)",
"--points-at",
hash,
}
o, err := executeGitCommand(path, cmd, true)
if err != nil {
return nil, err
}
lines := []string{hash}
text := strings.TrimSpace(string(o))
if text != "" {
lines = append(lines, strings.Split(text, "\n")...)
}
sort.Strings(lines)
return lines, nil
}
func (gd *GenericDownloader) CurrentBranch(path string) (string, error) {
cmd := []string{
"rev-parse",
"--abbrev-ref",
"HEAD",
}
o, err := executeGitCommand(path, cmd, true)
if err != nil {
return "", err
}
s := strings.TrimSpace(string(o))
if s == "HEAD" {
return "", nil
} else {
return s, nil
}
}
func (gd *GenericDownloader) UpstreamFor(repoDir string,
branch string) (string, error) {
return upstreamFor(repoDir, branch)
}
// Fetches the downloader's origin remote if it hasn't been fetched yet during
// this run.
func (gd *GenericDownloader) cachedFetch(fn func() error) error {
if gd.fetched {
return nil
}
if err := fn(); err != nil {
return err
}
gd.fetched = true
return nil
}
// Indicates whether the specified git repo is in a clean or dirty state.
//
// @param path The path of the git repo to check.
//
// @return string Text describing repo's dirty state, or "" if
// clean.
// @return error Error.
func (gd *GenericDownloader) DirtyState(path string) (string, error) {
// Check for local changes.
cmd := []string{
"diff",
"--name-only",
}
o, err := executeGitCommand(path, cmd, true)
if err != nil {
return "", err
}
if len(o) > 0 {
return "local changes", nil
}
// Check for staged changes.
cmd = []string{
"diff",
"--name-only",
"--staged",
}
o, err = executeGitCommand(path, cmd, true)
if err != nil {
return "", err
}
if len(o) > 0 {
return "staged changes", nil
}
// If on a branch, check for unpushed commits.
branch, err := gd.CurrentBranch(path)
if err != nil {
return "", err
}
if branch != "" {
cmd = []string{
"rev-list",
"@{u}..",
}
o, err = executeGitCommand(path, cmd, true)
if err != nil {
return "", err
}
if len(o) > 0 {
return "unpushed commits", nil
}
}
return "", nil
}
func (gd *GithubDownloader) fetch(repoDir string) error {
return gd.cachedFetch(func() error {
util.StatusMessage(util.VERBOSITY_VERBOSE, "Fetching repo %s\n",
gd.Repo)
_, err := gd.authenticatedCommand(repoDir, []string{"fetch", "--tags"})
return err
})
}
func (gd *GithubDownloader) password() string {
if gd.Password != "" {
return gd.Password
} else if gd.PasswordEnv != "" {
return os.Getenv(gd.PasswordEnv)
} else {
return ""
}
}
func (gd *GithubDownloader) authenticatedCommand(path string,
args []string) ([]byte, error) {
if err := gd.setRemoteAuth(path); err != nil {
return nil, err
}
defer gd.clearRemoteAuth(path)
return executeGitCommand(path, args, true)
}
func (gd *GithubDownloader) FetchFile(
commit string, path string, filename string, dstDir string) error {
if err := gd.fetch(path); err != nil {
return err
}
if err := showFile(path, commit, filename, dstDir); err != nil {
return err
}
return nil
}
func (gd *GithubDownloader) Pull(path string, branchName string) error {
err := gd.fetch(path)
if err != nil {
return err
}
// Ignore error, probably resulting from a branch not available at origin
// anymore.
rebase(path, branchName)
if err := checkout(path, branchName); err != nil {
return err
}
return nil
}
func (gd *GithubDownloader) remoteUrls() (string, string) {
server := "github.com"
if gd.Server != "" {
server = gd.Server
}
var auth string
if gd.Login != "" {
pw := gd.password()
auth = fmt.Sprintf("%s:%s@", gd.Login, pw)
}
url := fmt.Sprintf("https://%s%s/%s/%s.git", auth, server, gd.User,
gd.Repo)
publicUrl := fmt.Sprintf("https://%s/%s/%s.git", server, gd.User, gd.Repo)
return url, publicUrl
}
func (gd *GithubDownloader) setOriginUrl(path string, url string) error {
// Hide password in logged command.
safeUrl := url
pw := gd.password()
if pw != "" {
safeUrl = strings.Replace(safeUrl, pw, "<password-hidden>", -1)
}
util.LogShellCmd(setRemoteUrlCmd("origin", safeUrl), nil)
return setRemoteUrl(path, "origin", url, false)
}
func (gd *GithubDownloader) clearRemoteAuth(path string) error {
url, publicUrl := gd.remoteUrls()
if url == publicUrl {
return nil
}
return gd.setOriginUrl(path, publicUrl)
}
func (gd *GithubDownloader) setRemoteAuth(path string) error {
url, publicUrl := gd.remoteUrls()
if url == publicUrl {
return nil
}
return gd.setOriginUrl(path, url)
}
func (gd *GithubDownloader) Clone(commit string, dstPath string) error {
// Currently only the master branch is supported.
branch := "master"
url, publicUrl := gd.remoteUrls()
util.StatusMessage(util.VERBOSITY_DEFAULT,
"Downloading repository %s (commit: %s) from %s\n",
gd.Repo, commit, publicUrl)
gp, err := gitPath()
if err != nil {
return err
}
// Clone the repository.
cmd := []string{
gp,
"clone",
"-b",
branch,
url,
dstPath,
}
if util.Verbosity >= util.VERBOSITY_VERBOSE {
err = util.ShellInteractiveCommand(cmd, nil)
} else {
_, err = util.ShellCommand(cmd, nil)
}
if err != nil {
return err
}
defer gd.clearRemoteAuth(dstPath)
// Checkout the specified commit.
if err := checkout(dstPath, commit); err != nil {
return err
}
return nil
}
func (gd *GithubDownloader) FixupOrigin(path string) error {
curUrl, err := getRemoteUrl(path, "origin")
if err != nil {
return err
}
// Use the public URL, i.e., hide the login and password.
_, publicUrl := gd.remoteUrls()
if curUrl == publicUrl {
return nil
}
warnWrongOriginUrl(path, curUrl, publicUrl)
return gd.setOriginUrl(path, publicUrl)
}
func NewGithubDownloader() *GithubDownloader {
return &GithubDownloader{}
}
func (gd *GitDownloader) fetch(repoDir string) error {
return gd.cachedFetch(func() error {
util.StatusMessage(util.VERBOSITY_VERBOSE, "Fetching repo %s\n",
gd.Url)
_, err := executeGitCommand(repoDir, []string{"fetch", "--tags"}, true)
return err
})
}
func (gd *GitDownloader) FetchFile(
commit string, path string, filename string, dstDir string) error {
if err := gd.fetch(path); err != nil {
return err
}
if err := showFile(path, commit, filename, dstDir); err != nil {
return err
}
return nil
}
func (gd *GitDownloader) Pull(path string, branchName string) error {
err := gd.fetch(path)
if err != nil {
return err
}
// Ignore error, probably resulting from a branch not available at origin
// anymore.
rebase(path, branchName)
if err := checkout(path, branchName); err != nil {
return err
}
return nil
}
func (gd *GitDownloader) Clone(commit string, dstPath string) error {
// Currently only the master branch is supported.
branch := "master"
util.StatusMessage(util.VERBOSITY_DEFAULT,
"Downloading repository %s (commit: %s)\n", gd.Url, commit)
gp, err := gitPath()
if err != nil {
return err
}
// Clone the repository.
cmd := []string{
gp,
"clone",
"-b",
branch,
gd.Url,
dstPath,
}
if util.Verbosity >= util.VERBOSITY_VERBOSE {
err = util.ShellInteractiveCommand(cmd, nil)
} else {
_, err = util.ShellCommand(cmd, nil)
}
if err != nil {
return err
}
// Checkout the specified commit.
if err := checkout(dstPath, commit); err != nil {
return err
}
return nil
}
func (gd *GitDownloader) FixupOrigin(path string) error {
curUrl, err := getRemoteUrl(path, "origin")
if err != nil {
return err
}
if curUrl == gd.Url {
return nil
}
warnWrongOriginUrl(path, curUrl, gd.Url)
return setRemoteUrl(path, "origin", gd.Url, true)
}
func NewGitDownloader() *GitDownloader {
return &GitDownloader{}
}
func (ld *LocalDownloader) FetchFile(
commit string, path string, filename string, dstDir string) error {
srcPath := ld.Path + "/" + filename
dstPath := dstDir + "/" + filename
log.Debugf("Fetching file %s to %s", srcPath, dstPath)
if err := util.CopyFile(srcPath, dstPath); err != nil {
return err
}
return nil
}
func (ld *LocalDownloader) Pull(path string, branchName string) error {
os.RemoveAll(path)
return ld.Clone(branchName, path)
}
func (ld *LocalDownloader) Clone(commit string, dstPath string) error {
util.StatusMessage(util.VERBOSITY_DEFAULT,
"Downloading local repository %s\n", ld.Path)
if err := util.CopyDir(ld.Path, dstPath); err != nil {
return err
}
// Checkout the specified commit.
if err := checkout(dstPath, commit); err != nil {
return err
}
return nil
}
func (ld *LocalDownloader) FixupOrigin(path string) error {
return nil
}
func NewLocalDownloader() *LocalDownloader {
return &LocalDownloader{}
}
func loadError(format string, args ...interface{}) error {
return util.NewNewtError(
"error loading project.yml: " + fmt.Sprintf(format, args...))
}
func LoadDownloader(repoName string, repoVars map[string]string) (
Downloader, error) {
switch repoVars["type"] {
case "github":
gd := NewGithubDownloader()
gd.Server = repoVars["server"]
gd.User = repoVars["user"]
gd.Repo = repoVars["repo"]
// The project.yml file can contain github access tokens and
// authentication credentials, but this file is probably world-readable
// and therefore not a great place for this.
gd.Login = repoVars["login"]
gd.Password = repoVars["password"]
gd.PasswordEnv = repoVars["password_env"]
// Alternatively, the user can put security material in
// $HOME/.newt/repos.yml.
newtrc := settings.Newtrc()
privRepo := newtrc.GetValStringMapString("repository."+repoName, nil)
if privRepo != nil {
if gd.Login == "" {
gd.Login = privRepo["login"]
}
if gd.Password == "" {
gd.Password = privRepo["password"]
}
if gd.PasswordEnv == "" {
gd.PasswordEnv = privRepo["password_env"]
}
}
return gd, nil
case "git":
gd := NewGitDownloader()
gd.Url = repoVars["url"]
if gd.Url == "" {
return nil, loadError("repo \"%s\" missing required field \"url\"",
repoName)
}
return gd, nil
case "local":
ld := NewLocalDownloader()
ld.Path = repoVars["path"]
return ld, nil
default:
return nil, loadError("invalid repository type: %s", repoVars["type"])
}
}