1 """Provides commands and queries for Git."""
4 from cStringIO
import StringIO
8 from cola
import gitcmd
9 from cola
import errors
10 from cola
import utils
12 git
= gitcmd
.instance()
16 """Return the remote tracked by the current branch."""
17 branch
= current_branch()
18 branchconfig
= 'branch.%s.remote' % branch
20 return model
.local_config(branchconfig
, 'origin')
23 def corresponding_remote_ref():
24 """Return the remote branch tracked by the current branch."""
25 remote
= default_remote()
26 branch
= current_branch()
27 best_match
= '%s/%s' % (remote
, branch
)
28 remote_branches
= branch_list(remote
=True)
29 if not remote_branches
:
31 for rb
in remote_branches
:
35 return remote_branches
[0]
39 def diff_filenames(arg
):
40 """Return a list of filenames that have been modified"""
41 diff_zstr
= git
.diff(arg
, name_only
=True, z
=True).rstrip('\0')
42 return [core
.decode(f
) for f
in diff_zstr
.split('\0') if f
]
46 """Return the names of all files in the repository"""
47 return [core
.decode(f
)
48 for f
in git
.ls_files(z
=True)
49 .strip('\0').split('\0') if f
]
52 class _current_branch
:
53 """Stat cache for current_branch()."""
59 """Find the current branch."""
61 head
= os
.path
.abspath(model
.git_repo_path('HEAD'))
65 if _current_branch
.st_mtime
== st
.st_mtime
:
66 return _current_branch
.value
70 # Handle legacy .git/HEAD symlinks
71 if os
.path
.islink(head
):
72 refs_heads
= os
.path
.realpath(model
.git_repo_path('refs', 'heads'))
73 path
= os
.path
.abspath(head
).replace('\\', '/')
74 if path
.startswith(refs_heads
+ '/'):
75 value
= path
[len(refs_heads
)+1:]
76 _current_branch
.value
= value
77 _current_branch
.st_mtime
= st
.st_mtime
81 # Handle the common .git/HEAD "ref: refs/heads/master" file
82 if os
.path
.isfile(head
):
83 value
= utils
.slurp(head
).strip()
84 ref_prefix
= 'ref: refs/heads/'
85 if value
.startswith(ref_prefix
):
86 value
= value
[len(ref_prefix
):]
88 _current_branch
.st_mtime
= st
.st_mtime
89 _current_branch
.value
= value
92 # This shouldn't happen
96 def branch_list(remote
=False):
98 Return a list of local or remote branches
100 This explicitly removes HEAD from the list of remote branches.
104 return for_each_ref_basename('refs/remotes/')
106 return for_each_ref_basename('refs/heads/')
109 def for_each_ref_basename(refs
):
110 """Return refs starting with 'refs'."""
111 output
= git
.for_each_ref(refs
, format
='%(refname)').splitlines()
112 non_heads
= filter(lambda x
: not x
.endswith('/HEAD'), output
)
113 return map(lambda x
: x
[len(refs
):], non_heads
)
116 def tracked_branch(branch
=None):
117 """Return the remote branch associated with 'branch'."""
119 branch
= current_branch()
121 branch_remote
= 'local_branch_%s_remote' % branch
122 if not model
.has_param(branch_remote
):
124 remote
= model
.param(branch_remote
)
127 branch_merge
= 'local_branch_%s_merge' % branch
128 if not model
.has_param(branch_merge
):
130 ref
= model
.param(branch_merge
)
131 refs_heads
= 'refs/heads/'
132 if ref
.startswith(refs_heads
):
133 return remote
+ '/' + ref
[len(refs_heads
):]
137 def untracked_files():
138 """Returns a sorted list of all files, including untracked files."""
139 ls_files
= git
.ls_files(z
=True,
141 exclude_standard
=True)
142 return [core
.decode(f
) for f
in ls_files
.split('\0') if f
]
146 """Return a list of tags."""
147 tags
= for_each_ref_basename('refs/tags/')
152 def commit_diff(sha1
):
153 commit
= git
.show(sha1
)
154 first_newline
= commit
.index('\n')
155 if commit
[first_newline
+1:].startswith('Merge:'):
156 return (core
.decode(commit
) + '\n\n' +
157 core
.decode(diff_helper(commit
=sha1
,
159 suppress_header
=False)))
161 return core
.decode(commit
)
164 def diff_helper(commit
=None,
170 with_diff_header
=False,
171 suppress_header
=True,
173 "Invokes git diff on a filepath."
175 ref
, endref
= commit
+'^', commit
178 argv
.append('%s..%s' % (ref
, endref
))
180 for r
in ref
.strip().split():
187 if type(filename
) is list:
188 argv
.extend(filename
)
190 argv
.append(filename
)
193 del_tag
= 'deleted file mode '
196 deleted
= cached
and not os
.path
.exists(core
.encode(filename
))
198 diffoutput
= git
.diff(R
=reverse
,
202 # TODO factor our config object
203 unified
=cola
.model().diff_context
,
204 with_raw_output
=True,
209 if diffoutput
.startswith('fatal:'):
217 diff
= diffoutput
.split('\n')
218 for line
in map(core
.decode
, diff
):
219 if not start
and '@@' == line
[:2] and '@@' in line
[2:]:
221 if start
or (deleted
and del_tag
in line
):
222 output
.write(core
.encode(line
) + '\n')
225 headers
.append(core
.encode(line
))
226 elif not suppress_header
:
227 output
.write(core
.encode(line
) + '\n')
229 result
= core
.decode(output
.getvalue())
233 return('\n'.join(headers
), result
)
238 def format_patchsets(to_export
, revs
, output
='patches'):
240 Group contiguous revision selection into patchsets
242 Exists to handle multi-selection.
243 Multiple disparate ranges in the revision selection
244 are grouped into continuous lists.
250 cur_rev
= to_export
[0]
251 cur_master_idx
= revs
.index(cur_rev
)
253 patches_to_export
= [[cur_rev
]]
256 # Group the patches into continuous sets
257 for idx
, rev
in enumerate(to_export
[1:]):
258 # Limit the search to the current neighborhood for efficiency
259 master_idx
= revs
[cur_master_idx
:].index(rev
)
260 master_idx
+= cur_master_idx
261 if master_idx
== cur_master_idx
+ 1:
262 patches_to_export
[ patchset_idx
].append(rev
)
266 patches_to_export
.append([ rev
])
267 cur_master_idx
= master_idx
270 # Export each patchsets
272 for patchset
in patches_to_export
:
273 newstatus
, out
= export_patchset(patchset
[0],
278 patch_with_stat
=True)
282 return (status
, '\n'.join(outlines
))
285 def export_patchset(start
, end
, output
='patches', **kwargs
):
286 """Export patches from start^ to end."""
287 return git
.format_patch('-o', output
, start
+ '^..' + end
,
293 def unstage_paths(paths
):
294 """Unstages paths from the staging area and notifies observers."""
295 return reset_helper(paths
)
298 def reset_helper(args
):
299 """Removes files from the index
301 This handles the git init case, which is why it's not
302 just 'git reset name'. For the git init case this falls
303 back to 'git rm --cached'.
306 # fake the status because 'git reset' returns 1
307 # regardless of success/failure
309 output
= git
.reset('--', with_stderr
=True, *set(args
))
310 # handle git init: we have to use 'git rm --cached'
311 # detect this condition by checking if the file is still staged
312 state
= worktree_state()
314 rmargs
= [a
for a
in args
if a
in staged
]
316 return (status
, output
)
317 output
+= git
.rm('--', cached
=True, with_stderr
=True, *rmargs
)
319 return (status
, output
)
323 def worktree_state(head
='HEAD', staged_only
=False):
324 """Return a tuple of files in various states of being
326 Can be staged, unstaged, untracked, unmerged, or changed
330 git
.update_index(refresh
=True)
332 return _branch_status(head
)
336 upstream_changed_set
= set()
338 (staged
, modified
, unmerged
, untracked
, upstream_changed
) = (
341 output
= git
.diff_index(head
,
344 if output
.startswith('fatal:'):
345 raise errors
.GitInitError('git init')
346 for line
in output
.splitlines():
347 rest
, name
= line
.split('\t', 1)
349 name
= eval_path(name
)
353 # This file will also show up as 'M' without --cached
354 # so by default don't consider it modified unless
355 # it's truly modified
356 modified_set
.add(name
)
357 if not staged_only
and is_modified(name
):
358 modified
.append(name
)
365 modified_set
.add(name
)
367 unmerged
.append(name
)
368 modified_set
.add(name
)
370 except errors
.GitInitError
:
372 staged
.extend(all_files())
375 output
= git
.diff_index(head
, with_stderr
=True)
376 if output
.startswith('fatal:'):
377 raise errors
.GitInitError('git init')
378 for line
in output
.splitlines():
379 info
, name
= line
.split('\t', 1)
380 status
= info
.split()[-1]
381 if status
== 'M' or status
== 'D':
382 name
= eval_path(name
)
383 if name
not in modified_set
:
384 modified
.append(name
)
386 name
= eval_path(name
)
387 # newly-added yet modified
388 if (name
not in modified_set
and not staged_only
and
390 modified
.append(name
)
392 except errors
.GitInitError
:
394 ls_files
= git
.ls_files(modified
=True, z
=True)[:-1].split('\0')
395 modified
.extend(map(core
.decode
, [f
for f
in ls_files
if f
]))
397 untracked
.extend(untracked_files())
399 # Look for upstream modified files if this is a tracking branch
400 tracked
= tracked_branch()
403 diff_expr
= merge_base_to(tracked
)
404 output
= git
.diff(diff_expr
, name_only
=True, z
=True)
406 if output
.startswith('fatal:'):
407 raise errors
.GitInitError('git init')
409 for name
in [n
for n
in output
.split('\0') if n
]:
410 name
= core
.decode(name
)
411 upstream_changed
.append(name
)
412 upstream_changed_set
.add(name
)
414 except errors
.GitInitError
:
423 upstream_changed
.sort()
425 return (staged
, modified
, unmerged
, untracked
, upstream_changed
)
428 def _branch_status(branch
):
430 Returns a tuple of staged, unstaged, untracked, and unmerged files
432 This shows only the changes that were introduced in branch
435 status
, output
= git
.diff(name_only
=True,
439 *branch
.strip().split())
441 return ([], [], [], [], [])
443 staged
= map(core
.decode
, [n
for n
in output
.split('\0') if n
])
444 return (staged
, [], [], [], staged
)
447 def merge_base_to(ref
):
448 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
449 base
= git
.merge_base('HEAD', ref
)
450 return '%s..%s' % (base
, ref
)
453 def is_modified(name
):
454 status
, out
= git
.diff('--', name
,
462 """handles quoted paths."""
463 if path
.startswith('"') and path
.endswith('"'):
464 return core
.decode(eval(path
))
469 def renamed_files(start
, end
):
470 difflines
= git
.diff('%s..%s' % (start
, end
),
473 return [eval_path(r
[12:].rstrip())
474 for r
in difflines
if r
.startswith('rename from ')]
477 def changed_files(start
, end
):
478 zfiles_str
= git
.diff('%s..%s' % (start
, end
),
479 name_only
=True, z
=True).strip('\0')
480 return [core
.decode(enc
) for enc
in zfiles_str
.split('\0') if enc
]
483 def parse_ls_tree(rev
):
484 """Return a list of(mode, type, sha1, path) tuples."""
485 lines
= git
.ls_tree(rev
, r
=True).splitlines()
487 regex
= re
.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
489 match
= regex
.match(line
)
491 mode
= match
.group(1)
492 objtype
= match
.group(2)
493 sha1
= match
.group(3)
494 filename
= match
.group(4)
495 output
.append((mode
, objtype
, sha1
, filename
,) )