1 # Copyright (c) 2008 David Aguilar
2 """This module provides the cola model class.
10 from cStringIO
import StringIO
13 from cola
import utils
14 from cola
import model
15 from cola
.core
import encode
, decode
17 #+-------------------------------------------------------------------------
18 #+ A regex for matching the output of git(log|rev-list) --pretty=oneline
19 REV_LIST_REGEX
= re
.compile('([0-9a-f]+)\W(.*)')
21 class GitCola(git
.Git
):
22 """GitPython throws exceptions by default.
23 We suppress exceptions in favor of return values.
26 git
.Git
.__init
__(self
)
27 self
.load_worktree(os
.getcwd())
29 def load_worktree(self
, path
):
31 self
._work
_tree
= None
34 def get_work_tree(self
):
36 return self
._work
_tree
39 curdir
= self
._git
_dir
43 if self
._is
_git
_dir
(os
.path
.join(curdir
, '.git')):
46 # Handle bare repositories
47 if (len(os
.path
.basename(curdir
)) > 4
48 and curdir
.endswith('.git')):
50 if 'GIT_WORK_TREE' in os
.environ
:
51 self
._work
_tree
= os
.getenv('GIT_WORK_TREE')
52 if not self
._work
_tree
or not os
.path
.isdir(self
._work
_tree
):
54 gitparent
= os
.path
.join(os
.path
.abspath(self
._git
_dir
), '..')
55 self
._work
_tree
= os
.path
.abspath(gitparent
)
56 self
.set_cwd(self
._work
_tree
)
57 return self
._work
_tree
60 return self
._git
_dir
and self
._is
_git
_dir
(self
._git
_dir
)
62 def get_git_dir(self
):
65 if 'GIT_DIR' in os
.environ
:
66 self
._git
_dir
= os
.getenv('GIT_DIR')
68 curpath
= os
.path
.abspath(self
._git
_dir
)
70 curpath
= os
.path
.abspath(os
.getcwd())
71 # Search for a .git directory
73 if self
._is
_git
_dir
(curpath
):
74 self
._git
_dir
= curpath
76 gitpath
= os
.path
.join(curpath
, '.git')
77 if self
._is
_git
_dir
(gitpath
):
78 self
._git
_dir
= gitpath
80 curpath
, dummy
= os
.path
.split(curpath
)
85 def _is_git_dir(self
, d
):
86 """ This is taken from the git setup.c:is_git_directory
89 and os
.path
.isdir(os
.path
.join(d
, 'objects'))
90 and os
.path
.isdir(os
.path
.join(d
, 'refs'))):
91 headref
= os
.path
.join(d
, 'HEAD')
92 return (os
.path
.isfile(headref
)
93 or (os
.path
.islink(headref
)
94 and os
.readlink(headref
).startswith('refs')))
98 """handles quoted paths."""
99 if path
.startswith('"') and path
.endswith('"'):
100 return decode(eval(path
))
104 class Model(model
.Model
):
105 """Provides a friendly wrapper for doing commit git operations."""
108 worktree
= self
.git
.get_work_tree()
109 clone
= model
.Model
.clone(self
)
110 clone
.use_worktree(worktree
)
113 def use_worktree(self
, worktree
):
114 self
.git
.load_worktree(worktree
)
115 is_valid
= self
.git
.is_valid()
117 self
.__init
_config
_data
()
121 """Reads git repository settings and sets several methods
122 so that they refer to the git module. This object
123 encapsulates cola's interaction with git."""
125 # Initialize the git command object
127 self
.partially_staged
= set()
129 self
.fetch_helper
= self
.gen_remote_helper(self
.git
.fetch
)
130 self
.push_helper
= self
.gen_remote_helper(self
.git
.push
)
131 self
.pull_helper
= self
.gen_remote_helper(self
.git
.pull
)
134 #####################################################
135 # Used in various places
143 #####################################################
144 # Used primarily by the main UI
152 #####################################################
153 # Used by the create branch dialog
156 remote_branches
= [],
159 #####################################################
160 # Used by the commit/repo browser
165 # These are parallel lists
170 # All items below here are re-calculated in
171 # init_browser_data()
173 directory_entries
= {},
175 # These are also parallel lists
181 def __init_config_data(self
):
182 """Reads git config --list and creates parameters
184 # These parameters are saved in .gitconfig,
185 # so ideally these should be as short as possible.
187 # config items that are controllable globally
189 self
.__local
_and
_global
_defaults
= {
192 'merge_summary': False,
193 'merge_diffstat': True,
194 'merge_verbosity': 2,
195 'gui_diffcontext': 3,
196 'gui_pruneduringfetch': False,
198 # config items that are purely git config --global settings
199 self
.__global
_defaults
= {
202 'cola_fontuisize': 12,
204 'cola_fontdiffsize': 12,
205 'cola_savewindowsettings': False,
206 'merge_keepbackup': True,
207 'merge_tool': os
.getenv('MERGETOOL', 'xxdiff'),
208 'gui_editor': os
.getenv('EDITOR', 'gvim'),
209 'gui_historybrowser': 'gitk',
212 local_dict
= self
.config_dict(local
=True)
213 global_dict
= self
.config_dict(local
=False)
215 for k
,v
in local_dict
.iteritems():
216 self
.set_param('local_'+k
, v
)
217 for k
,v
in global_dict
.iteritems():
218 self
.set_param('global_'+k
, v
)
219 if k
not in local_dict
:
221 self
.set_param('local_'+k
, v
)
223 # Bootstrap the internal font*size variables
224 for param
in ('global_cola_fontui', 'global_cola_fontdiff'):
226 if hasattr(self
, param
):
227 font
= self
.get_param(param
)
230 size
= int(font
.split(',')[1])
231 self
.set_param(param
+'size', size
)
232 param
= param
[len('global_'):]
233 global_dict
[param
] = font
234 global_dict
[param
+'size'] = size
236 # Load defaults for all undefined items
237 local_and_global_defaults
= self
.__local
_and
_global
_defaults
238 for k
,v
in local_and_global_defaults
.iteritems():
239 if k
not in local_dict
:
240 self
.set_param('local_'+k
, v
)
241 if k
not in global_dict
:
242 self
.set_param('global_'+k
, v
)
244 global_defaults
= self
.__global
_defaults
245 for k
,v
in global_defaults
.iteritems():
246 if k
not in global_dict
:
247 self
.set_param('global_'+k
, v
)
249 # Load the diff context
250 self
.diff_context
= self
.local_gui_diffcontext
252 def get_global_config(self
, key
):
253 return getattr(self
, 'global_'+key
.replace('.', '_'))
255 def get_cola_config(self
, key
):
256 return getattr(self
, 'global_cola_'+key
)
258 def get_gui_config(self
, key
):
259 return getattr(self
, 'global_gui_'+key
)
261 def get_default_remote(self
):
262 branch
= self
.get_currentbranch()
263 branchconfig
= 'local_branch_%s_remote' % branch
264 if branchconfig
in self
.get_param_names():
265 remote
= self
.get_param(branchconfig
)
270 def get_corresponding_remote_ref(self
):
271 remote
= self
.get_default_remote()
272 branch
= self
.get_currentbranch()
273 best_match
= '%s/%s' % (remote
, branch
)
274 remote_branches
= self
.get_remote_branches()
275 if not remote_branches
:
277 for rb
in remote_branches
:
280 return remote_branches
[0]
282 def get_diff_filenames(self
, arg
):
283 diff_zstr
= self
.git
.diff(arg
, name_only
=True, z
=True).rstrip('\0')
284 return [ decode(f
) for f
in diff_zstr
.split('\0') if f
]
286 def branch_list(self
, remote
=False):
287 branches
= map(lambda x
: x
.lstrip('* '),
288 self
.git
.branch(r
=remote
).splitlines())
291 for branch
in branches
:
292 if branch
.endswith('/HEAD'):
294 remotes
.append(branch
)
298 def get_config_params(self
):
300 params
.extend(map(lambda x
: 'local_' + x
,
301 self
.__local
_and
_global
_defaults
.keys()))
302 params
.extend(map(lambda x
: 'global_' + x
,
303 self
.__local
_and
_global
_defaults
.keys()))
304 params
.extend(map(lambda x
: 'global_' + x
,
305 self
.__global
_defaults
.keys()))
306 return [ p
for p
in params
if not p
.endswith('size') ]
308 def save_config_param(self
, param
):
309 if param
not in self
.get_config_params():
311 value
= self
.get_param(param
)
312 if param
== 'local_gui_diffcontext':
313 self
.diff_context
= value
314 if param
.startswith('local_'):
315 param
= param
[len('local_'):]
317 elif param
.startswith('global_'):
318 param
= param
[len('global_'):]
321 raise Exception("Invalid param '%s' passed to " % param
322 +'save_config_param()')
323 param
= param
.replace('_', '.') # model -> git
324 return self
.config_set(param
, value
, local
=is_local
)
326 def init_browser_data(self
):
327 """This scans over self.(names, sha1s, types) to generate
328 directories, directory_entries, and subtree_*"""
330 # Collect data for the model
331 if not self
.get_currentbranch(): return
333 self
.subtree_types
= []
334 self
.subtree_sha1s
= []
335 self
.subtree_names
= []
336 self
.directories
= []
337 self
.directory_entries
= {}
339 # Lookup the tree info
340 tree_info
= self
.parse_ls_tree(self
.get_currentbranch())
342 self
.set_types(map( lambda(x
): x
[1], tree_info
))
343 self
.set_sha1s(map( lambda(x
): x
[2], tree_info
))
344 self
.set_names(map( lambda(x
): x
[3], tree_info
))
346 if self
.directory
: self
.directories
.append('..')
348 dir_entries
= self
.directory_entries
349 dir_regex
= re
.compile('([^/]+)/')
353 for idx
, name
in enumerate(self
.names
):
354 if not name
.startswith(self
.directory
):
356 name
= name
[ len(self
.directory
): ]
358 # This is a directory...
359 match
= dir_regex
.match(name
)
362 dirent
= match
.group(1) + '/'
363 if dirent
not in self
.directory_entries
:
364 self
.directory_entries
[dirent
] = []
366 if dirent
not in dirs_seen
:
367 dirs_seen
[dirent
] = True
368 self
.directories
.append(dirent
)
370 entry
= name
.replace(dirent
, '')
371 entry_match
= dir_regex
.match(entry
)
373 subdir
= entry_match
.group(1) + '/'
374 if subdir
in subdirs_seen
:
376 subdirs_seen
[subdir
] = True
377 dir_entries
[dirent
].append(subdir
)
379 dir_entries
[dirent
].append(entry
)
381 self
.subtree_types
.append(self
.types
[idx
])
382 self
.subtree_sha1s
.append(self
.sha1s
[idx
])
383 self
.subtree_names
.append(name
)
385 def add_or_remove(self
, to_process
):
386 """Invokes 'git add' to index the filenames in to_process that exist
387 and 'git rm' for those that do not exist."""
390 return 'No files to add or remove.'
395 for filename
in to_process
:
396 encfilename
= encode(filename
)
397 if os
.path
.exists(encfilename
):
398 to_add
.append(filename
)
401 output
= self
.git
.add(v
=True, *to_add
)
405 if len(to_add
) == len(to_process
):
406 # to_process only contained unremoved files --
407 # short-circuit the removal checks
410 # Process files to remote
411 for filename
in to_process
:
412 if not os
.path
.exists(filename
):
413 to_remove
.append(filename
)
414 output
+ '\n\n' + self
.git
.rm(*to_remove
)
416 def get_editor(self
):
417 return self
.get_gui_config('editor')
419 def get_mergetool(self
):
420 return self
.get_global_config('merge.tool')
422 def get_history_browser(self
):
423 return self
.get_gui_config('historybrowser')
425 def remember_gui_settings(self
):
426 return self
.get_cola_config('savewindowsettings')
428 def get_tree_node(self
, idx
):
429 return (self
.get_types()[idx
],
430 self
.get_sha1s()[idx
],
431 self
.get_names()[idx
] )
433 def get_subtree_node(self
, idx
):
434 return (self
.get_subtree_types()[idx
],
435 self
.get_subtree_sha1s()[idx
],
436 self
.get_subtree_names()[idx
] )
438 def get_all_branches(self
):
439 return (self
.get_local_branches() + self
.get_remote_branches())
441 def set_remote(self
, remote
):
444 self
.set_param('remote', remote
)
445 branches
= utils
.grep('%s/\S+$' % remote
,
446 self
.branch_list(remote
=True),
448 self
.set_remote_branches(branches
)
450 def add_signoff(self
,*rest
):
451 """Adds a standard Signed-off by: tag to the end
452 of the current commit message."""
453 msg
= self
.get_commitmsg()
454 signoff
=('\n\nSigned-off-by: %s <%s>\n'
455 % (self
.get_local_user_name(), self
.get_local_user_email()))
456 if signoff
not in msg
:
457 self
.set_commitmsg(msg
+ signoff
)
459 def apply_diff(self
, filename
):
460 return self
.git
.apply(filename
, index
=True, cached
=True)
462 def apply_diff_to_worktree(self
, filename
):
463 return self
.git
.apply(filename
)
465 def load_commitmsg(self
, path
):
466 file = open(path
, 'r')
467 contents
= decode(file.read())
469 self
.set_commitmsg(contents
)
471 def get_prev_commitmsg(self
,*rest
):
472 """Queries git for the latest commit message and sets it in
475 commit_lines
= decode(self
.git
.show('HEAD')).split('\n')
476 for idx
, msg
in enumerate(commit_lines
):
480 if msg
.startswith('diff --git'):
483 commit_msg
.append(msg
)
484 self
.set_commitmsg('\n'.join(commit_msg
).rstrip())
486 def load_commitmsg_template(self
):
488 template
= self
.get_global_config('commit.template')
489 except AttributeError:
491 self
.load_commitmsg(template
)
493 def update_status(self
, amend
=False):
494 # This allows us to defer notification until the
495 # we finish processing data
496 notify_enabled
= self
.get_notify()
497 self
.set_notify(False)
502 self
.untracked
) = self
.get_workdir_state(amend
=amend
)
503 # NOTE: the model's unstaged list holds an aggregate of the
504 # the modified, unmerged, and untracked file lists.
505 self
.set_unstaged(self
.modified
+ self
.unmerged
+ self
.untracked
)
506 self
.set_currentbranch(self
.current_branch())
507 self
.set_remotes(self
.git
.remote().splitlines())
508 self
.set_remote_branches(self
.branch_list(remote
=True))
509 self
.set_local_branches(self
.branch_list(remote
=False))
510 self
.set_tags(self
.git
.tag().splitlines())
511 self
.set_revision('')
512 self
.set_local_branch('')
513 self
.set_remote_branch('')
514 # Re-enable notifications and emit changes
515 self
.set_notify(notify_enabled
)
516 self
.notify_observers('staged','unstaged')
518 def delete_branch(self
, branch
):
519 return self
.git
.branch(branch
, D
=True)
521 def get_revision_sha1(self
, idx
):
522 return self
.get_revisions()[idx
]
524 def apply_font_size(self
, param
, default
):
525 old_font
= self
.get_param(param
)
528 size
= self
.get_param(param
+'size')
529 props
= old_font
.split(',')
531 new_font
= ','.join(props
)
533 self
.set_param(param
, new_font
)
535 def get_commit_diff(self
, sha1
):
536 commit
= self
.git
.show(sha1
)
537 first_newline
= commit
.index('\n')
538 if commit
[first_newline
+1:].startswith('Merge:'):
539 return (commit
+ '\n\n'
540 + self
.diff_helper(commit
=sha1
,
542 suppress_header
=False))
546 def get_filename(self
, idx
, staged
=True):
549 return self
.get_staged()[idx
]
551 return self
.get_unstaged()[idx
]
555 def get_diff_details(self
, idx
, ref
, staged
=True):
556 filename
= self
.get_filename(idx
, staged
=staged
)
558 return (None, None, None)
559 encfilename
= encode(filename
)
561 if os
.path
.exists(encfilename
):
562 status
= 'Staged for commit'
564 status
= 'Staged for removal'
565 diff
= self
.diff_helper(filename
=filename
,
569 if os
.path
.isdir(encfilename
):
570 status
= 'Untracked directory'
571 diff
= '\n'.join(os
.listdir(filename
))
573 elif filename
in self
.get_unmerged():
575 diff
= ('@@@+-+-+-+-+-+-+-+-+-+-+ UNMERGED +-+-+-+-+-+-+-+-+-+-+@@@\n\n'
576 '>>> %s is unmerged.\n' % filename
+
577 'Right-click on the filename '
578 'to launch "git mergetool".\n\n\n')
579 diff
+= self
.diff_helper(filename
=filename
,
581 patch_with_raw
=False)
582 elif filename
in self
.get_modified():
583 status
= 'Modified, not staged'
584 diff
= self
.diff_helper(filename
=filename
,
587 status
= 'Untracked, not staged'
588 diff
= 'SHA1: ' + self
.git
.hash_object(filename
)
589 return diff
, status
, filename
591 def stage_modified(self
):
592 output
= self
.git
.add(v
=True, *self
.get_modified())
596 def stage_untracked(self
):
597 output
= self
.git
.add(*self
.get_untracked())
601 def reset(self
, *items
):
602 output
= self
.git
.reset('--', *items
)
606 def unstage_all(self
):
607 output
= self
.git
.reset()
612 output
= self
.git
.add(v
=True,u
=True)
616 def save_gui_settings(self
):
617 self
.config_set('cola.geometry', utils
.get_geom(), local
=False)
619 def config_set(self
, key
=None, value
=None, local
=True):
620 if key
and value
is not None:
621 # git config category.key value
622 strval
= unicode(value
)
623 if type(value
) is bool:
624 # git uses "true" and "false"
625 strval
= strval
.lower()
627 argv
= [ key
, strval
]
629 argv
= [ '--global', key
, strval
]
630 return self
.git
.config(*argv
)
632 msg
= "oops in config_set(key=%s,value=%s,local=%s"
633 raise Exception(msg
% (key
, value
, local
))
635 def config_dict(self
, local
=True):
636 """parses the lines from git config --list into a dictionary"""
640 'global': not local
, # global is a python keyword
642 config_lines
= self
.git
.config(**kwargs
).splitlines()
644 for line
in config_lines
:
645 k
, v
= line
.split('=', 1)
647 k
= k
.replace('.','_') # git -> model
648 if v
== 'true' or v
== 'false':
649 v
= bool(eval(v
.title()))
657 def commit_with_msg(self
, msg
, amend
=False):
658 """Creates a git commit."""
660 if not msg
.endswith('\n'):
662 # Sure, this is a potential "security risk," but if someone
663 # is trying to intercept/re-write commit messages on your system,
664 # then you probably have bigger problems to worry about.
665 tmpfile
= self
.get_tmp_filename()
667 # Create the commit message file
668 fh
= open(tmpfile
, 'w')
673 (status
, stdout
, stderr
) = self
.git
.commit(F
=tmpfile
,
676 with_extended_output
=True)
679 return (status
, stdout
+stderr
)
683 return self
.git
.diff(unified
=self
.diff_context
,
687 def get_tmp_dir(self
):
688 # Allow TMPDIR/TMP with a fallback to /tmp
689 return os
.environ
.get('TMP', os
.environ
.get('TMPDIR', '/tmp'))
691 def get_tmp_file_pattern(self
):
692 return os
.path
.join(self
.get_tmp_dir(), '*.git-cola.%s.*' % os
.getpid())
694 def get_tmp_filename(self
, prefix
=''):
695 basename
= ((prefix
+'.git-cola.%s.%s'
696 % (os
.getpid(), time
.time())))
697 basename
= basename
.replace('/', '-')
698 basename
= basename
.replace('\\', '-')
699 tmpdir
= self
.get_tmp_dir()
700 return os
.path
.join(tmpdir
, basename
)
702 def log_helper(self
, all
=False):
704 Returns a pair of parallel arrays listing the revision sha1's
705 and commit summaries.
709 regex
= REV_LIST_REGEX
710 output
= self
.git
.log(pretty
='oneline', all
=all
)
711 for line
in output
.splitlines():
712 match
= regex
.match(line
)
714 revs
.append(match
.group(1))
715 summaries
.append(match
.group(2))
716 return (revs
, summaries
)
718 def parse_rev_list(self
, raw_revs
):
720 for line
in raw_revs
.splitlines():
721 match
= REV_LIST_REGEX
.match(line
)
723 rev_id
= match
.group(1)
724 summary
= match
.group(2)
725 revs
.append((rev_id
, summary
,))
728 def rev_list_range(self
, start
, end
):
729 range = '%s..%s' % (start
, end
)
730 raw_revs
= self
.git
.rev_list(range, pretty
='oneline')
731 return self
.parse_rev_list(raw_revs
)
733 def diff_helper(self
,
740 with_diff_header
=False,
741 suppress_header
=True,
743 patch_with_raw
=True):
744 "Invokes git diff on a filepath."
746 ref
, endref
= commit
+'^', commit
749 argv
.append('%s..%s' % (ref
, endref
))
757 if type(filename
) is list:
758 argv
.extend(filename
)
760 argv
.append(filename
)
764 del_tag
= 'deleted file mode '
767 deleted
= cached
and not os
.path
.exists(encode(filename
))
769 diffoutput
= self
.git
.diff(R
=reverse
,
771 patch_with_raw
=patch_with_raw
,
772 unified
=self
.diff_context
,
773 with_raw_output
=True,
775 diff
= diffoutput
.splitlines()
776 for line
in map(decode
, diff
):
777 if not start
and '@@' == line
[:2] and '@@' in line
[2:]:
779 if start
or(deleted
and del_tag
in line
):
780 output
.write(encode(line
) + '\n')
784 elif not suppress_header
:
785 output
.write(encode(line
) + '\n')
787 result
= decode(output
.getvalue())
791 return('\n'.join(headers
), result
)
795 def git_repo_path(self
, *subpaths
):
796 paths
= [ self
.git
.get_git_dir() ]
797 paths
.extend(subpaths
)
798 return os
.path
.realpath(os
.path
.join(*paths
))
800 def get_merge_message_path(self
):
801 for file in ('MERGE_MSG', 'SQUASH_MSG'):
802 path
= self
.git_repo_path(file)
803 if os
.path
.exists(path
):
807 def get_merge_message(self
):
808 return self
.git
.fmt_merge_msg('--file',
809 self
.git_repo_path('FETCH_HEAD'))
811 def abort_merge(self
):
813 output
= self
.git
.read_tree('HEAD', reset
=True, u
=True, v
=True)
815 merge_head
= self
.git_repo_path('MERGE_HEAD')
816 if os
.path
.exists(merge_head
):
817 os
.unlink(merge_head
)
818 # remove MERGE_MESSAGE, etc.
819 merge_msg_path
= self
.get_merge_message_path()
820 while merge_msg_path
:
821 os
.unlink(merge_msg_path
)
822 merge_msg_path
= self
.get_merge_message_path()
824 def get_workdir_state(self
, amend
=False):
825 """RETURNS: A tuple of staged, unstaged untracked, and unmerged
828 self
.partially_staged
= set()
832 (staged
, modified
, unmerged
, untracked
) = ([], [], [], [])
834 for name
in self
.git
.diff_index(head
).splitlines():
835 rest
, name
= name
.split('\t')
837 name
= eval_path(name
)
838 if status
== 'M' or status
== 'D':
839 modified
.append(name
)
842 for name
in (self
.git
.ls_files(modified
=True, z
=True)
845 modified
.append(decode(name
))
848 for name
in (self
.git
.diff_index(head
, cached
=True)
850 rest
, name
= name
.split('\t')
852 name
= eval_path(name
)
855 # is this file partially staged?
856 diff
= self
.git
.diff('--', name
, name_only
=True, z
=True)
858 modified
.remove(name
)
860 self
.partially_staged
.add(name
)
865 modified
.remove(name
)
867 unmerged
.append(name
)
870 for name
in self
.git
.ls_files(z
=True).strip('\0').split('\0'):
872 staged
.append(decode(name
))
874 for name
in self
.git
.ls_files(others
=True, exclude_standard
=True,
877 untracked
.append(decode(name
))
879 # remove duplicate merged and modified entries
884 return (staged
, modified
, unmerged
, untracked
)
886 def reset_helper(self
, args
):
887 """Removes files from the index.
888 This handles the git init case, which is why it's not
889 just git.reset(name).
890 For the git init case this fall back to git rm --cached.
892 output
= self
.git
.reset('--', *args
)
893 # handle git init -- we have to rm --cached them
894 state
= self
.get_workdir_state()
901 output
= self
.git
.rm('--', cached
=True, *newargs
)
904 def remote_url(self
, name
):
905 return self
.git
.config('remote.%s.url' % name
, get
=True)
907 def get_remote_args(self
, remote
,
908 local_branch
='', remote_branch
='',
909 ffwd
=True, tags
=False):
911 branch_arg
= '%s:%s' % ( remote_branch
, local_branch
)
913 branch_arg
= '+%s:%s' % ( remote_branch
, local_branch
)
915 if local_branch
and remote_branch
:
916 args
.append(branch_arg
)
921 return (args
, kwargs
)
923 def gen_remote_helper(self
, gitaction
):
924 """Generates a closure that calls git fetch, push or pull
926 def remote_helper(remote
, **kwargs
):
927 args
, kwargs
= self
.get_remote_args(remote
, **kwargs
)
928 return gitaction(*args
, **kwargs
)
931 def parse_ls_tree(self
, rev
):
932 """Returns a list of(mode, type, sha1, path) tuples."""
933 lines
= self
.git
.ls_tree(rev
, r
=True).splitlines()
935 regex
= re
.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
937 match
= regex
.match(line
)
939 mode
= match
.group(1)
940 objtype
= match
.group(2)
941 sha1
= match
.group(3)
942 filename
= match
.group(4)
943 output
.append((mode
, objtype
, sha1
, filename
,) )
946 def format_patch_helper(self
, to_export
, revs
, output
='patches'):
947 """writes patches named by to_export to the output directory."""
951 cur_rev
= to_export
[0]
952 cur_master_idx
= revs
.index(cur_rev
)
954 patches_to_export
= [ [cur_rev
] ]
957 # Group the patches into continuous sets
958 for idx
, rev
in enumerate(to_export
[1:]):
959 # Limit the search to the current neighborhood for efficiency
960 master_idx
= revs
[ cur_master_idx
: ].index(rev
)
961 master_idx
+= cur_master_idx
962 if master_idx
== cur_master_idx
+ 1:
963 patches_to_export
[ patchset_idx
].append(rev
)
967 patches_to_export
.append([ rev
])
968 cur_master_idx
= master_idx
971 # Export each patchsets
972 for patchset
in patches_to_export
:
973 cmdoutput
= self
.export_patchset(patchset
[0],
978 patch_with_stat
=True)
979 outlines
.append(cmdoutput
)
980 return '\n'.join(outlines
)
982 def export_patchset(self
, start
, end
, output
="patches", **kwargs
):
983 revarg
= '%s^..%s' % (start
, end
)
984 return self
.git
.format_patch("-o", output
, revarg
, **kwargs
)
986 def current_branch(self
):
987 """Parses 'git symbolic-ref' to find the current branch."""
988 headref
= self
.git
.symbolic_ref('HEAD')
989 if headref
.startswith('refs/heads/'):
991 elif headref
.startswith('fatal: '):
992 return 'Not currently on any branch'
995 def create_branch(self
, name
, base
, track
=False):
996 """Creates a branch starting from base. Pass track=True
997 to create a remote tracking branch."""
998 return self
.git
.branch(name
, base
, track
=track
)
1000 def cherry_pick_list(self
, revs
, **kwargs
):
1001 """Cherry-picks each revision into the current branch.
1002 Returns a list of command output strings (1 per cherry pick)"""
1007 cherries
.append(self
.git
.cherry_pick(rev
, **kwargs
))
1008 return '\n'.join(cherries
)
1010 def parse_stash_list(self
, revids
=False):
1011 """Parses "git stash list" and returns a list of stashes."""
1012 stashes
= self
.git
.stash("list").splitlines()
1014 return [ s
[:s
.index(':')] for s
in stashes
]
1016 return [ s
[s
.index(':')+1:] for s
in stashes
]
1019 return self
.git
.diff(
1021 unified
=self
.diff_context
,
1024 def pad(self
, pstr
, num
=22):
1025 topad
= num
-len(pstr
)
1027 return pstr
+ ' '*topad
1031 def describe(self
, revid
, descr
):
1032 version
= self
.git
.describe(revid
, tags
=True, always
=True,
1034 return version
+ ' - ' + descr
1036 def update_revision_lists(self
, filename
=None, show_versions
=False):
1037 num_results
= self
.get_num_results()
1039 rev_list
= self
.git
.log('--', filename
,
1040 max_count
=num_results
,
1043 rev_list
= self
.git
.log(max_count
=num_results
,
1044 pretty
='oneline', all
=True)
1046 commit_list
= self
.parse_rev_list(rev_list
)
1047 commit_list
.reverse()
1048 commits
= map(lambda x
: x
[0], commit_list
)
1049 descriptions
= map(lambda x
: decode(x
[1]), commit_list
)
1051 fancy_descr_list
= map(lambda x
: self
.describe(*x
), commit_list
)
1052 self
.set_descriptions_start(fancy_descr_list
)
1053 self
.set_descriptions_end(fancy_descr_list
)
1055 self
.set_descriptions_start(descriptions
)
1056 self
.set_descriptions_end(descriptions
)
1058 self
.set_revisions_start(commits
)
1059 self
.set_revisions_end(commits
)
1063 def get_changed_files(self
, start
, end
):
1064 zfiles_str
= self
.git
.diff('%s..%s' % (start
, end
),
1065 name_only
=True, z
=True).strip('\0')
1066 return [ decode(enc
) for enc
in zfiles_str
.split('\0') if enc
]
1068 def get_renamed_files(self
, start
, end
):
1070 difflines
= self
.git
.diff('%s..%s' % (start
, end
), M
=True).splitlines()
1071 return [ eval_path(r
[12:].rstrip())
1072 for r
in difflines
if r
.startswith('rename from ') ]