git-cola v1.9.4
[git-cola.git] / cola / gitcmds.py
blob5a1ec753310e372b077c8e857cc4de661d204fea
1 """Provides commands and queries for Git."""
2 import re
3 from cStringIO import StringIO
5 from cola import core
6 from cola import gitcfg
7 from cola import utils
8 from cola import version
9 from cola.compat import set
10 from cola.git import git
11 from cola.git import STDOUT
12 from cola.i18n import N_
14 config = gitcfg.instance()
17 class InvalidRepositoryError(StandardError):
18 pass
21 def default_remote(config=None):
22 """Return the remote tracked by the current branch."""
23 if config is None:
24 config = gitcfg.instance()
25 return config.get('branch.%s.remote' % current_branch())
28 def diff_index_filenames(ref):
29 """Return a of filenames that have been modified relative to the index"""
30 out = git.diff_index(ref, name_only=True, z=True)[STDOUT]
31 return _parse_diff_filenames(out)
34 def diff_filenames(*args):
35 """Return a list of filenames that have been modified"""
36 out = git.diff_tree(name_only=True, no_commit_id=True, r=True, z=True,
37 *args)[STDOUT]
38 return _parse_diff_filenames(out)
41 def diff(args):
42 """Return a list of filenames for the given diff arguments
44 :param args: list of arguments to pass to "git diff --name-only"
46 """
47 out = git.diff(name_only=True, z=True, *args)[STDOUT]
48 return _parse_diff_filenames(out)
51 def _parse_diff_filenames(out):
52 if out:
53 return out[:-1].split('\0')
54 else:
55 return []
58 def all_files():
59 """Return the names of all files in the repository"""
60 out = git.ls_files(z=True)[STDOUT]
61 if out:
62 return out[:-1].split('\0')
63 else:
64 return []
67 class _current_branch:
68 """Cache for current_branch()"""
69 key = None
70 value = None
73 def clear_cache():
74 _current_branch.key = None
77 def current_branch():
78 """Return the current branch"""
79 head = git.git_path('HEAD')
80 try:
81 key = core.stat(head).st_mtime
82 if _current_branch.key == key:
83 return _current_branch.value
84 except OSError:
85 pass
86 status, data, err = git.rev_parse('HEAD', symbolic_full_name=True)
87 if status != 0:
88 # git init -- read .git/HEAD. We could do this unconditionally...
89 data = _read_git_head(head)
91 for refs_prefix in ('refs/heads/', 'refs/remotes/', 'refs/tags/'):
92 if data.startswith(refs_prefix):
93 value = data[len(refs_prefix):]
94 _current_branch.key = key
95 _current_branch.value = value
96 return value
97 # Detached head
98 return data
101 def _read_git_head(head, default='master', git=git):
102 """Pure-python .git/HEAD reader"""
103 # Legacy .git/HEAD symlinks
104 if core.islink(head):
105 refs_heads = core.realpath(git.git_path('refs', 'heads'))
106 path = core.abspath(head).replace('\\', '/')
107 if path.startswith(refs_heads + '/'):
108 return path[len(refs_heads)+1:]
110 # Common .git/HEAD "ref: refs/heads/master" file
111 elif core.isfile(head):
112 data = core.read(head).rstrip()
113 ref_prefix = 'ref: '
114 if data.startswith(ref_prefix):
115 return data[len(ref_prefix):]
116 # Detached head
117 return data
119 return default
122 def branch_list(remote=False):
124 Return a list of local or remote branches
126 This explicitly removes HEAD from the list of remote branches.
129 if remote:
130 return for_each_ref_basename('refs/remotes')
131 else:
132 return for_each_ref_basename('refs/heads')
135 def for_each_ref_basename(refs, git=git):
136 """Return refs starting with 'refs'."""
137 out = git.for_each_ref(refs, format='%(refname)')[STDOUT]
138 output = out.splitlines()
139 non_heads = filter(lambda x: not x.endswith('/HEAD'), output)
140 return map(lambda x: x[len(refs) + 1:], non_heads)
143 def all_refs(split=False, git=git):
144 """Return a tuple of (local branches, remote branches, tags)."""
145 local_branches = []
146 remote_branches = []
147 tags = []
148 triple = lambda x, y: (x, len(x) + 1, y)
149 query = (triple('refs/tags', tags),
150 triple('refs/heads', local_branches),
151 triple('refs/remotes', remote_branches))
152 out = git.for_each_ref(format='%(refname)')[STDOUT]
153 for ref in out.splitlines():
154 for prefix, prefix_len, dst in query:
155 if ref.startswith(prefix) and not ref.endswith('/HEAD'):
156 dst.append(ref[prefix_len:])
157 continue
158 if split:
159 return local_branches, remote_branches, tags
160 else:
161 return local_branches + remote_branches + tags
164 def tracked_branch(branch=None, config=None):
165 """Return the remote branch associated with 'branch'."""
166 if config is None:
167 config = gitcfg.instance()
168 if branch is None:
169 branch = current_branch()
170 if branch is None:
171 return None
172 remote = config.get('branch.%s.remote' % branch)
173 if not remote:
174 return None
175 merge_ref = config.get('branch.%s.merge' % branch)
176 if not merge_ref:
177 return None
178 refs_heads = 'refs/heads/'
179 if merge_ref.startswith(refs_heads):
180 return remote + '/' + merge_ref[len(refs_heads):]
181 return None
184 def untracked_files(git=git):
185 """Returns a sorted list of untracked files."""
186 out = git.ls_files(z=True, others=True, exclude_standard=True)[STDOUT]
187 if out:
188 return out[:-1].split('\0')
189 return []
192 def tag_list():
193 """Return a list of tags."""
194 tags = for_each_ref_basename('refs/tags')
195 tags.reverse()
196 return tags
199 def log(git, *args, **kwargs):
200 return git.log(no_color=True, no_ext_diff=True, *args, **kwargs)[STDOUT]
203 def commit_diff(sha1, git=git):
204 return log(git, '-1', sha1, '--') + '\n\n' + sha1_diff(git, sha1)
207 _diff_overrides = {}
208 def update_diff_overrides(space_at_eol, space_change,
209 all_space, function_context):
210 _diff_overrides['ignore_space_at_eol'] = space_at_eol
211 _diff_overrides['ignore_space_change'] = space_change
212 _diff_overrides['ignore_all_space'] = all_space
213 _diff_overrides['function_context'] = function_context
216 def common_diff_opts(config=config):
217 submodule = version.check('diff-submodule', version.git_version())
218 opts = {
219 'patience': True,
220 'submodule': submodule,
221 'no_color': True,
222 'no_ext_diff': True,
223 'unified': config.get('gui.diffcontext', 3),
224 '_raw': True,
226 opts.update(_diff_overrides)
227 return opts
230 def sha1_diff(git, sha1, filename=None):
231 if filename is None:
232 return git.diff(sha1+'^!', **common_diff_opts())[STDOUT]
233 else:
234 return git.diff(sha1+'^!', filename, **common_diff_opts())[STDOUT]
237 def diff_info(sha1, git=git, filename=None):
238 decoded = log(git, '-1', sha1, '--', pretty='format:%b').strip()
239 if decoded:
240 decoded += '\n\n'
241 return decoded + sha1_diff(git, sha1, filename=filename)
244 def diff_helper(commit=None,
245 ref=None,
246 endref=None,
247 filename=None,
248 cached=True,
249 head=None,
250 amending=False,
251 with_diff_header=False,
252 suppress_header=True,
253 reverse=False,
254 git=git):
255 "Invokes git diff on a filepath."
256 if commit:
257 ref, endref = commit+'^', commit
258 argv = []
259 if ref and endref:
260 argv.append('%s..%s' % (ref, endref))
261 elif ref:
262 for r in utils.shell_split(ref.strip()):
263 argv.append(r)
264 elif head and amending and cached:
265 argv.append(head)
267 encoding = None
268 if filename:
269 argv.append('--')
270 if type(filename) is list:
271 argv.extend(filename)
272 else:
273 argv.append(filename)
274 encoding = config.file_encoding(filename)
276 if filename is not None:
277 deleted = cached and not core.exists(filename)
278 else:
279 deleted = False
281 status, out, err = git.diff(R=reverse, M=True, cached=cached,
282 _encoding=encoding,
283 *argv,
284 **common_diff_opts())
285 if status != 0:
286 # git init
287 if with_diff_header:
288 return ('', '')
289 else:
290 return ''
292 return extract_diff_header(status, deleted,
293 with_diff_header, suppress_header, out)
296 def extract_diff_header(status, deleted,
297 with_diff_header, suppress_header, diffoutput):
298 encode = core.encode
299 headers = []
301 if diffoutput.startswith('Submodule'):
302 if with_diff_header:
303 return ('', diffoutput)
304 else:
305 return diffoutput
307 start = False
308 del_tag = 'deleted file mode '
309 output = StringIO()
311 diff = diffoutput.split('\n')
312 for line in diff:
313 if not start and '@@' == line[:2] and '@@' in line[2:]:
314 start = True
315 if start or (deleted and del_tag in line):
316 output.write(encode(line) + '\n')
317 else:
318 if with_diff_header:
319 headers.append(line)
320 elif not suppress_header:
321 output.write(encode(line) + '\n')
323 result = core.decode(output.getvalue())
324 output.close()
326 if with_diff_header:
327 return('\n'.join(headers), result)
328 else:
329 return result
332 def format_patchsets(to_export, revs, output='patches'):
334 Group contiguous revision selection into patchsets
336 Exists to handle multi-selection.
337 Multiple disparate ranges in the revision selection
338 are grouped into continuous lists.
342 outs = []
343 errs = []
345 cur_rev = to_export[0]
346 cur_master_idx = revs.index(cur_rev)
348 patches_to_export = [[cur_rev]]
349 patchset_idx = 0
351 # Group the patches into continuous sets
352 for idx, rev in enumerate(to_export[1:]):
353 # Limit the search to the current neighborhood for efficiency
354 master_idx = revs[cur_master_idx:].index(rev)
355 master_idx += cur_master_idx
356 if master_idx == cur_master_idx + 1:
357 patches_to_export[ patchset_idx ].append(rev)
358 cur_master_idx += 1
359 continue
360 else:
361 patches_to_export.append([ rev ])
362 cur_master_idx = master_idx
363 patchset_idx += 1
365 # Export each patchsets
366 status = 0
367 for patchset in patches_to_export:
368 stat, out, err = export_patchset(patchset[0],
369 patchset[-1],
370 output='patches',
371 n=len(patchset) > 1,
372 thread=True,
373 patch_with_stat=True)
374 outs.append(out)
375 if err:
376 errs.append(err)
377 status = max(stat, status)
378 return (status, '\n'.join(outs), '\n'.join(errs))
381 def export_patchset(start, end, output='patches', **kwargs):
382 """Export patches from start^ to end."""
383 return git.format_patch('-o', output, start + '^..' + end, **kwargs)
386 def unstage_paths(args, head='HEAD'):
387 status, out, err = git.reset(head, '--', *set(args))
388 if status == 128:
389 # handle git init: we have to use 'git rm --cached'
390 # detect this condition by checking if the file is still staged
391 return untrack_paths(args, head=head)
392 else:
393 return (status, out, err)
396 def untrack_paths(args, head='HEAD'):
397 if not args:
398 return (-1, N_('Nothing to do'), '')
399 return git.update_index('--', force_remove=True, *set(args))
402 def worktree_state(head='HEAD'):
403 """Return a tuple of files in various states of being
405 Can be staged, unstaged, untracked, unmerged, or changed
406 upstream.
409 state = worktree_state_dict(head=head)
410 return(state.get('staged', []),
411 state.get('modified', []),
412 state.get('unmerged', []),
413 state.get('untracked', []),
414 state.get('upstream_changed', []))
417 def worktree_state_dict(head='HEAD', update_index=False, display_untracked=True):
418 """Return a dict of files in various states of being
420 :rtype: dict, keys are staged, unstaged, untracked, unmerged,
421 changed_upstream, and submodule.
424 if update_index:
425 git.update_index(refresh=True)
427 staged, unmerged, staged_submods = diff_index(head)
428 modified, modified_submods = diff_worktree()
429 untracked = display_untracked and untracked_files() or []
431 # Remove unmerged paths from the modified list
432 unmerged_set = set(unmerged)
433 modified_set = set(modified)
434 modified_unmerged = modified_set.intersection(unmerged_set)
435 for path in modified_unmerged:
436 modified.remove(path)
438 # All submodules
439 submodules = staged_submods.union(modified_submods)
441 # Only include the submodule in the staged list once it has
442 # been staged. Otherwise, we'll see the submodule as being
443 # both modified and staged.
444 modified_submods = modified_submods.difference(staged_submods)
446 # Add submodules to the staged and unstaged lists
447 staged.extend(list(staged_submods))
448 modified.extend(list(modified_submods))
450 # Look for upstream modified files if this is a tracking branch
451 upstream_changed = diff_upstream(head)
453 # Keep stuff sorted
454 staged.sort()
455 modified.sort()
456 unmerged.sort()
457 untracked.sort()
458 upstream_changed.sort()
460 return {'staged': staged,
461 'modified': modified,
462 'unmerged': unmerged,
463 'untracked': untracked,
464 'upstream_changed': upstream_changed,
465 'submodules': submodules}
468 def diff_index(head, cached=True):
469 submodules = set()
470 staged = []
471 unmerged = []
473 status, out, err = git.diff_index(head, '--', cached=cached, z=True)
474 if status != 0:
475 # handle git init
476 return all_files(), unmerged, submodules
478 while out:
479 rest, out = out.split('\0', 1)
480 name, out = out.split('\0', 1)
481 status = rest[-1]
482 if '160000' in rest[1:14]:
483 submodules.add(name)
484 elif status in 'DAMT':
485 staged.append(name)
486 elif status == 'U':
487 unmerged.append(name)
489 return staged, unmerged, submodules
492 def diff_worktree():
493 modified = []
494 submodules = set()
496 status, out, err = git.diff_files(z=True)
497 if status != 0:
498 # handle git init
499 out = git.ls_files(modified=True, z=True)[STDOUT]
500 if out:
501 modified = out[:-1].split('\0')
502 return modified, submodules
504 while out:
505 rest, out = out.split('\0', 1)
506 name, out = out.split('\0', 1)
507 status = rest[-1]
508 if '160000' in rest[1:14]:
509 submodules.add(name)
510 elif status in 'DAMT':
511 modified.append(name)
513 return modified, submodules
516 def diff_upstream(head):
517 tracked = tracked_branch()
518 if not tracked:
519 return []
520 base = merge_base(head, tracked)
521 return diff_filenames(base, tracked)
524 def _branch_status(branch):
526 Returns a tuple of staged, unstaged, untracked, and unmerged files
528 This shows only the changes that were introduced in branch
531 staged = diff_filenames(branch)
532 return {'staged': staged,
533 'upstream_changed': staged}
536 def merge_base(head, ref):
537 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
538 return git.merge_base(head, ref)[STDOUT]
541 def merge_base_parent(branch):
542 tracked = tracked_branch(branch=branch)
543 if tracked:
544 return tracked
545 return 'HEAD'
548 def parse_ls_tree(rev):
549 """Return a list of(mode, type, sha1, path) tuples."""
550 output = []
551 lines = git.ls_tree(rev, r=True)[STDOUT].splitlines()
552 regex = re.compile(r'^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
553 for line in lines:
554 match = regex.match(line)
555 if match:
556 mode = match.group(1)
557 objtype = match.group(2)
558 sha1 = match.group(3)
559 filename = match.group(4)
560 output.append((mode, objtype, sha1, filename,) )
561 return output
564 # A regex for matching the output of git(log|rev-list) --pretty=oneline
565 REV_LIST_REGEX = re.compile(r'^([0-9a-f]{40}) (.*)$')
567 def parse_rev_list(raw_revs):
568 """Parse `git log --pretty=online` output into (SHA-1, summary) pairs."""
569 revs = []
570 for line in raw_revs.splitlines():
571 match = REV_LIST_REGEX.match(line)
572 if match:
573 rev_id = match.group(1)
574 summary = match.group(2)
575 revs.append((rev_id, summary,))
576 return revs
579 def log_helper(all=False, extra_args=None):
580 """Return parallel arrays containing the SHA-1s and summaries."""
581 revs = []
582 summaries = []
583 args = []
584 if extra_args:
585 args = extra_args
586 output = log(git, pretty='oneline', all=all, *args)
587 for line in output.splitlines():
588 match = REV_LIST_REGEX.match(line)
589 if match:
590 revs.append(match.group(1))
591 summaries.append(match.group(2))
592 return (revs, summaries)
595 def rev_list_range(start, end):
596 """Return a (SHA-1, summary) pairs between start and end."""
597 revrange = '%s..%s' % (start, end)
598 out = git.rev_list(revrange, pretty='oneline')[STDOUT]
599 return parse_rev_list(out)
602 def commit_message_path():
603 """Return the path to .git/GIT_COLA_MSG"""
604 path = git.git_path("GIT_COLA_MSG")
605 if core.exists(path):
606 return path
607 return None
610 def merge_message_path():
611 """Return the path to .git/MERGE_MSG or .git/SQUASH_MSG."""
612 for basename in ('MERGE_MSG', 'SQUASH_MSG'):
613 path = git.git_path(basename)
614 if core.exists(path):
615 return path
616 return None
619 def abort_merge():
620 """Abort a merge by reading the tree at HEAD."""
621 # Reset the worktree
622 git.read_tree('HEAD', reset=True, u=True, v=True)
623 # remove MERGE_HEAD
624 merge_head = git.git_path('MERGE_HEAD')
625 if core.exists(merge_head):
626 core.unlink(merge_head)
627 # remove MERGE_MESSAGE, etc.
628 merge_msg_path = merge_message_path()
629 while merge_msg_path:
630 core.unlink(merge_msg_path)
631 merge_msg_path = merge_message_path()
634 def merge_message(revision):
635 """Return a merge message for FETCH_HEAD."""
636 fetch_head = git.git_path('FETCH_HEAD')
637 if core.exists(fetch_head):
638 return git.fmt_merge_msg('--file', fetch_head)[STDOUT]
639 return "Merge branch '%s'" % revision