1 """Provides commands and queries for Git."""
4 from cStringIO
import StringIO
7 from cola
import gitcfg
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):
20 def default_remote(config
=None):
21 """Return the remote tracked by the current branch."""
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
):
42 return core
.decode(diff_zstr
[:-1]).split('\0')
48 """Return the names of all files in the repository"""
49 ls_files
= git
.ls_files(z
=True)
51 return core
.decode(ls_files
[:-1]).split('\0')
56 class _current_branch
:
57 """Cache for current_branch()"""
63 _current_branch
.key
= None
67 """Return the current branch"""
69 head
= git
.git_path('HEAD')
71 key
= os
.stat(head
).st_mtime
72 if _current_branch
.key
== key
:
73 return _current_branch
.value
76 status
, data
= git
.rev_parse('HEAD', symbolic_full_name
=True, with_status
=True)
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
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()
104 if data
.startswith(ref_prefix
):
105 return data
[len(ref_prefix
):]
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.
120 return for_each_ref_basename('refs/remotes')
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)."""
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
:])
149 return local_branches
, remote_branches
, tags
151 return local_branches
+ remote_branches
+ tags
154 def tracked_branch(branch
=None, config
=None):
155 """Return the remote branch associated with 'branch'."""
157 config
= gitcfg
.instance()
159 branch
= current_branch()
162 remote
= config
.get('branch.%s.remote' % branch
)
165 merge_ref
= config
.get('branch.%s.merge' % branch
)
168 refs_heads
= 'refs/heads/'
169 if merge_ref
.startswith(refs_heads
):
170 return remote
+ '/' + merge_ref
[len(refs_heads
):]
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)
178 return core
.decode(ls_files
[:-1]).split('\0')
183 """Return a list of tags."""
184 tags
= for_each_ref_basename('refs/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
,
196 suppress_header
=False)))
198 return core
.decode(commit
)
201 def _common_diff_opts(config
=config
):
202 submodule
= version
.check('diff-submodule', version
.git_version())
205 'submodule': submodule
,
208 'with_raw_output': 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()
223 return decoded
+ sha1_diff(sha1
)
226 def diff_helper(commit
=None,
231 with_diff_header
=False,
232 suppress_header
=True,
235 "Invokes git diff on a filepath."
238 ref
, endref
= commit
+'^', commit
241 argv
.append('%s..%s' % (ref
, endref
))
243 for r
in utils
.shell_split(ref
.strip()):
248 if type(filename
) is list:
249 argv
.extend(filename
)
251 argv
.append(filename
)
254 del_tag
= 'deleted file mode '
257 deleted
= cached
and not os
.path
.exists(encode(filename
))
259 status
, diffoutput
= git
.diff(R
=reverse
, M
=True, cached
=cached
,
261 *argv
, **_common_diff_opts())
269 if diffoutput
.startswith('Submodule'):
271 return ('', diffoutput
)
277 diff
= core
.decode(diffoutput
).split('\n')
279 if not start
and '@@' == line
[:2] and '@@' in line
[2:]:
281 if start
or (deleted
and del_tag
in line
):
282 output
.write(encode(line
) + '\n')
285 headers
.append(encode(line
))
286 elif not suppress_header
:
287 output
.write(encode(line
) + '\n')
289 result
= core
.decode(output
.getvalue())
293 return('\n'.join(headers
), 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.
310 cur_rev
= to_export
[0]
311 cur_master_idx
= revs
.index(cur_rev
)
313 patches_to_export
= [[cur_rev
]]
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
)
326 patches_to_export
.append([ rev
])
327 cur_master_idx
= master_idx
330 # Export each patchsets
332 for patchset
in patches_to_export
:
333 newstatus
, out
= export_patchset(patchset
[0],
338 patch_with_stat
=True)
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
,
353 def unstage_paths(args
, head
='HEAD'):
354 status
, output
= git
.reset(head
, '--', with_stderr
=True, with_status
=True,
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 return untrack_paths(args
, head
=head
)
363 def untrack_paths(args
, head
='HEAD'):
365 return (-1, 'Nothing to do')
366 status
, output
= git
.update_index('--',
371 return (status
, output
)
374 def worktree_state(head
='HEAD', staged_only
=False):
375 """Return a tuple of files in various states of being
377 Can be staged, unstaged, untracked, unmerged, or changed
381 state
= worktree_state_dict(head
=head
, staged_only
=staged_only
)
382 return(state
.get('staged', []),
383 state
.get('modified', []),
384 state
.get('unmerged', []),
385 state
.get('untracked', []),
386 state
.get('upstream_changed', []))
389 def worktree_state_dict(head
='HEAD',
392 """Return a dict of files in various states of being
394 :rtype: dict, keys are staged, unstaged, untracked, unmerged,
395 changed_upstream, and submodule.
399 git
.update_index(refresh
=True)
402 return _branch_status(head
)
404 staged
, unmerged
, submodules
= diff_index(head
)
405 modified
, more_submods
= diff_worktree()
407 # Remove unmerged paths from the modified list
408 unmerged_set
= set(unmerged
)
409 modified_set
= set(modified
)
410 modified_unmerged
= modified_set
.intersection(unmerged_set
)
411 for path
in modified_unmerged
:
412 modified
.remove(path
)
414 submodules
= submodules
.union(more_submods
)
415 untracked
= untracked_files()
417 # Look for upstream modified files if this is a tracking branch
418 upstream_changed
= diff_upstream(head
)
425 upstream_changed
.sort()
427 return {'staged': staged
,
428 'modified': modified
,
429 'unmerged': unmerged
,
430 'untracked': untracked
,
431 'upstream_changed': upstream_changed
,
432 'submodules': submodules
}
435 def diff_index(head
):
441 output
= git
.diff_index(head
, z
=True, cached
=True, with_stderr
=True)
442 if output
.startswith('fatal:'):
444 return all_files(), unmerged
, submodules
447 rest
, output
= output
.split('\0', 1)
448 name
, output
= output
.split('\0', 1)
451 if '160000' in rest
[1:14]:
453 elif status
in 'DAMT':
456 unmerged
.append(name
)
458 return staged
, unmerged
, submodules
465 output
= git
.diff_files(z
=True, with_stderr
=True)
466 if output
.startswith('fatal:'):
468 ls_files
= core
.decode(git
.ls_files(modified
=True, z
=True))
470 modified
= ls_files
[:-1].split('\0')
471 return modified
, submodules
474 rest
, output
= output
.split('\0', 1)
475 name
, output
= output
.split('\0', 1)
477 name
= core
.decode(name
)
478 if '160000' in rest
[1:14]:
480 elif status
in 'DAMT':
481 modified
.append(name
)
483 return modified
, submodules
486 def diff_upstream(head
):
487 tracked
= tracked_branch()
490 merge_base
= merge_base_to(head
, tracked
)
491 return diff_filenames(merge_base
, tracked
)
494 def _branch_status(branch
):
496 Returns a tuple of staged, unstaged, untracked, and unmerged files
498 This shows only the changes that were introduced in branch
501 staged
= diff_filenames(branch
)
502 return {'staged': staged
,
503 'upstream_changed': staged
}
506 def merge_base_to(head
, ref
):
507 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
508 return git
.merge_base(head
, ref
)
511 def merge_base_parent(branch
):
512 tracked
= tracked_branch(branch
=branch
)
519 """handles quoted paths."""
520 if path
.startswith('"') and path
.endswith('"'):
521 return core
.decode(eval(path
))
523 return core
.decode(path
)
526 def renamed_files(start
, end
, git
=git
):
527 difflines
= git
.diff('%s..%s' % (start
, end
), M
=True,
528 **_common_diff_opts()).splitlines()
529 return [eval_path(r
[12:].rstrip())
530 for r
in difflines
if r
.startswith('rename from ')]
533 def parse_ls_tree(rev
):
534 """Return a list of(mode, type, sha1, path) tuples."""
535 lines
= git
.ls_tree(rev
, r
=True).splitlines()
537 regex
= re
.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
539 match
= regex
.match(line
)
541 mode
= match
.group(1)
542 objtype
= match
.group(2)
543 sha1
= match
.group(3)
544 filename
= match
.group(4)
545 output
.append((mode
, objtype
, sha1
, filename
,) )
549 # A regex for matching the output of git(log|rev-list) --pretty=oneline
550 REV_LIST_REGEX
= re
.compile('^([0-9a-f]{40}) (.*)$')
552 def parse_rev_list(raw_revs
):
553 """Parse `git log --pretty=online` output into (SHA-1, summary) pairs."""
555 for line
in map(core
.decode
, raw_revs
.splitlines()):
556 match
= REV_LIST_REGEX
.match(line
)
558 rev_id
= match
.group(1)
559 summary
= match
.group(2)
560 revs
.append((rev_id
, summary
,))
564 def log_helper(all
=False, extra_args
=None):
565 """Return parallel arrays containing the SHA-1s and summaries."""
571 output
= git
.log(pretty
='oneline', no_color
=True, all
=all
, *args
)
572 for line
in map(core
.decode
, output
.splitlines()):
573 match
= REV_LIST_REGEX
.match(line
)
575 revs
.append(match
.group(1))
576 summaries
.append(match
.group(2))
577 return (revs
, summaries
)
580 def rev_list_range(start
, end
):
581 """Return a (SHA-1, summary) pairs between start and end."""
582 revrange
= '%s..%s' % (start
, end
)
583 raw_revs
= git
.rev_list(revrange
, pretty
='oneline')
584 return parse_rev_list(raw_revs
)
587 def merge_message_path():
588 """Return the path to .git/MERGE_MSG or .git/SQUASH_MSG."""
589 for basename
in ('MERGE_MSG', 'SQUASH_MSG'):
590 path
= git
.git_path(basename
)
591 if os
.path
.exists(path
):
597 """Abort a merge by reading the tree at HEAD."""
599 git
.read_tree('HEAD', reset
=True, u
=True, v
=True)
601 merge_head
= git
.git_path('MERGE_HEAD')
602 if os
.path
.exists(merge_head
):
603 os
.unlink(merge_head
)
604 # remove MERGE_MESSAGE, etc.
605 merge_msg_path
= merge_message_path()
606 while merge_msg_path
:
607 os
.unlink(merge_msg_path
)
608 merge_msg_path
= merge_message_path()
611 def merge_message(revision
):
612 """Return a merge message for FETCH_HEAD."""
613 fetch_head
= git
.git_path('FETCH_HEAD')
614 if os
.path
.exists(fetch_head
):
615 return git
.fmt_merge_msg('--file', fetch_head
)
616 return "Merge branch '%s'" % revision