models: fix a bug from the run_cmd/GitPython refactor
[git-cola.git] / ugit / models.py
blob6f236830f412a4ceeabdbef387539190e1abb17f
1 import os
2 import sys
3 import re
4 import time
5 import subprocess
6 from cStringIO import StringIO
8 # GitPython http://gitorious.org/projects/git-python
9 import git
11 from ugit import utils
12 from ugit import model
14 #+-------------------------------------------------------------------------
15 #+ A regex for matching the output of git(log|rev-list) --pretty=oneline
16 REV_LIST_REGEX = re.compile('([0-9a-f]+)\W(.*)')
18 #+-------------------------------------------------------------------------
19 # List of functions available directly through model.command_name()
20 GIT_COMMANDS = """
21 am annotate apply archive archive_recursive
22 bisect blame branch bundle
23 checkout checkout_index cherry cherry_pick citool
24 clean commit config count_objects
25 describe diff
26 fast_export fetch filter_branch format_patch fsck
27 gc get_tar_commit_id grep gui
28 hard_repack imap_send init instaweb
29 log lost_found ls_files ls_remote ls_tree
30 merge mergetool mv name_rev pull push
31 read_tree rebase relink remote repack
32 request_pull reset revert rev_list rm
33 send_email shortlog show show_branch
34 show_ref stash status submodule svn
35 tag var verify_pack whatchanged
36 """.split()
38 class Model(model.Model):
39 """Provides a friendly wrapper for doing commit git operations."""
41 def init(self):
42 """Reads git repository settings and sets several methods
43 so that they refer to the git module. This object is
44 encapsulates ugit's interaction with git."""
46 # chdir to the root of the git tree.
47 # This keeps paths relative.
48 self.git = git.Git()
49 os.chdir( self.git.git_dir )
51 # Read git config
52 self.__init_config_data()
54 # Import all git commands from git.py
55 for cmd in GIT_COMMANDS:
56 setattr(self, cmd, getattr(self.git, cmd))
58 self.create(
59 #####################################################
60 # Used in various places
61 currentbranch = '',
62 remotes = [],
63 remotename = '',
64 local_branch = '',
65 remote_branch = '',
66 search_text = '',
67 git_version = self.git.version(),
69 #####################################################
70 # Used primarily by the main UI
71 project = os.path.basename(os.getcwd()),
72 commitmsg = '',
73 modified = [],
74 staged = [],
75 unstaged = [],
76 untracked = [],
77 window_geom = utils.parse_geom(
78 self.get_global_ugit_geometry()),
80 #####################################################
81 # Used by the create branch dialog
82 revision = '',
83 local_branches = [],
84 remote_branches = [],
85 tags = [],
87 #####################################################
88 # Used by the commit/repo browser
89 directory = '',
90 revisions = [],
91 summaries = [],
93 # These are parallel lists
94 types = [],
95 sha1s = [],
96 names = [],
98 # All items below here are re-calculated in
99 # init_browser_data()
100 directories = [],
101 directory_entries = {},
103 # These are also parallel lists
104 subtree_types = [],
105 subtree_sha1s = [],
106 subtree_names = [],
109 def __init_config_data(self):
110 """Reads git config --list and creates parameters
111 for each setting."""
112 # These parameters are saved in .gitconfig,
113 # so ideally these should be as short as possible.
115 # config items that are controllable globally
116 # and per-repository
117 self.__local_and_global_defaults = {
118 'user_name': '',
119 'user_email': '',
120 'merge_summary': False,
121 'merge_diffstat': True,
122 'merge_verbosity': 2,
123 'gui_diffcontext': 3,
124 'gui_pruneduringfetch': False,
126 # config items that are purely git config --global settings
127 self.__global_defaults = {
128 'ugit_geometry':'',
129 'ugit_fontui': '',
130 'ugit_fontui_size':12,
131 'ugit_fontdiff': '',
132 'ugit_fontdiff_size':12,
133 'ugit_historybrowser': 'gitk',
134 'ugit_savewindowsettings': False,
135 'ugit_saveatexit': False,
138 local_dict = self.config_dict(local=True)
139 global_dict = self.config_dict(local=False)
141 for k,v in local_dict.iteritems():
142 self.set_param('local_'+k, v)
143 for k,v in global_dict.iteritems():
144 self.set_param('global_'+k, v)
145 if k not in local_dict:
146 local_dict[k]=v
147 self.set_param('local_'+k, v)
149 # Bootstrap the internal font*_size variables
150 for param in ('global_ugit_fontui', 'global_ugit_fontdiff'):
151 if hasattr(self, param):
152 font = self.get_param(param)
153 if font:
154 size = int(font.split(',')[1])
155 self.set_param(param+'_size', size)
156 param = param[len('global_'):]
157 global_dict[param] = font
158 global_dict[param+'_size'] = size
160 # Load defaults for all undefined items
161 local_and_global_defaults = self.__local_and_global_defaults
162 for k,v in local_and_global_defaults.iteritems():
163 if k not in local_dict:
164 self.set_param('local_'+k, v)
165 if k not in global_dict:
166 self.set_param('global_'+k, v)
168 global_defaults = self.__global_defaults
169 for k,v in global_defaults.iteritems():
170 if k not in global_dict:
171 self.set_param('global_'+k, v)
173 # Load the diff context
174 self.diff_context = self.local_gui_diffcontext
176 def branch_list(self, remote=False):
177 branches = map(lambda x: x.lstrip('* '),
178 self.git.branch(r=remote).splitlines())
179 if remote:
180 remotes = []
181 for branch in branches:
182 if branch.endswith('/HEAD'):
183 continue
184 remotes.append(branch)
185 return remotes
186 return branches
188 def get_config_params(self):
189 params = []
190 params.extend(map(lambda x: 'local_' + x,
191 self.__local_and_global_defaults.keys()))
192 params.extend(map(lambda x: 'global_' + x,
193 self.__local_and_global_defaults.keys()))
194 params.extend(map(lambda x: 'global_' + x,
195 self.__global_defaults.keys()))
196 return params
198 def save_config_param(self, param):
199 if param not in self.get_config_params():
200 return
201 value = self.get_param(param)
202 if param == 'local_gui_diffcontext':
203 self.diff_context = value
204 if param.startswith('local_'):
205 param = param[len('local_'):]
206 is_local = True
207 elif param.startswith('global_'):
208 param = param[len('global_'):]
209 is_local = False
210 else:
211 raise Exception("Invalid param '%s' passed to " % param
212 + "save_config_param()")
213 param = param.replace('_','.') # model -> git
214 return self.config_set(param, value, local=is_local)
216 def init_browser_data(self):
217 '''This scans over self.(names, sha1s, types) to generate
218 directories, directory_entries, and subtree_*'''
220 # Collect data for the model
221 if not self.get_currentbranch(): return
223 self.subtree_types = []
224 self.subtree_sha1s = []
225 self.subtree_names = []
226 self.directories = []
227 self.directory_entries = {}
229 # Lookup the tree info
230 tree_info = self.parse_ls_tree(self.get_currentbranch())
232 self.set_types(map( lambda(x): x[1], tree_info ))
233 self.set_sha1s(map( lambda(x): x[2], tree_info ))
234 self.set_names(map( lambda(x): x[3], tree_info ))
236 if self.directory: self.directories.append('..')
238 dir_entries = self.directory_entries
239 dir_regex = re.compile('([^/]+)/')
240 dirs_seen = {}
241 subdirs_seen = {}
243 for idx, name in enumerate(self.names):
245 if not name.startswith(self.directory): continue
246 name = name[ len(self.directory): ]
248 if name.count('/'):
249 # This is a directory...
250 match = dir_regex.match(name)
251 if not match: continue
253 dirent = match.group(1) + '/'
254 if dirent not in self.directory_entries:
255 self.directory_entries[dirent] = []
257 if dirent not in dirs_seen:
258 dirs_seen[dirent] = True
259 self.directories.append(dirent)
261 entry = name.replace(dirent, '')
262 entry_match = dir_regex.match(entry)
263 if entry_match:
264 subdir = entry_match.group(1) + '/'
265 if subdir in subdirs_seen: continue
266 subdirs_seen[subdir] = True
267 dir_entries[dirent].append(subdir)
268 else:
269 dir_entries[dirent].append(entry)
270 else:
271 self.subtree_types.append(self.types[idx])
272 self.subtree_sha1s.append(self.sha1s[idx])
273 self.subtree_names.append(name)
275 def add_or_remove(self, *to_process):
276 """Invokes 'git add' to index the filenames in to_process that exist
277 and 'git rm' for those that do not exist."""
279 if not to_process:
280 return 'No files to add or remove.'
282 to_add = []
283 to_remove = []
285 for filename in to_process:
286 if os.path.exists(filename):
287 to_add.append(filename)
289 output = self.git.add(verbose=True, *to_add)
291 if len(to_add) == len(to_process):
292 # to_process only contained unremoved files --
293 # short-circuit the removal checks
294 return output
296 # Process files to remote
297 for filename in to_process:
298 if not os.path.exists(filename):
299 to_remove.append(filename)
300 output + '\n\n' + self.git.rm(*to_remove)
302 def get_history_browser(self):
303 return self.global_ugit_historybrowser
305 def remember_gui_settings(self):
306 return self.global_ugit_savewindowsettings
308 def save_at_exit(self):
309 return self.global_ugit_saveatexit
311 def get_tree_node(self, idx):
312 return (self.get_types()[idx],
313 self.get_sha1s()[idx],
314 self.get_names()[idx] )
316 def get_subtree_node(self, idx):
317 return (self.get_subtree_types()[idx],
318 self.get_subtree_sha1s()[idx],
319 self.get_subtree_names()[idx] )
321 def get_all_branches(self):
322 return (self.get_local_branches() + self.get_remote_branches())
324 def set_remote(self, remote):
325 if not remote: return
326 self.set_param('remote', remote)
327 branches = utils.grep( '%s/\S+$' % remote,
328 self.branch_list(remote=True), squash=False)
329 self.set_remote_branches(branches)
331 def add_signoff(self,*rest):
332 '''Adds a standard Signed-off by: tag to the end
333 of the current commit message.'''
335 msg = self.get_commitmsg()
336 signoff =('\n\nSigned-off-by: %s <%s>\n' % (
337 self.get_local_user_name(),
338 self.get_local_user_email()))
340 if signoff not in msg:
341 self.set_commitmsg(msg + signoff)
343 def apply_diff(self, filename):
344 return self.git.apply(filename, index=True, cached=True)
346 def load_commitmsg(self, path):
347 file = open(path, 'r')
348 contents = file.read()
349 file.close()
350 self.set_commitmsg(contents)
352 def get_prev_commitmsg(self,*rest):
353 '''Queries git for the latest commit message and sets it in
354 self.commitmsg.'''
355 commit_msg = []
356 commit_lines = self.git.show('HEAD').split('\n')
357 for idx, msg in enumerate(commit_lines):
358 if idx < 4: continue
359 msg = msg.lstrip()
360 if msg.startswith('diff --git'):
361 commit_msg.pop()
362 break
363 commit_msg.append(msg)
364 self.set_commitmsg('\n'.join(commit_msg).rstrip())
366 def update_status(self):
367 # This allows us to defer notification until the
368 # we finish processing data
369 notify_enabled = self.get_notify()
370 self.set_notify(False)
372 # Reset the staged and unstaged model lists
373 # NOTE: the model's unstaged list is used to
374 # hold both modified and untracked files.
375 self.staged = []
376 self.modified = []
377 self.untracked = []
379 # Read git status items
380 ( staged_items,
381 modified_items,
382 untracked_items ) = self.parse_status()
384 # Gather items to be committed
385 for staged in staged_items:
386 if staged not in self.get_staged():
387 self.add_staged(staged)
389 # Gather unindexed items
390 for modified in modified_items:
391 if modified not in self.get_modified():
392 self.add_modified(modified)
394 # Gather untracked items
395 for untracked in untracked_items:
396 if untracked not in self.get_untracked():
397 self.add_untracked(untracked)
399 self.set_currentbranch(self.current_branch())
400 self.set_unstaged(self.get_modified() + self.get_untracked())
401 self.set_remotes(self.git.remote().splitlines())
402 self.set_remote_branches(self.branch_list(remote=True))
403 self.set_local_branches(self.branch_list(remote=False))
404 self.set_tags(self.git.tag().splitlines())
405 self.set_revision('')
406 self.set_local_branch('')
407 self.set_remote_branch('')
408 # Re-enable notifications and emit changes
409 self.set_notify(notify_enabled)
410 self.notify_observers('staged','unstaged')
412 def delete_branch(self, branch):
413 return self.git.branch(branch, D=True)
415 def get_revision_sha1(self, idx):
416 return self.get_revisions()[idx]
418 def apply_font_size(self, param, default):
419 old_font = self.get_param(param)
420 if not old_font:
421 old_font = default
423 size = self.get_param(param+'_size')
424 props = old_font.split(',')
425 props[1] = str(size)
426 new_font = ','.join(props)
428 self.set_param(param, new_font)
430 def read_font_size(self, param, new_font):
431 new_size = int(new_font.split(',')[1])
432 self.set_param(param, new_size)
434 def get_commit_diff(self, sha1):
435 commit = self.git.show(sha1)
436 first_newline = commit.index('\n')
437 if commit[first_newline+1:].startswith('Merge:'):
438 return (commit
439 + '\n\n'
440 + self.diff_helper(
441 commit=sha1,
442 cached=False,
443 suppress_header=False,
446 else:
447 return commit
449 def get_diff_and_status(self, idx, staged=True):
450 if staged:
451 filename = self.get_staged()[idx]
452 if os.path.exists(filename):
453 status = 'Staged for commit'
454 else:
455 status = 'Staged for removal'
456 diff = self.diff_helper(
457 filename=filename,
458 cached=True,
460 else:
461 filename = self.get_unstaged()[idx]
462 if os.path.isdir(filename):
463 status = 'Untracked directory'
464 diff = '\n'.join(os.listdir(filename))
465 elif filename in self.get_modified():
466 status = 'Modified, not staged'
467 diff = self.diff_helper(
468 filename=filename,
469 cached=False,
471 else:
472 status = 'Untracked, not staged'
474 file_type = utils.run_cmd('file', '-b', filename)
475 if 'binary' in file_type or 'data' in file_type:
476 diff = utils.run_cmd('hexdump', '-C', filename)
477 else:
478 if os.path.exists(filename):
479 file = open(filename, 'r')
480 diff = file.read()
481 file.close()
482 else:
483 diff = ''
484 return diff, status
486 def stage_modified(self):
487 output = self.git.add(self.get_modified())
488 self.update_status()
489 return output
491 def stage_untracked(self):
492 output = self.git.add(self.get_untracked())
493 self.update_status()
494 return output
496 def reset(self, *items):
497 output = self.git.reset('--', *items)
498 self.update_status()
499 return output
501 def unstage_all(self):
502 self.git.reset('--', *self.get_staged())
503 self.update_status()
505 def save_gui_settings(self):
506 self.config_set('ugit.geometry', utils.get_geom(), local=False)
508 def config_set(self, key=None, value=None, local=True):
509 if key and value is not None:
510 # git config category.key value
511 strval = str(value)
512 if type(value) is bool:
513 # git uses "true" and "false"
514 strval = strval.lower()
515 if local:
516 argv = [ key, strval ]
517 else:
518 argv = [ '--global', key, strval ]
519 return self.git.config(*argv)
520 else:
521 msg = "oops in config_set(key=%s,value=%s,local=%s"
522 raise Exception(msg % (key, value, local))
524 def config_dict(self, local=True):
525 """parses the lines from git config --list into a dictionary"""
527 kwargs = {
528 'list': True,
529 'global': not local,
531 config_lines = self.git.config(**kwargs).splitlines()
532 newdict = {}
533 for line in config_lines:
534 k, v = line.split('=', 1)
535 k = k.replace('.','_') # git -> model
536 if v == 'true' or v == 'false':
537 v = bool(eval(v.title()))
538 try:
539 v = int(eval(v))
540 except:
541 pass
542 newdict[k]=v
543 return newdict
545 def commit_with_msg(self, msg, amend=False):
546 """Creates a git commit."""
548 if not msg.endswith('\n'):
549 msg += '\n'
550 # Sure, this is a potential "security risk," but if someone
551 # is trying to intercept/re-write commit messages on your system,
552 # then you probably have bigger problems to worry about.
553 tmpfile = self.get_tmp_filename()
555 # Create the commit message file
556 file = open(tmpfile, 'w')
557 file.write(msg)
558 file.close()
560 # Run 'git commit'
561 output = self.git.commit(F=tmpfile, amend=amend)
562 os.unlink(tmpfile)
564 return ('git commit -F %s --amend %s\n\n%s'
565 % ( tmpfile, amend, output ))
568 def diffindex(self):
569 return self.git.diff(
570 unified=self.diff_context,
571 stat=True,
572 cached=True
575 def get_tmp_filename(self):
576 # Allow TMPDIR/TMP with a fallback to /tmp
577 env = os.environ
578 basename = '.git.%s.%s' % ( os.getpid(), time.time() )
579 tmpdir = env.get('TMP', env.get('TMPDIR', '/tmp'))
580 return os.path.join( tmpdir, basename )
582 def log_helper(self, all=False):
583 """Returns a pair of parallel arrays listing the revision sha1's
584 and commit summaries."""
585 revs = []
586 summaries = []
587 regex = REV_LIST_REGEX
588 output = self.git.log(pretty='oneline', all=all)
589 for line in output.splitlines():
590 match = regex.match(line)
591 if match:
592 revs.append(match.group(1))
593 summaries.append(match.group(2))
594 return( revs, summaries )
596 def parse_rev_list(self, raw_revs):
597 revs = []
598 for line in raw_revs.splitlines():
599 match = REV_LIST_REGEX.match(line)
600 if match:
601 rev_id = match.group(1)
602 summary = match.group(2)
603 revs.append((rev_id, summary,) )
604 return revs
606 def rev_list_range(self, start, end):
607 range = '%s..%s' % ( start, end )
608 raw_revs = self.git.rev_list(range, pretty='oneline')
609 return self.parse_rev_list(raw_revs)
611 def diff_helper(self,
612 commit=None,
613 filename=None,
614 color=False,
615 cached=True,
616 with_diff_header=False,
617 suppress_header=True,
618 reverse=False):
619 "Invokes git diff on a filepath."
621 argv = []
622 if commit:
623 argv.append('%s^..%s' % (commit, commit))
625 if filename:
626 argv.append('--')
627 if type(filename) is list:
628 argv.extend(filename)
629 else:
630 argv.append(filename)
632 diff = self.git.diff(
633 R=reverse,
634 color=color,
635 cached=cached,
636 patch_with_raw=True,
637 unified=self.diff_context,
638 *argv
639 ).splitlines()
641 output = StringIO()
642 start = False
643 del_tag = 'deleted file mode '
645 headers = []
646 deleted = cached and not os.path.exists(filename)
647 for line in diff:
648 if not start and '@@ ' in line and ' @@' in line:
649 start = True
650 if start or(deleted and del_tag in line):
651 output.write(line + '\n')
652 else:
653 if with_diff_header:
654 headers.append(line)
655 elif not suppress_header:
656 output.write(line + '\n')
657 result = output.getvalue()
658 output.close()
659 if with_diff_header:
660 return('\n'.join(headers), result)
661 else:
662 return result
664 def git_repo_path(self, *subpaths):
665 paths = [ self.git.rev_parse(git_dir=True) ]
666 paths.extend(subpaths)
667 return os.path.realpath(os.path.join(*paths))
669 def get_merge_message_path(self):
670 for file in ('MERGE_MSG', 'SQUASH_MSG'):
671 path = self.git_repo_path(file)
672 if os.path.exists(path):
673 return path
674 return None
676 def get_merge_message(self):
677 return self.git.fmt_merge_msg(
678 '--file', self.git_repo_path('FETCH_HEAD')
681 def abort_merge(self):
682 # Reset the worktree
683 output = self.git.read_tree("HEAD", reset=True, u=True, v=True)
684 # remove MERGE_HEAD
685 merge_head = self.git_repo_path('MERGE_HEAD')
686 if os.path.exists(merge_head):
687 os.unlink(merge_head)
688 # remove MERGE_MESSAGE, etc.
689 merge_msg_path = self.get_merge_message_path()
690 while merge_msg_path:
691 os.unlink(merge_msg_path)
692 merge_msg_path = self.get_merge_message_path()
695 def parse_status(self):
696 """RETURNS: A tuple of staged, unstaged and untracked file lists.
698 def eval_path(path):
699 """handles quoted paths."""
700 if path.startswith('"') and path.endswith('"'):
701 return eval(path)
702 else:
703 return path
705 MODIFIED_TAG = '# Changed but not updated:'
706 UNTRACKED_TAG = '# Untracked files:'
707 RGX_RENAMED = re.compile(
708 '(#\trenamed:\s+)'
709 '(.*?)\s->\s(.*)'
711 RGX_MODIFIED = re.compile(
712 '(#\tmodified:\s+'
713 '|#\tnew file:\s+'
714 '|#\tdeleted:\s+)'
716 staged = []
717 unstaged = []
718 untracked = []
720 STAGED_MODE = 0
721 UNSTAGED_MODE = 1
722 UNTRACKED_MODE = 2
724 current_dest = staged
725 mode = STAGED_MODE
727 for status_line in self.git.status().splitlines():
728 if status_line == MODIFIED_TAG:
729 mode = UNSTAGED_MODE
730 current_dest = unstaged
731 continue
732 elif status_line == UNTRACKED_TAG:
733 mode = UNTRACKED_MODE
734 current_dest = untracked
735 continue
736 # Staged/unstaged modified/renamed/deleted files
737 if mode is STAGED_MODE or mode is UNSTAGED_MODE:
738 match = RGX_MODIFIED.match(status_line)
739 if match:
740 tag = match.group(0)
741 filename = status_line.replace(tag, '')
742 current_dest.append(eval_path(filename))
743 continue
744 match = RGX_RENAMED.match(status_line)
745 if match:
746 oldname = match.group(2)
747 newname = match.group(3)
748 current_dest.append(eval_path(oldname))
749 current_dest.append(eval_path(newname))
750 continue
751 # Untracked files
752 elif mode is UNTRACKED_MODE:
753 if status_line.startswith('#\t'):
754 current_dest.append(eval_path(status_line[2:]))
756 return( staged, unstaged, untracked )
758 def reset_helper(self, *args, **kwargs):
759 return self.git.reset('--', *args, **kwargs)
761 def remote_url(self, name):
762 return self.git.config('remote.%s.url' % name, get=True)
764 def push_helper(self, remote, local_branch, remote_branch,
765 ffwd=True, tags=False):
766 if ffwd:
767 branch_arg = '%s:%s' % ( local_branch, remote_branch )
768 else:
769 branch_arg = '+%s:%s' % ( local_branch, remote_branch )
770 return self.git.push(remote, branch_arg,
771 with_status=True, tags=tags
774 def parse_ls_tree(self, rev):
775 """Returns a list of(mode, type, sha1, path) tuples."""
776 lines = self.git.ls_tree(rev, r=True).splitlines()
777 output = []
778 regex = re.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
779 for line in lines:
780 match = regex.match(line)
781 if match:
782 mode = match.group(1)
783 objtype = match.group(2)
784 sha1 = match.group(3)
785 filename = match.group(4)
786 output.append((mode, objtype, sha1, filename,) )
787 return output
789 def format_patch_helper(self, to_export, revs, output='patches'):
790 """writes patches named by to_export to the output directory."""
792 outlines = []
794 cur_rev = to_export[0]
795 cur_master_idx = revs.index(cur_rev)
797 patches_to_export = [ [cur_rev] ]
798 patchset_idx = 0
800 for idx, rev in enumerate(to_export[1:]):
801 # Limit the search to the current neighborhood for efficiency
802 master_idx = revs[ cur_master_idx: ].index(rev)
803 master_idx += cur_master_idx
804 if master_idx == cur_master_idx + 1:
805 patches_to_export[ patchset_idx ].append(rev)
806 cur_master_idx += 1
807 continue
808 else:
809 patches_to_export.append([ rev ])
810 cur_master_idx = master_idx
811 patchset_idx += 1
813 for patchset in patches_to_export:
814 revarg = '%s^..%s' % (patchset[0], patchset[-1])
815 outlines.append(
816 self.git.format_patch(
817 revarg,
818 o=output,
819 n=len(patchset) > 1,
820 thread=True,
821 patch_with_stat=True
825 return '\n'.join(outlines)
827 def current_branch(self):
828 """Parses 'git branch' to find the current branch."""
829 branches = self.git.branch().splitlines()
830 for branch in branches:
831 if branch.startswith('* '):
832 return branch.lstrip('* ')
833 return 'Detached HEAD'
835 def create_branch(self, name, base, track=False):
836 """Creates a branch starting from base. Pass track=True
837 to create a remote tracking branch."""
838 return self.git.branch(name, base, track=track)
840 def cherry_pick_list(self, revs, **kwargs):
841 """Cherry-picks each revision into the current branch.
842 Returns a list of command output strings (1 per cherry pick)"""
843 if not revs:
844 return []
845 cherries = []
846 for rev in revs:
847 cherries.append(self.git.cherry_pick(rev, **kwargs))
848 return '\n'.join(cherries)