1 """Provides commands and queries for Git."""
3 from cStringIO
import StringIO
6 from cola
import gitcfg
8 from cola
import version
9 from cola
.compat
import set
10 from cola
.git
import git
11 from cola
.git
import STDOUT
12 from cola
.i18n
import N_
14 config
= gitcfg
.instance()
17 class InvalidRepositoryError(StandardError):
21 def default_remote(config
=None):
22 """Return the remote tracked by the current branch."""
24 config
= gitcfg
.instance()
25 return config
.get('branch.%s.remote' % current_branch())
28 def diff_index_filenames(ref
):
29 """Return a of filenames that have been modified relative to the index"""
30 out
= git
.diff_index(ref
, name_only
=True, z
=True)[STDOUT
]
31 return _parse_diff_filenames(out
)
34 def diff_filenames(*args
):
35 """Return a list of filenames that have been modified"""
36 out
= git
.diff_tree(name_only
=True, no_commit_id
=True, r
=True, z
=True,
38 return _parse_diff_filenames(out
)
42 """Return a list of filenames for the given diff arguments
44 :param args: list of arguments to pass to "git diff --name-only"
47 out
= git
.diff(name_only
=True, z
=True, *args
)[STDOUT
]
48 return _parse_diff_filenames(out
)
51 def _parse_diff_filenames(out
):
53 return out
[:-1].split('\0')
59 """Return the names of all files in the repository"""
60 out
= git
.ls_files(z
=True)[STDOUT
]
62 return out
[:-1].split('\0')
67 class _current_branch
:
68 """Cache for current_branch()"""
74 _current_branch
.key
= None
78 """Return the current branch"""
79 head
= git
.git_path('HEAD')
81 key
= core
.stat(head
).st_mtime
82 if _current_branch
.key
== key
:
83 return _current_branch
.value
86 status
, data
, err
= git
.rev_parse('HEAD', symbolic_full_name
=True)
88 # git init -- read .git/HEAD. We could do this unconditionally...
89 data
= _read_git_head(head
)
91 for refs_prefix
in ('refs/heads/', 'refs/remotes/', 'refs/tags/'):
92 if data
.startswith(refs_prefix
):
93 value
= data
[len(refs_prefix
):]
94 _current_branch
.key
= key
95 _current_branch
.value
= value
101 def _read_git_head(head
, default
='master', git
=git
):
102 """Pure-python .git/HEAD reader"""
103 # Legacy .git/HEAD symlinks
104 if core
.islink(head
):
105 refs_heads
= core
.realpath(git
.git_path('refs', 'heads'))
106 path
= core
.abspath(head
).replace('\\', '/')
107 if path
.startswith(refs_heads
+ '/'):
108 return path
[len(refs_heads
)+1:]
110 # Common .git/HEAD "ref: refs/heads/master" file
111 elif core
.isfile(head
):
112 data
= core
.read(head
).rstrip()
114 if data
.startswith(ref_prefix
):
115 return data
[len(ref_prefix
):]
122 def branch_list(remote
=False):
124 Return a list of local or remote branches
126 This explicitly removes HEAD from the list of remote branches.
130 return for_each_ref_basename('refs/remotes')
132 return for_each_ref_basename('refs/heads')
135 def for_each_ref_basename(refs
, git
=git
):
136 """Return refs starting with 'refs'."""
137 out
= git
.for_each_ref(refs
, format
='%(refname)')[STDOUT
]
138 output
= out
.splitlines()
139 non_heads
= filter(lambda x
: not x
.endswith('/HEAD'), output
)
140 return map(lambda x
: x
[len(refs
) + 1:], non_heads
)
143 def all_refs(split
=False, git
=git
):
144 """Return a tuple of (local branches, remote branches, tags)."""
148 triple
= lambda x
, y
: (x
, len(x
) + 1, y
)
149 query
= (triple('refs/tags', tags
),
150 triple('refs/heads', local_branches
),
151 triple('refs/remotes', remote_branches
))
152 out
= git
.for_each_ref(format
='%(refname)')[STDOUT
]
153 for ref
in out
.splitlines():
154 for prefix
, prefix_len
, dst
in query
:
155 if ref
.startswith(prefix
) and not ref
.endswith('/HEAD'):
156 dst
.append(ref
[prefix_len
:])
159 return local_branches
, remote_branches
, tags
161 return local_branches
+ remote_branches
+ tags
164 def tracked_branch(branch
=None, config
=None):
165 """Return the remote branch associated with 'branch'."""
167 config
= gitcfg
.instance()
169 branch
= current_branch()
172 remote
= config
.get('branch.%s.remote' % branch
)
175 merge_ref
= config
.get('branch.%s.merge' % branch
)
178 refs_heads
= 'refs/heads/'
179 if merge_ref
.startswith(refs_heads
):
180 return remote
+ '/' + merge_ref
[len(refs_heads
):]
184 def untracked_files(git
=git
):
185 """Returns a sorted list of untracked files."""
186 out
= git
.ls_files(z
=True, others
=True, exclude_standard
=True)[STDOUT
]
188 return out
[:-1].split('\0')
193 """Return a list of tags."""
194 tags
= for_each_ref_basename('refs/tags')
199 def log(git
, *args
, **kwargs
):
200 return git
.log(no_color
=True, no_ext_diff
=True, *args
, **kwargs
)[STDOUT
]
203 def commit_diff(sha1
, git
=git
):
204 return log(git
, '-1', sha1
, '--') + '\n\n' + sha1_diff(git
, sha1
)
208 def update_diff_overrides(space_at_eol
, space_change
,
209 all_space
, function_context
):
210 _diff_overrides
['ignore_space_at_eol'] = space_at_eol
211 _diff_overrides
['ignore_space_change'] = space_change
212 _diff_overrides
['ignore_all_space'] = all_space
213 _diff_overrides
['function_context'] = function_context
216 def common_diff_opts(config
=config
):
217 submodule
= version
.check('diff-submodule', version
.git_version())
220 'submodule': submodule
,
223 'unified': config
.get('gui.diffcontext', 3),
226 opts
.update(_diff_overrides
)
230 def sha1_diff(git
, sha1
):
231 return git
.diff(sha1
+'^!', **common_diff_opts())[STDOUT
]
234 def diff_info(sha1
, git
=git
):
235 decoded
= log(git
, '-1', sha1
, '--', pretty
='format:%b').strip()
238 return decoded
+ sha1_diff(git
, sha1
)
241 def diff_helper(commit
=None,
248 with_diff_header
=False,
249 suppress_header
=True,
252 "Invokes git diff on a filepath."
254 ref
, endref
= commit
+'^', commit
257 argv
.append('%s..%s' % (ref
, endref
))
259 for r
in utils
.shell_split(ref
.strip()):
261 elif head
and amending
and cached
:
267 if type(filename
) is list:
268 argv
.extend(filename
)
270 argv
.append(filename
)
271 encoding
= config
.file_encoding(filename
)
273 if filename
is not None:
274 deleted
= cached
and not core
.exists(filename
)
278 status
, out
, err
= git
.diff(R
=reverse
, M
=True, cached
=cached
,
281 **common_diff_opts())
289 return extract_diff_header(status
, deleted
,
290 with_diff_header
, suppress_header
, out
)
293 def extract_diff_header(status
, deleted
,
294 with_diff_header
, suppress_header
, diffoutput
):
298 if diffoutput
.startswith('Submodule'):
300 return ('', diffoutput
)
305 del_tag
= 'deleted file mode '
308 diff
= diffoutput
.split('\n')
310 if not start
and '@@' == line
[:2] and '@@' in line
[2:]:
312 if start
or (deleted
and del_tag
in line
):
313 output
.write(encode(line
) + '\n')
317 elif not suppress_header
:
318 output
.write(encode(line
) + '\n')
320 result
= core
.decode(output
.getvalue())
324 return('\n'.join(headers
), result
)
329 def format_patchsets(to_export
, revs
, output
='patches'):
331 Group contiguous revision selection into patchsets
333 Exists to handle multi-selection.
334 Multiple disparate ranges in the revision selection
335 are grouped into continuous lists.
342 cur_rev
= to_export
[0]
343 cur_master_idx
= revs
.index(cur_rev
)
345 patches_to_export
= [[cur_rev
]]
348 # Group the patches into continuous sets
349 for idx
, rev
in enumerate(to_export
[1:]):
350 # Limit the search to the current neighborhood for efficiency
351 master_idx
= revs
[cur_master_idx
:].index(rev
)
352 master_idx
+= cur_master_idx
353 if master_idx
== cur_master_idx
+ 1:
354 patches_to_export
[ patchset_idx
].append(rev
)
358 patches_to_export
.append([ rev
])
359 cur_master_idx
= master_idx
362 # Export each patchsets
364 for patchset
in patches_to_export
:
365 stat
, out
, err
= export_patchset(patchset
[0],
370 patch_with_stat
=True)
374 status
= max(stat
, status
)
375 return (status
, '\n'.join(outs
), '\n'.join(errs
))
378 def export_patchset(start
, end
, output
='patches', **kwargs
):
379 """Export patches from start^ to end."""
380 return git
.format_patch('-o', output
, start
+ '^..' + end
, **kwargs
)
383 def unstage_paths(args
, head
='HEAD'):
384 status
, out
, err
= git
.reset(head
, '--', *set(args
))
386 # handle git init: we have to use 'git rm --cached'
387 # detect this condition by checking if the file is still staged
388 return untrack_paths(args
, head
=head
)
390 return (status
, out
, err
)
393 def untrack_paths(args
, head
='HEAD'):
395 return (-1, N_('Nothing to do'), '')
396 return git
.update_index('--', force_remove
=True, *set(args
))
399 def worktree_state(head
='HEAD'):
400 """Return a tuple of files in various states of being
402 Can be staged, unstaged, untracked, unmerged, or changed
406 state
= worktree_state_dict(head
=head
)
407 return(state
.get('staged', []),
408 state
.get('modified', []),
409 state
.get('unmerged', []),
410 state
.get('untracked', []),
411 state
.get('upstream_changed', []))
414 def worktree_state_dict(head
='HEAD', update_index
=False, display_untracked
=True):
415 """Return a dict of files in various states of being
417 :rtype: dict, keys are staged, unstaged, untracked, unmerged,
418 changed_upstream, and submodule.
422 git
.update_index(refresh
=True)
424 staged
, unmerged
, staged_submods
= diff_index(head
)
425 modified
, modified_submods
= diff_worktree()
426 untracked
= display_untracked
and untracked_files() or []
428 # Remove unmerged paths from the modified list
429 unmerged_set
= set(unmerged
)
430 modified_set
= set(modified
)
431 modified_unmerged
= modified_set
.intersection(unmerged_set
)
432 for path
in modified_unmerged
:
433 modified
.remove(path
)
436 submodules
= staged_submods
.union(modified_submods
)
438 # Only include the submodule in the staged list once it has
439 # been staged. Otherwise, we'll see the submodule as being
440 # both modified and staged.
441 modified_submods
= modified_submods
.difference(staged_submods
)
443 # Add submodules to the staged and unstaged lists
444 staged
.extend(list(staged_submods
))
445 modified
.extend(list(modified_submods
))
447 # Look for upstream modified files if this is a tracking branch
448 upstream_changed
= diff_upstream(head
)
455 upstream_changed
.sort()
457 return {'staged': staged
,
458 'modified': modified
,
459 'unmerged': unmerged
,
460 'untracked': untracked
,
461 'upstream_changed': upstream_changed
,
462 'submodules': submodules
}
465 def diff_index(head
, cached
=True):
470 status
, out
, err
= git
.diff_index(head
, '--', cached
=cached
, z
=True)
473 return all_files(), unmerged
, submodules
476 rest
, out
= out
.split('\0', 1)
477 name
, out
= out
.split('\0', 1)
479 if '160000' in rest
[1:14]:
481 elif status
in 'DAMT':
484 unmerged
.append(name
)
486 return staged
, unmerged
, submodules
493 status
, out
, err
= git
.diff_files(z
=True)
496 out
= git
.ls_files(modified
=True, z
=True)[STDOUT
]
498 modified
= out
[:-1].split('\0')
499 return modified
, submodules
502 rest
, out
= out
.split('\0', 1)
503 name
, out
= out
.split('\0', 1)
505 if '160000' in rest
[1:14]:
507 elif status
in 'DAMT':
508 modified
.append(name
)
510 return modified
, submodules
513 def diff_upstream(head
):
514 tracked
= tracked_branch()
517 base
= merge_base(head
, tracked
)
518 return diff_filenames(base
, tracked
)
521 def _branch_status(branch
):
523 Returns a tuple of staged, unstaged, untracked, and unmerged files
525 This shows only the changes that were introduced in branch
528 staged
= diff_filenames(branch
)
529 return {'staged': staged
,
530 'upstream_changed': staged
}
533 def merge_base(head
, ref
):
534 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
535 return git
.merge_base(head
, ref
)[STDOUT
]
538 def merge_base_parent(branch
):
539 tracked
= tracked_branch(branch
=branch
)
545 def parse_ls_tree(rev
):
546 """Return a list of(mode, type, sha1, path) tuples."""
548 lines
= git
.ls_tree(rev
, r
=True)[STDOUT
].splitlines()
549 regex
= re
.compile(r
'^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
551 match
= regex
.match(line
)
553 mode
= match
.group(1)
554 objtype
= match
.group(2)
555 sha1
= match
.group(3)
556 filename
= match
.group(4)
557 output
.append((mode
, objtype
, sha1
, filename
,) )
561 # A regex for matching the output of git(log|rev-list) --pretty=oneline
562 REV_LIST_REGEX
= re
.compile(r
'^([0-9a-f]{40}) (.*)$')
564 def parse_rev_list(raw_revs
):
565 """Parse `git log --pretty=online` output into (SHA-1, summary) pairs."""
567 for line
in raw_revs
.splitlines():
568 match
= REV_LIST_REGEX
.match(line
)
570 rev_id
= match
.group(1)
571 summary
= match
.group(2)
572 revs
.append((rev_id
, summary
,))
576 def log_helper(all
=False, extra_args
=None):
577 """Return parallel arrays containing the SHA-1s and summaries."""
583 output
= log(git
, pretty
='oneline', all
=all
, *args
)
584 for line
in output
.splitlines():
585 match
= REV_LIST_REGEX
.match(line
)
587 revs
.append(match
.group(1))
588 summaries
.append(match
.group(2))
589 return (revs
, summaries
)
592 def rev_list_range(start
, end
):
593 """Return a (SHA-1, summary) pairs between start and end."""
594 revrange
= '%s..%s' % (start
, end
)
595 out
= git
.rev_list(revrange
, pretty
='oneline')[STDOUT
]
596 return parse_rev_list(out
)
599 def commit_message_path():
600 """Return the path to .git/GIT_COLA_MSG"""
601 path
= git
.git_path("GIT_COLA_MSG")
602 if core
.exists(path
):
607 def merge_message_path():
608 """Return the path to .git/MERGE_MSG or .git/SQUASH_MSG."""
609 for basename
in ('MERGE_MSG', 'SQUASH_MSG'):
610 path
= git
.git_path(basename
)
611 if core
.exists(path
):
617 """Abort a merge by reading the tree at HEAD."""
619 git
.read_tree('HEAD', reset
=True, u
=True, v
=True)
621 merge_head
= git
.git_path('MERGE_HEAD')
622 if core
.exists(merge_head
):
623 core
.unlink(merge_head
)
624 # remove MERGE_MESSAGE, etc.
625 merge_msg_path
= merge_message_path()
626 while merge_msg_path
:
627 core
.unlink(merge_msg_path
)
628 merge_msg_path
= merge_message_path()
631 def merge_message(revision
):
632 """Return a merge message for FETCH_HEAD."""
633 fetch_head
= git
.git_path('FETCH_HEAD')
634 if core
.exists(fetch_head
):
635 return git
.fmt_merge_msg('--file', fetch_head
)[STDOUT
]
636 return "Merge branch '%s'" % revision