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
12 git
= gitcmd
.instance()
13 config
= gitcfg
.instance()
17 """Return the remote tracked by the current branch."""
18 return config
.get('branch.%s.remote' % current_branch())
21 def diff_filenames(arg
):
22 """Return a list of filenames that have been modified"""
23 diff_zstr
= git
.diff(arg
, name_only
=True, z
=True).rstrip('\0')
24 return [core
.decode(f
) for f
in diff_zstr
.split('\0') if f
]
28 """Return the names of all files in the repository"""
29 return [core
.decode(f
)
30 for f
in git
.ls_files(z
=True)
31 .strip('\0').split('\0') if f
]
34 class _current_branch
:
35 """Cache for current_branch()."""
41 """Find the current branch."""
42 head
= git
.git_path('HEAD')
44 data
= utils
.slurp(head
)
45 if _current_branch
.data
== data
:
46 return _current_branch
.value
49 # Handle legacy .git/HEAD symlinks
50 if os
.path
.islink(head
):
51 refs_heads
= os
.path
.realpath(git
.git_path('refs', 'heads'))
52 path
= os
.path
.abspath(head
).replace('\\', '/')
53 if path
.startswith(refs_heads
+ '/'):
54 value
= path
[len(refs_heads
)+1:]
55 _current_branch
.value
= value
56 _current_branch
.data
= data
60 # Handle the common .git/HEAD "ref: refs/heads/master" file
61 if os
.path
.isfile(head
):
63 ref_prefix
= 'ref: refs/heads/'
64 if value
.startswith(ref_prefix
):
65 value
= value
[len(ref_prefix
):]
67 _current_branch
.data
= data
68 _current_branch
.value
= value
71 # This shouldn't happen
75 def branch_list(remote
=False):
77 Return a list of local or remote branches
79 This explicitly removes HEAD from the list of remote branches.
83 return for_each_ref_basename('refs/remotes')
85 return for_each_ref_basename('refs/heads')
88 def for_each_ref_basename(refs
):
89 """Return refs starting with 'refs'."""
90 output
= git
.for_each_ref(refs
, format
='%(refname)').splitlines()
91 non_heads
= filter(lambda x
: not x
.endswith('/HEAD'), output
)
92 return map(lambda x
: x
[len(refs
) + 1:], non_heads
)
95 def all_refs(split
=False):
96 """Return a tuple of (local branches, remote branches, tags)."""
100 triple
= lambda x
, y
: (x
, len(x
) + 1, y
)
101 query
= (triple('refs/tags', tags
),
102 triple('refs/heads', local_branches
),
103 triple('refs/remotes', remote_branches
))
104 for ref
in git
.for_each_ref(format
='%(refname)').splitlines():
105 for prefix
, prefix_len
, dst
in query
:
106 if ref
.startswith(prefix
) and not ref
.endswith('/HEAD'):
107 dst
.append(ref
[prefix_len
:])
110 return local_branches
, remote_branches
, tags
112 return local_branches
+ remote_branches
+ tags
115 def tracked_branch(branch
=None):
116 """Return the remote branch associated with 'branch'."""
118 branch
= current_branch()
119 remote
= config
.get('branch.%s.remote' % branch
)
122 merge_ref
= config
.get('branch.%s.merge' % branch
)
125 refs_heads
= 'refs/heads/'
126 if merge_ref
.startswith(refs_heads
):
127 return remote
+ '/' + merge_ref
[len(refs_heads
):]
131 def untracked_files():
132 """Returns a sorted list of untracked files."""
133 ls_files
= git
.ls_files(z
=True,
135 exclude_standard
=True)
136 return [core
.decode(f
) for f
in ls_files
.split('\0') if f
]
140 """Return a list of tags."""
141 tags
= for_each_ref_basename('refs/tags')
146 def commit_diff(sha1
):
147 commit
= git
.show(sha1
)
148 first_newline
= commit
.index('\n')
149 if commit
[first_newline
+1:].startswith('Merge:'):
150 return (core
.decode(commit
) + '\n\n' +
151 core
.decode(diff_helper(commit
=sha1
,
153 suppress_header
=False)))
155 return core
.decode(commit
)
158 def diff_helper(commit
=None,
164 with_diff_header
=False,
165 suppress_header
=True,
167 "Invokes git diff on a filepath."
169 ref
, endref
= commit
+'^', commit
172 argv
.append('%s..%s' % (ref
, endref
))
174 for r
in ref
.strip().split():
181 if type(filename
) is list:
182 argv
.extend(filename
)
184 argv
.append(filename
)
187 del_tag
= 'deleted file mode '
190 deleted
= cached
and not os
.path
.exists(core
.encode(filename
))
192 diffoutput
= git
.diff(R
=reverse
,
196 unified
=config
.get('diff.context', 3),
197 with_raw_output
=True,
202 if diffoutput
.startswith('fatal:'):
210 diff
= diffoutput
.split('\n')
211 for line
in map(core
.decode
, diff
):
212 if not start
and '@@' == line
[:2] and '@@' in line
[2:]:
214 if start
or (deleted
and del_tag
in line
):
215 output
.write(core
.encode(line
) + '\n')
218 headers
.append(core
.encode(line
))
219 elif not suppress_header
:
220 output
.write(core
.encode(line
) + '\n')
222 result
= core
.decode(output
.getvalue())
226 return('\n'.join(headers
), result
)
231 def format_patchsets(to_export
, revs
, output
='patches'):
233 Group contiguous revision selection into patchsets
235 Exists to handle multi-selection.
236 Multiple disparate ranges in the revision selection
237 are grouped into continuous lists.
243 cur_rev
= to_export
[0]
244 cur_master_idx
= revs
.index(cur_rev
)
246 patches_to_export
= [[cur_rev
]]
249 # Group the patches into continuous sets
250 for idx
, rev
in enumerate(to_export
[1:]):
251 # Limit the search to the current neighborhood for efficiency
252 master_idx
= revs
[cur_master_idx
:].index(rev
)
253 master_idx
+= cur_master_idx
254 if master_idx
== cur_master_idx
+ 1:
255 patches_to_export
[ patchset_idx
].append(rev
)
259 patches_to_export
.append([ rev
])
260 cur_master_idx
= master_idx
263 # Export each patchsets
265 for patchset
in patches_to_export
:
266 newstatus
, out
= export_patchset(patchset
[0],
271 patch_with_stat
=True)
275 return (status
, '\n'.join(outlines
))
278 def export_patchset(start
, end
, output
='patches', **kwargs
):
279 """Export patches from start^ to end."""
280 return git
.format_patch('-o', output
, start
+ '^..' + end
,
286 def unstage_paths(args
):
288 Unstages paths from the staging area
290 This handles the git init case, which is why it's not
291 just 'git reset name'. For the git init case this falls
292 back to 'git rm --cached'.
295 # fake the status because 'git reset' returns 1
296 # regardless of success/failure
298 output
= git
.reset('--', with_stderr
=True, *set(args
))
299 # handle git init: we have to use 'git rm --cached'
300 # detect this condition by checking if the file is still staged
301 state
= worktree_state()
303 rmargs
= [a
for a
in args
if a
in staged
]
305 return (status
, output
)
306 output
+= git
.rm('--', cached
=True, with_stderr
=True, *rmargs
)
308 return (status
, output
)
312 def worktree_state(head
='HEAD', staged_only
=False):
313 """Return a tuple of files in various states of being
315 Can be staged, unstaged, untracked, unmerged, or changed
319 git
.update_index(refresh
=True)
321 return _branch_status(head
)
326 (staged
, modified
, unmerged
, untracked
, upstream_changed
) = (
329 output
= git
.diff_index(head
,
332 if output
.startswith('fatal:'):
333 raise errors
.GitInitError('git init')
334 for line
in output
.splitlines():
335 rest
, name
= line
.split('\t', 1)
337 name
= eval_path(name
)
341 # This file will also show up as 'M' without --cached
342 # so by default don't consider it modified unless
343 # it's truly modified
344 modified_set
.add(name
)
345 if not staged_only
and is_modified(name
):
346 modified
.append(name
)
353 modified_set
.add(name
)
355 unmerged
.append(name
)
356 modified_set
.add(name
)
358 except errors
.GitInitError
:
360 staged
.extend(all_files())
363 output
= git
.diff_index(head
, with_stderr
=True)
364 if output
.startswith('fatal:'):
365 raise errors
.GitInitError('git init')
366 for line
in output
.splitlines():
367 info
, name
= line
.split('\t', 1)
368 status
= info
.split()[-1]
369 if status
== 'M' or status
== 'D':
370 name
= eval_path(name
)
371 if name
not in modified_set
:
372 modified
.append(name
)
374 name
= eval_path(name
)
375 # newly-added yet modified
376 if (name
not in modified_set
and not staged_only
and
378 modified
.append(name
)
380 except errors
.GitInitError
:
382 ls_files
= git
.ls_files(modified
=True, z
=True)[:-1].split('\0')
383 modified
.extend(map(core
.decode
, [f
for f
in ls_files
if f
]))
385 untracked
.extend(untracked_files())
387 # Look for upstream modified files if this is a tracking branch
388 tracked
= tracked_branch()
391 diff_expr
= merge_base_to(tracked
)
392 output
= git
.diff(diff_expr
, name_only
=True, z
=True)
394 if output
.startswith('fatal:'):
395 raise errors
.GitInitError('git init')
397 for name
in [n
for n
in output
.split('\0') if n
]:
398 name
= core
.decode(name
)
399 upstream_changed
.append(name
)
401 except errors
.GitInitError
:
410 upstream_changed
.sort()
412 return (staged
, modified
, unmerged
, untracked
, upstream_changed
)
415 def _branch_status(branch
):
417 Returns a tuple of staged, unstaged, untracked, and unmerged files
419 This shows only the changes that were introduced in branch
422 status
, output
= git
.diff(name_only
=True,
426 *branch
.strip().split())
428 return ([], [], [], [], [])
430 staged
= map(core
.decode
, [n
for n
in output
.split('\0') if n
])
431 return (staged
, [], [], [], staged
)
434 def merge_base_to(ref
):
435 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
436 base
= git
.merge_base('HEAD', ref
)
437 return '%s..%s' % (base
, ref
)
440 def is_modified(name
):
441 status
, out
= git
.diff('--', name
,
449 """handles quoted paths."""
450 if path
.startswith('"') and path
.endswith('"'):
451 return core
.decode(eval(path
))
456 def renamed_files(start
, end
):
457 difflines
= git
.diff('%s..%s' % (start
, end
),
460 return [eval_path(r
[12:].rstrip())
461 for r
in difflines
if r
.startswith('rename from ')]
464 def changed_files(start
, end
):
465 zfiles_str
= git
.diff('%s..%s' % (start
, end
),
466 name_only
=True, z
=True).strip('\0')
467 return [core
.decode(enc
) for enc
in zfiles_str
.split('\0') if enc
]
470 def parse_ls_tree(rev
):
471 """Return a list of(mode, type, sha1, path) tuples."""
472 lines
= git
.ls_tree(rev
, r
=True).splitlines()
474 regex
= re
.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
476 match
= regex
.match(line
)
478 mode
= match
.group(1)
479 objtype
= match
.group(2)
480 sha1
= match
.group(3)
481 filename
= match
.group(4)
482 output
.append((mode
, objtype
, sha1
, filename
,) )
486 # A regex for matching the output of git(log|rev-list) --pretty=oneline
487 REV_LIST_REGEX
= re
.compile('^([0-9a-f]{40}) (.*)$')
489 def parse_rev_list(raw_revs
):
490 """Parse `git log --pretty=online` output into (SHA-1, summary) pairs."""
492 for line
in map(core
.decode
, raw_revs
.splitlines()):
493 match
= REV_LIST_REGEX
.match(line
)
495 rev_id
= match
.group(1)
496 summary
= match
.group(2)
497 revs
.append((rev_id
, summary
,))
501 def log_helper(all
=False, extra_args
=None):
502 """Return parallel arrays containing the SHA-1s and summaries."""
508 output
= git
.log(pretty
='oneline', all
=all
, *args
)
509 for line
in map(core
.decode
, output
.splitlines()):
510 match
= REV_LIST_REGEX
.match(line
)
512 revs
.append(match
.group(1))
513 summaries
.append(match
.group(2))
514 return (revs
, summaries
)
517 def rev_list_range(start
, end
):
518 """Return a (SHA-1, summary) pairs between start and end."""
519 revrange
= '%s..%s' % (start
, end
)
520 raw_revs
= git
.rev_list(revrange
, pretty
='oneline')
521 return parse_rev_list(raw_revs
)
524 def merge_message_path():
525 """Return the path to .git/MERGE_MSG or .git/SQUASH_MSG."""
526 for basename
in ('MERGE_MSG', 'SQUASH_MSG'):
527 path
= git
.git_path(basename
)
528 if os
.path
.exists(path
):
534 """Abort a merge by reading the tree at HEAD."""
536 git
.read_tree('HEAD', reset
=True, u
=True, v
=True)
538 merge_head
= git
.git_path('MERGE_HEAD')
539 if os
.path
.exists(merge_head
):
540 os
.unlink(merge_head
)
541 # remove MERGE_MESSAGE, etc.
542 merge_msg_path
= merge_message_path()
543 while merge_msg_path
:
544 os
.unlink(merge_msg_path
)
545 merge_msg_path
= merge_message_path()
548 def merge_message(self
):
549 """Return a merge message for FETCH_HEAD."""
550 return git
.fmt_merge_msg('--file', git
.git_path('FETCH_HEAD'))