models: Add a BrowserModel for use by the repobrowser
[git-cola.git] / cola / gitcmds.py
blob237f3186d9766db11b80e0dfc4c3c7819aa81c16
1 """Provides commands and queries for Git."""
2 import os
3 import re
4 from cStringIO import StringIO
6 import cola
7 from cola import core
8 from cola import gitcmd
9 from cola import errors
10 from cola import utils
12 git = gitcmd.instance()
15 def default_remote():
16 """Return the remote tracked by the current branch."""
17 branch = current_branch()
18 branchconfig = 'branch.%s.remote' % branch
19 model = cola.model()
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:
30 return remote
31 for rb in remote_branches:
32 if rb == best_match:
33 return rb
34 if remote_branches:
35 return remote_branches[0]
36 return remote
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]
45 def all_files():
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()."""
54 st_mtime = 0
55 value = None
58 def current_branch():
59 """Find the current branch."""
60 model = cola.model()
61 head = os.path.abspath(model.git_repo_path('HEAD'))
63 try:
64 st = os.stat(head)
65 if _current_branch.st_mtime == st.st_mtime:
66 return _current_branch.value
67 except OSError, e:
68 pass
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
78 return value
79 return ''
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
90 return value
92 # This shouldn't happen
93 return ''
96 def branch_list(remote=False):
97 """
98 Return a list of local or remote branches
100 This explicitly removes HEAD from the list of remote branches.
103 if remote:
104 return for_each_ref_basename('refs/remotes/')
105 else:
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'."""
118 if branch is None:
119 branch = current_branch()
120 model = cola.model()
121 branch_remote = 'local_branch_%s_remote' % branch
122 if not model.has_param(branch_remote):
123 return ''
124 remote = model.param(branch_remote)
125 if not remote:
126 return ''
127 branch_merge = 'local_branch_%s_merge' % branch
128 if not model.has_param(branch_merge):
129 return ''
130 ref = model.param(branch_merge)
131 refs_heads = 'refs/heads/'
132 if ref.startswith(refs_heads):
133 return remote + '/' + ref[len(refs_heads):]
134 return ''
137 def untracked_files():
138 """Returns a sorted list of all files, including untracked files."""
139 ls_files = git.ls_files(z=True,
140 others=True,
141 exclude_standard=True)
142 return [core.decode(f) for f in ls_files.split('\0') if f]
145 def tag_list():
146 """Return a list of tags."""
147 tags = for_each_ref_basename('refs/tags/')
148 tags.reverse()
149 return 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,
158 cached=False,
159 suppress_header=False)))
160 else:
161 return core.decode(commit)
164 def diff_helper(commit=None,
165 branch=None,
166 ref=None,
167 endref=None,
168 filename=None,
169 cached=True,
170 with_diff_header=False,
171 suppress_header=True,
172 reverse=False):
173 "Invokes git diff on a filepath."
174 if commit:
175 ref, endref = commit+'^', commit
176 argv = []
177 if ref and endref:
178 argv.append('%s..%s' % (ref, endref))
179 elif ref:
180 for r in ref.strip().split():
181 argv.append(r)
182 elif branch:
183 argv.append(branch)
185 if filename:
186 argv.append('--')
187 if type(filename) is list:
188 argv.extend(filename)
189 else:
190 argv.append(filename)
192 start = False
193 del_tag = 'deleted file mode '
195 headers = []
196 deleted = cached and not os.path.exists(core.encode(filename))
198 diffoutput = git.diff(R=reverse,
199 M=True,
200 no_color=True,
201 cached=cached,
202 # TODO factor our config object
203 unified=cola.model().diff_context,
204 with_raw_output=True,
205 with_stderr=True,
206 *argv)
208 # Handle 'git init'
209 if diffoutput.startswith('fatal:'):
210 if with_diff_header:
211 return ('', '')
212 else:
213 return ''
215 output = StringIO()
217 diff = diffoutput.split('\n')
218 for line in map(core.decode, diff):
219 if not start and '@@' == line[:2] and '@@' in line[2:]:
220 start = True
221 if start or (deleted and del_tag in line):
222 output.write(core.encode(line) + '\n')
223 else:
224 if with_diff_header:
225 headers.append(core.encode(line))
226 elif not suppress_header:
227 output.write(core.encode(line) + '\n')
229 result = core.decode(output.getvalue())
230 output.close()
232 if with_diff_header:
233 return('\n'.join(headers), result)
234 else:
235 return 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.
248 outlines = []
250 cur_rev = to_export[0]
251 cur_master_idx = revs.index(cur_rev)
253 patches_to_export = [[cur_rev]]
254 patchset_idx = 0
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)
263 cur_master_idx += 1
264 continue
265 else:
266 patches_to_export.append([ rev ])
267 cur_master_idx = master_idx
268 patchset_idx += 1
270 # Export each patchsets
271 status = 0
272 for patchset in patches_to_export:
273 newstatus, out = export_patchset(patchset[0],
274 patchset[-1],
275 output='patches',
276 n=len(patchset) > 1,
277 thread=True,
278 patch_with_stat=True)
279 outlines.append(out)
280 if status == 0:
281 status += newstatus
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,
288 with_stderr=True,
289 with_status=True,
290 **kwargs)
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
308 status = 0
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()
313 staged = state[0]
314 rmargs = [a for a in args if a in staged]
315 if not rmargs:
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
327 upstream.
330 git.update_index(refresh=True)
331 if staged_only:
332 return _branch_status(head)
334 staged_set = set()
335 modified_set = set()
336 upstream_changed_set = set()
338 (staged, modified, unmerged, untracked, upstream_changed) = (
339 [], [], [], [], [])
340 try:
341 output = git.diff_index(head,
342 cached=True,
343 with_stderr=True)
344 if output.startswith('fatal:'):
345 raise errors.GitInitError('git init')
346 for line in output.splitlines():
347 rest, name = line.split('\t', 1)
348 status = rest[-1]
349 name = eval_path(name)
350 if status == 'M':
351 staged.append(name)
352 staged_set.add(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)
359 elif status == 'A':
360 staged.append(name)
361 staged_set.add(name)
362 elif status == 'D':
363 staged.append(name)
364 staged_set.add(name)
365 modified_set.add(name)
366 elif status == 'U':
367 unmerged.append(name)
368 modified_set.add(name)
370 except errors.GitInitError:
371 # handle git init
372 staged.extend(all_files())
374 try:
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)
385 elif status == 'A':
386 name = eval_path(name)
387 # newly-added yet modified
388 if (name not in modified_set and not staged_only and
389 is_modified(name)):
390 modified.append(name)
392 except errors.GitInitError:
393 # handle git init
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()
401 if tracked:
402 try:
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:
415 # handle git init
416 pass
418 # Keep stuff sorted
419 staged.sort()
420 modified.sort()
421 unmerged.sort()
422 untracked.sort()
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,
436 M=True, z=True,
437 with_stderr=True,
438 with_status=True,
439 *branch.strip().split())
440 if status != 0:
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,
455 name_only=True,
456 exit_code=True,
457 with_status=True)
458 return status != 0
461 def eval_path(path):
462 """handles quoted paths."""
463 if path.startswith('"') and path.endswith('"'):
464 return core.decode(eval(path))
465 else:
466 return path
469 def renamed_files(start, end):
470 difflines = git.diff('%s..%s' % (start, end),
471 no_color=True,
472 M=True).splitlines()
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()
486 output = []
487 regex = re.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
488 for line in lines:
489 match = regex.match(line)
490 if match:
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,) )
496 return output