1 """Provides commands and queries for Git."""
4 from cStringIO
import StringIO
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
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)
355 return _branch_status(head
)
364 upstream_changed
= []
367 output
= git
.diff_index(head
, cached
=True, with_stderr
=True)
368 if output
.startswith('fatal:'):
369 raise errors
.GitInitError('git init')
370 for line
in output
.splitlines():
371 rest
, name
= line
.split('\t', 1)
373 name
= eval_path(name
)
374 if '160000' in rest
[1:14]:
379 # This file will also show up as 'M' without --cached
380 # so by default don't consider it modified unless
381 # it's truly modified
382 modified_set
.add(name
)
383 if not staged_only
and is_modified(name
):
384 modified
.append(name
)
391 modified_set
.add(name
)
393 unmerged
.append(name
)
394 modified_set
.add(name
)
396 except errors
.GitInitError
:
398 staged
.extend(all_files())
401 output
= git
.diff_index(head
, with_stderr
=True)
402 if output
.startswith('fatal:'):
403 raise errors
.GitInitError('git init')
404 for line
in output
.splitlines():
405 rest
, name
= line
.split('\t', 1)
407 name
= eval_path(name
)
408 if '160000' in rest
[1:13]:
410 if status
== 'M' or status
== 'D':
411 if name
not in modified_set
:
412 modified
.append(name
)
414 # newly-added yet modified
415 if (name
not in modified_set
and not staged_only
and
417 modified
.append(name
)
419 except errors
.GitInitError
:
421 ls_files
= git
.ls_files(modified
=True, z
=True)[:-1].split('\0')
422 modified
.extend(map(core
.decode
, [f
for f
in ls_files
if f
]))
424 untracked
.extend(untracked_files())
426 # Look for upstream modified files if this is a tracking branch
427 tracked
= tracked_branch()
430 diff_expr
= merge_base_to(tracked
)
431 output
= git
.diff(diff_expr
, name_only
=True, z
=True)
433 if output
.startswith('fatal:'):
434 raise errors
.GitInitError('git init')
436 for name
in [n
for n
in output
.split('\0') if n
]:
437 name
= core
.decode(name
)
438 upstream_changed
.append(name
)
440 except errors
.GitInitError
:
449 upstream_changed
.sort()
451 return {'staged': staged
,
452 'modified': modified
,
453 'unmerged': unmerged
,
454 'untracked': untracked
,
455 'upstream_changed': upstream_changed
,
456 'submodules': submodules
}
459 def _branch_status(branch
):
461 Returns a tuple of staged, unstaged, untracked, and unmerged files
463 This shows only the changes that were introduced in branch
466 status
, output
= git
.diff(name_only
=True,
470 *branch
.strip().split())
474 staged
= map(core
.decode
, [n
for n
in output
.split('\0') if n
])
475 return {'staged': staged
,
476 'upstream_changed': staged
}
479 def merge_base_to(ref
):
480 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
481 base
= git
.merge_base('HEAD', ref
)
482 return '%s..%s' % (base
, ref
)
485 def merge_base_parent(branch
):
486 tracked
= tracked_branch(branch
=branch
)
488 return '%s..%s' % (tracked
, branch
)
489 return 'master..%s' % branch
492 def is_modified(name
):
493 status
, out
= git
.diff('--', name
,
501 """handles quoted paths."""
502 if path
.startswith('"') and path
.endswith('"'):
503 return core
.decode(eval(path
))
508 def renamed_files(start
, end
):
509 difflines
= git
.diff('%s..%s' % (start
, end
),
512 return [eval_path(r
[12:].rstrip())
513 for r
in difflines
if r
.startswith('rename from ')]
516 def changed_files(start
, end
):
517 zfiles_str
= git
.diff('%s..%s' % (start
, end
),
518 name_only
=True, z
=True).strip('\0')
519 return [core
.decode(enc
) for enc
in zfiles_str
.split('\0') if enc
]
522 def parse_ls_tree(rev
):
523 """Return a list of(mode, type, sha1, path) tuples."""
524 lines
= git
.ls_tree(rev
, r
=True).splitlines()
526 regex
= re
.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
528 match
= regex
.match(line
)
530 mode
= match
.group(1)
531 objtype
= match
.group(2)
532 sha1
= match
.group(3)
533 filename
= match
.group(4)
534 output
.append((mode
, objtype
, sha1
, filename
,) )
538 # A regex for matching the output of git(log|rev-list) --pretty=oneline
539 REV_LIST_REGEX
= re
.compile('^([0-9a-f]{40}) (.*)$')
541 def parse_rev_list(raw_revs
):
542 """Parse `git log --pretty=online` output into (SHA-1, summary) pairs."""
544 for line
in map(core
.decode
, raw_revs
.splitlines()):
545 match
= REV_LIST_REGEX
.match(line
)
547 rev_id
= match
.group(1)
548 summary
= match
.group(2)
549 revs
.append((rev_id
, summary
,))
553 def log_helper(all
=False, extra_args
=None):
554 """Return parallel arrays containing the SHA-1s and summaries."""
560 output
= git
.log(pretty
='oneline', no_color
=True, all
=all
, *args
)
561 for line
in map(core
.decode
, output
.splitlines()):
562 match
= REV_LIST_REGEX
.match(line
)
564 revs
.append(match
.group(1))
565 summaries
.append(match
.group(2))
566 return (revs
, summaries
)
569 def rev_list_range(start
, end
):
570 """Return a (SHA-1, summary) pairs between start and end."""
571 revrange
= '%s..%s' % (start
, end
)
572 raw_revs
= git
.rev_list(revrange
, pretty
='oneline')
573 return parse_rev_list(raw_revs
)
576 def merge_message_path():
577 """Return the path to .git/MERGE_MSG or .git/SQUASH_MSG."""
578 for basename
in ('MERGE_MSG', 'SQUASH_MSG'):
579 path
= git
.git_path(basename
)
580 if os
.path
.exists(path
):
586 """Abort a merge by reading the tree at HEAD."""
588 git
.read_tree('HEAD', reset
=True, u
=True, v
=True)
590 merge_head
= git
.git_path('MERGE_HEAD')
591 if os
.path
.exists(merge_head
):
592 os
.unlink(merge_head
)
593 # remove MERGE_MESSAGE, etc.
594 merge_msg_path
= merge_message_path()
595 while merge_msg_path
:
596 os
.unlink(merge_msg_path
)
597 merge_msg_path
= merge_message_path()
600 def merge_message(revision
):
601 """Return a merge message for FETCH_HEAD."""
602 fetch_head
= git
.git_path('FETCH_HEAD')
603 if os
.path
.exists(fetch_head
):
604 return git
.fmt_merge_msg('--file', fetch_head
)
605 return "Merge branch '%s'" % revision