Merge pull request #488 from AndiDog/feature/status-context-menu-view-history
[git-cola.git] / cola / gitcmds.py
blob6a8e7771297a9d2a90e9a5fb25afea2887fbced8
1 """Provides commands and queries for Git."""
2 from __future__ import division, absolute_import, unicode_literals
4 import re
5 from io import StringIO
7 from cola import core
8 from cola import gitcfg
9 from cola import utils
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):
20 pass
23 def default_remote(config=None):
24 """Return the remote tracked by the current branch."""
25 if config is None:
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 *args)[STDOUT]
40 return _parse_diff_filenames(out)
43 def diff(args):
44 """Return a list of filenames for the given diff arguments
46 :param args: list of arguments to pass to "git diff --name-only"
48 """
49 out = git.diff(name_only=True, z=True, *args)[STDOUT]
50 return _parse_diff_filenames(out)
53 def _parse_diff_filenames(out):
54 if out:
55 return out[:-1].split('\0')
56 else:
57 return []
60 def tracked_files(*args):
61 """Return the names of all files in the repository"""
62 out = git.ls_files('--', *args, z=True)[STDOUT]
63 if out:
64 return sorted(out[:-1].split('\0'))
65 else:
66 return []
69 def all_files(*args):
70 """Returns a sorted list of all files, including untracked files."""
71 ls_files = git.ls_files('--', *args,
72 z=True,
73 cached=True,
74 others=True,
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()"""
81 key = None
82 value = None
85 def reset():
86 _current_branch.key = None
89 def current_branch():
90 """Return the current branch"""
91 head = git.git_path('HEAD')
92 try:
93 key = core.stat(head).st_mtime
94 if _current_branch.key == key:
95 return _current_branch.value
96 except OSError:
97 # OSError means we can't use the stat cache
98 key = 0
100 status, data, err = git.rev_parse('HEAD', symbolic_full_name=True)
101 if status != 0:
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
110 return value
111 # Detached head
112 return data
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()
127 ref_prefix = 'ref: '
128 if data.startswith(ref_prefix):
129 return data[len(ref_prefix):]
130 # Detached head
131 return data
133 return default
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.
143 if remote:
144 return for_each_ref_basename('refs/remotes')
145 else:
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)."""
159 local_branches = []
160 remote_branches = []
161 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:])
171 continue
172 if split:
173 return local_branches, remote_branches, tags
174 else:
175 return local_branches + remote_branches + tags
178 def tracked_branch(branch=None, config=None):
179 """Return the remote branch associated with 'branch'."""
180 if config is None:
181 config = gitcfg.current()
182 if branch is None:
183 branch = current_branch()
184 if branch is None:
185 return None
186 remote = config.get('branch.%s.remote' % branch)
187 if not remote:
188 return None
189 merge_ref = config.get('branch.%s.merge' % branch)
190 if not merge_ref:
191 return None
192 refs_heads = 'refs/heads/'
193 if merge_ref.startswith(refs_heads):
194 return remote + '/' + merge_ref[len(refs_heads):]
195 return None
198 def untracked_files(git=git, paths=None):
199 """Returns a sorted list of untracked files."""
201 if paths is None:
202 paths = []
203 args = ['--'] + paths
204 out = git.ls_files(z=True, others=True, exclude_standard=True,
205 *args)[STDOUT]
206 if out:
207 return out[:-1].split('\0')
208 return []
211 def tag_list():
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)
225 _diff_overrides = {}
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):
235 if config is None:
236 config = gitcfg.current()
237 submodule = version.check('diff-submodule', version.git_version())
238 opts = {
239 'patience': True,
240 'submodule': submodule,
241 'no_color': True,
242 'no_ext_diff': True,
243 'unified': config.get('gui.diffcontext', 3),
244 '_raw': True,
246 opts.update(_diff_overrides)
247 return opts
250 def _add_filename(args, filename):
251 if 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)
264 if status != 0:
265 # We probably don't have "$sha1~" because this is the root commit.
266 # "git show" is clever enough to handle the root commit.
267 args = [sha1 + '^!']
268 _add_filename(args, filename)
269 status, out, err = git.show(pretty='format:', *args, **opts)
270 out = out.lstrip()
271 return out
274 def diff_info(sha1, git=git, filename=None):
275 decoded = log(git, '-1', sha1, '--', pretty='format:%b').strip()
276 if decoded:
277 decoded += '\n\n'
278 return decoded + sha1_diff(git, sha1, filename=filename)
281 def diff_helper(commit=None,
282 ref=None,
283 endref=None,
284 filename=None,
285 cached=True,
286 deleted=False,
287 head=None,
288 amending=False,
289 with_diff_header=False,
290 suppress_header=True,
291 reverse=False,
292 git=git):
293 "Invokes git diff on a filepath."
294 if commit:
295 ref, endref = commit+'^', commit
296 argv = []
297 if ref and endref:
298 argv.append('%s..%s' % (ref, endref))
299 elif ref:
300 for r in utils.shell_split(ref.strip()):
301 argv.append(r)
302 elif head and amending and cached:
303 argv.append(head)
305 encoding = None
306 if filename:
307 argv.append('--')
308 if type(filename) is list:
309 argv.extend(filename)
310 else:
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,
316 _encoding=encoding,
317 *argv,
318 **common_diff_opts())
319 if status != 0:
320 # git init
321 if with_diff_header:
322 return ('', '')
323 else:
324 return ''
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):
332 headers = []
334 if diffoutput.startswith('Submodule'):
335 if with_diff_header:
336 return ('', diffoutput)
337 else:
338 return diffoutput
340 start = False
341 del_tag = 'deleted file mode '
342 output = StringIO()
344 diff = diffoutput.split('\n')
345 for line in diff:
346 if not start and '@@' == line[:2] and '@@' in line[2:]:
347 start = True
348 if start or (deleted and del_tag in line):
349 output.write(line + '\n')
350 else:
351 if with_diff_header:
352 headers.append(line)
353 elif not suppress_header:
354 output.write(line + '\n')
356 result = output.getvalue()
357 output.close()
359 if with_diff_header:
360 return('\n'.join(headers), result)
361 else:
362 return 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.
375 outs = []
376 errs = []
378 cur_rev = to_export[0]
379 cur_master_idx = revs.index(cur_rev)
381 patches_to_export = [[cur_rev]]
382 patchset_idx = 0
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)
391 cur_master_idx += 1
392 continue
393 else:
394 patches_to_export.append([ rev ])
395 cur_master_idx = master_idx
396 patchset_idx += 1
398 # Export each patchsets
399 status = 0
400 for patchset in patches_to_export:
401 stat, out, err = export_patchset(patchset[0],
402 patchset[-1],
403 output='patches',
404 n=len(patchset) > 1,
405 thread=True,
406 patch_with_stat=True)
407 outs.append(out)
408 if err:
409 errs.append(err)
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))
421 if status == 128:
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)
425 else:
426 return (status, out, err)
429 def untrack_paths(args, head='HEAD'):
430 if not args:
431 return (-1, N_('Nothing to do'), '')
432 return git.update_index('--', force_remove=True, *set(args))
435 def worktree_state(head='HEAD',
436 update_index=False,
437 display_untracked=True,
438 paths=None):
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.
445 if update_index:
446 git.update_index(refresh=True)
448 staged, unmerged, staged_deleted, staged_submods = diff_index(head,
449 paths=paths)
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
454 if unmerged:
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)
461 # Keep stuff sorted
462 staged.sort()
463 modified.sort()
464 unmerged.sort()
465 untracked.sort()
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):
479 while out:
480 info, path, out = out.split('\0', 2)
481 status = info[-1]
482 is_submodule = ('160000' in info[1:14])
483 yield (path, status, is_submodule)
486 def diff_index(head, cached=True, paths=None):
487 staged = []
488 unmerged = []
489 deleted = set()
490 submodules = set()
492 if paths is None:
493 paths = []
494 args = [head, '--'] + paths
495 status, out, err = git.diff_index(cached=cached, z=True, *args)
496 if status != 0:
497 # handle git init
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):
502 if is_submodule:
503 submodules.add(path)
504 if status in 'DAMT':
505 staged.append(path)
506 if status == 'D':
507 deleted.add(path)
508 elif status == 'U':
509 unmerged.append(path)
511 return staged, unmerged, deleted, submodules
514 def diff_worktree(paths=None):
515 modified = []
516 deleted = set()
517 submodules = set()
519 if paths is None:
520 paths = []
521 args = ['--'] + paths
522 status, out, err = git.diff_files(z=True, *args)
523 for path, status, is_submodule in _parse_raw_diff(out):
524 if is_submodule:
525 submodules.add(path)
526 if status in 'DAMT':
527 modified.append(path)
528 if status == 'D':
529 deleted.add(path)
531 return modified, deleted, submodules
534 def diff_upstream(head):
535 tracked = tracked_branch()
536 if not tracked:
537 return []
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)
561 if tracked:
562 return tracked
563 return 'HEAD'
566 def parse_ls_tree(rev):
567 """Return a list of(mode, type, sha1, path) tuples."""
568 output = []
569 lines = git.ls_tree(rev, r=True)[STDOUT].splitlines()
570 regex = re.compile(r'^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
571 for line in lines:
572 match = regex.match(line)
573 if match:
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,) )
579 return output
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."""
587 revs = []
588 for line in raw_revs.splitlines():
589 match = REV_LIST_REGEX.match(line)
590 if match:
591 rev_id = match.group(1)
592 summary = match.group(2)
593 revs.append((rev_id, summary,))
594 return revs
597 def log_helper(all=False, extra_args=None):
598 """Return parallel arrays containing the SHA-1s and summaries."""
599 revs = []
600 summaries = []
601 args = []
602 if extra_args:
603 args = extra_args
604 output = log(git, pretty='oneline', all=all, *args)
605 for line in output.splitlines():
606 match = REV_LIST_REGEX.match(line)
607 if match:
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):
624 return path
625 return None
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):
633 return path
634 return None
637 def abort_merge():
638 """Abort a merge by reading the tree at HEAD."""
639 # Reset the worktree
640 git.read_tree('HEAD', reset=True, u=True, v=True)
641 # remove MERGE_HEAD
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