blob: 8af651ce6a928ab13e4e2abc75a5041c1ba282f1 [file] [log] [blame]
# Copyright 2014 Google Inc. All Rights Reserved.
#
# 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.
"""A utility script for checking out subdirectories of many GIT repositories
to specified locations, like is possible with SVN and gclient. This uses a
combination of GIT, sparse-checkout, shallow-clone and filesystem junctions.
For each dependency in a 'gitdeps' file this script will checkout one
subdirectory of one repository into a specified location. The input is as
follows:
- The user specifies a local destination for the checkout.
- The user specifies a source repository.
- The user specifies a list of subdirectories of the repository to get.
- The user specifies a revision.
The checkout works as follows:
- An empty git checkout is initialized in the cache directory. This will be
in a subfolder with an essentially random name.
- The specified repository is added as a remote to that repo.
- A sparse-checkout directive is added to select only the desired
subdirectories.
- The repository is cloned using a depth of 1 (no history, only the actual
contents of the desired revision).
- The destination directories are created as junctions pointing to the
desired subdirectory of the checkout in the cache directory.
The script maintains its state in the root of the cache directory, allowing it
to reuse checkout directories when possible.
"""
import ast
import glob
import hashlib
import logging
import optparse
import os
import random
import re
import subprocess
import threading
_LOGGER = logging.getLogger(os.path.basename(__file__))
# Matches a SHA1 hash used as a git revision.
_GIT_SHA1_RE = re.compile('^[A-Fa-f0-9]{40}$')
def _ParseCommandLine():
"""Parses the command-line and returns an options structure."""
option_parser = optparse.OptionParser()
option_parser.add_option('--cache-dir', type='string',
default='.gitdeps-cache',
help='The directory to be used for storing cache files. Defaults to '
'.gitdeps-cache in the current working directory.')
option_parser.add_option('--output-dir', type='string', default='.',
help='The directory to be used as the root of all output. Defaults to '
'the current working directory.')
option_parser.add_option('--dry-run', action='store_true', default=False,
help='If true then will simply list actions that would be performed.')
option_parser.add_option('--force', action='store_true', default=False,
help='If true then will force the checkout to be completely rebuilt.')
option_parser.add_option('--verbose', dest='log_level', action='store_const',
default=logging.INFO, const=logging.DEBUG,
help='Enables verbose logging.')
option_parser.add_option('--quiet', dest='log_level', action='store_const',
default=logging.INFO, const=logging.ERROR,
help='Disables all output except for errors.')
options, args = option_parser.parse_args()
# Configure logging.
logging.basicConfig(level=options.log_level)
# Set default values.
if not args:
# Default to checking for a file in the current working directory.
_LOGGER.info('Defaulting to using GITDEPS in current working directory.')
args = ['GITDEPS']
# Validate arguments and options.
if not os.path.isdir(options.output_dir):
option_parser.error('Output directory does not exist: %s' %
options.output_dir)
for path in args:
if not os.path.exists(path):
option_parser.error('Missing dependency file: %s' % path)
# Normalize local paths for prettier output.
options.cache_dir = os.path.normpath(os.path.abspath(options.cache_dir))
options.output_dir = os.path.normpath(os.path.abspath(options.output_dir))
return options, args
class RepoOptions(object):
"""Light object used for shuttling around information about a dependency."""
def __init__(self):
self.repository = None
self.revision = None
self.output_dir = None
self.remote_dirs = []
self.deps_file = None
self.checkout_dir = None
self.recurse = False
def __str__(self):
"""Stringifies this object for debugging."""
return ('RepoOptions(repository=%s, revision=%s, output_dir=%s, '
'remote_dirs=%s, deps_file=%s, checkout_dir=%s, recurse=%s)') % (
self.repository.__repr__(),
self.revision.__repr__(),
self.output_dir.__repr__(),
self.remote_dirs.__repr__(),
self.deps_file.__repr__(),
self.checkout_dir.__repr__(),
self.recurse.__repr__())
def _ParseRepoOptions(cache_dir, root_output_dir, deps_file_path, key, value):
"""Given the |root_output_dir| specified on the command line, a |key| and
|value| pair from a GITDEPS file, and the path of the deps file, generates
a corresponding RepoOptions object. The |key| is the output path of the
checkout relative to |root_output_dir|, and |value| consists of a
(repository URL, remote directory, revision hash) tuple. This can raise an
Exception on failure.
"""
bad = False
if ((type(value) != list and type(value) != tuple) or len(value) < 3 or
len(value) > 4 or (type(value[1]) != list and type(value[1]) != tuple)):
bad = True
if len(value) == 4 and type(value[3]) != dict:
bad = True
if bad:
_LOGGER.error('Invalid dependency tuple: %s', value)
raise Exception()
# Always use lowercase SHA1 hashes for consistency.
refspec = value[2]
if _GIT_SHA1_RE.match(refspec):
refspec = refspec.lower()
repo_options = RepoOptions()
repo_options.output_dir = os.path.normpath(os.path.abspath(os.path.join(
root_output_dir, key)))
repo_options.repository = value[0]
repo_options.remote_dirs = value[1]
repo_options.revision = refspec
repo_options.deps_file = deps_file_path
# Parse additional options.
if len(value) > 3:
repo_options.recurse = value[3].get('recurse', False) == True
# Create a unique name for the checkout in the cache directory. Make the
# output directory relative to the cache directory so that they can be
# moved around together.
output_dir_rel = os.path.relpath(repo_options.output_dir,
root_output_dir).lower()
if output_dir_rel.startswith('..'):
raise Exception('Invalid output directory: %s' % key)
n = hashlib.md5(output_dir_rel).hexdigest()
repo_options.checkout_dir = os.path.abspath(os.path.join(cache_dir, n, 'src'))
return repo_options
def _EnsureDirectoryExists(path, comment_name, dry_run):
"""Ensures that the given |path| exists. Only actually creates the directory
if |dry_run| is False. |comment_name| is used during logging of this
operation.
"""
if not comment_name:
comment_name += ' '
else:
comment_name = ''
if not os.path.exists(path):
_LOGGER.debug('Creating %sdirectory: %s', comment_name, path)
if not dry_run:
os.makedirs(path)
def _GetCasedFilename(filename):
"""Returns the full case-sensitive filename for the given |filename|. If the
path does not exist, returns the original |filename| as is.
"""
pattern = '%s[%s]' % (filename[:-1], filename[-1])
filenames = glob.glob(pattern)
if not filenames:
return filename
return filenames[0]
def _Shell(*cmd, **kw):
"""Runs |cmd|, returns the results from Popen(cmd).communicate(). Additional
keyword arguments are passed on to subprocess.Popen. If |stdout| and |stderr|
are not specified, they default to subprocess.PIPE. If |dry_run| is not
specified it defaults to True. The command is only actually run if |dry_run|
is False. This can raise a RuntimeError on failure.
"""
if 'cwd' in kw:
_LOGGER.debug('Executing %s in "%s".', cmd, kw['cwd'])
else:
_LOGGER.debug('Executing %s.', cmd)
if kw.get('dry_run', True):
return ('', '')
kw.pop('dry_run', None)
dump_on_error = kw.pop('dump_on_error', False)
kw['shell'] = True
kw.setdefault('stdout', subprocess.PIPE)
kw.setdefault('stderr', subprocess.PIPE)
prog = subprocess.Popen(cmd, **kw)
stdout, stderr = prog.communicate()
if prog.returncode != 0:
if dump_on_error:
print stdout
print stderr
raise RuntimeError('Command "%s" returned %d.' % (cmd, prog.returncode))
return (stdout, stderr)
def _IsGitCheckoutRoot(path):
"""Return true if the given |path| is the root of a git checkout."""
return os.path.exists(os.path.join(path, '.git'))
# Matches a GIT config file section header, and grabs the name of the section
# in the first group. Used by _GetGitOrigin.
_GIT_CONFIG_SECTION_RE = re.compile(r'^\s*\[(.*?)\]\s*$')
# Matches the URL line from a 'remote' section of a GIT config. Used by
# _GetGitOrigin.
_GIT_CONFIG_REMOTE_URL_RE = re.compile(r'^\s*url\s*=\s*(.*?)\s*$')
def _GetGitOrigin(path):
"""Returns the URL of the 'origin' remote for the git repo in |path|. Returns
None if the 'origin' remote doesn't exist. Raises an IOError if |path| doesn't
exist or is not a git repo.
"""
section = None
for line in open(os.path.join(path, '.git', 'config'), 'rb'):
m = _GIT_CONFIG_SECTION_RE.match(line)
if m:
section = m.group(1)
continue
# We only care about the 'origin' configuration.
if section != 'remote "origin"':
continue
m = _GIT_CONFIG_REMOTE_URL_RE.match(line)
if m:
return m.group(1).strip()
return None
def _GetGitHead(path):
"""Returns the hash of the head of the git repo in |path|. Raises an IOError
if |path| doesn't exist or is not a git repo.
"""
return open(os.path.join(path, '.git', 'HEAD'), 'rb').read().strip()
def _NormalizeGitPath(path):
"""Given a |path| in a GIT repository (relative to its root), normalizes it so
it will match only that exact path in a sparse checkout.
"""
path = path.strip()
if not path.startswith('/'):
path = '/' + path
if not path.endswith('/'):
path += '/'
return path
def _RenameCheckout(path, dry_run):
"""Renames the checkout in |path| so that it can be subsequently deleted.
Only actually does the work if |dry_run| is False. Returns the path of the
renamed checkout directory. Raises an Exception on failure.
"""
def _RenameCheckoutImpl(path, dry_run):
if dry_run:
return path + '-old-dryrun'
attempts = 0
while attempts < 10:
newpath = '%s-old-%04d' % (path, random.randint(0, 999))
try:
os.rename(path, newpath)
return newpath
except WindowsError:
attempts += 1
raise Exception('Unable to rename checkout directory: %s' % path)
newpath = _RenameCheckoutImpl(path, dry_run)
_LOGGER.debug('Renamed checkout directory: %s', newpath)
return newpath
def _DeleteCheckout(path, dry_run):
"""Deletes the checkout in |path|. Only actually deletes the checkout if
|dry_run| is False.
"""
_LOGGER.info('Deleting checkout directory: %s', path)
if dry_run:
return
_Shell('rmdir', '/S', '/Q', path, dry_run=False)
def _GenerateSparseCheckoutPathAndContents(repo):
"""Generates the path to the sparse checkout file, and the desired
contents. Returns a tuple of (path, contents). |repo| is a RepoOptions object.
"""
sparse_file = os.path.join(repo.checkout_dir, '.git', 'info',
'sparse-checkout')
if not repo.remote_dirs:
contents = '*\n'
else:
contents = ''.join(_NormalizeGitPath(dir) + '\n'
for dir in repo.remote_dirs)
return (sparse_file, contents)
def _HasValidSparseCheckoutConfig(repo):
"""Determines if the GIT repo in |path| has a valid sparse-checkout
configuration as configured by the RepoOptions |repo|. Returns True or False.
"""
(sparse_file, contents) = _GenerateSparseCheckoutPathAndContents(repo)
try:
if open(sparse_file, 'rb').read() == contents:
return True
return False
except IOError:
return False
def _CreateCheckout(path, repo, dry_run):
"""Creates a checkout in the provided |path|. The |path| must not already
exist. Uses the repository configuration from the provided |repo| RepoOptions
object. Only actually creates the checkout if |dry_run| is false.
"""
# We expect the directory not to exist, as this is a fresh checkout we are
# creating.
if not dry_run:
if os.path.exists(path):
raise Exception('Checkout directory already exists: %s' % path)
_LOGGER.info('Creating checkout directory: %s', path)
if not dry_run:
os.makedirs(path)
_LOGGER.debug('Initializing the checkout.')
_Shell('git', 'init', cwd=path, dry_run=dry_run)
_Shell('git', 'remote', 'add', 'origin', repo.repository, cwd=path,
dry_run=dry_run)
_Shell('git', 'config', 'core.sparsecheckout', 'true', cwd=path,
dry_run=dry_run)
if not dry_run:
_LOGGER.debug('Creating sparse checkout configuration file for '
'directory: %s', repo.remote_dirs)
if not dry_run:
(path, contents) = _GenerateSparseCheckoutPathAndContents(repo)
with open(path, 'wb') as io:
io.write(contents)
def _UpdateCheckout(path, repo, dry_run):
"""Updates a GIT checkout in |path| by pulling down a specific revision
from it, as configured by RepoOptions |repo|. Only actually runs if
|dry_run| is False.
"""
try:
# Try a checkout first. If this fails then we'll actually need to fetch
# the revision.
_LOGGER.info('Trying to checkout revision %s.', repo.revision)
_Shell('git', 'checkout', repo.revision, cwd=path,
dry_run=dry_run)
return
except RuntimeError:
pass
# Fetch the revision and then check it out. Let output go to screen rather
# than be buffered.
_LOGGER.info('Fetching and checking out revision %s.', repo.revision)
_Shell('git', 'fetch', '--depth=1', 'origin', repo.revision,
cwd=path, dry_run=dry_run, stdout=None, stderr=None)
_Shell('git', 'checkout', repo.revision, cwd=path,
dry_run=dry_run, stdout=None, stderr=None)
# Used by _GetJunctionInfo to extract information about junctions.
_DIR_JUNCTION_RE = re.compile(r'^.*<JUNCTION>\s+(.+)\s+\[(.+)\]$')
# TODO(chrisha): This is ugly, and there has to be a better way!
def _GetJunctionInfo(junction):
"""Returns the target of a junction, if it exists, None otherwise."""
dirname = os.path.dirname(junction)
basename = os.path.basename(junction)
try:
stdout, dummy_stderr = _Shell('dir', '/AL', '/N', dirname, dry_run=False)
except RuntimeError:
return
lines = stdout.splitlines(False)
for line in stdout.splitlines(False):
m = _DIR_JUNCTION_RE.match(line)
if not m:
continue
if m.group(1).lower() == basename.lower():
return m.group(2)
return None
def _EnsureJunction(cache_dir, target_dir, options, repo):
"""Ensures that the appropriate junction exists from the configured output
directory to the specified sub-directory of the GIT checkout.
"""
# Ensure that the target directory was created.
target_cache_dir = _GetCasedFilename(os.path.normpath(
os.path.join(cache_dir, target_dir)))
if not options.dry_run and not os.path.isdir(target_cache_dir):
raise Exception('Checkout does not contain the desired remote folder.')
# Ensure the parent directory exists before checking if the junction needs to
# be created.
output_dir = os.path.normpath(os.path.join(repo.output_dir, target_dir))
_EnsureDirectoryExists(
os.path.dirname(output_dir), 'junction', options.dry_run)
# Determine if the link needs to be created.
create_link = True
if os.path.exists(output_dir):
dest = _GetJunctionInfo(output_dir)
# If the junction is valid nothing needs to be done. If it points to the
# wrong place or isn't a junction then delete it and let it be remade.
if dest == target_cache_dir:
_LOGGER.debug('Junction is up to date.')
create_link = False
else:
if dest:
_LOGGER.info('Erasing existing junction: %s', output_dir)
else:
_LOGGER.info('Deleting existing directory: %s', output_dir)
_Shell('rmdir', '/S', '/Q', output_dir, dry_run=options.dry_run)
if create_link:
_LOGGER.info('Creating output junction: %s', output_dir)
_Shell('mklink', '/J', output_dir, target_cache_dir,
dry_run=options.dry_run)
def _InstallRepository(options, repo):
"""Installs a repository as configured by the options. Assumes that the
specified cache directory already exists.
Returns True if the checkout was modified, False otherwise.
"""
_LOGGER.debug('Processing directories "%s" from repository "%s".',
repo.remote_dirs, repo.repository)
# Ensure the output directory's *parent* exists.
output_dirname = os.path.dirname(repo.output_dir)
output_basename = os.path.basename(repo.output_dir)
_EnsureDirectoryExists(output_dirname, 'output', options.dry_run)
# Get the properly cased names for the output directories.
output_dirname = _GetCasedFilename(output_dirname)
repo.output_dir = os.path.join(output_dirname, output_basename)
# These are the 3 basic steps that need to occur. Depending on the state of
# the checkout we may not need to perform all of them. We assume initially
# that everything needs to be done, unless proven otherwise.
create_checkout = True
update_checkout = True
# If the cache directory exists then lookup the repo and the revision and see
# what needs to be updated.
threads = []
if os.path.exists(repo.checkout_dir):
keep_cache_dir = False
# Only run these checks if we're not in 'force' mode. Otherwise, we
# deliberately turf the cache directory and start from scratch.
if not options.force and _IsGitCheckoutRoot(repo.checkout_dir):
# Get the repo origin.
repo_url = _GetGitOrigin(repo.checkout_dir)
if (repo_url == repo.repository and
_HasValidSparseCheckoutConfig(repo)):
_LOGGER.debug('Checkout is for correct repository and subdirectory.')
keep_cache_dir = True
create_checkout = False
# Get the checked out revision.
revhash = _GetGitHead(repo.checkout_dir)
if revhash == repo.revision:
_LOGGER.debug('Checkout is already up to date.')
update_checkout = False
if not keep_cache_dir:
# The old checkout directory is renamed and erased in a separate thread
# so that the new checkout can start immediately.
_LOGGER.info('Erasing stale checkout directory: %s', repo.checkout_dir)
# Any existing junctions to this repo must be removed otherwise the
# rename may fail.
for d in repo.remote_dirs:
j = os.path.abspath(os.path.join(repo.output_dir, d))
_RemoveOrphanedJunction(options, j)
newpath = _RenameCheckout(repo.checkout_dir, options.dry_run)
thread = threading.Thread(target=_DeleteCheckout,
args=(newpath, options.dry_run))
threads.append(thread)
thread.start()
# Create and update the checkout as necessary.
if create_checkout:
_CreateCheckout(repo.checkout_dir, repo, options.dry_run)
else:
_LOGGER.debug('Reusing checkout directory: %s', repo.checkout_dir)
if update_checkout:
_UpdateCheckout(repo.checkout_dir, repo, options.dry_run)
# Ensure the junctions exists.
if repo.remote_dirs:
for remote_dir in repo.remote_dirs:
_EnsureJunction(repo.checkout_dir, remote_dir, options, repo)
else:
_EnsureJunction(repo.checkout_dir, '', options, repo)
# Join any worker threads that are ongoing.
for thread in threads:
thread.join()
# Return True if any modifications were made.
return create_checkout or update_checkout
def _WriteIfChanged(path, contents, dry_run):
if os.path.exists(path):
d = open(path, 'rb').read()
if d == contents:
_LOGGER.debug('Contents unchanged, not writing file: %s', path)
return
_LOGGER.info('Writing file: %s', path)
if not dry_run:
open(path, 'wb').write(contents)
def _RecurseRepository(options, repo):
"""Recursively follows dependencies in the given repository."""
# Only run if there's an appropriate DEPS file.
deps = os.path.isfile(os.path.join(repo.checkout_dir, 'DEPS'))
gitdeps = os.path.isfile(os.path.join(repo.checkout_dir, '.DEPS.git'))
if not deps and not gitdeps:
_LOGGER.debug('No deps file found in repository: %s', repo.repository)
return
# Generate the .gclient solution file.
cache_dir = os.path.dirname(os.path.abspath(repo.checkout_dir))
gclient_file = os.path.join(cache_dir, '.gclient')
deps_file = 'DEPS'
if gitdeps:
deps_file = '.DEPS.git'
solutions = [
{
'name': 'src',
'url': repo.repository,
'managed': False,
'custom_deps': [],
'deps_file': deps_file,
'safesync_url': '',
}
]
solutions = 'solutions=%s' % solutions.__repr__()
_WriteIfChanged(gclient_file, solutions, options.dry_run)
# Invoke 'gclient' on the sub-repository.
_Shell('gclient', 'sync', cwd=repo.checkout_dir, dry_run=options.dry_run)
def _FindGlobalVariableInAstTree(tree, name, functions=None):
"""Finds and evaluates to global assignment of the variables |name| in the
AST |tree|. Will allow the evaluations of some functions as defined in
|functions|.
"""
if functions is None:
functions = {}
class FunctionEvaluator(ast.NodeTransformer):
"""A tree transformer that evaluates permitted functions."""
def visit_BinOp(self, binop_node):
"""Is called for BinOp nodes. We only support string additions."""
if type(binop_node.op) != ast.Add:
return binop_node
left = ast.literal_eval(self.visit(binop_node.left))
right = ast.literal_eval(self.visit(binop_node.right))
value = left + right
new_node = ast.Str(s=value)
new_node = ast.copy_location(new_node, binop_node)
return new_node
def visit_Call(self, call_node):
"""Evaluates function calls that return a single string as output."""
func_name = call_node.func.id
if func_name not in functions:
return call_node
func = functions[func_name]
# Evaluate the arguments. We don't care about starargs, keywords or
# kwargs.
args = [ast.literal_eval(self.visit(arg)) for arg in
call_node.args]
# Now evaluate the function.
value = func(*args)
new_node = ast.Str(s=value)
new_node = ast.copy_location(new_node, call_node)
return new_node
# Look for assignment nodes.
for node in tree.body:
if type(node) != ast.Assign:
continue
# Look for assignment in the 'store' context, to a variable with
# the given name.
for target in node.targets:
if type(target) != ast.Name:
continue
if type(target.ctx) != ast.Store:
continue
if target.id == name:
value = FunctionEvaluator().visit(node.value)
value = ast.fix_missing_locations(value)
value = ast.literal_eval(value)
return value
def _ParseDepsFile(path):
"""Parsed a DEPS-like file at the given |path|."""
# Utility function for performing variable expansions.
vars_dict = {}
def _Var(s):
return vars_dict[s]
contents = open(path, 'rb').read()
tree = ast.parse(contents, path)
vars_dict = _FindGlobalVariableInAstTree(tree, 'vars')
deps_dict = _FindGlobalVariableInAstTree(
tree, 'deps', functions={'Var': _Var})
return deps_dict
def _RemoveFile(options, path):
"""Removes the provided file. If it doesn't exist, raises an Exception."""
_LOGGER.debug('Removing file: %s', path)
if not os.path.isfile(path):
raise Exception('Path does not exist: %s' % path)
if not options.dry_run:
os.remove(path)
def _RemoveOrphanedJunction(options, junction):
"""Removes an orphaned junction at the path |junction|. If the path doesn't
exist or is not a junction, raises an Exception.
"""
_LOGGER.debug('Removing orphaned junction: %s', junction)
absdir = os.path.join(options.output_dir, junction)
if not os.path.exists(absdir):
_LOGGER.debug('Junction path does not exist, ignoring.')
return
if not _GetJunctionInfo(absdir):
_LOGGER.error('Path is not a junction: %s', absdir)
raise Exception()
_Shell('rmdir', '/S', '/Q', absdir, dry_run=options.dry_run)
reldir = os.path.dirname(junction)
while reldir:
absdir = os.path.join(options.output_dir, reldir)
if os.listdir(absdir):
return
_LOGGER.debug('Removing empty parent directory of junction: %s', absdir)
_Shell('rmdir', '/S', '/Q', absdir, dry_run=options.dry_run)
reldir = os.path.dirname(reldir)
def _GetCacheDirEntryVersion(path):
"""Returns the version of the cache directory entry, -1 if invalid."""
git = os.path.join(path, '.git')
src = os.path.join(path, 'src')
gclient = os.path.join(path, '.gclient')
# Version 0 contains a '.git' directory and no '.gclient' entry.
if os.path.isdir(git):
if os.path.exists(gclient):
return -1
return 0
# Version 1 contains a 'src' directory and no '.git' entry.
if os.path.isdir(src):
if os.path.exists(git):
return -1
return 1
def _GetCacheDirEntries(cache_dir):
"""Returns the list of entries in the given |cache_dir|."""
entries = []
for path in os.listdir(cache_dir):
if not re.match('^[a-z0-9]{32}$', path):
continue
entries.append(path)
return entries
def _GetCacheDirVersion(cache_dir):
"""Returns the version of the cache directory."""
# If it doesn't exist then it's clearly the latest version.
if not os.path.exists(cache_dir):
return 1
cache_version = None
for path in _GetCacheDirEntries(cache_dir):
repo = os.path.join(cache_dir, path)
if not os.path.isdir(repo):
return -1
entry_version = _GetCacheDirEntryVersion(repo)
if entry_version == -1:
return -1
if cache_version == None:
cache_version = entry_version
else:
if cache_version != entry_version:
return -1
# If there are no entries in the cache it may as well be the latest version.
if cache_version is None:
return 1
return cache_version
def _GetJunctionStatePath(options):
"""Returns the junction state file path."""
return os.path.join(options.cache_dir, '.gitdeps_junctions')
def _ReadJunctions(options):
"""Reads the list of junctions as a dictionary."""
state_path = _GetJunctionStatePath(options)
old_junctions = {}
if os.path.exists(state_path):
_LOGGER.debug('Loading list of existing junctions.')
for j in open(state_path, 'rb'):
old_junctions[j.strip()] = True
return old_junctions
def _Rename(src, dst, dry_run):
_LOGGER.debug('Renaming "%s" to "%s".', src, dst)
if not dry_run:
os.rename(src, dst)
def _UpgradeCacheDir(options):
"""Upgrades the cache directory format to the most modern layout.
Returns true on success, false otherwise.
"""
cache_version = _GetCacheDirVersion(options.cache_dir)
if cache_version == 1:
_LOGGER.debug('No cache directory upgrade required.')
return
_LOGGER.debug('Upgrading cache directory from version 0 to 1.')
_LOGGER.debug('Removing all junctions.')
junctions = _ReadJunctions(options).keys()
junctions = sorted(junctions, key=lambda j: len(j), reverse=True)
for junction in junctions:
_RemoveOrphanedJunction(options, junction)
_RemoveFile(options, _GetJunctionStatePath(options))
for entry in _GetCacheDirEntries(options.cache_dir):
_LOGGER.debug('Upgrading cache entry "%s".', entry)
tmp_entry = os.path.abspath(os.path.join(
options.cache_dir,
'TMP%d-%04d' % (os.getpid(), random.randint(0, 999))))
abs_entry = os.path.abspath(os.path.join(options.cache_dir, entry))
src = os.path.join(abs_entry, 'src')
_Rename(abs_entry, tmp_entry, options.dry_run)
_EnsureDirectoryExists(abs_entry, 'cache entry', options.dry_run)
_Rename(tmp_entry, src, options.dry_run)
if options.dry_run:
_LOGGER.debug('Cache needs upgrading, unable to further simulate dry-run.')
raise Exception("")
def main():
options, args = _ParseCommandLine()
# Upgrade the cache directory if necessary.
_UpgradeCacheDir(options)
# Ensure the cache directory exists and get the full properly cased path to
# it.
_EnsureDirectoryExists(options.cache_dir, 'cache', options.dry_run)
options.cache_dir = _GetCasedFilename(options.cache_dir)
# Read junctions that have been written in previous runs.
state_path = _GetJunctionStatePath(options)
old_junctions = _ReadJunctions(options)
# Parse each deps file in order, and extract the dependencies, looking for
# conflicts in the output directories.
output_dirs = {}
all_deps = []
for deps_file in args:
deps = _ParseDepsFile(deps_file)
for key, value in deps.iteritems():
repo_options = _ParseRepoOptions(
options.cache_dir, options.output_dir, deps_file, key, value)
if repo_options.output_dir in output_dirs:
other_repo_options = output_dirs[repo_options.output_dir]
_LOGGER.error('Conflicting output directory: %s',
repo_options.output_dir)
_LOGGER.error('First specified in file: %s',
other_repo_options.deps_file)
_LOGGER.error('And then specified in file: %s', repo_options.deps_file)
output_dirs[repo_options.output_dir] = repo_options
all_deps.append(repo_options)
output_dirs = {}
# Handle each dependency, in order of shortest path names first. This ensures
# that nested dependencies are handled properly.
checkout_dirs = {}
deps = sorted(all_deps, key=lambda x: len(x.deps_file))
junctions = []
for repo in all_deps:
changes_made = _InstallRepository(options, repo)
checkout_dirs[repo.checkout_dir] = changes_made
new_junction_dirs = repo.remote_dirs if repo.remote_dirs else ['']
for new_junction_dir in new_junction_dirs:
junction = os.path.relpath(
os.path.join(repo.output_dir, new_junction_dir),
options.output_dir)
old_junctions.pop(junction, None)
# Write each junction as we create it. This allows for recovery from
# partial runs.
if not options.dry_run:
open(state_path, 'ab').write(junction + '\n')
junctions.append(junction)
# Clean up orphaned junctions if there are any.
if old_junctions:
_LOGGER.debug('Removing orphaned junctions.')
for j in old_junctions.iterkeys():
_RemoveOrphanedJunction(options, j)
# Output the final list of junctions.
_LOGGER.debug('Writing final list of junctions.')
if not options.dry_run:
with open(state_path, 'wb') as io:
for j in sorted(junctions):
io.write(j)
io.write('\n')
# Iterate all directories in the cache directory. Any that we didn't
# specifically create or update should be cleaned up. Do this in parallel
# so things are cleaned up as soon as possible.
threads = []
for path in glob.glob(os.path.join(options.cache_dir, '*')):
if os.path.join(path, 'src') not in checkout_dirs:
_LOGGER.debug('Erasing orphaned checkout directory: %s', path)
thread = threading.Thread(target=_DeleteCheckout,
args=(path, options.dry_run))
threads.append(thread)
thread.start()
for thread in threads:
thread.join()
# Recursively process other dependencies.
for repo in all_deps:
if not repo.recurse:
continue
if not checkout_dirs[repo.checkout_dir] and not options.force:
continue
_RecurseRepository(options, repo)
return
if __name__ == '__main__':
main()