1 """Provides commands and queries for Git."""
4 from cStringIO
import StringIO
7 from cola
import gitcmd
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
= gitcmd
.instance()
15 config
= gitcfg
.instance()
17 class InvalidRepositoryError(StandardError):
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
]
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()"""
45 """Return the current branch"""
46 head
= git
.git_path('HEAD')
48 key
= os
.stat(head
).st_mtime
49 if _current_branch
.key
== key
:
50 return _current_branch
.value
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
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
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()
83 if data
.startswith(ref_prefix
):
84 return data
[len(ref_prefix
):]
91 def branch_list(remote
=False):
93 Return a list of local or remote branches
95 This explicitly removes HEAD from the list of remote branches.
99 return for_each_ref_basename('refs/remotes')
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)."""
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
:])
126 return local_branches
, remote_branches
, tags
128 return local_branches
+ remote_branches
+ tags
131 def tracked_branch(branch
=None):
132 """Return the remote branch associated with 'branch'."""
134 branch
= current_branch()
135 remote
= config
.get('branch.%s.remote' % branch
)
138 merge_ref
= config
.get('branch.%s.merge' % branch
)
141 refs_heads
= 'refs/heads/'
142 if merge_ref
.startswith(refs_heads
):
143 return remote
+ '/' + merge_ref
[len(refs_heads
):]
147 def untracked_files():
148 """Returns a sorted list of untracked files."""
149 ls_files
= git
.ls_files(z
=True,
151 exclude_standard
=True)
152 return [core
.decode(f
) for f
in ls_files
.split('\0') if f
]
156 """Return a list of tags."""
157 tags
= for_each_ref_basename('refs/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
,
169 suppress_header
=False)))
171 return core
.decode(commit
)
174 def diff_helper(commit
=None,
180 with_diff_header
=False,
181 suppress_header
=True,
183 "Invokes git diff on a filepath."
185 ref
, endref
= commit
+'^', commit
188 argv
.append('%s..%s' % (ref
, endref
))
190 for r
in ref
.strip().split():
197 if type(filename
) is list:
198 argv
.extend(filename
)
200 argv
.append(filename
)
203 del_tag
= 'deleted file mode '
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
,
217 unified
=config
.get('diff.context', 3),
218 with_raw_output
=True,
224 if diffoutput
.startswith('fatal:'):
230 if diffoutput
.startswith('Submodule'):
232 return ('', diffoutput
)
238 diff
= diffoutput
.split('\n')
239 for line
in map(core
.decode
, diff
):
240 if not start
and '@@' == line
[:2] and '@@' in line
[2:]:
242 if start
or (deleted
and del_tag
in line
):
243 output
.write(core
.encode(line
) + '\n')
246 headers
.append(core
.encode(line
))
247 elif not suppress_header
:
248 output
.write(core
.encode(line
) + '\n')
250 result
= core
.decode(output
.getvalue())
254 return('\n'.join(headers
), 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.
271 cur_rev
= to_export
[0]
272 cur_master_idx
= revs
.index(cur_rev
)
274 patches_to_export
= [[cur_rev
]]
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
)
287 patches_to_export
.append([ rev
])
288 cur_master_idx
= master_idx
291 # Export each patchsets
293 for patchset
in patches_to_export
:
294 newstatus
, out
= export_patchset(patchset
[0],
299 patch_with_stat
=True)
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
,
314 def unstage_paths(args
):
315 status
, output
= git
.reset('--', with_stderr
=True, with_status
=True,
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('--',
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
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 return _branch_status(head
)
363 upstream_changed
= []
366 output
= git
.diff_index(head
,
369 if output
.startswith('fatal:'):
370 raise errors
.GitInitError('git init')
371 for line
in output
.splitlines():
372 rest
, name
= line
.split('\t', 1)
374 name
= eval_path(name
)
375 if '160000' in rest
[1:14]:
380 # This file will also show up as 'M' without --cached
381 # so by default don't consider it modified unless
382 # it's truly modified
383 modified_set
.add(name
)
384 if not staged_only
and is_modified(name
):
385 modified
.append(name
)
392 modified_set
.add(name
)
394 unmerged
.append(name
)
395 modified_set
.add(name
)
397 except errors
.GitInitError
:
399 staged
.extend(all_files())
402 output
= git
.diff_index(head
, with_stderr
=True)
403 if output
.startswith('fatal:'):
404 raise errors
.GitInitError('git init')
405 for line
in output
.splitlines():
406 rest
, name
= line
.split('\t', 1)
408 name
= eval_path(name
)
409 if '160000' in rest
[1:13]:
411 if status
== 'M' or status
== 'D':
412 if name
not in modified_set
:
413 modified
.append(name
)
415 # newly-added yet modified
416 if (name
not in modified_set
and not staged_only
and
418 modified
.append(name
)
420 except errors
.GitInitError
:
422 ls_files
= git
.ls_files(modified
=True, z
=True)[:-1].split('\0')
423 modified
.extend(map(core
.decode
, [f
for f
in ls_files
if f
]))
425 untracked
.extend(untracked_files())
427 # Look for upstream modified files if this is a tracking branch
428 tracked
= tracked_branch()
431 diff_expr
= merge_base_to(tracked
)
432 output
= git
.diff(diff_expr
, name_only
=True, z
=True)
434 if output
.startswith('fatal:'):
435 raise errors
.GitInitError('git init')
437 for name
in [n
for n
in output
.split('\0') if n
]:
438 name
= core
.decode(name
)
439 upstream_changed
.append(name
)
441 except errors
.GitInitError
:
450 upstream_changed
.sort()
452 return {'staged': staged
,
453 'modified': modified
,
454 'unmerged': unmerged
,
455 'untracked': untracked
,
456 'upstream_changed': upstream_changed
,
457 'submodules': submodules
}
460 def _branch_status(branch
):
462 Returns a tuple of staged, unstaged, untracked, and unmerged files
464 This shows only the changes that were introduced in branch
467 status
, output
= git
.diff(name_only
=True,
471 *branch
.strip().split())
475 staged
= map(core
.decode
, [n
for n
in output
.split('\0') if n
])
476 return {'staged': staged
,
477 'upstream_changed': staged
}
480 def merge_base_to(ref
):
481 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
482 base
= git
.merge_base('HEAD', ref
)
483 return '%s..%s' % (base
, ref
)
486 def merge_base_parent(branch
):
487 tracked
= tracked_branch(branch
=branch
)
489 return '%s..%s' % (tracked
, branch
)
490 return 'master..%s' % branch
493 def is_modified(name
):
494 status
, out
= git
.diff('--', name
,
502 """handles quoted paths."""
503 if path
.startswith('"') and path
.endswith('"'):
504 return core
.decode(eval(path
))
509 def renamed_files(start
, end
):
510 difflines
= git
.diff('%s..%s' % (start
, end
),
513 return [eval_path(r
[12:].rstrip())
514 for r
in difflines
if r
.startswith('rename from ')]
517 def changed_files(start
, end
):
518 zfiles_str
= git
.diff('%s..%s' % (start
, end
),
519 name_only
=True, z
=True).strip('\0')
520 return [core
.decode(enc
) for enc
in zfiles_str
.split('\0') if enc
]
523 def parse_ls_tree(rev
):
524 """Return a list of(mode, type, sha1, path) tuples."""
525 lines
= git
.ls_tree(rev
, r
=True).splitlines()
527 regex
= re
.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
529 match
= regex
.match(line
)
531 mode
= match
.group(1)
532 objtype
= match
.group(2)
533 sha1
= match
.group(3)
534 filename
= match
.group(4)
535 output
.append((mode
, objtype
, sha1
, filename
,) )
539 # A regex for matching the output of git(log|rev-list) --pretty=oneline
540 REV_LIST_REGEX
= re
.compile('^([0-9a-f]{40}) (.*)$')
542 def parse_rev_list(raw_revs
):
543 """Parse `git log --pretty=online` output into (SHA-1, summary) pairs."""
545 for line
in map(core
.decode
, raw_revs
.splitlines()):
546 match
= REV_LIST_REGEX
.match(line
)
548 rev_id
= match
.group(1)
549 summary
= match
.group(2)
550 revs
.append((rev_id
, summary
,))
554 def log_helper(all
=False, extra_args
=None):
555 """Return parallel arrays containing the SHA-1s and summaries."""
561 output
= git
.log(pretty
='oneline', no_color
=True, all
=all
, *args
)
562 for line
in map(core
.decode
, output
.splitlines()):
563 match
= REV_LIST_REGEX
.match(line
)
565 revs
.append(match
.group(1))
566 summaries
.append(match
.group(2))
567 return (revs
, summaries
)
570 def rev_list_range(start
, end
):
571 """Return a (SHA-1, summary) pairs between start and end."""
572 revrange
= '%s..%s' % (start
, end
)
573 raw_revs
= git
.rev_list(revrange
, pretty
='oneline')
574 return parse_rev_list(raw_revs
)
577 def merge_message_path():
578 """Return the path to .git/MERGE_MSG or .git/SQUASH_MSG."""
579 for basename
in ('MERGE_MSG', 'SQUASH_MSG'):
580 path
= git
.git_path(basename
)
581 if os
.path
.exists(path
):
587 """Abort a merge by reading the tree at HEAD."""
589 git
.read_tree('HEAD', reset
=True, u
=True, v
=True)
591 merge_head
= git
.git_path('MERGE_HEAD')
592 if os
.path
.exists(merge_head
):
593 os
.unlink(merge_head
)
594 # remove MERGE_MESSAGE, etc.
595 merge_msg_path
= merge_message_path()
596 while merge_msg_path
:
597 os
.unlink(merge_msg_path
)
598 merge_msg_path
= merge_message_path()
602 """Return a merge message for FETCH_HEAD."""
603 return git
.fmt_merge_msg('--file', git
.git_path('FETCH_HEAD'))