Merge pull request #30363 from terminalmage/issue30203

Use native "list" subcommand to list git worktrees
This commit is contained in:
Mike Place 2016-01-19 13:35:41 -07:00
commit 6e8b1e89a5

View file

@ -35,6 +35,21 @@ def __virtual__():
return True if salt.utils.which('git') else False
def _check_worktree_support(failhard=True):
'''
Ensure that we don't try to operate on worktrees in git < 2.5.0.
'''
git_version = version(versioninfo=False)
if _LooseVersion(git_version) < _LooseVersion('2.5.0'):
if failhard:
raise CommandExecutionError(
'Worktrees are only supported in git 2.5.0 and newer '
'(detected git version: ' + git_version + ')'
)
return False
return True
def _config_getter(get_opt,
key,
value_regex=None,
@ -1664,13 +1679,19 @@ def list_worktrees(cwd, stale=False, user=None, **kwargs):
'''
.. versionadded:: 2015.8.0
Return a dictionary mapping worktrees to their locations.
Returns information on worktrees
.. versionchanged:: 2015.8.4
Version 2.7.0 added the ``list`` subcommand to `git-worktree(1)`_ which
provides a lot of additional information. The return data has been
changed to include this information, even for pre-2.7.0 versions of
git. In addition, if a worktree has a detached head, then any tags
which point to the worktree's HEAD will be included in the return data.
.. note::
This information is compiled by analyzing the administrative data in
$GIT_DIR/worktrees. By default, only worktrees for which the gitdir is
still present are returned, but this can be changed using the ``all``
and ``stale`` arguments (described below).
By default, only worktrees for which the worktree directory is still
present are returned, but this can be changed using the ``all`` and
``stale`` arguments (described below).
cwd
The path to the git checkout
@ -1680,11 +1701,17 @@ def list_worktrees(cwd, stale=False, user=None, **kwargs):
by the user under which the minion is running.
all : False
If ``True``, then return all worktrees, including ones whose gitdir is
no longer present.
If ``True``, then return all worktrees tracked under
$GIT_DIR/worktrees, including ones for which the gitdir is no longer
present.
stale : False
If ``True``, return only worktrees whose gitdir is no longer present.
If ``True``, return *only* worktrees whose gitdir is no longer present.
.. note::
Only one of ``all`` and ``stale`` can be set to ``True``.
.. _`git-worktree(1)`: http://git-scm.com/docs/git-worktree
CLI Examples:
@ -1695,6 +1722,8 @@ def list_worktrees(cwd, stale=False, user=None, **kwargs):
salt myminion git.list_worktrees /path/to/repo all=True
salt myminion git.list_worktrees /path/to/repo stale=True
'''
if not _check_worktree_support(failhard=True):
return {}
cwd = _expand_path(cwd, user)
kwargs = salt.utils.clean_kwargs(**kwargs)
all_ = kwargs.pop('all', False)
@ -1706,54 +1735,226 @@ def list_worktrees(cwd, stale=False, user=None, **kwargs):
'\'all\' and \'stale\' cannot both be set to True'
)
try:
worktree_root = rev_parse(cwd, opts=['--git-path', 'worktrees'])
except CommandExecutionError as exc:
msg = 'Failed to find worktree location for ' + cwd
log.error(msg, exc_info_on_loglevel=logging.DEBUG)
raise CommandExecutionError(msg)
if worktree_root.startswith('.git'):
worktree_root = os.path.join(cwd, worktree_root)
if not os.path.isdir(worktree_root):
return {}
def _git_tag_points_at(cwd, rev, user=None):
'''
Get any tags that point at a
'''
return _git_run(['git', 'tag', '--points-at', rev],
cwd=cwd,
runas=user)['stdout'].splitlines()
worktree_info = {}
for worktree_name in os.listdir(worktree_root):
gitdir_file = os.path.join(worktree_root, worktree_name, 'gitdir')
try:
with salt.utils.fopen(gitdir_file, 'r') as fp_:
for line in fp_:
worktree_loc = line.rstrip('\n')
if worktree_loc.endswith('/.git'):
worktree_loc = worktree_loc[:-5]
worktree_info[worktree_name] = worktree_loc
break
except (IOError, OSError) as exc:
if exc.errno == errno.EEXIST:
log.warning(
gitdir_file + ' does not exist, data for worktree ' +
worktree_name + ' may be corrupted. Try pruning worktrees.'
)
elif exc.errno == errno.EACCES:
raise CommandExecutionError(
'Permission denied reading from ' + gitdir_file
)
else:
raise CommandExecutionError(
'Error {0} encountered reading from {1}: {2}'.format(
exc.errno, gitdir_file, exc.strerror
)
)
def _desired(is_stale, all_, stale):
'''
Common logic to determine whether or not to include the worktree info
in the return data.
'''
if is_stale:
if not all_ and not stale:
# Stale worktrees are not desired, skip this one
return False
else:
if stale:
# Only stale worktrees are desired, skip this one
return False
return True
if all_ or not worktree_info:
return worktree_info
def _duplicate_worktree_path(path):
'''
Log errors to the minion log notifying of duplicate worktree paths.
These should not be there, but may show up due to a bug in git 2.7.0.
'''
log.error(
'git.worktree: Duplicate worktree path {0}. This may be caused by '
'a known issue in git 2.7.0 (see '
'http://permalink.gmane.org/gmane.comp.version-control.git/283998)'
.format(path)
)
tracked_data_points = ('worktree', 'HEAD', 'branch')
ret = {}
for worktree_name, worktree_loc in six.iteritems(worktree_info):
worktree_is_stale = not os.path.isdir(worktree_loc)
if (stale and worktree_is_stale) \
or (not stale and not worktree_is_stale):
ret[worktree_name] = worktree_loc
git_version = _LooseVersion(version(versioninfo=False))
has_native_list_subcommand = git_version >= _LooseVersion('2.7.0')
if has_native_list_subcommand:
out = _git_run(['git', 'worktree', 'list', '--porcelain'],
cwd=cwd,
runas=user)
if out['retcode'] != 0:
msg = 'Failed to list worktrees'
if out['stderr']:
msg += ': {0}'.format(out['stderr'])
raise CommandExecutionError(msg)
def _untracked_item(line):
'''
Log a warning
'''
log.warning(
'git.worktree: Untracked line item \'{0}\''.format(line)
)
for individual_worktree in \
salt.utils.itertools.split(out['stdout'].strip(), '\n\n'):
# Initialize the dict where we're storing the tracked data points
worktree_data = dict([(x, '') for x in tracked_data_points])
for line in salt.utils.itertools.split(individual_worktree, '\n'):
try:
type_, value = line.strip().split(None, 1)
except ValueError:
if line == 'detached':
type_ = 'branch'
value = 'detached'
else:
_untracked_item(line)
continue
if type_ not in tracked_data_points:
_untracked_item(line)
continue
if worktree_data[type_]:
log.error(
'git.worktree: Unexpected duplicate {0} entry '
'\'{1}\', skipping'.format(type_, line)
)
continue
worktree_data[type_] = value
# Check for missing data points
missing = [x for x in tracked_data_points if not worktree_data[x]]
if missing:
log.error(
'git.worktree: Incomplete worktree data, missing the '
'following information: {0}. Full data below:\n{1}'
.format(', '.join(missing), individual_worktree)
)
continue
worktree_is_stale = not os.path.isdir(worktree_data['worktree'])
if not _desired(worktree_is_stale, all_, stale):
continue
if worktree_data['worktree'] in ret:
_duplicate_worktree_path(worktree_data['worktree'])
wt_ptr = ret.setdefault(worktree_data['worktree'], {})
wt_ptr['stale'] = worktree_is_stale
wt_ptr['HEAD'] = worktree_data['HEAD']
wt_ptr['detached'] = worktree_data['branch'] == 'detached'
if wt_ptr['detached']:
wt_ptr['branch'] = None
# Check to see if HEAD points at a tag
tags_found = _git_tag_points_at(cwd, wt_ptr['HEAD'], user)
if tags_found:
wt_ptr['tags'] = tags_found
else:
wt_ptr['branch'] = \
worktree_data['branch'].replace('refs/heads/', '', 1)
return ret
else:
toplevel = _get_toplevel(cwd, user)
try:
worktree_root = rev_parse(cwd,
opts=['--git-path', 'worktrees'],
user=user)
except CommandExecutionError as exc:
msg = 'Failed to find worktree location for ' + cwd
log.error(msg, exc_info_on_loglevel=logging.DEBUG)
raise CommandExecutionError(msg)
if worktree_root.startswith('.git'):
worktree_root = os.path.join(cwd, worktree_root)
if not os.path.isdir(worktree_root):
raise CommandExecutionError(
'Worktree admin directory {0} not present'
.format(worktree_root)
)
def _read_file(path):
'''
Return contents of a single line file with EOF newline stripped
'''
try:
with salt.utils.fopen(path, 'r') as fp_:
for line in fp_:
ret = line.strip()
# Ignore other lines, if they exist (which they
# shouldn't)
break
return ret
except (IOError, OSError) as exc:
if exc.errno == errno.ENOENT:
raise CommandExecutionError(
'{0} does not exist'.format(path)
)
elif exc.errno == errno.EACCES:
raise CommandExecutionError(
'Permission denied reading from {0}'.format(path)
)
else:
raise CommandExecutionError(
'Error {0} encountered reading from {1}: {2}'.format(
exc.errno, path, exc.strerror
)
)
for worktree_name in os.listdir(worktree_root):
admin_dir = os.path.join(worktree_root, worktree_name)
gitdir_file = os.path.join(admin_dir, 'gitdir')
head_file = os.path.join(admin_dir, 'HEAD')
wt_loc = _read_file(gitdir_file)
head_ref = _read_file(head_file)
if not os.path.isabs(wt_loc):
log.error(
'Non-absolute path found in {0}. If git 2.7.0 was '
'installed and then downgraded, this was likely caused '
'by a known issue in git 2.7.0. See '
'http://permalink.gmane.org/gmane.comp.version-control'
'.git/283998 for more information.'.format(gitdir_file)
)
# Emulate what 'git worktree list' does under-the-hood, and
# that is using the toplevel directory. It will still give
# inaccurate results, but will avoid a traceback.
wt_loc = toplevel
if wt_loc.endswith('/.git'):
wt_loc = wt_loc[:-5]
worktree_is_stale = not os.path.isdir(wt_loc)
if not _desired(worktree_is_stale, all_, stale):
continue
if wt_loc in ret:
_duplicate_worktree_path(wt_loc)
if head_ref.startswith('ref: '):
head_ref = head_ref.split(None, 1)[-1]
wt_branch = head_ref.replace('refs/heads/', '', 1)
wt_head = rev_parse(cwd, rev=head_ref, user=user)
wt_detached = False
else:
wt_branch = None
wt_head = head_ref
wt_detached = True
wt_ptr = ret.setdefault(wt_loc, {})
wt_ptr['stale'] = worktree_is_stale
wt_ptr['branch'] = wt_branch
wt_ptr['HEAD'] = wt_head
wt_ptr['detached'] = wt_detached
# Check to see if HEAD points at a tag
if wt_detached:
tags_found = _git_tag_points_at(cwd, wt_head, user)
if tags_found:
wt_ptr['tags'] = tags_found
return ret
@ -2097,7 +2298,7 @@ def merge_base(cwd,
# returns an identical commit to the resolved first ref, we know
# that the first ref is an ancestor of the second ref.
first_commit = rev_parse(cwd,
refs[0],
rev=refs[0],
opts=['--verify'],
user=user,
ignore_retcode=ignore_retcode)
@ -3121,9 +3322,9 @@ def submodule(cwd,
)
if not isinstance(command, six.string_types):
command = str(command)
command = ['git', 'submodule', command]
command.extend(_format_opts(opts))
return _git_run(command,
cmd = ['git', 'submodule', command]
cmd.extend(_format_opts(opts))
return _git_run(cmd,
cwd=cwd,
runas=user,
identity=identity,
@ -3328,6 +3529,7 @@ def worktree_add(cwd,
salt myminion git.worktree_add /path/to/repo/main ../hotfix ref=origin/master
salt myminion git.worktree_add /path/to/repo/main ../hotfix branch=hotfix21 ref=v2.1.9.3
'''
_check_worktree_support()
kwargs = salt.utils.clean_kwargs(**kwargs)
branch_ = kwargs.pop('branch', None)
if kwargs:
@ -3433,6 +3635,7 @@ def worktree_prune(cwd,
salt myminion git.worktree_prune /path/to/repo dry_run=True
salt myminion git.worktree_prune /path/to/repo expire=1.day.ago
'''
_check_worktree_support()
cwd = _expand_path(cwd, user)
command = ['git', 'worktree', 'prune']
if dry_run:
@ -3479,6 +3682,7 @@ def worktree_rm(cwd, user=None):
salt myminion git.worktree_rm /path/to/worktree
'''
_check_worktree_support()
cwd = _expand_path(cwd, user)
if not os.path.exists(cwd):
raise CommandExecutionError(cwd + ' does not exist')