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