1 """Provides commands and queries for Git."""
2 from __future__
import division
, absolute_import
, unicode_literals
5 from io
import StringIO
8 from cola
import gitcfg
10 from cola
import version
11 from cola
.git
import git
12 from cola
.git
import STDOUT
13 from cola
.i18n
import N_
16 EMPTY_TREE_SHA1
= '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,
40 return _parse_diff_filenames(out
)
44 """Return a list of filenames for the given diff arguments
46 :param args: list of arguments to pass to "git diff --name-only"
49 out
= git
.diff(name_only
=True, z
=True, *args
)[STDOUT
]
50 return _parse_diff_filenames(out
)
53 def _parse_diff_filenames(out
):
55 return out
[:-1].split('\0')
60 def tracked_files(*args
):
61 """Return the names of all files in the repository"""
62 out
= git
.ls_files('--', *args
, z
=True)[STDOUT
]
64 return sorted(out
[:-1].split('\0'))
70 """Returns a sorted list of all files, including untracked files."""
71 ls_files
= git
.ls_files('--', *args
,
75 exclude_standard
=True)[STDOUT
]
76 return sorted([f
for f
in ls_files
.split('\0') if f
])
79 class _current_branch
:
80 """Cache for current_branch()"""
86 _current_branch
.key
= None
90 """Return the current branch"""
91 head
= git
.git_path('HEAD')
93 key
= core
.stat(head
).st_mtime
94 if _current_branch
.key
== key
:
95 return _current_branch
.value
97 # OSError means we can't use the stat cache
100 status
, data
, err
= git
.rev_parse('HEAD', symbolic_full_name
=True)
102 # git init -- read .git/HEAD. We could do this unconditionally...
103 data
= _read_git_head(head
)
105 for refs_prefix
in ('refs/heads/', 'refs/remotes/', 'refs/tags/'):
106 if data
.startswith(refs_prefix
):
107 value
= data
[len(refs_prefix
):]
108 _current_branch
.key
= key
109 _current_branch
.value
= value
115 def _read_git_head(head
, default
='master', git
=git
):
116 """Pure-python .git/HEAD reader"""
117 # Legacy .git/HEAD symlinks
118 if core
.islink(head
):
119 refs_heads
= core
.realpath(git
.git_path('refs', 'heads'))
120 path
= core
.abspath(head
).replace('\\', '/')
121 if path
.startswith(refs_heads
+ '/'):
122 return path
[len(refs_heads
)+1:]
124 # Common .git/HEAD "ref: refs/heads/master" file
125 elif core
.isfile(head
):
126 data
= core
.read(head
).rstrip()
128 if data
.startswith(ref_prefix
):
129 return data
[len(ref_prefix
):]
136 def branch_list(remote
=False):
138 Return a list of local or remote branches
140 This explicitly removes HEAD from the list of remote branches.
144 return for_each_ref_basename('refs/remotes')
146 return for_each_ref_basename('refs/heads')
149 def for_each_ref_basename(refs
, git
=git
):
150 """Return refs starting with 'refs'."""
151 out
= git
.for_each_ref(refs
, format
='%(refname)')[STDOUT
]
152 output
= out
.splitlines()
153 non_heads
= filter(lambda x
: not x
.endswith('/HEAD'), output
)
154 return list(map(lambda x
: x
[len(refs
) + 1:], non_heads
))
157 def all_refs(split
=False, git
=git
):
158 """Return a tuple of (local branches, remote branches, tags)."""
162 triple
= lambda x
, y
: (x
, len(x
) + 1, y
)
163 query
= (triple('refs/tags', tags
),
164 triple('refs/heads', local_branches
),
165 triple('refs/remotes', remote_branches
))
166 out
= git
.for_each_ref(format
='%(refname)')[STDOUT
]
167 for ref
in out
.splitlines():
168 for prefix
, prefix_len
, dst
in query
:
169 if ref
.startswith(prefix
) and not ref
.endswith('/HEAD'):
170 dst
.append(ref
[prefix_len
:])
173 return local_branches
, remote_branches
, tags
175 return local_branches
+ remote_branches
+ tags
178 def tracked_branch(branch
=None, config
=None):
179 """Return the remote branch associated with 'branch'."""
181 config
= gitcfg
.current()
183 branch
= current_branch()
186 remote
= config
.get('branch.%s.remote' % branch
)
189 merge_ref
= config
.get('branch.%s.merge' % branch
)
192 refs_heads
= 'refs/heads/'
193 if merge_ref
.startswith(refs_heads
):
194 return remote
+ '/' + merge_ref
[len(refs_heads
):]
198 def untracked_files(git
=git
, paths
=None):
199 """Returns a sorted list of untracked files."""
203 args
= ['--'] + paths
204 out
= git
.ls_files(z
=True, others
=True, exclude_standard
=True,
207 return out
[:-1].split('\0')
212 """Return a list of tags."""
213 return list(reversed(for_each_ref_basename('refs/tags')))
216 def log(git
, *args
, **kwargs
):
217 return git
.log(no_color
=True, no_abbrev_commit
=True,
218 no_ext_diff
=True, *args
, **kwargs
)[STDOUT
]
221 def commit_diff(sha1
, git
=git
):
222 return log(git
, '-1', sha1
, '--') + '\n\n' + sha1_diff(git
, sha1
)
226 def update_diff_overrides(space_at_eol
, space_change
,
227 all_space
, function_context
):
228 _diff_overrides
['ignore_space_at_eol'] = space_at_eol
229 _diff_overrides
['ignore_space_change'] = space_change
230 _diff_overrides
['ignore_all_space'] = all_space
231 _diff_overrides
['function_context'] = function_context
234 def common_diff_opts(config
=None):
236 config
= gitcfg
.current()
237 submodule
= version
.check('diff-submodule', version
.git_version())
240 'submodule': submodule
,
243 'unified': config
.get('gui.diffcontext', 3),
246 opts
.update(_diff_overrides
)
250 def _add_filename(args
, filename
):
252 args
.extend(['--', filename
])
255 def sha1_diff(git
, sha1
, filename
=None):
256 """Return the diff for a sha1"""
257 # Naively "$sha1^!" is what we'd like to use but that doesn't
258 # give the correct result for merges--the diff is reversed.
259 # Be explicit and compare sha1 against its first parent.
260 args
= [sha1
+ '~', sha1
]
261 opts
= common_diff_opts()
262 _add_filename(args
, filename
)
263 status
, out
, err
= git
.diff(*args
, **opts
)
265 # We probably don't have "$sha1~" because this is the root commit.
266 # "git show" is clever enough to handle the root commit.
268 _add_filename(args
, filename
)
269 status
, out
, err
= git
.show(pretty
='format:', *args
, **opts
)
274 def diff_info(sha1
, git
=git
, filename
=None):
275 decoded
= log(git
, '-1', sha1
, '--', pretty
='format:%b').strip()
278 return decoded
+ sha1_diff(git
, sha1
, filename
=filename
)
281 def diff_helper(commit
=None,
289 with_diff_header
=False,
290 suppress_header
=True,
293 "Invokes git diff on a filepath."
295 ref
, endref
= commit
+'^', commit
298 argv
.append('%s..%s' % (ref
, endref
))
300 for r
in utils
.shell_split(ref
.strip()):
302 elif head
and amending
and cached
:
308 if type(filename
) is list:
309 argv
.extend(filename
)
311 argv
.append(filename
)
312 cfg
= gitcfg
.current()
313 encoding
= cfg
.file_encoding(filename
)
315 status
, out
, err
= git
.diff(R
=reverse
, M
=True, cached
=cached
,
318 **common_diff_opts())
326 return extract_diff_header(status
, deleted
,
327 with_diff_header
, suppress_header
, out
)
330 def extract_diff_header(status
, deleted
,
331 with_diff_header
, suppress_header
, diffoutput
):
334 if diffoutput
.startswith('Submodule'):
336 return ('', diffoutput
)
341 del_tag
= 'deleted file mode '
344 diff
= diffoutput
.split('\n')
346 if not start
and '@@' == line
[:2] and '@@' in line
[2:]:
348 if start
or (deleted
and del_tag
in line
):
349 output
.write(line
+ '\n')
353 elif not suppress_header
:
354 output
.write(line
+ '\n')
356 result
= output
.getvalue()
360 return('\n'.join(headers
), result
)
365 def format_patchsets(to_export
, revs
, output
='patches'):
367 Group contiguous revision selection into patchsets
369 Exists to handle multi-selection.
370 Multiple disparate ranges in the revision selection
371 are grouped into continuous lists.
378 cur_rev
= to_export
[0]
379 cur_master_idx
= revs
.index(cur_rev
)
381 patches_to_export
= [[cur_rev
]]
384 # Group the patches into continuous sets
385 for idx
, rev
in enumerate(to_export
[1:]):
386 # Limit the search to the current neighborhood for efficiency
387 master_idx
= revs
[cur_master_idx
:].index(rev
)
388 master_idx
+= cur_master_idx
389 if master_idx
== cur_master_idx
+ 1:
390 patches_to_export
[ patchset_idx
].append(rev
)
394 patches_to_export
.append([ rev
])
395 cur_master_idx
= master_idx
398 # Export each patchsets
400 for patchset
in patches_to_export
:
401 stat
, out
, err
= export_patchset(patchset
[0],
406 patch_with_stat
=True)
410 status
= max(stat
, status
)
411 return (status
, '\n'.join(outs
), '\n'.join(errs
))
414 def export_patchset(start
, end
, output
='patches', **kwargs
):
415 """Export patches from start^ to end."""
416 return git
.format_patch('-o', output
, start
+ '^..' + end
, **kwargs
)
419 def unstage_paths(args
, head
='HEAD'):
420 status
, out
, err
= git
.reset(head
, '--', *set(args
))
422 # handle git init: we have to use 'git rm --cached'
423 # detect this condition by checking if the file is still staged
424 return untrack_paths(args
, head
=head
)
426 return (status
, out
, err
)
429 def untrack_paths(args
, head
='HEAD'):
431 return (-1, N_('Nothing to do'), '')
432 return git
.update_index('--', force_remove
=True, *set(args
))
435 def worktree_state(head
='HEAD',
437 display_untracked
=True,
439 """Return a dict of files in various states of being
441 :rtype: dict, keys are staged, unstaged, untracked, unmerged,
442 changed_upstream, and submodule.
446 git
.update_index(refresh
=True)
448 staged
, unmerged
, staged_deleted
, staged_submods
= diff_index(head
,
450 modified
, unstaged_deleted
, modified_submods
= diff_worktree(paths
)
451 untracked
= display_untracked
and untracked_files(paths
=paths
) or []
453 # Remove unmerged paths from the modified list
455 unmerged_set
= set(unmerged
)
456 modified
= [path
for path
in modified
if path
not in unmerged_set
]
458 # Look for upstream modified files if this is a tracking branch
459 upstream_changed
= diff_upstream(head
)
466 upstream_changed
.sort()
468 return {'staged': staged
,
469 'modified': modified
,
470 'unmerged': unmerged
,
471 'untracked': untracked
,
472 'upstream_changed': upstream_changed
,
473 'staged_deleted': staged_deleted
,
474 'unstaged_deleted': unstaged_deleted
,
475 'submodules': staged_submods | modified_submods
}
478 def _parse_raw_diff(out
):
480 info
, path
, out
= out
.split('\0', 2)
482 is_submodule
= ('160000' in info
[1:14])
483 yield (path
, status
, is_submodule
)
486 def diff_index(head
, cached
=True, paths
=None):
494 args
= [head
, '--'] + paths
495 status
, out
, err
= git
.diff_index(cached
=cached
, z
=True, *args
)
498 args
[0] = EMPTY_TREE_SHA1
499 status
, out
, err
= git
.diff_index(cached
=cached
, z
=True, *args
)
501 for path
, status
, is_submodule
in _parse_raw_diff(out
):
509 unmerged
.append(path
)
511 return staged
, unmerged
, deleted
, submodules
514 def diff_worktree(paths
=None):
521 args
= ['--'] + paths
522 status
, out
, err
= git
.diff_files(z
=True, *args
)
523 for path
, status
, is_submodule
in _parse_raw_diff(out
):
527 modified
.append(path
)
531 return modified
, deleted
, submodules
534 def diff_upstream(head
):
535 tracked
= tracked_branch()
538 base
= merge_base(head
, tracked
)
539 return diff_filenames(base
, tracked
)
542 def _branch_status(branch
):
544 Returns a tuple of staged, unstaged, untracked, and unmerged files
546 This shows only the changes that were introduced in branch
549 staged
= diff_filenames(branch
)
550 return {'staged': staged
,
551 'upstream_changed': staged
}
554 def merge_base(head
, ref
):
555 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
556 return git
.merge_base(head
, ref
)[STDOUT
]
559 def merge_base_parent(branch
):
560 tracked
= tracked_branch(branch
=branch
)
566 def parse_ls_tree(rev
):
567 """Return a list of(mode, type, sha1, path) tuples."""
569 lines
= git
.ls_tree(rev
, r
=True)[STDOUT
].splitlines()
570 regex
= re
.compile(r
'^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
572 match
= regex
.match(line
)
574 mode
= match
.group(1)
575 objtype
= match
.group(2)
576 sha1
= match
.group(3)
577 filename
= match
.group(4)
578 output
.append((mode
, objtype
, sha1
, filename
,) )
582 # A regex for matching the output of git(log|rev-list) --pretty=oneline
583 REV_LIST_REGEX
= re
.compile(r
'^([0-9a-f]{40}) (.*)$')
585 def parse_rev_list(raw_revs
):
586 """Parse `git log --pretty=online` output into (SHA-1, summary) pairs."""
588 for line
in raw_revs
.splitlines():
589 match
= REV_LIST_REGEX
.match(line
)
591 rev_id
= match
.group(1)
592 summary
= match
.group(2)
593 revs
.append((rev_id
, summary
,))
597 def log_helper(all
=False, extra_args
=None):
598 """Return parallel arrays containing the SHA-1s and summaries."""
604 output
= log(git
, pretty
='oneline', all
=all
, *args
)
605 for line
in output
.splitlines():
606 match
= REV_LIST_REGEX
.match(line
)
608 revs
.append(match
.group(1))
609 summaries
.append(match
.group(2))
610 return (revs
, summaries
)
613 def rev_list_range(start
, end
):
614 """Return a (SHA-1, summary) pairs between start and end."""
615 revrange
= '%s..%s' % (start
, end
)
616 out
= git
.rev_list(revrange
, pretty
='oneline')[STDOUT
]
617 return parse_rev_list(out
)
620 def commit_message_path():
621 """Return the path to .git/GIT_COLA_MSG"""
622 path
= git
.git_path("GIT_COLA_MSG")
623 if core
.exists(path
):
628 def merge_message_path():
629 """Return the path to .git/MERGE_MSG or .git/SQUASH_MSG."""
630 for basename
in ('MERGE_MSG', 'SQUASH_MSG'):
631 path
= git
.git_path(basename
)
632 if core
.exists(path
):
638 """Abort a merge by reading the tree at HEAD."""
640 git
.read_tree('HEAD', reset
=True, u
=True, v
=True)
642 merge_head
= git
.git_path('MERGE_HEAD')
643 if core
.exists(merge_head
):
644 core
.unlink(merge_head
)
645 # remove MERGE_MESSAGE, etc.
646 merge_msg_path
= merge_message_path()
647 while merge_msg_path
:
648 core
.unlink(merge_msg_path
)
649 merge_msg_path
= merge_message_path()
652 def merge_message(revision
):
653 """Return a merge message for FETCH_HEAD."""
654 fetch_head
= git
.git_path('FETCH_HEAD')
655 if core
.exists(fetch_head
):
656 return git
.fmt_merge_msg('--file', fetch_head
)[STDOUT
]
657 return "Merge branch '%s'" % revision