1 """Provides commands and queries for Git."""
2 from __future__
import division
, absolute_import
, unicode_literals
5 from io
import StringIO
12 from .git
import STDOUT
16 EMPTY_TREE_OID
= '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
19 class InvalidRepositoryError(Exception):
23 def default_remote(config
=None):
24 """Return the remote tracked by the current branch."""
26 config
= gitcfg
.current()
27 return config
.get('branch.%s.remote' % current_branch())
30 def diff_index_filenames(ref
):
31 """Return a of filenames that have been modified relative to the index"""
32 out
= git
.diff_index(ref
, name_only
=True, z
=True)[STDOUT
]
33 return _parse_diff_filenames(out
)
36 def diff_filenames(*args
):
37 """Return a list of filenames that have been modified"""
38 out
= git
.diff_tree(name_only
=True, no_commit_id
=True, r
=True, z
=True,
39 _readonly
=True, *args
)[STDOUT
]
40 return _parse_diff_filenames(out
)
43 def listdir(dirname
, ref
='HEAD'):
44 """Get the contents of a directory according to Git
46 Query Git for the content of a directory, taking ignored
53 # first, parse git ls-tree to get the tracked files
54 # in a list of (type, path) tuples
55 entries
= ls_tree(dirname
, ref
=ref
)
57 if entry
[0][0] == 't': # tree
60 files
.append(entry
[1])
62 # gather untracked files
63 untracked
= untracked_files(paths
=[dirname
], directory
=True)
64 for path
in untracked
:
65 if path
.endswith('/'):
66 dirs
.append(path
[:-1])
77 """Return a list of filenames for the given diff arguments
79 :param args: list of arguments to pass to "git diff --name-only"
82 out
= git
.diff(name_only
=True, z
=True, *args
)[STDOUT
]
83 return _parse_diff_filenames(out
)
86 def _parse_diff_filenames(out
):
88 return out
[:-1].split('\0')
93 def tracked_files(*args
):
94 """Return the names of all files in the repository"""
95 out
= git
.ls_files('--', *args
, z
=True)[STDOUT
]
97 return sorted(out
[:-1].split('\0'))
102 def all_files(*args
):
103 """Returns a sorted list of all files, including untracked files."""
104 ls_files
= git
.ls_files('--', *args
,
108 exclude_standard
=True)[STDOUT
]
109 return sorted([f
for f
in ls_files
.split('\0') if f
])
112 class _current_branch(object):
113 """Cache for current_branch()"""
119 _current_branch
.key
= None
122 def current_branch():
123 """Return the current branch"""
124 head
= git
.git_path('HEAD')
126 key
= core
.stat(head
).st_mtime
127 if _current_branch
.key
== key
:
128 return _current_branch
.value
130 # OSError means we can't use the stat cache
133 status
, data
, err
= git
.rev_parse('HEAD', symbolic_full_name
=True)
135 # git init -- read .git/HEAD. We could do this unconditionally...
136 data
= _read_git_head(head
)
138 for refs_prefix
in ('refs/heads/', 'refs/remotes/', 'refs/tags/'):
139 if data
.startswith(refs_prefix
):
140 value
= data
[len(refs_prefix
):]
141 _current_branch
.key
= key
142 _current_branch
.value
= value
148 def _read_git_head(head
, default
='master', git
=git
):
149 """Pure-python .git/HEAD reader"""
150 # Common .git/HEAD "ref: refs/heads/master" files
151 islink
= core
.islink(head
)
152 if core
.isfile(head
) and not islink
:
153 data
= core
.read(head
).rstrip()
155 if data
.startswith(ref_prefix
):
156 return data
[len(ref_prefix
):]
159 # Legacy .git/HEAD symlinks
161 refs_heads
= core
.realpath(git
.git_path('refs', 'heads'))
162 path
= core
.abspath(head
).replace('\\', '/')
163 if path
.startswith(refs_heads
+ '/'):
164 return path
[len(refs_heads
)+1:]
169 def branch_list(remote
=False):
171 Return a list of local or remote branches
173 This explicitly removes HEAD from the list of remote branches.
177 return for_each_ref_basename('refs/remotes')
179 return for_each_ref_basename('refs/heads')
182 def for_each_ref_basename(refs
, git
=git
):
183 """Return refs starting with 'refs'."""
184 out
= git
.for_each_ref(refs
, format
='%(refname)', _readonly
=True)[STDOUT
]
185 output
= out
.splitlines()
186 non_heads
= [x
for x
in output
if not x
.endswith('/HEAD')]
187 return list(map(lambda x
: x
[len(refs
) + 1:], non_heads
))
191 return (x
, len(x
) + 1, y
)
194 def all_refs(split
=False, git
=git
):
195 """Return a tuple of (local branches, remote branches, tags)."""
200 query
= (triple('refs/tags', tags
),
201 triple('refs/heads', local_branches
),
202 triple('refs/remotes', remote_branches
))
203 out
= git
.for_each_ref(format
='%(refname)', _readonly
=True)[STDOUT
]
204 for ref
in out
.splitlines():
205 for prefix
, prefix_len
, dst
in query
:
206 if ref
.startswith(prefix
) and not ref
.endswith('/HEAD'):
207 dst
.append(ref
[prefix_len
:])
210 return local_branches
, remote_branches
, tags
212 return local_branches
+ remote_branches
+ tags
215 def tracked_branch(branch
=None, config
=None):
216 """Return the remote branch associated with 'branch'."""
218 config
= gitcfg
.current()
220 branch
= current_branch()
223 remote
= config
.get('branch.%s.remote' % branch
)
226 merge_ref
= config
.get('branch.%s.merge' % branch
)
229 refs_heads
= 'refs/heads/'
230 if merge_ref
.startswith(refs_heads
):
231 return remote
+ '/' + merge_ref
[len(refs_heads
):]
235 def untracked_files(git
=git
, paths
=None, **kwargs
):
236 """Returns a sorted list of untracked files."""
240 args
= ['--'] + paths
241 out
= git
.ls_files(z
=True, others
=True, exclude_standard
=True,
242 *args
, **kwargs
)[STDOUT
]
244 return out
[:-1].split('\0')
249 """Return a list of tags."""
250 return list(reversed(for_each_ref_basename('refs/tags')))
253 def log(git
, *args
, **kwargs
):
254 return git
.log(no_color
=True, no_abbrev_commit
=True,
255 no_ext_diff
=True, _readonly
=True, *args
, **kwargs
)[STDOUT
]
258 def commit_diff(oid
, git
=git
):
259 return log(git
, '-1', oid
, '--') + '\n\n' + oid_diff(git
, oid
)
265 def update_diff_overrides(space_at_eol
, space_change
,
266 all_space
, function_context
):
267 _diff_overrides
['ignore_space_at_eol'] = space_at_eol
268 _diff_overrides
['ignore_space_change'] = space_change
269 _diff_overrides
['ignore_all_space'] = all_space
270 _diff_overrides
['function_context'] = function_context
273 def common_diff_opts(config
=None):
275 config
= gitcfg
.current()
276 submodule
= version
.check('diff-submodule', version
.git_version())
279 'submodule': submodule
,
282 'unified': config
.get('gui.diffcontext', 3),
285 opts
.update(_diff_overrides
)
289 def _add_filename(args
, filename
):
291 args
.extend(['--', filename
])
294 def oid_diff(git
, oid
, filename
=None):
295 """Return the diff for an oid"""
296 # Naively "$oid^!" is what we'd like to use but that doesn't
297 # give the correct result for merges--the diff is reversed.
298 # Be explicit and compare oid against its first parent.
299 args
= [oid
+ '~', oid
]
300 opts
= common_diff_opts()
301 _add_filename(args
, filename
)
302 status
, out
, err
= git
.diff(*args
, **opts
)
304 # We probably don't have "$oid~" because this is the root commit.
305 # "git show" is clever enough to handle the root commit.
307 _add_filename(args
, filename
)
308 status
, out
, err
= git
.show(pretty
='format:', _readonly
=True,
314 def diff_info(oid
, git
=git
, filename
=None):
315 decoded
= log(git
, '-1', oid
, '--', pretty
='format:%b').strip()
318 return decoded
+ oid_diff(git
, oid
, filename
=filename
)
321 def diff_helper(commit
=None,
329 with_diff_header
=False,
330 suppress_header
=True,
333 "Invokes git diff on a filepath."
335 ref
, endref
= commit
+'^', commit
338 argv
.append('%s..%s' % (ref
, endref
))
340 for r
in utils
.shell_split(ref
.strip()):
342 elif head
and amending
and cached
:
348 if type(filename
) is list:
349 argv
.extend(filename
)
351 argv
.append(filename
)
352 cfg
= gitcfg
.current()
353 encoding
= cfg
.file_encoding(filename
)
355 status
, out
, err
= git
.diff(R
=reverse
, M
=True, cached
=cached
,
358 **common_diff_opts())
366 return extract_diff_header(status
, deleted
,
367 with_diff_header
, suppress_header
, out
)
370 def extract_diff_header(status
, deleted
,
371 with_diff_header
, suppress_header
, diffoutput
):
374 if diffoutput
.startswith('Submodule'):
376 return ('', diffoutput
)
381 del_tag
= 'deleted file mode '
384 for line
in diffoutput
.splitlines():
385 if not start
and '@@' == line
[:2] and '@@' in line
[2:]:
387 if start
or (deleted
and del_tag
in line
):
388 output
.write(line
+ '\n')
392 elif not suppress_header
:
393 output
.write(line
+ '\n')
395 result
= output
.getvalue().rstrip('\n')
399 return('\n'.join(headers
), result
)
404 def format_patchsets(to_export
, revs
, output
='patches'):
406 Group contiguous revision selection into patchsets
408 Exists to handle multi-selection.
409 Multiple disparate ranges in the revision selection
410 are grouped into continuous lists.
417 cur_rev
= to_export
[0]
418 cur_master_idx
= revs
.index(cur_rev
)
420 patches_to_export
= [[cur_rev
]]
423 # Group the patches into continuous sets
424 for idx
, rev
in enumerate(to_export
[1:]):
425 # Limit the search to the current neighborhood for efficiency
427 master_idx
= revs
[cur_master_idx
:].index(rev
)
428 master_idx
+= cur_master_idx
430 master_idx
= revs
.index(rev
)
432 if master_idx
== cur_master_idx
+ 1:
433 patches_to_export
[patchset_idx
].append(rev
)
437 patches_to_export
.append([rev
])
438 cur_master_idx
= master_idx
441 # Export each patchsets
443 for patchset
in patches_to_export
:
444 stat
, out
, err
= export_patchset(patchset
[0],
449 patch_with_stat
=True)
453 status
= max(stat
, status
)
454 return (status
, '\n'.join(outs
), '\n'.join(errs
))
457 def export_patchset(start
, end
, output
='patches', **kwargs
):
458 """Export patches from start^ to end."""
459 return git
.format_patch('-o', output
, start
+ '^..' + end
, **kwargs
)
462 def unstage_paths(args
, head
='HEAD'):
463 status
, out
, err
= git
.reset(head
, '--', *set(args
))
465 # handle git init: we have to use 'git rm --cached'
466 # detect this condition by checking if the file is still staged
467 return untrack_paths(args
, head
=head
)
469 return (status
, out
, err
)
472 def untrack_paths(args
, head
='HEAD'):
474 return (-1, N_('Nothing to do'), '')
475 return git
.update_index('--', force_remove
=True, *set(args
))
478 def worktree_state(head
='HEAD',
480 display_untracked
=True,
482 """Return a dict of files in various states of being
484 :rtype: dict, keys are staged, unstaged, untracked, unmerged,
485 changed_upstream, and submodule.
489 git
.update_index(refresh
=True)
491 staged
, unmerged
, staged_deleted
, staged_submods
= diff_index(head
,
493 modified
, unstaged_deleted
, modified_submods
= diff_worktree(paths
)
494 untracked
= display_untracked
and untracked_files(paths
=paths
) or []
496 # Remove unmerged paths from the modified list
498 unmerged_set
= set(unmerged
)
499 modified
= [path
for path
in modified
if path
not in unmerged_set
]
501 # Look for upstream modified files if this is a tracking branch
502 upstream_changed
= diff_upstream(head
)
509 upstream_changed
.sort()
511 return {'staged': staged
,
512 'modified': modified
,
513 'unmerged': unmerged
,
514 'untracked': untracked
,
515 'upstream_changed': upstream_changed
,
516 'staged_deleted': staged_deleted
,
517 'unstaged_deleted': unstaged_deleted
,
518 'submodules': staged_submods | modified_submods
}
521 def _parse_raw_diff(out
):
523 info
, path
, out
= out
.split('\0', 2)
525 is_submodule
= ('160000' in info
[1:14])
526 yield (path
, status
, is_submodule
)
529 def diff_index(head
, cached
=True, paths
=None):
537 args
= [head
, '--'] + paths
538 status
, out
, err
= git
.diff_index(cached
=cached
, z
=True, *args
)
541 args
[0] = EMPTY_TREE_OID
542 status
, out
, err
= git
.diff_index(cached
=cached
, z
=True, *args
)
544 for path
, status
, is_submodule
in _parse_raw_diff(out
):
552 unmerged
.append(path
)
554 return staged
, unmerged
, deleted
, submodules
557 def diff_worktree(paths
=None):
564 args
= ['--'] + paths
565 status
, out
, err
= git
.diff_files(z
=True, *args
)
566 for path
, status
, is_submodule
in _parse_raw_diff(out
):
570 modified
.append(path
)
574 return modified
, deleted
, submodules
577 def diff_upstream(head
):
578 tracked
= tracked_branch()
581 base
= merge_base(head
, tracked
)
582 return diff_filenames(base
, tracked
)
585 def _branch_status(branch
):
587 Returns a tuple of staged, unstaged, untracked, and unmerged files
589 This shows only the changes that were introduced in branch
592 staged
= diff_filenames(branch
)
593 return {'staged': staged
,
594 'upstream_changed': staged
}
597 def merge_base(head
, ref
):
598 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
599 return git
.merge_base(head
, ref
, _readonly
=True)[STDOUT
]
602 def merge_base_parent(branch
):
603 tracked
= tracked_branch(branch
=branch
)
609 def parse_ls_tree(rev
):
610 """Return a list of (mode, type, oid, path) tuples."""
612 lines
= git
.ls_tree(rev
, r
=True, _readonly
=True)[STDOUT
].splitlines()
613 regex
= re
.compile(r
'^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
615 match
= regex
.match(line
)
617 mode
= match
.group(1)
618 objtype
= match
.group(2)
620 filename
= match
.group(4)
621 output
.append((mode
, objtype
, oid
, filename
,))
625 def ls_tree(path
, ref
='HEAD'):
626 """Return a parsed git ls-tree result for a single directory"""
629 status
, out
, err
= git
.ls_tree(ref
, '--', path
, z
=True, full_tree
=True)
630 if status
== 0 and out
:
631 for line
in out
[:-1].split('\0'):
632 # .....6 ...4 ......................................40
633 # 040000 tree c127cde9a0c644a3a8fef449a244f47d5272dfa6 relative
634 # 100644 blob 139e42bf4acaa4927ec9be1ec55a252b97d3f1e2 relative/path
635 # 0..... 7... 12...................................... 53
636 # path offset = 6 + 1 + 4 + 1 + 40 + 1 = 53
639 result
.append((objtype
, relpath
))
643 # A regex for matching the output of git(log|rev-list) --pretty=oneline
644 REV_LIST_REGEX
= re
.compile(r
'^([0-9a-f]{40}) (.*)$')
647 def parse_rev_list(raw_revs
):
648 """Parse `git log --pretty=online` output into (oid, summary) pairs."""
650 for line
in raw_revs
.splitlines():
651 match
= REV_LIST_REGEX
.match(line
)
653 rev_id
= match
.group(1)
654 summary
= match
.group(2)
655 revs
.append((rev_id
, summary
,))
659 def log_helper(all
=False, extra_args
=None):
660 """Return parallel arrays containing oids and summaries."""
666 output
= log(git
, pretty
='oneline', all
=all
, *args
)
667 for line
in output
.splitlines():
668 match
= REV_LIST_REGEX
.match(line
)
670 revs
.append(match
.group(1))
671 summaries
.append(match
.group(2))
672 return (revs
, summaries
)
675 def rev_list_range(start
, end
):
676 """Return (oid, summary) pairs between start and end."""
677 revrange
= '%s..%s' % (start
, end
)
678 out
= git
.rev_list(revrange
, pretty
='oneline')[STDOUT
]
679 return parse_rev_list(out
)
682 def commit_message_path():
683 """Return the path to .git/GIT_COLA_MSG"""
684 path
= git
.git_path('GIT_COLA_MSG')
685 if core
.exists(path
):
690 def merge_message_path():
691 """Return the path to .git/MERGE_MSG or .git/SQUASH_MSG."""
692 for basename
in ('MERGE_MSG', 'SQUASH_MSG'):
693 path
= git
.git_path(basename
)
694 if core
.exists(path
):
699 def prepare_commit_message_hook(config
=None):
700 default_hook
= git
.git_path('hooks', 'cola-prepare-commit-msg')
702 config
= gitcfg
.current()
703 return config
.get('cola.preparecommitmessagehook', default_hook
)
707 """Abort a merge by reading the tree at HEAD."""
709 git
.read_tree('HEAD', reset
=True, u
=True, v
=True)
711 merge_head
= git
.git_path('MERGE_HEAD')
712 if core
.exists(merge_head
):
713 core
.unlink(merge_head
)
714 # remove MERGE_MESSAGE, etc.
715 merge_msg_path
= merge_message_path()
716 while merge_msg_path
:
717 core
.unlink(merge_msg_path
)
718 merge_msg_path
= merge_message_path()
721 def strip_remote(remotes
, remote_branch
):
722 for remote
in remotes
:
723 prefix
= remote
+ '/'
724 if remote_branch
.startswith(prefix
):
725 return remote_branch
[len(prefix
):]
726 return remote_branch
.split('/', 1)[-1]