gitcmds: Add a 'split' parameter to all_refs()
[git-cola.git] / cola / gitcmds.py
blob5dd97aa3307b8af90ed6914b307301beb0732b3c
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 gitcmd
8 from cola import gitcfg
9 from cola import errors
10 from cola import utils
12 git = gitcmd.instance()
13 config = gitcfg.instance()
16 def default_remote():
17 """Return the remote tracked by the current branch."""
18 return config.get('branch.%s.remote' % current_branch())
21 def diff_filenames(arg):
22 """Return a list of filenames that have been modified"""
23 diff_zstr = git.diff(arg, name_only=True, z=True).rstrip('\0')
24 return [core.decode(f) for f in diff_zstr.split('\0') if f]
27 def all_files():
28 """Return the names of all files in the repository"""
29 return [core.decode(f)
30 for f in git.ls_files(z=True)
31 .strip('\0').split('\0') if f]
34 class _current_branch:
35 """Cache for current_branch()."""
36 data = None
37 value = None
40 def current_branch():
41 """Find the current branch."""
42 head = git.git_path('HEAD')
43 try:
44 data = utils.slurp(head)
45 if _current_branch.data == data:
46 return _current_branch.value
47 except OSError, e:
48 pass
49 # Handle legacy .git/HEAD symlinks
50 if os.path.islink(head):
51 refs_heads = os.path.realpath(git.git_path('refs', 'heads'))
52 path = os.path.abspath(head).replace('\\', '/')
53 if path.startswith(refs_heads + '/'):
54 value = path[len(refs_heads)+1:]
55 _current_branch.value = value
56 _current_branch.data = data
57 return value
58 return ''
60 # Handle the common .git/HEAD "ref: refs/heads/master" file
61 if os.path.isfile(head):
62 value = data.rstrip()
63 ref_prefix = 'ref: refs/heads/'
64 if value.startswith(ref_prefix):
65 value = value[len(ref_prefix):]
67 _current_branch.data = data
68 _current_branch.value = value
69 return value
71 # This shouldn't happen
72 return ''
75 def branch_list(remote=False):
76 """
77 Return a list of local or remote branches
79 This explicitly removes HEAD from the list of remote branches.
81 """
82 if remote:
83 return for_each_ref_basename('refs/remotes')
84 else:
85 return for_each_ref_basename('refs/heads')
88 def for_each_ref_basename(refs):
89 """Return refs starting with 'refs'."""
90 output = git.for_each_ref(refs, format='%(refname)').splitlines()
91 non_heads = filter(lambda x: not x.endswith('/HEAD'), output)
92 return map(lambda x: x[len(refs) + 1:], non_heads)
95 def all_refs(split=False):
96 """Return a tuple of (local branches, remote branches, tags)."""
97 local_branches = []
98 remote_branches = []
99 tags = []
100 triple = lambda x, y: (x, len(x) + 1, y)
101 query = (triple('refs/tags', tags),
102 triple('refs/heads', local_branches),
103 triple('refs/remotes', remote_branches))
104 for ref in git.for_each_ref(format='%(refname)').splitlines():
105 for prefix, prefix_len, dst in query:
106 if ref.startswith(prefix) and not ref.endswith('/HEAD'):
107 dst.append(ref[prefix_len:])
108 continue
109 if split:
110 return local_branches, remote_branches, tags
111 else:
112 return local_branches + remote_branches + tags
115 def tracked_branch(branch=None):
116 """Return the remote branch associated with 'branch'."""
117 if branch is None:
118 branch = current_branch()
119 remote = config.get('branch.%s.remote' % branch)
120 if not remote:
121 return None
122 merge_ref = config.get('branch.%s.merge' % branch)
123 if not merge_ref:
124 return None
125 refs_heads = 'refs/heads/'
126 if merge_ref.startswith(refs_heads):
127 return remote + '/' + merge_ref[len(refs_heads):]
128 return None
131 def untracked_files():
132 """Returns a sorted list of untracked files."""
133 ls_files = git.ls_files(z=True,
134 others=True,
135 exclude_standard=True)
136 return [core.decode(f) for f in ls_files.split('\0') if f]
139 def tag_list():
140 """Return a list of tags."""
141 tags = for_each_ref_basename('refs/tags')
142 tags.reverse()
143 return tags
146 def commit_diff(sha1):
147 commit = git.show(sha1)
148 first_newline = commit.index('\n')
149 if commit[first_newline+1:].startswith('Merge:'):
150 return (core.decode(commit) + '\n\n' +
151 core.decode(diff_helper(commit=sha1,
152 cached=False,
153 suppress_header=False)))
154 else:
155 return core.decode(commit)
158 def diff_helper(commit=None,
159 branch=None,
160 ref=None,
161 endref=None,
162 filename=None,
163 cached=True,
164 with_diff_header=False,
165 suppress_header=True,
166 reverse=False):
167 "Invokes git diff on a filepath."
168 if commit:
169 ref, endref = commit+'^', commit
170 argv = []
171 if ref and endref:
172 argv.append('%s..%s' % (ref, endref))
173 elif ref:
174 for r in ref.strip().split():
175 argv.append(r)
176 elif branch:
177 argv.append(branch)
179 if filename:
180 argv.append('--')
181 if type(filename) is list:
182 argv.extend(filename)
183 else:
184 argv.append(filename)
186 start = False
187 del_tag = 'deleted file mode '
189 headers = []
190 deleted = cached and not os.path.exists(core.encode(filename))
192 diffoutput = git.diff(R=reverse,
193 M=True,
194 no_color=True,
195 cached=cached,
196 unified=config.get('diff.context', 3),
197 with_raw_output=True,
198 with_stderr=True,
199 *argv)
201 # Handle 'git init'
202 if diffoutput.startswith('fatal:'):
203 if with_diff_header:
204 return ('', '')
205 else:
206 return ''
208 output = StringIO()
210 diff = diffoutput.split('\n')
211 for line in map(core.decode, diff):
212 if not start and '@@' == line[:2] and '@@' in line[2:]:
213 start = True
214 if start or (deleted and del_tag in line):
215 output.write(core.encode(line) + '\n')
216 else:
217 if with_diff_header:
218 headers.append(core.encode(line))
219 elif not suppress_header:
220 output.write(core.encode(line) + '\n')
222 result = core.decode(output.getvalue())
223 output.close()
225 if with_diff_header:
226 return('\n'.join(headers), result)
227 else:
228 return result
231 def format_patchsets(to_export, revs, output='patches'):
233 Group contiguous revision selection into patchsets
235 Exists to handle multi-selection.
236 Multiple disparate ranges in the revision selection
237 are grouped into continuous lists.
241 outlines = []
243 cur_rev = to_export[0]
244 cur_master_idx = revs.index(cur_rev)
246 patches_to_export = [[cur_rev]]
247 patchset_idx = 0
249 # Group the patches into continuous sets
250 for idx, rev in enumerate(to_export[1:]):
251 # Limit the search to the current neighborhood for efficiency
252 master_idx = revs[cur_master_idx:].index(rev)
253 master_idx += cur_master_idx
254 if master_idx == cur_master_idx + 1:
255 patches_to_export[ patchset_idx ].append(rev)
256 cur_master_idx += 1
257 continue
258 else:
259 patches_to_export.append([ rev ])
260 cur_master_idx = master_idx
261 patchset_idx += 1
263 # Export each patchsets
264 status = 0
265 for patchset in patches_to_export:
266 newstatus, out = export_patchset(patchset[0],
267 patchset[-1],
268 output='patches',
269 n=len(patchset) > 1,
270 thread=True,
271 patch_with_stat=True)
272 outlines.append(out)
273 if status == 0:
274 status += newstatus
275 return (status, '\n'.join(outlines))
278 def export_patchset(start, end, output='patches', **kwargs):
279 """Export patches from start^ to end."""
280 return git.format_patch('-o', output, start + '^..' + end,
281 with_stderr=True,
282 with_status=True,
283 **kwargs)
286 def unstage_paths(args):
288 Unstages paths from the staging area
290 This handles the git init case, which is why it's not
291 just 'git reset name'. For the git init case this falls
292 back to 'git rm --cached'.
295 # fake the status because 'git reset' returns 1
296 # regardless of success/failure
297 status = 0
298 output = git.reset('--', with_stderr=True, *set(args))
299 # handle git init: we have to use 'git rm --cached'
300 # detect this condition by checking if the file is still staged
301 state = worktree_state()
302 staged = state[0]
303 rmargs = [a for a in args if a in staged]
304 if not rmargs:
305 return (status, output)
306 output += git.rm('--', cached=True, with_stderr=True, *rmargs)
308 return (status, output)
312 def worktree_state(head='HEAD', staged_only=False):
313 """Return a tuple of files in various states of being
315 Can be staged, unstaged, untracked, unmerged, or changed
316 upstream.
319 git.update_index(refresh=True)
320 if staged_only:
321 return _branch_status(head)
323 staged_set = set()
324 modified_set = set()
326 (staged, modified, unmerged, untracked, upstream_changed) = (
327 [], [], [], [], [])
328 try:
329 output = git.diff_index(head,
330 cached=True,
331 with_stderr=True)
332 if output.startswith('fatal:'):
333 raise errors.GitInitError('git init')
334 for line in output.splitlines():
335 rest, name = line.split('\t', 1)
336 status = rest[-1]
337 name = eval_path(name)
338 if status == 'M':
339 staged.append(name)
340 staged_set.add(name)
341 # This file will also show up as 'M' without --cached
342 # so by default don't consider it modified unless
343 # it's truly modified
344 modified_set.add(name)
345 if not staged_only and is_modified(name):
346 modified.append(name)
347 elif status == 'A':
348 staged.append(name)
349 staged_set.add(name)
350 elif status == 'D':
351 staged.append(name)
352 staged_set.add(name)
353 modified_set.add(name)
354 elif status == 'U':
355 unmerged.append(name)
356 modified_set.add(name)
358 except errors.GitInitError:
359 # handle git init
360 staged.extend(all_files())
362 try:
363 output = git.diff_index(head, with_stderr=True)
364 if output.startswith('fatal:'):
365 raise errors.GitInitError('git init')
366 for line in output.splitlines():
367 info, name = line.split('\t', 1)
368 status = info.split()[-1]
369 if status == 'M' or status == 'D':
370 name = eval_path(name)
371 if name not in modified_set:
372 modified.append(name)
373 elif status == 'A':
374 name = eval_path(name)
375 # newly-added yet modified
376 if (name not in modified_set and not staged_only and
377 is_modified(name)):
378 modified.append(name)
380 except errors.GitInitError:
381 # handle git init
382 ls_files = git.ls_files(modified=True, z=True)[:-1].split('\0')
383 modified.extend(map(core.decode, [f for f in ls_files if f]))
385 untracked.extend(untracked_files())
387 # Look for upstream modified files if this is a tracking branch
388 tracked = tracked_branch()
389 if tracked:
390 try:
391 diff_expr = merge_base_to(tracked)
392 output = git.diff(diff_expr, name_only=True, z=True)
394 if output.startswith('fatal:'):
395 raise errors.GitInitError('git init')
397 for name in [n for n in output.split('\0') if n]:
398 name = core.decode(name)
399 upstream_changed.append(name)
401 except errors.GitInitError:
402 # handle git init
403 pass
405 # Keep stuff sorted
406 staged.sort()
407 modified.sort()
408 unmerged.sort()
409 untracked.sort()
410 upstream_changed.sort()
412 return (staged, modified, unmerged, untracked, upstream_changed)
415 def _branch_status(branch):
417 Returns a tuple of staged, unstaged, untracked, and unmerged files
419 This shows only the changes that were introduced in branch
422 status, output = git.diff(name_only=True,
423 M=True, z=True,
424 with_stderr=True,
425 with_status=True,
426 *branch.strip().split())
427 if status != 0:
428 return ([], [], [], [], [])
430 staged = map(core.decode, [n for n in output.split('\0') if n])
431 return (staged, [], [], [], staged)
434 def merge_base_to(ref):
435 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
436 base = git.merge_base('HEAD', ref)
437 return '%s..%s' % (base, ref)
440 def is_modified(name):
441 status, out = git.diff('--', name,
442 name_only=True,
443 exit_code=True,
444 with_status=True)
445 return status != 0
448 def eval_path(path):
449 """handles quoted paths."""
450 if path.startswith('"') and path.endswith('"'):
451 return core.decode(eval(path))
452 else:
453 return path
456 def renamed_files(start, end):
457 difflines = git.diff('%s..%s' % (start, end),
458 no_color=True,
459 M=True).splitlines()
460 return [eval_path(r[12:].rstrip())
461 for r in difflines if r.startswith('rename from ')]
464 def changed_files(start, end):
465 zfiles_str = git.diff('%s..%s' % (start, end),
466 name_only=True, z=True).strip('\0')
467 return [core.decode(enc) for enc in zfiles_str.split('\0') if enc]
470 def parse_ls_tree(rev):
471 """Return a list of(mode, type, sha1, path) tuples."""
472 lines = git.ls_tree(rev, r=True).splitlines()
473 output = []
474 regex = re.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
475 for line in lines:
476 match = regex.match(line)
477 if match:
478 mode = match.group(1)
479 objtype = match.group(2)
480 sha1 = match.group(3)
481 filename = match.group(4)
482 output.append((mode, objtype, sha1, filename,) )
483 return output
486 # A regex for matching the output of git(log|rev-list) --pretty=oneline
487 REV_LIST_REGEX = re.compile('^([0-9a-f]{40}) (.*)$')
489 def parse_rev_list(raw_revs):
490 """Parse `git log --pretty=online` output into (SHA-1, summary) pairs."""
491 revs = []
492 for line in map(core.decode, raw_revs.splitlines()):
493 match = REV_LIST_REGEX.match(line)
494 if match:
495 rev_id = match.group(1)
496 summary = match.group(2)
497 revs.append((rev_id, summary,))
498 return revs
501 def log_helper(all=False, extra_args=None):
502 """Return parallel arrays containing the SHA-1s and summaries."""
503 revs = []
504 summaries = []
505 args = []
506 if extra_args:
507 args = extra_args
508 output = git.log(pretty='oneline', all=all, *args)
509 for line in map(core.decode, output.splitlines()):
510 match = REV_LIST_REGEX.match(line)
511 if match:
512 revs.append(match.group(1))
513 summaries.append(match.group(2))
514 return (revs, summaries)
517 def rev_list_range(start, end):
518 """Return a (SHA-1, summary) pairs between start and end."""
519 revrange = '%s..%s' % (start, end)
520 raw_revs = git.rev_list(revrange, pretty='oneline')
521 return parse_rev_list(raw_revs)
524 def merge_message_path():
525 """Return the path to .git/MERGE_MSG or .git/SQUASH_MSG."""
526 for basename in ('MERGE_MSG', 'SQUASH_MSG'):
527 path = git.git_path(basename)
528 if os.path.exists(path):
529 return path
530 return None
533 def abort_merge():
534 """Abort a merge by reading the tree at HEAD."""
535 # Reset the worktree
536 git.read_tree('HEAD', reset=True, u=True, v=True)
537 # remove MERGE_HEAD
538 merge_head = git.git_path('MERGE_HEAD')
539 if os.path.exists(merge_head):
540 os.unlink(merge_head)
541 # remove MERGE_MESSAGE, etc.
542 merge_msg_path = merge_message_path()
543 while merge_msg_path:
544 os.unlink(merge_msg_path)
545 merge_msg_path = merge_message_path()
548 def merge_message(self):
549 """Return a merge message for FETCH_HEAD."""
550 return git.fmt_merge_msg('--file', git.git_path('FETCH_HEAD'))