1 """Git commands and queries for Git"""
2 from __future__
import division
, absolute_import
, unicode_literals
6 from io
import StringIO
11 from .git
import STDOUT
12 from .git
import EMPTY_TREE_OID
13 from .git
import OID_LENGTH
15 from .interaction
import Interaction
18 class InvalidRepositoryError(Exception):
22 def add(context
, items
, u
=False):
23 """Run "git add" while preventing argument overflow"""
25 return utils
.slice_fn(
26 items
, lambda paths
: fn('--', force
=True, verbose
=True, u
=u
, *paths
))
29 def apply_diff(context
, filename
):
31 return git
.apply(filename
, index
=True, cached
=True)
34 def apply_diff_to_worktree(context
, filename
):
36 return git
.apply(filename
)
39 def get_branch(context
, branch
):
41 branch
= current_branch(context
)
45 def upstream_remote(context
, branch
=None):
46 """Return the remote associated with the specified branch"""
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"""
55 url
= config
.get('remote.%s.url' % remote
, '')
57 url
= config
.get('remote.%s.pushurl' % remote
, url
)
61 def diff_index_filenames(context
, ref
):
63 Return a diff of filenames that have been modified relative to the index
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"""
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
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
)
92 if entry
[0][0] == 't': # tree
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])
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"
118 out
= git
.diff(name_only
=True, z
=True, *args
)[STDOUT
]
119 return _parse_diff_filenames(out
)
122 def _parse_diff_filenames(out
):
124 return out
[:-1].split('\0')
128 def tracked_files(context
, *args
):
129 """Return the names of all files in the repository"""
131 out
= git
.ls_files('--', *args
, z
=True)[STDOUT
]
133 return sorted(out
[:-1].split('\0'))
137 def all_files(context
, *args
):
138 """Returns a sorted list of all files, including untracked files."""
140 ls_files
= git
.ls_files('--', *args
,
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()"""
155 _current_branch
.key
= None
158 def current_branch(context
):
159 """Return the current branch"""
161 head
= git
.git_path('HEAD')
163 key
= core
.stat(head
).st_mtime
164 if _current_branch
.key
== key
:
165 return _current_branch
.value
167 # OSError means we can't use the stat cache
170 status
, data
, _
= git
.rev_parse('HEAD', symbolic_full_name
=True)
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
185 def _read_git_head(context
, head
, default
='master'):
186 """Pure-python .git/HEAD reader"""
187 # Common .git/HEAD "ref: refs/heads/master" files
189 islink
= core
.islink(head
)
190 if core
.isfile(head
) and not islink
:
191 data
= core
.read(head
).rstrip()
193 if data
.startswith(ref_prefix
):
194 return data
[len(ref_prefix
):]
197 # Legacy .git/HEAD symlinks
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:]
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.
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'):
227 def for_each_ref_basename(context
, refs
):
228 """Return refs starting with 'refs'."""
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
]
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)."""
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
:])
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'."""
270 branch
= current_branch(context
)
274 remote
= config
.get('branch.%s.remote' % branch
)
277 merge_ref
= config
.get('branch.%s.merge' % branch
)
280 refs_heads
= 'refs/heads/'
281 if merge_ref
.startswith(refs_heads
):
282 return remote
+ '/' + merge_ref
[len(refs_heads
):]
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
)
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."""
303 args
= ['--'] + paths
304 out
= git
.ls_files(z
=True, others
=True, exclude_standard
=True,
305 *args
, **kwargs
)[STDOUT
]
307 return out
[:-1].split('\0')
311 def tag_list(context
):
312 """Return a list of tags."""
313 result
= for_each_ref_basename(context
, 'refs/tags')
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
):
325 return log(git
, '-1', oid
, '--') + '\n\n' + oid_diff(context
, oid
)
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
):
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')
345 'patience': patience
,
346 'submodule': submodule
,
349 'unified': config
.get('gui.diffcontext', default
=3),
352 opts
.update(_diff_overrides
)
356 def _add_filename(args
, 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.
367 args
= [oid
+ '~', oid
]
368 opts
= common_diff_opts(context
)
369 _add_filename(args
, filename
)
370 status
, out
, _
= git
.diff(*args
, **opts
)
372 # We probably don't have "$oid~" because this is the root commit.
373 # "git show" is clever enough to handle the root commit.
375 _add_filename(args
, filename
)
376 _
, out
, _
= git
.show(pretty
='format:', _readonly
=True, *args
, **opts
)
381 def diff_info(context
, oid
, filename
=None):
383 decoded
= log(git
, '-1', oid
, '--', pretty
='format:%b').strip()
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."
396 ref
, endref
= commit
+'^', commit
399 argv
.append('%s..%s' % (ref
, endref
))
401 for r
in utils
.shell_split(ref
.strip()):
403 elif head
and amending
and cached
:
409 if isinstance(filename
, (list, tuple)):
410 argv
.extend(filename
)
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
))
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'):
435 return ('', diffoutput
)
439 del_tag
= 'deleted file mode '
444 for line
in diffoutput
.split('\n'):
445 if not start
and line
[:2] == '@@' and '@@' in line
[2:]:
447 if start
or (deleted
and del_tag
in line
):
448 output
.write(line
+ '\n')
451 headers
.write(line
+ '\n')
452 elif not suppress_header
:
453 output
.write(line
+ '\n')
455 output_text
= output
.getvalue()
458 headers_text
= headers
.getvalue()
462 return (headers_text
, 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.
479 cur_rev
= to_export
[0]
480 cur_master_idx
= revs
.index(cur_rev
)
482 patches_to_export
= [[cur_rev
]]
485 # Group the patches into continuous sets
486 for rev
in to_export
[1:]:
487 # Limit the search to the current neighborhood for efficiency
489 master_idx
= revs
[cur_master_idx
:].index(rev
)
490 master_idx
+= cur_master_idx
492 master_idx
= revs
.index(rev
)
494 if master_idx
== cur_master_idx
+ 1:
495 patches_to_export
[patchset_idx
].append(rev
)
499 patches_to_export
.append([rev
])
500 cur_master_idx
= master_idx
503 # Export each patchsets
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)
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."""
520 return git
.format_patch('-o', output
, start
+ '^..' + end
, **kwargs
)
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'):
533 status
, out
, err
= git
.reset(head
, '--', *set(args
))
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
):
543 return (-1, N_('Nothing to do'), '')
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.
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(
564 if display_untracked
:
565 untracked
= untracked_files(context
, paths
=paths
)
569 # Remove unmerged paths from the modified list
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
)
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
):
596 info
, path
, out
= out
.split('\0', 2)
598 is_submodule
= ('160000' in info
[1:14])
599 yield (path
, status
, is_submodule
)
602 def diff_index(context
, head
, cached
=True, paths
=None):
611 args
= [head
, '--'] + paths
612 status
, out
, _
= git
.diff_index(cached
=cached
, z
=True, *args
)
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
):
626 unmerged
.append(path
)
628 return staged
, unmerged
, deleted
, submodules
631 def diff_worktree(context
, paths
=None):
639 args
= ['--'] + paths
640 status
, out
, _
= git
.diff_files(z
=True, *args
)
641 for path
, status
, is_submodule
in _parse_raw_diff(out
):
645 modified
.append(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
)
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)"""
664 status
, data
, _
= git
.submodule('status')
666 if status
== 0 and data
:
667 data
= data
.splitlines()
668 # see git submodule status
669 # TODO better separation
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
))
682 def merge_base(context
, head
, ref
):
683 """Return the merge-base of head and ref"""
685 return git
.merge_base(head
, ref
, _readonly
=True)[STDOUT
]
688 def merge_base_parent(context
, branch
):
689 tracked
= tracked_branch(context
, branch
=branch
)
696 def parse_ls_tree(context
, rev
):
697 """Return a list of (mode, type, oid, path) tuples."""
700 lines
= git
.ls_tree(rev
, r
=True, _readonly
=True)[STDOUT
].splitlines()
701 regex
= re
.compile(r
'^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
703 match
= regex
.match(line
)
705 mode
= match
.group(1)
706 objtype
= match
.group(2)
708 filename
= match
.group(4)
709 output
.append((mode
, objtype
, oid
, filename
,))
714 def ls_tree(context
, path
, ref
='HEAD'):
715 """Return a parsed git ls-tree result for a single directory"""
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'):
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
729 relpath
= line
[path_offset
:]
730 result
.append((objtype
, relpath
))
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."""
742 for line
in raw_revs
.splitlines():
743 match
= REV_LIST_REGEX
.match(line
)
745 rev_id
= match
.group(1)
746 summary
= match
.group(2)
747 revs
.append((rev_id
, summary
,))
751 # pylint: disable=redefined-builtin
752 def log_helper(context
, all
=False, extra_args
=None):
753 """Return parallel arrays containing oids and summaries."""
760 output
= log(git
, pretty
='oneline', all
=all
, *args
)
761 for line
in output
.splitlines():
762 match
= REV_LIST_REGEX
.match(line
)
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."""
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"""
780 path
= git
.git_path('GIT_COLA_MSG')
781 if core
.exists(path
):
786 def merge_message_path(context
):
787 """Return the path to .git/MERGE_MSG or .git/SQUASH_MSG."""
789 for basename
in ('MERGE_MSG', 'SQUASH_MSG'):
790 path
= git
.git_path(basename
)
791 if core
.exists(path
):
796 def prepare_commit_message_hook(context
):
797 default_hook
= context
.git
.git_path('hooks', 'cola-prepare-commit-msg')
799 return config
.get('cola.preparecommitmessagehook', default
=default_hook
)
802 def abort_merge(context
):
803 """Abort a merge by reading the tree at HEAD."""
806 status
, out
, err
= git
.read_tree('HEAD', reset
=True, u
=True, v
=True)
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"""
830 status
, out
, _
= git
.rev_parse(*argv
)
832 oids
= [oid
for oid
in out
.splitlines() if oid
]
838 def prev_commitmsg(context
, *args
):
839 """Queries git for the latest commit message."""
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"""
848 status
, out
, _
= git
.rev_parse(name
)
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"""
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
)
889 N_('Error'), 'git cat-file', status
, out
, err
)
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"""
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
)
914 for line
in out
.splitlines():
915 info
= json
.loads(line
)
917 annex_file
= info
['file']
918 except (ValueError, KeyError):
920 # we only care about this file so we can skip the rest
921 if annex_file
== filename
:
924 key
= annex_info
.get('key', '')
926 status
, out
, _
= git
.annex('contentlocation', key
)
927 if status
== 0 and os
.path
.exists(out
):