blob: 7fb6581bb7bd4ea42d327278ea919cce1e1f183c [file] [log] [blame]
"""Functions for interacting with hg"""
import os
import subprocess
import urlparse
import urllib
import re
from util.commands import run_cmd, remove_path, run_quiet_cmd
from util.file import safe_unlink
import logging
log = logging.getLogger(__name__)
class DefaultShareBase:
pass
DefaultShareBase = DefaultShareBase()
def _make_absolute(repo):
if repo.startswith("file://"):
# Make file:// urls absolute
path = repo[len("file://"):]
repo = "file://%s" % os.path.abspath(path)
elif "://" not in repo:
repo = os.path.abspath(repo)
return repo
def get_repo_name(repo):
bits = urlparse.urlsplit(repo)
host = urllib.quote(bits.netloc, "")
path = urllib.quote(bits.path.lstrip("/"), "")
return os.path.join(host, path)
def has_revision(dest, revision):
"""Returns True if revision exists in dest"""
try:
run_quiet_cmd(['git', 'log', '--oneline', '-n1', revision], cwd=dest)
return True
except subprocess.CalledProcessError:
return False
def has_ref(dest, refname):
"""Returns True if refname exists in dest.
refname can be a branch or tag name."""
try:
run_quiet_cmd(['git', 'show-ref', '-d', refname], cwd=dest)
return True
except subprocess.CalledProcessError:
return False
def init(dest, bare=False):
"""Initializes an empty repository at dest. If dest exists and isn't empty, it will be removed.
If `bare` is True, then a bare repo will be created."""
if not os.path.isdir(dest):
log.info("removing %s", dest)
safe_unlink(dest)
else:
for f in os.listdir(dest):
f = os.path.join(dest, f)
log.info("removing %s", f)
if os.path.isdir(f):
remove_path(f)
else:
safe_unlink(f)
# Git will freak out if it tries to create intermediate directories for
# dest, and then they exist. We can hit this when pulling in multiple repos
# in parallel to shared repo paths that contain common parent directories
# Let's create all the directories first
try:
os.makedirs(dest)
except OSError, e:
if e.errno == 20:
# Not a directory error...one of the parents of dest isn't a
# directory
raise
if bare:
cmd = ['git', 'init', '--bare', '-q', dest]
else:
cmd = ['git', 'init', '-q', dest]
log.info(" ".join(cmd))
run_quiet_cmd(cmd)
def is_git_repo(dest):
"""Returns True if dest is a valid git repo"""
if not os.path.isdir(dest):
return False
try:
proc = subprocess.Popen(["git", "rev-parse", "--git-dir"], cwd=dest, stdout=subprocess.PIPE)
if proc.wait() != 0:
return False
output = proc.stdout.read().strip()
git_dir = os.path.normpath(os.path.join(dest, output))
retval = (git_dir == dest or git_dir == os.path.join(dest, ".git"))
return retval
except subprocess.CalledProcessError:
return False
def get_git_dir(dest):
"""Returns the path to the git directory for dest. For bare repos this is
dest itself, for regular repos this is dest/.git"""
assert is_git_repo(dest)
cmd = ['git', 'config', '--bool', '--get', 'core.bare']
proc = subprocess.Popen(cmd, cwd=dest, stdout=subprocess.PIPE)
proc.wait()
is_bare = proc.stdout.read().strip()
if is_bare == "false":
d = os.path.join(dest, ".git")
else:
d = dest
assert os.path.exists(d)
return d
def set_share(repo, share):
alternates = os.path.join(get_git_dir(repo), 'objects', 'info', 'alternates')
share_objects = os.path.join(get_git_dir(share), 'objects')
with open(alternates, 'w') as f:
f.write("%s\n" % share_objects)
def clean(repo):
# Two '-f's means "clean submodules", which is what we want so far.
run_cmd(['git', 'clean', '-f', '-f', '-d', '-x'], cwd=repo, stdout=subprocess.PIPE)
def add_remote(repo, remote_name, remote_repo):
"""Adds a remote named `remote_name` to the local git repo in `repo`
pointing to `remote_repo`"""
run_cmd(['git', 'remote', 'add', remote_name, remote_repo], cwd=repo)
def set_remote_url(repo, remote_name, remote_repo):
"""Sets the url for a remote"""
run_cmd(['git', 'remote', 'set-url', remote_name, remote_repo], cwd=repo)
def get_remote(repo, remote_name):
"""Returns the url for the given remote, or None if the remote doesn't
exist"""
cmd = ['git', 'remote', '-v']
proc = subprocess.Popen(cmd, cwd=repo, stdout=subprocess.PIPE)
proc.wait()
for line in proc.stdout.readlines():
m = re.match(r"%s\s+(\S+) \(fetch\)$" % re.escape(remote_name), line)
if m:
return m.group(1)
def set_remote(repo, remote_name, remote_repo):
"""Sets remote named `remote_name` to `remote_repo`"""
log.debug("%s: getting remotes", repo)
my_remote = get_remote(repo, remote_name)
if my_remote == remote_repo:
return
if my_remote is not None:
log.info("%s: setting remote %s to %s (was %s)", repo, remote_name, remote_repo, my_remote)
set_remote_url(repo, remote_name, remote_repo)
else:
log.info("%s: adding remote %s %s", repo, remote_name, remote_repo)
add_remote(repo, remote_name, remote_repo)
def git(repo, dest, refname=None, revision=None, update_dest=True,
shareBase=DefaultShareBase, mirrors=None, clean_dest=False):
"""Makes sure that `dest` is has `revision` or `refname` checked out from
`repo`.
Do what it takes to make that happen, including possibly clobbering
dest.
If `mirrors` is set, will try and use the mirrors before `repo`.
"""
if shareBase is DefaultShareBase:
shareBase = os.environ.get("GIT_SHARE_BASE_DIR", None)
if shareBase is not None:
repo_name = get_repo_name(repo)
share_dir = os.path.join(shareBase, repo_name)
else:
share_dir = None
if share_dir is not None and not is_git_repo(share_dir):
log.info("creating bare repo %s", share_dir)
try:
init(share_dir, bare=True)
os.utime(share_dir, None)
except Exception:
log.warning("couldn't create shared repo %s; disabling sharing", share_dir, exc_info=True)
shareBase = None
share_dir = None
dest = os.path.abspath(dest)
log.info("Checking dest %s", dest)
if not is_git_repo(dest):
if os.path.exists(dest):
log.warning("%s doesn't appear to be a valid git directory; clobbering", dest)
remove_path(dest)
if share_dir is not None:
# Initialize the repo and set up the share
init(dest)
set_share(dest, share_dir)
else:
# Otherwise clone into dest
clone(repo, dest, refname=refname, mirrors=mirrors, update_dest=False)
# Make sure our share is pointing to the right place
if share_dir is not None:
lock_file = os.path.join(get_git_dir(share_dir), "index.lock")
if os.path.exists(lock_file):
log.info("removing %s", lock_file)
safe_unlink(lock_file)
set_share(dest, share_dir)
# If we're supposed to be updating to a revision, check if we
# have that revision already. If so, then there's no need to
# fetch anything.
do_fetch = False
if revision is None:
# we don't have a revision specified, so pull in everything
do_fetch = True
elif has_ref(dest, revision):
# revision is actually a ref name, so we need to run fetch
# to make sure we update the ref
do_fetch = True
elif not has_revision(dest, revision):
# we don't have this revision, so need to fetch it
do_fetch = True
if do_fetch:
if share_dir:
# Fetch our refs into our share
try:
# TODO: Handle fetching refnames like refs/tags/XXXX
if refname is None:
fetch(repo, share_dir, mirrors=mirrors)
else:
fetch(repo, share_dir, mirrors=mirrors, refname=refname)
except subprocess.CalledProcessError:
# Something went wrong!
# Clobber share_dir and re-raise
log.info("error fetching into %s - clobbering", share_dir)
remove_path(share_dir)
raise
try:
if refname is None:
fetch(share_dir, dest, fetch_remote="origin")
else:
fetch(share_dir, dest, fetch_remote="origin", refname=refname)
except subprocess.CalledProcessError:
log.info("clobbering %s", share_dir)
remove_path(share_dir)
log.info("error fetching into %s - clobbering", dest)
remove_path(dest)
raise
else:
try:
fetch(repo, dest, mirrors=mirrors, refname=refname)
except Exception:
log.info("error fetching into %s - clobbering", dest)
remove_path(dest)
raise
# Set our remote
set_remote(dest, 'origin', repo)
if update_dest:
log.info("Updating local copy refname: %s; revision: %s", refname, revision)
# Sometimes refname is passed in as a revision
if revision:
if not has_revision(dest, revision) and has_ref(dest, 'origin/%s' % revision):
log.info("Using %s as ref name instead of revision", revision)
refname = revision
revision = None
rev = update(dest, refname=refname, revision=revision)
if clean_dest:
clean(dest)
return rev
if clean_dest:
clean(dest)
def clone(repo, dest, refname=None, mirrors=None, shared=False, update_dest=True):
"""Clones git repo and places it at `dest`, replacing whatever else is
there. The working copy will be empty.
If `mirrors` is set, will try and clone from the mirrors before
cloning from `repo`.
If `shared` is True, then git shared repos will be used
If `update_dest` is False, then no working copy will be created
"""
if os.path.exists(dest):
remove_path(dest)
if mirrors:
log.info("Attempting to clone from mirrors")
for mirror in mirrors:
log.info("Cloning from %s", mirror)
try:
retval = clone(mirror, dest, refname, update_dest=update_dest)
return retval
except KeyboardInterrupt:
raise
except Exception:
log.exception("Problem cloning from mirror %s", mirror)
continue
else:
log.info("Pulling from mirrors failed; falling back to %s", repo)
cmd = ['git', 'clone', '-q']
if not update_dest:
# TODO: Use --bare/--mirror here?
cmd.append('--no-checkout')
if refname:
cmd.extend(['--branch', refname])
if shared:
cmd.append('--shared')
cmd.extend([repo, dest])
run_cmd(cmd)
if update_dest:
return get_revision(dest)
def update(dest, refname=None, revision=None, remote_name="origin"):
"""Updates working copy `dest` to `refname` or `revision`. If neither is
set then the working copy will be updated to the latest revision on the
current refname. Local changes will be discarded."""
# If we have a revision, switch to that
# We use revision^0 (and refname^0 below) to force the names to be
# dereferenced into commit ids, which makes git check them out in a
# detached state. This is equivalent to 'git checkout --detach', except is
# supported by older versions of git
if revision is not None:
cmd = ['git', 'checkout', '-q', '-f', revision + '^0']
run_cmd(cmd, cwd=dest)
else:
if not refname:
refname = '%s/master' % remote_name
else:
refname = '%s/%s' % (remote_name, refname)
cmd = ['git', 'checkout', '-q', '-f', refname + '^0']
run_cmd(cmd, cwd=dest)
return get_revision(dest)
def fetch(repo, dest, refname=None, remote_name="origin", fetch_remote=None, mirrors=None, fetch_tags=True):
"""Fetches changes from git repo and places it in `dest`.
If `mirrors` is set, will try and fetch from the mirrors first before
`repo`."""
if mirrors:
for mirror in mirrors:
try:
return fetch(mirror, dest, refname=refname)
except KeyboardInterrupt:
raise
except Exception:
log.exception("Problem fetching from mirror %s", mirror)
continue
else:
log.info("Pulling from mirrors failed; falling back to %s", repo)
# Convert repo to an absolute path if it's a local repository
repo = _make_absolute(repo)
cmd = ['git', 'fetch', '-q', repo]
if not fetch_tags:
# Don't fetch tags into our local tags/ refs since we have no way to
# associate those with this remote and can't purge it later.
cmd.append('--no-tags')
if refname:
if fetch_remote:
cmd.append("+refs/remotes/{fetch_remote}/{refname}:refs/remotes/{remote_name}/{refname}".format(refname=refname, remote_name=remote_name, fetch_remote=fetch_remote))
else:
cmd.append("+refs/heads/{refname}:refs/remotes/{remote_name}/{refname}".format(refname=refname, remote_name=remote_name))
else:
if fetch_remote:
cmd.append("+refs/remotes/{fetch_remote}/*:refs/remotes/{remote_name}/*".format(remote_name=remote_name, fetch_remote=fetch_remote))
else:
cmd.append("+refs/heads/*:refs/remotes/{remote_name}/*".format(remote_name=remote_name))
run_cmd(cmd, cwd=dest)
def get_revision(path):
"""Returns which revision directory `path` currently has checked out."""
proc = subprocess.Popen(['git', 'rev-parse', 'HEAD'], cwd=path, stdout=subprocess.PIPE)
proc.wait()
return proc.stdout.read().strip()