1"""Git commands and queries for Git"""
2from __future__ import division, absolute_import, unicode_literals
3import json
4import os
5import re
6from io import StringIO
7
8from . import core
9from . import utils
10from . import version
11from .git import STDOUT
12from .git import EMPTY_TREE_OID
13from .git import OID_LENGTH
14from .i18n import N_
15from .interaction import Interaction
16
17
18class InvalidRepositoryError(Exception):
19    pass
20
21
22def add(context, items, u=False):
23    """Run "git add" while preventing argument overflow"""
24    fn = context.git.add
25    return utils.slice_fn(
26        items, lambda paths: fn('--', force=True, verbose=True, u=u, *paths)
27    )
28
29
30def apply_diff(context, filename):
31    git = context.git
32    return git.apply(filename, index=True, cached=True)
33
34
35def apply_diff_to_worktree(context, filename):
36    git = context.git
37    return git.apply(filename)
38
39
40def get_branch(context, branch):
41    if branch is None:
42        branch = current_branch(context)
43    return branch
44
45
46def upstream_remote(context, branch=None):
47    """Return the remote associated with the specified branch"""
48    config = context.cfg
49    branch = get_branch(context, branch)
50    return config.get('branch.%s.remote' % branch)
51
52
53def remote_url(context, remote, push=False):
54    """Return the URL for the specified remote"""
55    config = context.cfg
56    url = config.get('remote.%s.url' % remote, '')
57    if push:
58        url = config.get('remote.%s.pushurl' % remote, url)
59    return url
60
61
62def diff_index_filenames(context, ref):
63    """
64    Return a diff of filenames that have been modified relative to the index
65    """
66    git = context.git
67    out = git.diff_index(ref, name_only=True, z=True)[STDOUT]
68    return _parse_diff_filenames(out)
69
70
71def diff_filenames(context, *args):
72    """Return a list of filenames that have been modified"""
73    git = context.git
74    out = git.diff_tree(
75        name_only=True, no_commit_id=True, r=True, z=True, _readonly=True, *args
76    )[STDOUT]
77    return _parse_diff_filenames(out)
78
79
80def listdir(context, dirname, ref='HEAD'):
81    """Get the contents of a directory according to Git
82
83    Query Git for the content of a directory, taking ignored
84    files into account.
85
86    """
87    dirs = []
88    files = []
89
90    # first, parse git ls-tree to get the tracked files
91    # in a list of (type, path) tuples
92    entries = ls_tree(context, dirname, ref=ref)
93    for entry in entries:
94        if entry[0][0] == 't':  # tree
95            dirs.append(entry[1])
96        else:
97            files.append(entry[1])
98
99    # gather untracked files
100    untracked = untracked_files(context, paths=[dirname], directory=True)
101    for path in untracked:
102        if path.endswith('/'):
103            dirs.append(path[:-1])
104        else:
105            files.append(path)
106
107    dirs.sort()
108    files.sort()
109
110    return (dirs, files)
111
112
113def diff(context, args):
114    """Return a list of filenames for the given diff arguments
115
116    :param args: list of arguments to pass to "git diff --name-only"
117
118    """
119    git = context.git
120    out = git.diff(name_only=True, z=True, *args)[STDOUT]
121    return _parse_diff_filenames(out)
122
123
124def _parse_diff_filenames(out):
125    if out:
126        return out[:-1].split('\0')
127    return []
128
129
130def tracked_files(context, *args):
131    """Return the names of all files in the repository"""
132    git = context.git
133    out = git.ls_files('--', *args, z=True)[STDOUT]
134    if out:
135        return sorted(out[:-1].split('\0'))
136    return []
137
138
139def all_files(context, *args):
140    """Returns a sorted list of all files, including untracked files."""
141    git = context.git
142    ls_files = git.ls_files(
143        '--', *args, z=True, cached=True, others=True, exclude_standard=True
144    )[STDOUT]
145    return sorted([f for f in ls_files.split('\0') if f])
146
147
148class _current_branch(object):
149    """Cache for current_branch()"""
150
151    key = None
152    value = None
153
154
155def reset():
156    _current_branch.key = None
157
158
159def current_branch(context):
160    """Return the current branch"""
161    git = context.git
162    head = git.git_path('HEAD')
163    try:
164        key = core.stat(head).st_mtime
165        if _current_branch.key == key:
166            return _current_branch.value
167    except OSError:
168        # OSError means we can't use the stat cache
169        key = 0
170
171    status, data, _ = git.rev_parse('HEAD', symbolic_full_name=True)
172    if status != 0:
173        # git init -- read .git/HEAD.  We could do this unconditionally...
174        data = _read_git_head(context, head)
175
176    for refs_prefix in ('refs/heads/', 'refs/remotes/', 'refs/tags/'):
177        if data.startswith(refs_prefix):
178            value = data[len(refs_prefix) :]
179            _current_branch.key = key
180            _current_branch.value = value
181            return value
182    # Detached head
183    return data
184
185
186def _read_git_head(context, head, default='main'):
187    """Pure-python .git/HEAD reader"""
188    # Common .git/HEAD "ref: refs/heads/main" files
189    git = context.git
190    islink = core.islink(head)
191    if core.isfile(head) and not islink:
192        data = core.read(head).rstrip()
193        ref_prefix = 'ref: '
194        if data.startswith(ref_prefix):
195            return data[len(ref_prefix) :]
196        # Detached head
197        return data
198    # Legacy .git/HEAD symlinks
199    elif islink:
200        refs_heads = core.realpath(git.git_path('refs', 'heads'))
201        path = core.abspath(head).replace('\\', '/')
202        if path.startswith(refs_heads + '/'):
203            return path[len(refs_heads) + 1 :]
204
205    return default
206
207
208def branch_list(context, remote=False):
209    """
210    Return a list of local or remote branches
211
212    This explicitly removes HEAD from the list of remote branches.
213
214    """
215    if remote:
216        return for_each_ref_basename(context, 'refs/remotes')
217    return for_each_ref_basename(context, 'refs/heads')
218
219
220def _version_sort(context, key='version:refname'):
221    if version.check_git(context, 'version-sort'):
222        sort = key
223    else:
224        sort = False
225    return sort
226
227
228def for_each_ref_basename(context, refs):
229    """Return refs starting with 'refs'."""
230    git = context.git
231    sort = _version_sort(context)
232    _, out, _ = git.for_each_ref(refs, format='%(refname)', sort=sort, _readonly=True)
233    output = out.splitlines()
234    non_heads = [x for x in output if not x.endswith('/HEAD')]
235    offset = len(refs) + 1
236    return [x[offset:] for x in non_heads]
237
238
239def _triple(x, y):
240    return (x, len(x) + 1, y)
241
242
243def all_refs(context, split=False, sort_key='version:refname'):
244    """Return a tuple of (local branches, remote branches, tags)."""
245    git = context.git
246    local_branches = []
247    remote_branches = []
248    tags = []
249    triple = _triple
250    query = (
251        triple('refs/tags', tags),
252        triple('refs/heads', local_branches),
253        triple('refs/remotes', remote_branches),
254    )
255    sort = _version_sort(context, key=sort_key)
256    _, out, _ = git.for_each_ref(format='%(refname)', sort=sort, _readonly=True)
257    for ref in out.splitlines():
258        for prefix, prefix_len, dst in query:
259            if ref.startswith(prefix) and not ref.endswith('/HEAD'):
260                dst.append(ref[prefix_len:])
261                continue
262    tags.reverse()
263    if split:
264        return local_branches, remote_branches, tags
265    return local_branches + remote_branches + tags
266
267
268def tracked_branch(context, branch=None):
269    """Return the remote branch associated with 'branch'."""
270    if branch is None:
271        branch = current_branch(context)
272    if branch is None:
273        return None
274    config = context.cfg
275    remote = config.get('branch.%s.remote' % branch)
276    if not remote:
277        return None
278    merge_ref = config.get('branch.%s.merge' % branch)
279    if not merge_ref:
280        return None
281    refs_heads = 'refs/heads/'
282    if merge_ref.startswith(refs_heads):
283        return remote + '/' + merge_ref[len(refs_heads) :]
284    return None
285
286
287def parse_remote_branch(branch):
288    """Split a remote branch apart into (remote, name) components"""
289    rgx = re.compile(r'^(?P<remote>[^/]+)/(?P<branch>.+)$')
290    match = rgx.match(branch)
291    remote = ''
292    branch = ''
293    if match:
294        remote = match.group('remote')
295        branch = match.group('branch')
296    return (remote, branch)
297
298
299def untracked_files(context, paths=None, **kwargs):
300    """Returns a sorted list of untracked files."""
301    git = context.git
302    if paths is None:
303        paths = []
304    args = ['--'] + paths
305    out = git.ls_files(z=True, others=True, exclude_standard=True, *args, **kwargs)[
306        STDOUT
307    ]
308    if out:
309        return out[:-1].split('\0')
310    return []
311
312
313def tag_list(context):
314    """Return a list of tags."""
315    result = for_each_ref_basename(context, 'refs/tags')
316    result.reverse()
317    return result
318
319
320def log(git, *args, **kwargs):
321    return git.log(
322        no_color=True,
323        no_abbrev_commit=True,
324        no_ext_diff=True,
325        _readonly=True,
326        *args,
327        **kwargs
328    )[STDOUT]
329
330
331def commit_diff(context, oid):
332    git = context.git
333    return log(git, '-1', oid, '--') + '\n\n' + oid_diff(context, oid)
334
335
336_diff_overrides = {}
337
338
339def update_diff_overrides(space_at_eol, space_change, all_space, function_context):
340    _diff_overrides['ignore_space_at_eol'] = space_at_eol
341    _diff_overrides['ignore_space_change'] = space_change
342    _diff_overrides['ignore_all_space'] = all_space
343    _diff_overrides['function_context'] = function_context
344
345
346def common_diff_opts(context):
347    config = context.cfg
348    # Default to --patience when diff.algorithm is unset
349    patience = not config.get('diff.algorithm', default='')
350    submodule = version.check_git(context, 'diff-submodule')
351    opts = {
352        'patience': patience,
353        'submodule': submodule,
354        'no_color': True,
355        'no_ext_diff': True,
356        'unified': config.get('gui.diffcontext', default=3),
357        '_raw': True,
358    }
359    opts.update(_diff_overrides)
360    return opts
361
362
363def _add_filename(args, filename):
364    if filename:
365        args.extend(['--', filename])
366
367
368def oid_diff(context, oid, filename=None):
369    """Return the diff for an oid"""
370    # Naively "$oid^!" is what we'd like to use but that doesn't
371    # give the correct result for merges--the diff is reversed.
372    # Be explicit and compare oid against its first parent.
373    git = context.git
374    args = [oid + '~', oid]
375    opts = common_diff_opts(context)
376    _add_filename(args, filename)
377    status, out, _ = git.diff(*args, **opts)
378    if status != 0:
379        # We probably don't have "$oid~" because this is the root commit.
380        # "git show" is clever enough to handle the root commit.
381        args = [oid + '^!']
382        _add_filename(args, filename)
383        _, out, _ = git.show(pretty='format:', _readonly=True, *args, **opts)
384        out = out.lstrip()
385    return out
386
387
388def diff_info(context, oid, filename=None):
389    git = context.git
390    decoded = log(git, '-1', oid, '--', pretty='format:%b').strip()
391    if decoded:
392        decoded += '\n\n'
393    return decoded + oid_diff(context, oid, filename=filename)
394
395
396def diff_helper(
397    context,
398    commit=None,
399    ref=None,
400    endref=None,
401    filename=None,
402    cached=True,
403    deleted=False,
404    head=None,
405    amending=False,
406    with_diff_header=False,
407    suppress_header=True,
408    reverse=False,
409):
410    "Invokes git diff on a filepath."
411    git = context.git
412    cfg = context.cfg
413    if commit:
414        ref, endref = commit + '^', commit
415    argv = []
416    if ref and endref:
417        argv.append('%s..%s' % (ref, endref))
418    elif ref:
419        for r in utils.shell_split(ref.strip()):
420            argv.append(r)
421    elif head and amending and cached:
422        argv.append(head)
423
424    encoding = None
425    if filename:
426        argv.append('--')
427        if isinstance(filename, (list, tuple)):
428            argv.extend(filename)
429        else:
430            argv.append(filename)
431            encoding = cfg.file_encoding(filename)
432
433    status, out, _ = git.diff(
434        R=reverse,
435        M=True,
436        cached=cached,
437        _encoding=encoding,
438        *argv,
439        **common_diff_opts(context)
440    )
441    if status != 0:
442        # git init
443        if with_diff_header:
444            return ('', '')
445        return ''
446
447    result = extract_diff_header(deleted, with_diff_header, suppress_header, out)
448    return core.UStr(result, out.encoding)
449
450
451def extract_diff_header(deleted, with_diff_header, suppress_header, diffoutput):
452    """Split a diff into a header section and payload section"""
453
454    if diffoutput.startswith('Submodule'):
455        if with_diff_header:
456            return ('', diffoutput)
457        return diffoutput
458
459    start = False
460    del_tag = 'deleted file mode '
461
462    output = StringIO()
463    headers = StringIO()
464
465    for line in diffoutput.split('\n'):
466        if not start and line[:2] == '@@' and '@@' in line[2:]:
467            start = True
468        if start or (deleted and del_tag in line):
469            output.write(line + '\n')
470        else:
471            if with_diff_header:
472                headers.write(line + '\n')
473            elif not suppress_header:
474                output.write(line + '\n')
475
476    output_text = output.getvalue()
477    output.close()
478
479    headers_text = headers.getvalue()
480    headers.close()
481
482    if with_diff_header:
483        return (headers_text, output_text)
484    return output_text
485
486
487def format_patchsets(context, to_export, revs, output='patches'):
488    """
489    Group contiguous revision selection into patchsets
490
491    Exists to handle multi-selection.
492    Multiple disparate ranges in the revision selection
493    are grouped into continuous lists.
494
495    """
496
497    outs = []
498    errs = []
499
500    cur_rev = to_export[0]
501    cur_rev_idx = revs.index(cur_rev)
502
503    patches_to_export = [[cur_rev]]
504    patchset_idx = 0
505
506    # Group the patches into continuous sets
507    for rev in to_export[1:]:
508        # Limit the search to the current neighborhood for efficiency
509        try:
510            rev_idx = revs[cur_rev_idx:].index(rev)
511            rev_idx += cur_rev_idx
512        except ValueError:
513            rev_idx = revs.index(rev)
514
515        if rev_idx == cur_rev_idx + 1:
516            patches_to_export[patchset_idx].append(rev)
517            cur_rev_idx += 1
518        else:
519            patches_to_export.append([rev])
520            cur_rev_idx = rev_idx
521            patchset_idx += 1
522
523    # Export each patchsets
524    status = 0
525    for patchset in patches_to_export:
526        stat, out, err = export_patchset(
527            context,
528            patchset[0],
529            patchset[-1],
530            output=output,
531            n=len(patchset) > 1,
532            thread=True,
533            patch_with_stat=True,
534        )
535        outs.append(out)
536        if err:
537            errs.append(err)
538        status = max(stat, status)
539    return (status, '\n'.join(outs), '\n'.join(errs))
540
541
542def export_patchset(context, start, end, output='patches', **kwargs):
543    """Export patches from start^ to end."""
544    git = context.git
545    return git.format_patch('-o', output, start + '^..' + end, **kwargs)
546
547
548def reset_paths(context, head, items):
549    """Run "git reset" while preventing argument overflow"""
550    items = list(set(items))
551    fn = context.git.reset
552    status, out, err = utils.slice_fn(items, lambda paths: fn(head, '--', *paths))
553    return (status, out, err)
554
555
556def unstage_paths(context, args, head='HEAD'):
557    """Unstage paths while accounting for git init"""
558    status, out, err = reset_paths(context, head, args)
559    if status == 128:
560        # handle git init: we have to use 'git rm --cached'
561        # detect this condition by checking if the file is still staged
562        return untrack_paths(context, args)
563    return (status, out, err)
564
565
566def untrack_paths(context, args):
567    if not args:
568        return (-1, N_('Nothing to do'), '')
569    git = context.git
570    return git.update_index('--', force_remove=True, *set(args))
571
572
573def worktree_state(
574    context, head='HEAD', update_index=False, display_untracked=True, paths=None
575):
576    """Return a dict of files in various states of being
577
578    :rtype: dict, keys are staged, unstaged, untracked, unmerged,
579            changed_upstream, and submodule.
580
581    """
582    git = context.git
583    if update_index:
584        git.update_index(refresh=True)
585
586    staged, unmerged, staged_deleted, staged_submods = diff_index(
587        context, head, paths=paths
588    )
589    modified, unstaged_deleted, modified_submods = diff_worktree(context, paths)
590    if display_untracked:
591        untracked = untracked_files(context, paths=paths)
592    else:
593        untracked = []
594
595    # Remove unmerged paths from the modified list
596    if unmerged:
597        unmerged_set = set(unmerged)
598        modified = [path for path in modified if path not in unmerged_set]
599
600    # Look for upstream modified files if this is a tracking branch
601    upstream_changed = diff_upstream(context, head)
602
603    # Keep stuff sorted
604    staged.sort()
605    modified.sort()
606    unmerged.sort()
607    untracked.sort()
608    upstream_changed.sort()
609
610    return {
611        'staged': staged,
612        'modified': modified,
613        'unmerged': unmerged,
614        'untracked': untracked,
615        'upstream_changed': upstream_changed,
616        'staged_deleted': staged_deleted,
617        'unstaged_deleted': unstaged_deleted,
618        'submodules': staged_submods | modified_submods,
619    }
620
621
622def _parse_raw_diff(out):
623    while out:
624        info, path, out = out.split('\0', 2)
625        status = info[-1]
626        is_submodule = '160000' in info[1:14]
627        yield (path, status, is_submodule)
628
629
630def diff_index(context, head, cached=True, paths=None):
631    git = context.git
632    staged = []
633    unmerged = []
634    deleted = set()
635    submodules = set()
636
637    if paths is None:
638        paths = []
639    args = [head, '--'] + paths
640    status, out, _ = git.diff_index(cached=cached, z=True, *args)
641    if status != 0:
642        # handle git init
643        args[0] = EMPTY_TREE_OID
644        status, out, _ = git.diff_index(cached=cached, z=True, *args)
645
646    for path, status, is_submodule in _parse_raw_diff(out):
647        if is_submodule:
648            submodules.add(path)
649        if status in 'DAMT':
650            staged.append(path)
651            if status == 'D':
652                deleted.add(path)
653        elif status == 'U':
654            unmerged.append(path)
655
656    return staged, unmerged, deleted, submodules
657
658
659def diff_worktree(context, paths=None):
660    git = context.git
661    modified = []
662    deleted = set()
663    submodules = set()
664
665    if paths is None:
666        paths = []
667    args = ['--'] + paths
668    status, out, _ = git.diff_files(z=True, *args)
669    for path, status, is_submodule in _parse_raw_diff(out):
670        if is_submodule:
671            submodules.add(path)
672        if status in 'DAMT':
673            modified.append(path)
674            if status == 'D':
675                deleted.add(path)
676
677    return modified, deleted, submodules
678
679
680def diff_upstream(context, head):
681    """Given `ref`, return $(git merge-base ref HEAD)..ref."""
682    tracked = tracked_branch(context)
683    if not tracked:
684        return []
685    base = merge_base(context, head, tracked)
686    return diff_filenames(context, base, tracked)
687
688
689def list_submodule(context):
690    """Return submodules in the format(state, sha1, path, describe)"""
691    git = context.git
692    status, data, _ = git.submodule('status')
693    ret = []
694    if status == 0 and data:
695        data = data.splitlines()
696        # see git submodule status
697        # TODO better separation
698        for line in data:
699            state = line[0].strip()
700            sha1 = line[1 : OID_LENGTH + 1]
701            left_bracket = line.find('(', OID_LENGTH + 3)
702            if left_bracket == -1:
703                left_bracket = len(line) + 1
704            path = line[OID_LENGTH + 2 : left_bracket - 1]
705            describe = line[left_bracket + 1 : -1]
706            ret.append((state, sha1, path, describe))
707    return ret
708
709
710def merge_base(context, head, ref):
711    """Return the merge-base of head and ref"""
712    git = context.git
713    return git.merge_base(head, ref, _readonly=True)[STDOUT]
714
715
716def merge_base_parent(context, branch):
717    tracked = tracked_branch(context, branch=branch)
718    if tracked:
719        return tracked
720    return 'HEAD'
721
722
723# TODO Unused?
724def parse_ls_tree(context, rev):
725    """Return a list of (mode, type, oid, path) tuples."""
726    output = []
727    git = context.git
728    lines = git.ls_tree(rev, r=True, _readonly=True)[STDOUT].splitlines()
729    regex = re.compile(r'^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
730    for line in lines:
731        match = regex.match(line)
732        if match:
733            mode = match.group(1)
734            objtype = match.group(2)
735            oid = match.group(3)
736            filename = match.group(4)
737            output.append(
738                (
739                    mode,
740                    objtype,
741                    oid,
742                    filename,
743                )
744            )
745    return output
746
747
748# TODO unused?
749def ls_tree(context, path, ref='HEAD'):
750    """Return a parsed git ls-tree result for a single directory"""
751    git = context.git
752    result = []
753    status, out, _ = git.ls_tree(ref, '--', path, z=True, full_tree=True)
754    if status == 0 and out:
755        path_offset = 6 + 1 + 4 + 1 + OID_LENGTH + 1
756        for line in out[:-1].split('\0'):
757            #       1    1                                        1
758            # .....6 ...4 ......................................40
759            # 040000 tree c127cde9a0c644a3a8fef449a244f47d5272dfa6	relative
760            # 100644 blob 139e42bf4acaa4927ec9be1ec55a252b97d3f1e2	relative/path
761            # 0..... 7... 12......................................	53
762            # path_offset = 6 + 1 + 4 + 1 + OID_LENGTH(40) + 1
763            objtype = line[7:11]
764            relpath = line[path_offset:]
765            result.append((objtype, relpath))
766
767    return result
768
769
770# A regex for matching the output of git(log|rev-list) --pretty=oneline
771REV_LIST_REGEX = re.compile(r'^([0-9a-f]{40}) (.*)$')
772
773
774def parse_rev_list(raw_revs):
775    """Parse `git log --pretty=online` output into (oid, summary) pairs."""
776    revs = []
777    for line in raw_revs.splitlines():
778        match = REV_LIST_REGEX.match(line)
779        if match:
780            rev_id = match.group(1)
781            summary = match.group(2)
782            revs.append(
783                (
784                    rev_id,
785                    summary,
786                )
787            )
788    return revs
789
790
791# pylint: disable=redefined-builtin
792def log_helper(context, all=False, extra_args=None):
793    """Return parallel arrays containing oids and summaries."""
794    revs = []
795    summaries = []
796    args = []
797    if extra_args:
798        args = extra_args
799    git = context.git
800    output = log(git, pretty='oneline', all=all, *args)
801    for line in output.splitlines():
802        match = REV_LIST_REGEX.match(line)
803        if match:
804            revs.append(match.group(1))
805            summaries.append(match.group(2))
806    return (revs, summaries)
807
808
809def rev_list_range(context, start, end):
810    """Return (oid, summary) pairs between start and end."""
811    git = context.git
812    revrange = '%s..%s' % (start, end)
813    out = git.rev_list(revrange, pretty='oneline')[STDOUT]
814    return parse_rev_list(out)
815
816
817def commit_message_path(context):
818    """Return the path to .git/GIT_COLA_MSG"""
819    git = context.git
820    path = git.git_path('GIT_COLA_MSG')
821    if core.exists(path):
822        return path
823    return None
824
825
826def merge_message_path(context):
827    """Return the path to .git/MERGE_MSG or .git/SQUASH_MSG."""
828    git = context.git
829    for basename in ('MERGE_MSG', 'SQUASH_MSG'):
830        path = git.git_path(basename)
831        if core.exists(path):
832            return path
833    return None
834
835
836def prepare_commit_message_hook(context):
837    """Run the cola.preparecommitmessagehook to prepare the commit message"""
838    config = context.cfg
839    default_hook = config.hooks_path('cola-prepare-commit-msg')
840    return config.get('cola.preparecommitmessagehook', default=default_hook)
841
842
843def abort_merge(context):
844    """Abort a merge by reading the tree at HEAD."""
845    # Reset the worktree
846    git = context.git
847    status, out, err = git.read_tree('HEAD', reset=True, u=True, v=True)
848    # remove MERGE_HEAD
849    merge_head = git.git_path('MERGE_HEAD')
850    if core.exists(merge_head):
851        core.unlink(merge_head)
852    # remove MERGE_MESSAGE, etc.
853    merge_msg_path = merge_message_path(context)
854    while merge_msg_path:
855        core.unlink(merge_msg_path)
856        merge_msg_path = merge_message_path(context)
857    return status, out, err
858
859
860def strip_remote(remotes, remote_branch):
861    for remote in remotes:
862        prefix = remote + '/'
863        if remote_branch.startswith(prefix):
864            return remote_branch[len(prefix) :]
865    return remote_branch.split('/', 1)[-1]
866
867
868def parse_refs(context, argv):
869    """Parse command-line arguments into object IDs"""
870    git = context.git
871    status, out, _ = git.rev_parse(*argv)
872    if status == 0:
873        oids = [oid for oid in out.splitlines() if oid]
874    else:
875        oids = argv
876    return oids
877
878
879def prev_commitmsg(context, *args):
880    """Queries git for the latest commit message."""
881    git = context.git
882    return git.log('-1', no_color=True, pretty='format:%s%n%n%b', *args)[STDOUT]
883
884
885def rev_parse(context, name):
886    """Call git rev-parse and return the output"""
887    git = context.git
888    status, out, _ = git.rev_parse(name)
889    if status == 0:
890        result = out.strip()
891    else:
892        result = name
893    return result
894
895
896def write_blob(context, oid, filename):
897    """Write a blob to a temporary file and return the path
898
899    Modern versions of Git allow invoking filters.  Older versions
900    get the object content as-is.
901
902    """
903    if version.check_git(context, 'cat-file-filters-path'):
904        return cat_file_to_path(context, filename, oid)
905    return cat_file_blob(context, filename, oid)
906
907
908def cat_file_blob(context, filename, oid):
909    return cat_file(context, filename, 'blob', oid)
910
911
912def cat_file_to_path(context, filename, oid):
913    return cat_file(context, filename, oid, path=filename, filters=True)
914
915
916def cat_file(context, filename, *args, **kwargs):
917    """Redirect git cat-file output to a path"""
918    result = None
919    git = context.git
920    # Use the original filename in the suffix so that the generated filename
921    # has the correct extension, and so that it resembles the original name.
922    basename = os.path.basename(filename)
923    suffix = '-' + basename  # ensures the correct filename extension
924    path = utils.tmp_filename('blob', suffix=suffix)
925    with open(path, 'wb') as fp:
926        status, out, err = git.cat_file(
927            _raw=True, _readonly=True, _stdout=fp, *args, **kwargs
928        )
929        Interaction.command(N_('Error'), 'git cat-file', status, out, err)
930        if status == 0:
931            result = path
932    if not result:
933        core.unlink(path)
934    return result
935
936
937def write_blob_path(context, head, oid, filename):
938    """Use write_blob() when modern git is available"""
939    if version.check_git(context, 'cat-file-filters-path'):
940        return write_blob(context, oid, filename)
941    return cat_file_blob(context, filename, head + ':' + filename)
942
943
944def annex_path(context, head, filename):
945    """Return the git-annex path for a filename at the specified commit"""
946    git = context.git
947    path = None
948    annex_info = {}
949
950    # unfortunately there's no way to filter this down to a single path
951    # so we just have to scan all reported paths
952    status, out, _ = git.annex('findref', '--json', head)
953    if status == 0:
954        for line in out.splitlines():
955            info = json.loads(line)
956            try:
957                annex_file = info['file']
958            except (ValueError, KeyError):
959                continue
960            # we only care about this file so we can skip the rest
961            if annex_file == filename:
962                annex_info = info
963                break
964    key = annex_info.get('key', '')
965    if key:
966        status, out, _ = git.annex('contentlocation', key)
967        if status == 0 and os.path.exists(out):
968            path = out
969
970    return path
971