1 """Provides commands and queries for Git."""
2 from __future__
import division
, absolute_import
, unicode_literals
5 from io
import StringIO
8 from cola
import gitcfg
10 from cola
import version
11 from cola
.git
import git
12 from cola
.git
import STDOUT
13 from cola
.i18n
import N_
16 EMPTY_TREE_SHA1
= '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
19 class InvalidRepositoryError(Exception):
23 def default_remote(config
=None):
24 """Return the remote tracked by the current branch."""
26 config
= gitcfg
.current()
27 return config
.get('branch.%s.remote' % current_branch())
30 def diff_index_filenames(ref
):
31 """Return a of filenames that have been modified relative to the index"""
32 out
= git
.diff_index(ref
, name_only
=True, z
=True)[STDOUT
]
33 return _parse_diff_filenames(out
)
36 def diff_filenames(*args
):
37 """Return a list of filenames that have been modified"""
38 out
= git
.diff_tree(name_only
=True, no_commit_id
=True, r
=True, z
=True,
40 return _parse_diff_filenames(out
)
44 """Return a list of filenames for the given diff arguments
46 :param args: list of arguments to pass to "git diff --name-only"
49 out
= git
.diff(name_only
=True, z
=True, *args
)[STDOUT
]
50 return _parse_diff_filenames(out
)
53 def _parse_diff_filenames(out
):
55 return out
[:-1].split('\0')
60 def tracked_files(*args
):
61 """Return the names of all files in the repository"""
62 out
= git
.ls_files('--', *args
, z
=True)[STDOUT
]
64 return sorted(out
[:-1].split('\0'))
70 """Returns a sorted list of all files, including untracked files."""
71 ls_files
= git
.ls_files('--', *args
,
75 exclude_standard
=True)[STDOUT
]
76 return sorted([f
for f
in ls_files
.split('\0') if f
])
79 class _current_branch
:
80 """Cache for current_branch()"""
86 _current_branch
.key
= None
90 """Return the current branch"""
91 head
= git
.git_path('HEAD')
93 key
= core
.stat(head
).st_mtime
94 if _current_branch
.key
== key
:
95 return _current_branch
.value
97 # OSError means we can't use the stat cache
100 status
, data
, err
= git
.rev_parse('HEAD', symbolic_full_name
=True)
102 # git init -- read .git/HEAD. We could do this unconditionally...
103 data
= _read_git_head(head
)
105 for refs_prefix
in ('refs/heads/', 'refs/remotes/', 'refs/tags/'):
106 if data
.startswith(refs_prefix
):
107 value
= data
[len(refs_prefix
):]
108 _current_branch
.key
= key
109 _current_branch
.value
= value
115 def _read_git_head(head
, default
='master', git
=git
):
116 """Pure-python .git/HEAD reader"""
117 # Legacy .git/HEAD symlinks
118 if core
.islink(head
):
119 refs_heads
= core
.realpath(git
.git_path('refs', 'heads'))
120 path
= core
.abspath(head
).replace('\\', '/')
121 if path
.startswith(refs_heads
+ '/'):
122 return path
[len(refs_heads
)+1:]
124 # Common .git/HEAD "ref: refs/heads/master" file
125 elif core
.isfile(head
):
126 data
= core
.read(head
).rstrip()
128 if data
.startswith(ref_prefix
):
129 return data
[len(ref_prefix
):]
136 def branch_list(remote
=False):
138 Return a list of local or remote branches
140 This explicitly removes HEAD from the list of remote branches.
144 return for_each_ref_basename('refs/remotes')
146 return for_each_ref_basename('refs/heads')
149 def for_each_ref_basename(refs
, git
=git
):
150 """Return refs starting with 'refs'."""
151 out
= git
.for_each_ref(refs
, format
='%(refname)')[STDOUT
]
152 output
= out
.splitlines()
153 non_heads
= filter(lambda x
: not x
.endswith('/HEAD'), output
)
154 return list(map(lambda x
: x
[len(refs
) + 1:], non_heads
))
157 def all_refs(split
=False, git
=git
):
158 """Return a tuple of (local branches, remote branches, tags)."""
162 triple
= lambda x
, y
: (x
, len(x
) + 1, y
)
163 query
= (triple('refs/tags', tags
),
164 triple('refs/heads', local_branches
),
165 triple('refs/remotes', remote_branches
))
166 out
= git
.for_each_ref(format
='%(refname)')[STDOUT
]
167 for ref
in out
.splitlines():
168 for prefix
, prefix_len
, dst
in query
:
169 if ref
.startswith(prefix
) and not ref
.endswith('/HEAD'):
170 dst
.append(ref
[prefix_len
:])
173 return local_branches
, remote_branches
, tags
175 return local_branches
+ remote_branches
+ tags
178 def tracked_branch(branch
=None, config
=None):
179 """Return the remote branch associated with 'branch'."""
181 config
= gitcfg
.current()
183 branch
= current_branch()
186 remote
= config
.get('branch.%s.remote' % branch
)
189 merge_ref
= config
.get('branch.%s.merge' % branch
)
192 refs_heads
= 'refs/heads/'
193 if merge_ref
.startswith(refs_heads
):
194 return remote
+ '/' + merge_ref
[len(refs_heads
):]
198 def untracked_files(git
=git
, paths
=None):
199 """Returns a sorted list of untracked files."""
203 args
= ['--'] + paths
204 out
= git
.ls_files(z
=True, others
=True, exclude_standard
=True,
207 return out
[:-1].split('\0')
212 """Return a list of tags."""
213 return list(reversed(for_each_ref_basename('refs/tags')))
216 def log(git
, *args
, **kwargs
):
217 return git
.log(no_color
=True, no_abbrev_commit
=True,
218 no_ext_diff
=True, *args
, **kwargs
)[STDOUT
]
221 def commit_diff(sha1
, git
=git
):
222 return log(git
, '-1', sha1
, '--') + '\n\n' + sha1_diff(git
, sha1
)
226 def update_diff_overrides(space_at_eol
, space_change
,
227 all_space
, function_context
):
228 _diff_overrides
['ignore_space_at_eol'] = space_at_eol
229 _diff_overrides
['ignore_space_change'] = space_change
230 _diff_overrides
['ignore_all_space'] = all_space
231 _diff_overrides
['function_context'] = function_context
234 def common_diff_opts(config
=None):
236 config
= gitcfg
.current()
237 submodule
= version
.check('diff-submodule', version
.git_version())
240 'submodule': submodule
,
243 'unified': config
.get('gui.diffcontext', 3),
246 opts
.update(_diff_overrides
)
250 def _add_filename(args
, filename
):
252 args
.extend(['--', filename
])
255 def sha1_diff(git
, sha1
, filename
=None):
256 """Return the diff for a sha1"""
257 # Naively "$sha1^!" is what we'd like to use but that doesn't
258 # give the correct result for merges--the diff is reversed.
259 # Be explicit and compare sha1 against its first parent.
260 args
= [sha1
+ '~', sha1
]
261 opts
= common_diff_opts()
262 _add_filename(args
, filename
)
263 status
, out
, err
= git
.diff(*args
, **opts
)
265 # We probably don't have "$sha1~" because this is the root commit.
266 # "git show" is clever enough to handle the root commit.
268 _add_filename(args
, filename
)
269 status
, out
, err
= git
.show(pretty
='format:', *args
, **opts
)
274 def diff_info(sha1
, git
=git
, filename
=None):
275 decoded
= log(git
, '-1', sha1
, '--', pretty
='format:%b').strip()
278 return decoded
+ sha1_diff(git
, sha1
, filename
=filename
)
281 def diff_helper(commit
=None,
289 with_diff_header
=False,
290 suppress_header
=True,
293 "Invokes git diff on a filepath."
295 ref
, endref
= commit
+'^', commit
298 argv
.append('%s..%s' % (ref
, endref
))
300 for r
in utils
.shell_split(ref
.strip()):
302 elif head
and amending
and cached
:
308 if type(filename
) is list:
309 argv
.extend(filename
)
311 argv
.append(filename
)
312 cfg
= gitcfg
.current()
313 encoding
= cfg
.file_encoding(filename
)
315 status
, out
, err
= git
.diff(R
=reverse
, M
=True, cached
=cached
,
318 **common_diff_opts())
326 return extract_diff_header(status
, deleted
,
327 with_diff_header
, suppress_header
, out
)
330 def extract_diff_header(status
, deleted
,
331 with_diff_header
, suppress_header
, diffoutput
):
334 if diffoutput
.startswith('Submodule'):
336 return ('', diffoutput
)
341 del_tag
= 'deleted file mode '
344 diff
= diffoutput
.split('\n')
346 if not start
and '@@' == line
[:2] and '@@' in line
[2:]:
348 if start
or (deleted
and del_tag
in line
):
349 output
.write(line
+ '\n')
353 elif not suppress_header
:
354 output
.write(line
+ '\n')
356 result
= output
.getvalue()
360 return('\n'.join(headers
), result
)
365 def format_patchsets(to_export
, revs
, output
='patches'):
367 Group contiguous revision selection into patchsets
369 Exists to handle multi-selection.
370 Multiple disparate ranges in the revision selection
371 are grouped into continuous lists.
378 cur_rev
= to_export
[0]
379 cur_master_idx
= revs
.index(cur_rev
)
381 patches_to_export
= [[cur_rev
]]
384 # Group the patches into continuous sets
385 for idx
, rev
in enumerate(to_export
[1:]):
386 # Limit the search to the current neighborhood for efficiency
388 master_idx
= revs
[cur_master_idx
:].index(rev
)
389 master_idx
+= cur_master_idx
391 master_idx
= revs
.index(rev
)
393 if master_idx
== cur_master_idx
+ 1:
394 patches_to_export
[patchset_idx
].append(rev
)
398 patches_to_export
.append([rev
])
399 cur_master_idx
= master_idx
402 # Export each patchsets
404 for patchset
in patches_to_export
:
405 stat
, out
, err
= export_patchset(patchset
[0],
410 patch_with_stat
=True)
414 status
= max(stat
, status
)
415 return (status
, '\n'.join(outs
), '\n'.join(errs
))
418 def export_patchset(start
, end
, output
='patches', **kwargs
):
419 """Export patches from start^ to end."""
420 return git
.format_patch('-o', output
, start
+ '^..' + end
, **kwargs
)
423 def unstage_paths(args
, head
='HEAD'):
424 status
, out
, err
= git
.reset(head
, '--', *set(args
))
426 # handle git init: we have to use 'git rm --cached'
427 # detect this condition by checking if the file is still staged
428 return untrack_paths(args
, head
=head
)
430 return (status
, out
, err
)
433 def untrack_paths(args
, head
='HEAD'):
435 return (-1, N_('Nothing to do'), '')
436 return git
.update_index('--', force_remove
=True, *set(args
))
439 def worktree_state(head
='HEAD',
441 display_untracked
=True,
443 """Return a dict of files in various states of being
445 :rtype: dict, keys are staged, unstaged, untracked, unmerged,
446 changed_upstream, and submodule.
450 git
.update_index(refresh
=True)
452 staged
, unmerged
, staged_deleted
, staged_submods
= diff_index(head
,
454 modified
, unstaged_deleted
, modified_submods
= diff_worktree(paths
)
455 untracked
= display_untracked
and untracked_files(paths
=paths
) or []
457 # Remove unmerged paths from the modified list
459 unmerged_set
= set(unmerged
)
460 modified
= [path
for path
in modified
if path
not in unmerged_set
]
462 # Look for upstream modified files if this is a tracking branch
463 upstream_changed
= diff_upstream(head
)
470 upstream_changed
.sort()
472 return {'staged': staged
,
473 'modified': modified
,
474 'unmerged': unmerged
,
475 'untracked': untracked
,
476 'upstream_changed': upstream_changed
,
477 'staged_deleted': staged_deleted
,
478 'unstaged_deleted': unstaged_deleted
,
479 'submodules': staged_submods | modified_submods
}
482 def _parse_raw_diff(out
):
484 info
, path
, out
= out
.split('\0', 2)
486 is_submodule
= ('160000' in info
[1:14])
487 yield (path
, status
, is_submodule
)
490 def diff_index(head
, cached
=True, paths
=None):
498 args
= [head
, '--'] + paths
499 status
, out
, err
= git
.diff_index(cached
=cached
, z
=True, *args
)
502 args
[0] = EMPTY_TREE_SHA1
503 status
, out
, err
= git
.diff_index(cached
=cached
, z
=True, *args
)
505 for path
, status
, is_submodule
in _parse_raw_diff(out
):
513 unmerged
.append(path
)
515 return staged
, unmerged
, deleted
, submodules
518 def diff_worktree(paths
=None):
525 args
= ['--'] + paths
526 status
, out
, err
= git
.diff_files(z
=True, *args
)
527 for path
, status
, is_submodule
in _parse_raw_diff(out
):
531 modified
.append(path
)
535 return modified
, deleted
, submodules
538 def diff_upstream(head
):
539 tracked
= tracked_branch()
542 base
= merge_base(head
, tracked
)
543 return diff_filenames(base
, tracked
)
546 def _branch_status(branch
):
548 Returns a tuple of staged, unstaged, untracked, and unmerged files
550 This shows only the changes that were introduced in branch
553 staged
= diff_filenames(branch
)
554 return {'staged': staged
,
555 'upstream_changed': staged
}
558 def merge_base(head
, ref
):
559 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
560 return git
.merge_base(head
, ref
)[STDOUT
]
563 def merge_base_parent(branch
):
564 tracked
= tracked_branch(branch
=branch
)
570 def parse_ls_tree(rev
):
571 """Return a list of(mode, type, sha1, path) tuples."""
573 lines
= git
.ls_tree(rev
, r
=True)[STDOUT
].splitlines()
574 regex
= re
.compile(r
'^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
576 match
= regex
.match(line
)
578 mode
= match
.group(1)
579 objtype
= match
.group(2)
580 sha1
= match
.group(3)
581 filename
= match
.group(4)
582 output
.append((mode
, objtype
, sha1
, filename
,) )
586 # A regex for matching the output of git(log|rev-list) --pretty=oneline
587 REV_LIST_REGEX
= re
.compile(r
'^([0-9a-f]{40}) (.*)$')
589 def parse_rev_list(raw_revs
):
590 """Parse `git log --pretty=online` output into (SHA-1, summary) pairs."""
592 for line
in raw_revs
.splitlines():
593 match
= REV_LIST_REGEX
.match(line
)
595 rev_id
= match
.group(1)
596 summary
= match
.group(2)
597 revs
.append((rev_id
, summary
,))
601 def log_helper(all
=False, extra_args
=None):
602 """Return parallel arrays containing the SHA-1s and summaries."""
608 output
= log(git
, pretty
='oneline', all
=all
, *args
)
609 for line
in output
.splitlines():
610 match
= REV_LIST_REGEX
.match(line
)
612 revs
.append(match
.group(1))
613 summaries
.append(match
.group(2))
614 return (revs
, summaries
)
617 def rev_list_range(start
, end
):
618 """Return a (SHA-1, summary) pairs between start and end."""
619 revrange
= '%s..%s' % (start
, end
)
620 out
= git
.rev_list(revrange
, pretty
='oneline')[STDOUT
]
621 return parse_rev_list(out
)
624 def commit_message_path():
625 """Return the path to .git/GIT_COLA_MSG"""
626 path
= git
.git_path("GIT_COLA_MSG")
627 if core
.exists(path
):
632 def merge_message_path():
633 """Return the path to .git/MERGE_MSG or .git/SQUASH_MSG."""
634 for basename
in ('MERGE_MSG', 'SQUASH_MSG'):
635 path
= git
.git_path(basename
)
636 if core
.exists(path
):
642 """Abort a merge by reading the tree at HEAD."""
644 git
.read_tree('HEAD', reset
=True, u
=True, v
=True)
646 merge_head
= git
.git_path('MERGE_HEAD')
647 if core
.exists(merge_head
):
648 core
.unlink(merge_head
)
649 # remove MERGE_MESSAGE, etc.
650 merge_msg_path
= merge_message_path()
651 while merge_msg_path
:
652 core
.unlink(merge_msg_path
)
653 merge_msg_path
= merge_message_path()
656 def strip_remote(remotes
, remote_branch
):
657 for remote
in remotes
:
658 prefix
= remote
+ '/'
659 if remote_branch
.startswith(prefix
):
660 return remote_branch
[len(prefix
):]
661 return remote_branch
.split('/', 1)[-1]