Update release notes for 1.4.2.2
[git-cola.git] / cola / gitcmds.py
blobcdc6e86a4ec411666a7b367f097871fb00db2ac6
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 git
8 from cola import gitcfg
9 from cola import errors
10 from cola import utils
11 from cola import version
12 from cola.compat import set
14 git = git.instance()
15 config = gitcfg.instance()
17 class InvalidRepositoryError(StandardError):
18 pass
21 def default_remote():
22 """Return the remote tracked by the current branch."""
23 return config.get('branch.%s.remote' % current_branch())
26 def diff_filenames(arg):
27 """Return a list of filenames that have been modified"""
28 diff_zstr = git.diff(arg, name_only=True, z=True).rstrip('\0')
29 return [core.decode(f) for f in diff_zstr.split('\0') if f]
32 def all_files():
33 """Return the names of all files in the repository"""
34 return [core.decode(f)
35 for f in git.ls_files(z=True)
36 .strip('\0').split('\0') if f]
39 class _current_branch:
40 """Cache for current_branch()"""
41 key = None
42 value = None
44 def current_branch():
45 """Return the current branch"""
46 head = git.git_path('HEAD')
47 try:
48 key = os.stat(head).st_mtime
49 if _current_branch.key == key:
50 return _current_branch.value
51 except OSError, e:
52 pass
53 data = git.rev_parse('HEAD', with_stderr=True, symbolic_full_name=True)
54 if data.startswith('fatal:'):
55 # git init -- read .git/HEAD. We could do this unconditionally
56 # and avoid the subprocess call. It's probably time to start
57 # using dulwich.
58 data = _read_git_head(head)
60 for refs_prefix in ('refs/heads/', 'refs/remotes/'):
61 if data.startswith(refs_prefix):
62 value = data[len(refs_prefix):]
63 _current_branch.key = key
64 _current_branch.value = value
65 return value
66 # Detached head
67 return data
70 def _read_git_head(head, default='master'):
71 """Pure-python .git/HEAD reader"""
72 # Legacy .git/HEAD symlinks
73 if os.path.islink(head):
74 refs_heads = os.path.realpath(git.git_path('refs', 'heads'))
75 path = os.path.abspath(head).replace('\\', '/')
76 if path.startswith(refs_heads + '/'):
77 return path[len(refs_heads)+1:]
79 # Common .git/HEAD "ref: refs/heads/master" file
80 elif os.path.isfile(head):
81 data = utils.slurp(head).rstrip()
82 ref_prefix = 'ref: '
83 if data.startswith(ref_prefix):
84 return data[len(ref_prefix):]
85 # Detached head
86 return data
88 return default
91 def branch_list(remote=False):
92 """
93 Return a list of local or remote branches
95 This explicitly removes HEAD from the list of remote branches.
97 """
98 if remote:
99 return for_each_ref_basename('refs/remotes')
100 else:
101 return for_each_ref_basename('refs/heads')
104 def for_each_ref_basename(refs):
105 """Return refs starting with 'refs'."""
106 output = git.for_each_ref(refs, format='%(refname)').splitlines()
107 non_heads = filter(lambda x: not x.endswith('/HEAD'), output)
108 return map(lambda x: x[len(refs) + 1:], non_heads)
111 def all_refs(split=False):
112 """Return a tuple of (local branches, remote branches, tags)."""
113 local_branches = []
114 remote_branches = []
115 tags = []
116 triple = lambda x, y: (x, len(x) + 1, y)
117 query = (triple('refs/tags', tags),
118 triple('refs/heads', local_branches),
119 triple('refs/remotes', remote_branches))
120 for ref in git.for_each_ref(format='%(refname)').splitlines():
121 for prefix, prefix_len, dst in query:
122 if ref.startswith(prefix) and not ref.endswith('/HEAD'):
123 dst.append(ref[prefix_len:])
124 continue
125 if split:
126 return local_branches, remote_branches, tags
127 else:
128 return local_branches + remote_branches + tags
131 def tracked_branch(branch=None):
132 """Return the remote branch associated with 'branch'."""
133 if branch is None:
134 branch = current_branch()
135 remote = config.get('branch.%s.remote' % branch)
136 if not remote:
137 return None
138 merge_ref = config.get('branch.%s.merge' % branch)
139 if not merge_ref:
140 return None
141 refs_heads = 'refs/heads/'
142 if merge_ref.startswith(refs_heads):
143 return remote + '/' + merge_ref[len(refs_heads):]
144 return None
147 def untracked_files():
148 """Returns a sorted list of untracked files."""
149 ls_files = git.ls_files(z=True,
150 others=True,
151 exclude_standard=True)
152 return [core.decode(f) for f in ls_files.split('\0') if f]
155 def tag_list():
156 """Return a list of tags."""
157 tags = for_each_ref_basename('refs/tags')
158 tags.reverse()
159 return tags
162 def commit_diff(sha1):
163 commit = git.show(sha1)
164 first_newline = commit.index('\n')
165 if commit[first_newline+1:].startswith('Merge:'):
166 return (core.decode(commit) + '\n\n' +
167 core.decode(diff_helper(commit=sha1,
168 cached=False,
169 suppress_header=False)))
170 else:
171 return core.decode(commit)
174 def diff_helper(commit=None,
175 branch=None,
176 ref=None,
177 endref=None,
178 filename=None,
179 cached=True,
180 with_diff_header=False,
181 suppress_header=True,
182 reverse=False):
183 "Invokes git diff on a filepath."
184 if commit:
185 ref, endref = commit+'^', commit
186 argv = []
187 if ref and endref:
188 argv.append('%s..%s' % (ref, endref))
189 elif ref:
190 for r in ref.strip().split():
191 argv.append(r)
192 elif branch:
193 argv.append(branch)
195 if filename:
196 argv.append('--')
197 if type(filename) is list:
198 argv.extend(filename)
199 else:
200 argv.append(filename)
202 start = False
203 del_tag = 'deleted file mode '
205 headers = []
206 deleted = cached and not os.path.exists(core.encode(filename))
208 # The '--patience' option did not appear until git 1.6.2
209 # so don't allow it to be used on version previous to that
210 patience = version.check('patience', version.git_version())
211 submodule = version.check('diff-submodule', version.git_version())
213 diffoutput = git.diff(R=reverse,
214 M=True,
215 no_color=True,
216 cached=cached,
217 unified=config.get('diff.context', 3),
218 with_raw_output=True,
219 with_stderr=True,
220 patience=patience,
221 submodule=submodule,
222 *argv)
223 # Handle 'git init'
224 if diffoutput.startswith('fatal:'):
225 if with_diff_header:
226 return ('', '')
227 else:
228 return ''
230 if diffoutput.startswith('Submodule'):
231 if with_diff_header:
232 return ('', diffoutput)
233 else:
234 return diffoutput
236 output = StringIO()
238 diff = diffoutput.split('\n')
239 for line in map(core.decode, diff):
240 if not start and '@@' == line[:2] and '@@' in line[2:]:
241 start = True
242 if start or (deleted and del_tag in line):
243 output.write(core.encode(line) + '\n')
244 else:
245 if with_diff_header:
246 headers.append(core.encode(line))
247 elif not suppress_header:
248 output.write(core.encode(line) + '\n')
250 result = core.decode(output.getvalue())
251 output.close()
253 if with_diff_header:
254 return('\n'.join(headers), result)
255 else:
256 return result
259 def format_patchsets(to_export, revs, output='patches'):
261 Group contiguous revision selection into patchsets
263 Exists to handle multi-selection.
264 Multiple disparate ranges in the revision selection
265 are grouped into continuous lists.
269 outlines = []
271 cur_rev = to_export[0]
272 cur_master_idx = revs.index(cur_rev)
274 patches_to_export = [[cur_rev]]
275 patchset_idx = 0
277 # Group the patches into continuous sets
278 for idx, rev in enumerate(to_export[1:]):
279 # Limit the search to the current neighborhood for efficiency
280 master_idx = revs[cur_master_idx:].index(rev)
281 master_idx += cur_master_idx
282 if master_idx == cur_master_idx + 1:
283 patches_to_export[ patchset_idx ].append(rev)
284 cur_master_idx += 1
285 continue
286 else:
287 patches_to_export.append([ rev ])
288 cur_master_idx = master_idx
289 patchset_idx += 1
291 # Export each patchsets
292 status = 0
293 for patchset in patches_to_export:
294 newstatus, out = export_patchset(patchset[0],
295 patchset[-1],
296 output='patches',
297 n=len(patchset) > 1,
298 thread=True,
299 patch_with_stat=True)
300 outlines.append(out)
301 if status == 0:
302 status += newstatus
303 return (status, '\n'.join(outlines))
306 def export_patchset(start, end, output='patches', **kwargs):
307 """Export patches from start^ to end."""
308 return git.format_patch('-o', output, start + '^..' + end,
309 with_stderr=True,
310 with_status=True,
311 **kwargs)
314 def unstage_paths(args):
315 status, output = git.reset('--', with_stderr=True, with_status=True,
316 *set(args))
317 if status != 128:
318 return (status, output)
319 # handle git init: we have to use 'git rm --cached'
320 # detect this condition by checking if the file is still staged
321 status, output = git.update_index('--',
322 force_remove=True,
323 with_status=True,
324 with_stderr=True,
325 *set(args))
326 return (status, output)
330 def worktree_state(head='HEAD', staged_only=False):
331 """Return a tuple of files in various states of being
333 Can be staged, unstaged, untracked, unmerged, or changed
334 upstream.
337 state = worktree_state_dict(head=head, staged_only=staged_only)
338 return(state.get('staged', []),
339 state.get('modified', []),
340 state.get('unmerged', []),
341 state.get('untracked', []),
342 state.get('upstream_changed', []))
345 def worktree_state_dict(head='HEAD', staged_only=False):
346 """Return a dict of files in various states of being
348 :rtype: dict, keys are staged, unstaged, untracked, unmerged,
349 changed_upstream, and submodule.
352 git.update_index(refresh=True)
354 if staged_only:
355 return _branch_status(head)
357 staged_set = set()
358 modified_set = set()
360 staged = []
361 modified = []
362 unmerged = []
363 untracked = []
364 upstream_changed = []
365 submodules = set()
366 try:
367 output = git.diff_index(head, cached=True, with_stderr=True)
368 if output.startswith('fatal:'):
369 raise errors.GitInitError('git init')
370 for line in output.splitlines():
371 rest, name = line.split('\t', 1)
372 status = rest[-1]
373 name = eval_path(name)
374 if '160000' in rest[1:14]:
375 submodules.add(name)
376 if status == 'M':
377 staged.append(name)
378 staged_set.add(name)
379 # This file will also show up as 'M' without --cached
380 # so by default don't consider it modified unless
381 # it's truly modified
382 modified_set.add(name)
383 if not staged_only and is_modified(name):
384 modified.append(name)
385 elif status == 'A':
386 staged.append(name)
387 staged_set.add(name)
388 elif status == 'D':
389 staged.append(name)
390 staged_set.add(name)
391 modified_set.add(name)
392 elif status == 'U':
393 unmerged.append(name)
394 modified_set.add(name)
396 except errors.GitInitError:
397 # handle git init
398 staged.extend(all_files())
400 try:
401 output = git.diff_index(head, with_stderr=True)
402 if output.startswith('fatal:'):
403 raise errors.GitInitError('git init')
404 for line in output.splitlines():
405 rest , name = line.split('\t', 1)
406 status = rest[-1]
407 name = eval_path(name)
408 if '160000' in rest[1:13]:
409 submodules.add(name)
410 if status == 'M' or status == 'D':
411 if name not in modified_set:
412 modified.append(name)
413 elif status == 'A':
414 # newly-added yet modified
415 if (name not in modified_set and not staged_only and
416 is_modified(name)):
417 modified.append(name)
419 except errors.GitInitError:
420 # handle git init
421 ls_files = git.ls_files(modified=True, z=True)[:-1].split('\0')
422 modified.extend(map(core.decode, [f for f in ls_files if f]))
424 untracked.extend(untracked_files())
426 # Look for upstream modified files if this is a tracking branch
427 tracked = tracked_branch()
428 if tracked:
429 try:
430 diff_expr = merge_base_to(tracked)
431 output = git.diff(diff_expr, name_only=True, z=True)
433 if output.startswith('fatal:'):
434 raise errors.GitInitError('git init')
436 for name in [n for n in output.split('\0') if n]:
437 name = core.decode(name)
438 upstream_changed.append(name)
440 except errors.GitInitError:
441 # handle git init
442 pass
444 # Keep stuff sorted
445 staged.sort()
446 modified.sort()
447 unmerged.sort()
448 untracked.sort()
449 upstream_changed.sort()
451 return {'staged': staged,
452 'modified': modified,
453 'unmerged': unmerged,
454 'untracked': untracked,
455 'upstream_changed': upstream_changed,
456 'submodules': submodules}
459 def _branch_status(branch):
461 Returns a tuple of staged, unstaged, untracked, and unmerged files
463 This shows only the changes that were introduced in branch
466 status, output = git.diff(name_only=True,
467 M=True, z=True,
468 with_stderr=True,
469 with_status=True,
470 *branch.strip().split())
471 if status != 0:
472 return {}
474 staged = map(core.decode, [n for n in output.split('\0') if n])
475 return {'staged': staged,
476 'upstream_changed': staged}
479 def merge_base_to(ref):
480 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
481 base = git.merge_base('HEAD', ref)
482 return '%s..%s' % (base, ref)
485 def merge_base_parent(branch):
486 tracked = tracked_branch(branch=branch)
487 if tracked:
488 return '%s..%s' % (tracked, branch)
489 return 'master..%s' % branch
492 def is_modified(name):
493 status, out = git.diff('--', name,
494 name_only=True,
495 exit_code=True,
496 with_status=True)
497 return status != 0
500 def eval_path(path):
501 """handles quoted paths."""
502 if path.startswith('"') and path.endswith('"'):
503 return core.decode(eval(path))
504 else:
505 return path
508 def renamed_files(start, end):
509 difflines = git.diff('%s..%s' % (start, end),
510 no_color=True,
511 M=True).splitlines()
512 return [eval_path(r[12:].rstrip())
513 for r in difflines if r.startswith('rename from ')]
516 def changed_files(start, end):
517 zfiles_str = git.diff('%s..%s' % (start, end),
518 name_only=True, z=True).strip('\0')
519 return [core.decode(enc) for enc in zfiles_str.split('\0') if enc]
522 def parse_ls_tree(rev):
523 """Return a list of(mode, type, sha1, path) tuples."""
524 lines = git.ls_tree(rev, r=True).splitlines()
525 output = []
526 regex = re.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
527 for line in lines:
528 match = regex.match(line)
529 if match:
530 mode = match.group(1)
531 objtype = match.group(2)
532 sha1 = match.group(3)
533 filename = match.group(4)
534 output.append((mode, objtype, sha1, filename,) )
535 return output
538 # A regex for matching the output of git(log|rev-list) --pretty=oneline
539 REV_LIST_REGEX = re.compile('^([0-9a-f]{40}) (.*)$')
541 def parse_rev_list(raw_revs):
542 """Parse `git log --pretty=online` output into (SHA-1, summary) pairs."""
543 revs = []
544 for line in map(core.decode, raw_revs.splitlines()):
545 match = REV_LIST_REGEX.match(line)
546 if match:
547 rev_id = match.group(1)
548 summary = match.group(2)
549 revs.append((rev_id, summary,))
550 return revs
553 def log_helper(all=False, extra_args=None):
554 """Return parallel arrays containing the SHA-1s and summaries."""
555 revs = []
556 summaries = []
557 args = []
558 if extra_args:
559 args = extra_args
560 output = git.log(pretty='oneline', no_color=True, all=all, *args)
561 for line in map(core.decode, output.splitlines()):
562 match = REV_LIST_REGEX.match(line)
563 if match:
564 revs.append(match.group(1))
565 summaries.append(match.group(2))
566 return (revs, summaries)
569 def rev_list_range(start, end):
570 """Return a (SHA-1, summary) pairs between start and end."""
571 revrange = '%s..%s' % (start, end)
572 raw_revs = git.rev_list(revrange, pretty='oneline')
573 return parse_rev_list(raw_revs)
576 def merge_message_path():
577 """Return the path to .git/MERGE_MSG or .git/SQUASH_MSG."""
578 for basename in ('MERGE_MSG', 'SQUASH_MSG'):
579 path = git.git_path(basename)
580 if os.path.exists(path):
581 return path
582 return None
585 def abort_merge():
586 """Abort a merge by reading the tree at HEAD."""
587 # Reset the worktree
588 git.read_tree('HEAD', reset=True, u=True, v=True)
589 # remove MERGE_HEAD
590 merge_head = git.git_path('MERGE_HEAD')
591 if os.path.exists(merge_head):
592 os.unlink(merge_head)
593 # remove MERGE_MESSAGE, etc.
594 merge_msg_path = merge_message_path()
595 while merge_msg_path:
596 os.unlink(merge_msg_path)
597 merge_msg_path = merge_message_path()
600 def merge_message(revision):
601 """Return a merge message for FETCH_HEAD."""
602 fetch_head = git.git_path('FETCH_HEAD')
603 if os.path.exists(fetch_head):
604 return git.fmt_merge_msg('--file', fetch_head)
605 return "Merge branch '%s'" % revision