win32/install.iss: Support spaces in git-cola path
[git-cola.git] / cola / gitcmds.py
blob9b017073b438161e6c557685bf3d4caf365571de
1 """Provides commands and queries for Git."""
2 import os
3 import re
4 from cStringIO import StringIO
6 from cola import core
7 from cola import gitcfg
8 from cola import utils
9 from cola import version
10 from cola.compat import set
11 from cola.git import git
13 config = gitcfg.instance()
16 class InvalidRepositoryError(StandardError):
17 pass
20 def default_remote(config=None):
21 """Return the remote tracked by the current branch."""
22 if config is None:
23 config = gitcfg.instance()
24 return config.get('branch.%s.remote' % current_branch())
27 def diff_index_filenames(ref):
28 """Return a of filenames that have been modified relative to the index"""
29 diff_zstr = git.diff_index(ref, name_only=True, z=True)
30 return _parse_diff_filenames(diff_zstr)
33 def diff_filenames(*args):
34 """Return a list of filenames that have been modified"""
35 diff_zstr = git.diff_tree(name_only=True,
36 no_commit_id=True, r=True, z=True, *args)
37 return _parse_diff_filenames(diff_zstr)
40 def _parse_diff_filenames(diff_zstr):
41 if diff_zstr:
42 return core.decode(diff_zstr[:-1]).split('\0')
43 else:
44 return []
47 def all_files():
48 """Return the names of all files in the repository"""
49 ls_files = git.ls_files(z=True)
50 if ls_files:
51 return core.decode(ls_files[:-1]).split('\0')
52 else:
53 return []
56 class _current_branch:
57 """Cache for current_branch()"""
58 key = None
59 value = None
62 def clear_cache():
63 _current_branch.key = None
66 def current_branch():
67 """Return the current branch"""
68 decode = core.decode
69 head = git.git_path('HEAD')
70 try:
71 key = os.stat(head).st_mtime
72 if _current_branch.key == key:
73 return _current_branch.value
74 except OSError:
75 pass
76 status, data = git.rev_parse('HEAD', symbolic_full_name=True, with_status=True)
77 if status != 0:
78 # git init -- read .git/HEAD. We could do this unconditionally...
79 data = _read_git_head(head)
81 for refs_prefix in ('refs/heads/', 'refs/remotes/', 'refs/tags/'):
82 if data.startswith(refs_prefix):
83 value = decode(data[len(refs_prefix):])
84 _current_branch.key = key
85 _current_branch.value = value
86 return value
87 # Detached head
88 return data
91 def _read_git_head(head, default='master', git=git):
92 """Pure-python .git/HEAD reader"""
93 # Legacy .git/HEAD symlinks
94 if os.path.islink(head):
95 refs_heads = os.path.realpath(git.git_path('refs', 'heads'))
96 path = os.path.abspath(head).replace('\\', '/')
97 if path.startswith(refs_heads + '/'):
98 return path[len(refs_heads)+1:]
100 # Common .git/HEAD "ref: refs/heads/master" file
101 elif os.path.isfile(head):
102 data = utils.slurp(core.decode(head)).rstrip()
103 ref_prefix = 'ref: '
104 if data.startswith(ref_prefix):
105 return data[len(ref_prefix):]
106 # Detached head
107 return data
109 return default
112 def branch_list(remote=False):
114 Return a list of local or remote branches
116 This explicitly removes HEAD from the list of remote branches.
119 if remote:
120 return for_each_ref_basename('refs/remotes')
121 else:
122 return for_each_ref_basename('refs/heads')
125 def for_each_ref_basename(refs, git=git):
126 """Return refs starting with 'refs'."""
127 git_output = git.for_each_ref(refs, format='%(refname)')
128 output = core.decode(git_output).splitlines()
129 non_heads = filter(lambda x: not x.endswith('/HEAD'), output)
130 return map(lambda x: x[len(refs) + 1:], non_heads)
133 def all_refs(split=False, git=git):
134 """Return a tuple of (local branches, remote branches, tags)."""
135 local_branches = []
136 remote_branches = []
137 tags = []
138 triple = lambda x, y: (x, len(x) + 1, y)
139 query = (triple('refs/tags', tags),
140 triple('refs/heads', local_branches),
141 triple('refs/remotes', remote_branches))
142 cmdout = core.decode(git.for_each_ref(format='%(refname)'))
143 for ref in cmdout.splitlines():
144 for prefix, prefix_len, dst in query:
145 if ref.startswith(prefix) and not ref.endswith('/HEAD'):
146 dst.append(ref[prefix_len:])
147 continue
148 if split:
149 return local_branches, remote_branches, tags
150 else:
151 return local_branches + remote_branches + tags
154 def tracked_branch(branch=None, config=None):
155 """Return the remote branch associated with 'branch'."""
156 if config is None:
157 config = gitcfg.instance()
158 if branch is None:
159 branch = current_branch()
160 if branch is None:
161 return None
162 remote = config.get('branch.%s.remote' % branch)
163 if not remote:
164 return None
165 merge_ref = config.get('branch.%s.merge' % branch)
166 if not merge_ref:
167 return None
168 refs_heads = 'refs/heads/'
169 if merge_ref.startswith(refs_heads):
170 return remote + '/' + merge_ref[len(refs_heads):]
171 return None
174 def untracked_files(git=git):
175 """Returns a sorted list of untracked files."""
176 ls_files = git.ls_files(z=True, others=True, exclude_standard=True)
177 if ls_files:
178 return core.decode(ls_files[:-1]).split('\0')
179 return []
182 def tag_list():
183 """Return a list of tags."""
184 tags = for_each_ref_basename('refs/tags')
185 tags.reverse()
186 return tags
189 def commit_diff(sha1, git=git):
190 commit = git.show(sha1)
191 first_newline = commit.index('\n')
192 if commit[first_newline+1:].startswith('Merge:'):
193 return (core.decode(commit) + '\n\n' +
194 core.decode(diff_helper(commit=sha1,
195 cached=False,
196 suppress_header=False)))
197 else:
198 return core.decode(commit)
201 def _common_diff_opts(config=config):
202 submodule = version.check('diff-submodule', version.git_version())
203 return {
204 'patience': True,
205 'submodule': submodule,
206 'no_color': True,
207 'no_ext_diff': True,
208 'with_raw_output': True,
209 'with_stderr': True,
210 'unified': config.get('gui.diffcontext', 3),
214 def sha1_diff(sha1, git=git):
215 return core.decode(git.diff(sha1 + '^!', **_common_diff_opts()))
218 def diff_info(sha1, git=git):
219 log = git.log('-1', '--pretty=format:%b', sha1)
220 decoded = core.decode(log).strip()
221 if decoded:
222 decoded += '\n\n'
223 return decoded + sha1_diff(sha1)
226 def diff_helper(commit=None,
227 ref=None,
228 endref=None,
229 filename=None,
230 cached=True,
231 with_diff_header=False,
232 suppress_header=True,
233 reverse=False,
234 git=git):
235 "Invokes git diff on a filepath."
236 encode = core.encode
237 if commit:
238 ref, endref = commit+'^', commit
239 argv = []
240 if ref and endref:
241 argv.append('%s..%s' % (ref, endref))
242 elif ref:
243 for r in utils.shell_split(ref.strip()):
244 argv.append(r)
246 if filename:
247 argv.append('--')
248 if type(filename) is list:
249 argv.extend(filename)
250 else:
251 argv.append(filename)
253 start = False
254 del_tag = 'deleted file mode '
256 headers = []
257 deleted = cached and not os.path.exists(encode(filename))
259 status, diffoutput = git.diff(R=reverse, M=True, cached=cached,
260 with_status=True,
261 *argv, **_common_diff_opts())
262 if status != 0:
263 # git init
264 if with_diff_header:
265 return ('', '')
266 else:
267 return ''
269 if diffoutput.startswith('Submodule'):
270 if with_diff_header:
271 return ('', diffoutput)
272 else:
273 return diffoutput
275 output = StringIO()
277 diff = core.decode(diffoutput).split('\n')
278 for line in diff:
279 if not start and '@@' == line[:2] and '@@' in line[2:]:
280 start = True
281 if start or (deleted and del_tag in line):
282 output.write(encode(line) + '\n')
283 else:
284 if with_diff_header:
285 headers.append(encode(line))
286 elif not suppress_header:
287 output.write(encode(line) + '\n')
289 result = core.decode(output.getvalue())
290 output.close()
292 if with_diff_header:
293 return('\n'.join(headers), result)
294 else:
295 return result
298 def format_patchsets(to_export, revs, output='patches'):
300 Group contiguous revision selection into patchsets
302 Exists to handle multi-selection.
303 Multiple disparate ranges in the revision selection
304 are grouped into continuous lists.
308 outlines = []
310 cur_rev = to_export[0]
311 cur_master_idx = revs.index(cur_rev)
313 patches_to_export = [[cur_rev]]
314 patchset_idx = 0
316 # Group the patches into continuous sets
317 for idx, rev in enumerate(to_export[1:]):
318 # Limit the search to the current neighborhood for efficiency
319 master_idx = revs[cur_master_idx:].index(rev)
320 master_idx += cur_master_idx
321 if master_idx == cur_master_idx + 1:
322 patches_to_export[ patchset_idx ].append(rev)
323 cur_master_idx += 1
324 continue
325 else:
326 patches_to_export.append([ rev ])
327 cur_master_idx = master_idx
328 patchset_idx += 1
330 # Export each patchsets
331 status = 0
332 for patchset in patches_to_export:
333 newstatus, out = export_patchset(patchset[0],
334 patchset[-1],
335 output='patches',
336 n=len(patchset) > 1,
337 thread=True,
338 patch_with_stat=True)
339 outlines.append(out)
340 if status == 0:
341 status += newstatus
342 return (status, '\n'.join(outlines))
345 def export_patchset(start, end, output='patches', **kwargs):
346 """Export patches from start^ to end."""
347 return git.format_patch('-o', output, start + '^..' + end,
348 with_stderr=True,
349 with_status=True,
350 **kwargs)
353 def unstage_paths(args, head='HEAD'):
354 status, output = git.reset(head, '--', with_stderr=True, with_status=True,
355 *set(args))
356 if status != 128:
357 return (status, output)
358 # handle git init: we have to use 'git rm --cached'
359 # detect this condition by checking if the file is still staged
360 status, output = git.update_index('--',
361 force_remove=True,
362 with_status=True,
363 with_stderr=True,
364 *set(args))
365 return (status, output)
369 def worktree_state(head='HEAD', staged_only=False):
370 """Return a tuple of files in various states of being
372 Can be staged, unstaged, untracked, unmerged, or changed
373 upstream.
376 state = worktree_state_dict(head=head, staged_only=staged_only)
377 return(state.get('staged', []),
378 state.get('modified', []),
379 state.get('unmerged', []),
380 state.get('untracked', []),
381 state.get('upstream_changed', []))
384 def worktree_state_dict(head='HEAD',
385 staged_only=False,
386 update_index=False):
387 """Return a dict of files in various states of being
389 :rtype: dict, keys are staged, unstaged, untracked, unmerged,
390 changed_upstream, and submodule.
393 if update_index:
394 git.update_index(refresh=True)
396 if staged_only:
397 return _branch_status(head)
399 staged, unmerged, submodules = diff_index(head)
400 modified, more_submods = diff_worktree()
402 # Remove unmerged paths from the modified list
403 unmerged_set = set(unmerged)
404 modified_set = set(modified)
405 modified_unmerged = modified_set.intersection(unmerged_set)
406 for path in modified_unmerged:
407 modified.remove(path)
409 submodules = submodules.union(more_submods)
410 untracked = untracked_files()
412 # Look for upstream modified files if this is a tracking branch
413 upstream_changed = diff_upstream(head)
415 # Keep stuff sorted
416 staged.sort()
417 modified.sort()
418 unmerged.sort()
419 untracked.sort()
420 upstream_changed.sort()
422 return {'staged': staged,
423 'modified': modified,
424 'unmerged': unmerged,
425 'untracked': untracked,
426 'upstream_changed': upstream_changed,
427 'submodules': submodules}
430 def diff_index(head):
431 decode = core.decode
432 submodules = set()
433 staged = []
434 unmerged = []
436 output = git.diff_index(head, z=True, cached=True, with_stderr=True)
437 if output.startswith('fatal:'):
438 # handle git init
439 return all_files(), unmerged, submodules
441 while output:
442 rest, output = output.split('\0', 1)
443 name, output = output.split('\0', 1)
444 status = rest[-1]
445 name = decode(name)
446 if '160000' in rest[1:14]:
447 submodules.add(name)
448 elif status in 'DAMT':
449 staged.append(name)
450 elif status == 'U':
451 unmerged.append(name)
453 return staged, unmerged, submodules
456 def diff_worktree():
457 modified = []
458 submodules = set()
460 output = git.diff_files(z=True, with_stderr=True)
461 if output.startswith('fatal:'):
462 # handle git init
463 ls_files = core.decode(git.ls_files(modified=True, z=True))
464 if ls_files:
465 modified = ls_files[:-1].split('\0')
466 return modified, submodules
468 while output:
469 rest, output = output.split('\0', 1)
470 name, output = output.split('\0', 1)
471 status = rest[-1]
472 name = core.decode(name)
473 if '160000' in rest[1:14]:
474 submodules.add(name)
475 elif status in 'DAMT':
476 modified.append(name)
478 return modified, submodules
481 def diff_upstream(head):
482 tracked = tracked_branch()
483 if not tracked:
484 return []
485 merge_base = merge_base_to(head, tracked)
486 return diff_filenames(merge_base, tracked)
489 def _branch_status(branch):
491 Returns a tuple of staged, unstaged, untracked, and unmerged files
493 This shows only the changes that were introduced in branch
496 staged = diff_filenames(branch)
497 return {'staged': staged,
498 'upstream_changed': staged}
501 def merge_base_to(head, ref):
502 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
503 return git.merge_base(head, ref)
506 def merge_base_parent(branch):
507 tracked = tracked_branch(branch=branch)
508 if tracked:
509 return tracked
510 return 'HEAD'
513 def eval_path(path):
514 """handles quoted paths."""
515 if path.startswith('"') and path.endswith('"'):
516 return core.decode(eval(path))
517 else:
518 return core.decode(path)
521 def renamed_files(start, end, git=git):
522 difflines = git.diff('%s..%s' % (start, end), M=True,
523 **_common_diff_opts()).splitlines()
524 return [eval_path(r[12:].rstrip())
525 for r in difflines if r.startswith('rename from ')]
528 def parse_ls_tree(rev):
529 """Return a list of(mode, type, sha1, path) tuples."""
530 lines = git.ls_tree(rev, r=True).splitlines()
531 output = []
532 regex = re.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
533 for line in lines:
534 match = regex.match(line)
535 if match:
536 mode = match.group(1)
537 objtype = match.group(2)
538 sha1 = match.group(3)
539 filename = match.group(4)
540 output.append((mode, objtype, sha1, filename,) )
541 return output
544 # A regex for matching the output of git(log|rev-list) --pretty=oneline
545 REV_LIST_REGEX = re.compile('^([0-9a-f]{40}) (.*)$')
547 def parse_rev_list(raw_revs):
548 """Parse `git log --pretty=online` output into (SHA-1, summary) pairs."""
549 revs = []
550 for line in map(core.decode, raw_revs.splitlines()):
551 match = REV_LIST_REGEX.match(line)
552 if match:
553 rev_id = match.group(1)
554 summary = match.group(2)
555 revs.append((rev_id, summary,))
556 return revs
559 def log_helper(all=False, extra_args=None):
560 """Return parallel arrays containing the SHA-1s and summaries."""
561 revs = []
562 summaries = []
563 args = []
564 if extra_args:
565 args = extra_args
566 output = git.log(pretty='oneline', no_color=True, all=all, *args)
567 for line in map(core.decode, output.splitlines()):
568 match = REV_LIST_REGEX.match(line)
569 if match:
570 revs.append(match.group(1))
571 summaries.append(match.group(2))
572 return (revs, summaries)
575 def rev_list_range(start, end):
576 """Return a (SHA-1, summary) pairs between start and end."""
577 revrange = '%s..%s' % (start, end)
578 raw_revs = git.rev_list(revrange, pretty='oneline')
579 return parse_rev_list(raw_revs)
582 def merge_message_path():
583 """Return the path to .git/MERGE_MSG or .git/SQUASH_MSG."""
584 for basename in ('MERGE_MSG', 'SQUASH_MSG'):
585 path = git.git_path(basename)
586 if os.path.exists(path):
587 return path
588 return None
591 def abort_merge():
592 """Abort a merge by reading the tree at HEAD."""
593 # Reset the worktree
594 git.read_tree('HEAD', reset=True, u=True, v=True)
595 # remove MERGE_HEAD
596 merge_head = git.git_path('MERGE_HEAD')
597 if os.path.exists(merge_head):
598 os.unlink(merge_head)
599 # remove MERGE_MESSAGE, etc.
600 merge_msg_path = merge_message_path()
601 while merge_msg_path:
602 os.unlink(merge_msg_path)
603 merge_msg_path = merge_message_path()
606 def merge_message(revision):
607 """Return a merge message for FETCH_HEAD."""
608 fetch_head = git.git_path('FETCH_HEAD')
609 if os.path.exists(fetch_head):
610 return git.fmt_merge_msg('--file', fetch_head)
611 return "Merge branch '%s'" % revision