1 # Copyright (c) 2008 David Aguilar
2 """This module provides the central cola model.
10 from cStringIO
import StringIO
13 from cola
import utils
14 from cola
import errors
15 from cola
import gitcmd
16 from cola
import gitcmds
17 from cola
.models
.observable
import ObservableModel
19 #+-------------------------------------------------------------------------
20 #+ A regex for matching the output of git(log|rev-list) --pretty=oneline
21 REV_LIST_REGEX
= re
.compile('([0-9a-f]+)\W(.*)')
23 # Provides access to a global MainModel instance
26 """Returns the main model singleton"""
30 _instance
= MainModel()
35 """handles quoted paths."""
36 if path
.startswith('"') and path
.endswith('"'):
37 return core
.decode(eval(path
))
42 class MainModel(ObservableModel
):
43 """Provides a friendly wrapper for doing common git operations."""
46 message_updated
= 'updated'
47 message_about_to_update
= 'about_to_update'
50 mode_none
= 'none' # Default: nothing's happened, do nothing
51 mode_worktree
= 'worktree' # Comparing index to worktree
52 mode_index
= 'index' # Comparing index to last commit
53 mode_amend
= 'amend' # Amending a commit
54 mode_grep
= 'grep' # We ran Search -> Grep
55 mode_branch
= 'branch' # Applying changes from a branch
56 mode_diff
= 'diff' # Diffing against an arbitrary branch
57 mode_diff_expr
= 'diff_expr' # Diffing using arbitrary expression
58 mode_review
= 'review' # Reviewing a branch
60 # Modes where we don't do anything like staging, etc.
61 modes_read_only
= (mode_branch
, mode_grep
,
62 mode_diff
, mode_diff_expr
, mode_review
)
63 # Modes where we can checkout files from the $head
64 modes_undoable
= (mode_none
, mode_index
, mode_worktree
)
66 def __init__(self
, cwd
=None):
67 """Reads git repository settings and sets several methods
68 so that they refer to the git module. This object
69 encapsulates cola's interaction with git."""
70 ObservableModel
.__init
__(self
)
72 # Initialize the git command object
73 self
.git
= gitcmd
.instance()
75 #####################################################
77 self
.mode
= self
.mode_none
80 self
.currentbranch
= ''
81 self
.trackedbranch
= ''
83 self
.git_version
= self
.git
.version()
86 self
.local_branch
= ''
87 self
.remote_branch
= ''
89 #####################################################
97 self
.upstream_changed
= []
99 #####################################################
102 self
.local_branches
= []
103 self
.remote_branches
= []
108 # These are parallel lists
114 self
.directories
= []
115 self
.directory_entries
= {}
118 self
.subtree_types
= []
119 self
.subtree_sha1s
= []
120 self
.subtree_names
= []
122 self
.fetch_helper
= None
123 self
.push_helper
= None
124 self
.pull_helper
= None
125 self
.generate_remote_helpers()
127 self
.use_worktree(cwd
)
130 return self
.mode
in self
.modes_read_only
133 """Whether we can checkout files from the $head."""
134 return self
.mode
in self
.modes_undoable
136 def enable_staging(self
):
137 """Whether staging should be allowed."""
138 return self
.mode
== self
.mode_worktree
140 def generate_remote_helpers(self
):
141 """Generates helper methods for fetch, push and pull"""
142 self
.push_helper
= self
.gen_remote_helper(self
.git
.push
, push
=True)
143 self
.fetch_helper
= self
.gen_remote_helper(self
.git
.fetch
)
144 self
.pull_helper
= self
.gen_remote_helper(self
.git
.pull
)
146 def use_worktree(self
, worktree
):
147 self
.git
.load_worktree(worktree
)
148 is_valid
= self
.git
.is_valid()
150 self
._init
_config
_data
()
151 self
.set_project(os
.path
.basename(self
.git
.worktree()))
154 def _init_config_data(self
):
155 """Reads git config --list and creates parameters
157 # These parameters are saved in .gitconfig,
158 # so ideally these should be as short as possible.
160 # config items that are controllable globally
162 self
._local
_and
_global
_defaults
= {
165 'merge_summary': False,
166 'merge_diffstat': True,
167 'merge_verbosity': 2,
168 'gui_diffcontext': 3,
169 'gui_pruneduringfetch': False,
171 # config items that are purely git config --global settings
172 self
._global
_defaults
= {
175 'cola_fontdiff_size': 12,
176 'cola_savewindowsettings': False,
177 'cola_showoutput': 'errors',
179 'merge_keepbackup': True,
180 'diff_tool': os
.getenv('GIT_DIFF_TOOL', 'xxdiff'),
181 'merge_tool': os
.getenv('GIT_MERGE_TOOL', 'xxdiff'),
182 'gui_editor': os
.getenv('EDITOR', 'gvim'),
183 'gui_historybrowser': 'gitk',
186 local_dict
= self
.config_dict(local
=True)
187 global_dict
= self
.config_dict(local
=False)
189 for k
,v
in local_dict
.iteritems():
190 self
.set_param('local_'+k
, v
)
191 for k
,v
in global_dict
.iteritems():
192 self
.set_param('global_'+k
, v
)
193 if k
not in local_dict
:
195 self
.set_param('local_'+k
, v
)
197 # Bootstrap the internal font*size variables
198 for param
in ('global_cola_fontdiff'):
200 if hasattr(self
, param
):
201 font
= getattr(self
, param
)
204 size
= int(font
.split(',')[1])
205 self
.set_param(param
+'_size', size
)
206 param
= param
[len('global_'):]
207 global_dict
[param
] = font
208 global_dict
[param
+'_size'] = size
210 # Load defaults for all undefined items
211 local_and_global_defaults
= self
._local
_and
_global
_defaults
212 for k
,v
in local_and_global_defaults
.iteritems():
213 if k
not in local_dict
:
214 self
.set_param('local_'+k
, v
)
215 if k
not in global_dict
:
216 self
.set_param('global_'+k
, v
)
218 global_defaults
= self
._global
_defaults
219 for k
,v
in global_defaults
.iteritems():
220 if k
not in global_dict
:
221 self
.set_param('global_'+k
, v
)
223 # Load the diff context
224 self
.diff_context
= self
.local_config('gui.diffcontext', 3)
226 def global_config(self
, key
, default
=None):
227 return self
.param('global_'+key
.replace('.', '_'),
230 def local_config(self
, key
, default
=None):
231 return self
.param('local_'+key
.replace('.', '_'),
234 def cola_config(self
, key
):
235 return getattr(self
, 'global_cola_'+key
)
237 def gui_config(self
, key
):
238 return getattr(self
, 'global_gui_'+key
)
240 def config_params(self
):
242 params
.extend(map(lambda x
: 'local_' + x
,
243 self
._local
_and
_global
_defaults
.keys()))
244 params
.extend(map(lambda x
: 'global_' + x
,
245 self
._local
_and
_global
_defaults
.keys()))
246 params
.extend(map(lambda x
: 'global_' + x
,
247 self
._global
_defaults
.keys()))
248 return [ p
for p
in params
if not p
.endswith('_size') ]
250 def save_config_param(self
, param
):
251 if param
not in self
.config_params():
253 value
= getattr(self
, param
)
254 if param
== 'local_gui_diffcontext':
255 self
.diff_context
= value
256 if param
.startswith('local_'):
257 param
= param
[len('local_'):]
259 elif param
.startswith('global_'):
260 param
= param
[len('global_'):]
263 raise Exception("Invalid param '%s' passed to " % param
264 +'save_config_param()')
265 param
= param
.replace('_', '.') # model -> git
266 return self
.config_set(param
, value
, local
=is_local
)
268 def init_browser_data(self
):
269 """This scans over self.(names, sha1s, types) to generate
270 directories, directory_entries, and subtree_*"""
272 # Collect data for the model
273 if not self
.currentbranch
:
276 self
.subtree_types
= []
277 self
.subtree_sha1s
= []
278 self
.subtree_names
= []
279 self
.directories
= []
280 self
.directory_entries
= {}
282 # Lookup the tree info
283 tree_info
= self
.parse_ls_tree(self
.currentbranch
)
285 self
.set_types(map(lambda(x
): x
[1], tree_info
))
286 self
.set_sha1s(map(lambda(x
): x
[2], tree_info
))
287 self
.set_names(map(lambda(x
): x
[3], tree_info
))
289 if self
.directory
: self
.directories
.append('..')
291 dir_entries
= self
.directory_entries
292 dir_regex
= re
.compile('([^/]+)/')
296 for idx
, name
in enumerate(self
.names
):
297 if not name
.startswith(self
.directory
):
299 name
= name
[ len(self
.directory
): ]
301 # This is a directory...
302 match
= dir_regex
.match(name
)
305 dirent
= match
.group(1) + '/'
306 if dirent
not in self
.directory_entries
:
307 self
.directory_entries
[dirent
] = []
309 if dirent
not in dirs_seen
:
310 dirs_seen
[dirent
] = True
311 self
.directories
.append(dirent
)
313 entry
= name
.replace(dirent
, '')
314 entry_match
= dir_regex
.match(entry
)
316 subdir
= entry_match
.group(1) + '/'
317 if subdir
in subdirs_seen
:
319 subdirs_seen
[subdir
] = True
320 dir_entries
[dirent
].append(subdir
)
322 dir_entries
[dirent
].append(entry
)
324 self
.subtree_types
.append(self
.types
[idx
])
325 self
.subtree_sha1s
.append(self
.sha1s
[idx
])
326 self
.subtree_names
.append(name
)
329 return self
.gui_config('editor')
331 def history_browser(self
):
332 return self
.gui_config('historybrowser')
334 def remember_gui_settings(self
):
335 return self
.cola_config('savewindowsettings')
337 def subtree_node(self
, idx
):
338 return (self
.subtree_types
[idx
],
339 self
.subtree_sha1s
[idx
],
340 self
.subtree_names
[idx
])
342 def all_branches(self
):
343 return (self
.local_branches
+ self
.remote_branches
)
345 def set_remote(self
, remote
):
348 self
.set_param('remote', remote
)
349 branches
= utils
.grep('%s/\S+$' % remote
,
350 gitcmds
.branch_list(remote
=True),
352 self
.set_remote_branches(branches
)
354 def apply_diff(self
, filename
):
355 return self
.git
.apply(filename
, index
=True, cached
=True)
357 def apply_diff_to_worktree(self
, filename
):
358 return self
.git
.apply(filename
)
360 def load_commitmsg(self
, path
):
362 contents
= core
.decode(core
.read_nointr(fh
))
364 self
.set_commitmsg(contents
)
366 def prev_commitmsg(self
):
367 """Queries git for the latest commit message."""
368 return core
.decode(self
.git
.log('-1', pretty
='format:%s%n%n%b'))
370 def load_commitmsg_template(self
):
371 template
= self
.global_config('commit.template')
373 self
.load_commitmsg(template
)
375 def update_status(self
):
376 # Give observers a chance to respond
377 self
.notify_message_observers(self
.message_about_to_update
)
378 # This allows us to defer notification until the
379 # we finish processing data
380 staged_only
= self
.read_only()
382 notify_enabled
= self
.notification_enabled
383 self
.notification_enabled
= False
385 # Set these early since they are used to calculate 'upstream_changed'.
386 self
.set_trackedbranch(gitcmds
.tracked_branch())
387 self
.set_currentbranch(gitcmds
.current_branch())
393 self
.upstream_changed
) = self
.worktree_state(head
=head
,
394 staged_only
=staged_only
)
395 # NOTE: the model's unstaged list holds an aggregate of the
396 # the modified, unmerged, and untracked file lists.
397 self
.set_unstaged(self
.modified
+ self
.unmerged
+ self
.untracked
)
398 self
.set_remotes(self
.git
.remote().splitlines())
399 self
.set_tags(gitcmds
.tag_list())
400 self
.set_remote_branches(gitcmds
.branch_list(remote
=True))
401 self
.set_local_branches(gitcmds
.branch_list(remote
=False))
402 self
.set_revision('')
403 self
.set_local_branch('')
404 self
.set_remote_branch('')
405 # Re-enable notifications and emit changes
406 self
.notification_enabled
= notify_enabled
408 self
.read_font_sizes()
409 self
.notify_observers('staged', 'unstaged')
410 self
.notify_message_observers(self
.message_updated
)
412 def read_font_sizes(self
):
413 """Read font sizes from the configuration."""
414 value
= self
.cola_config('fontdiff')
417 items
= value
.split(',')
420 self
.global_cola_fontdiff_size
= int(float(items
[1]))
422 def set_diff_font(self
, fontstr
):
423 """Set the diff font string."""
424 self
.global_cola_fontdiff
= fontstr
425 self
.read_font_sizes()
427 def delete_branch(self
, branch
):
428 return self
.git
.branch(branch
,
433 def revision_sha1(self
, idx
):
434 return self
.revisions
[idx
]
436 def apply_diff_font_size(self
, default
):
437 old_font
= self
.cola_config('fontdiff')
440 size
= self
.cola_config('fontdiff_size')
441 props
= old_font
.split(',')
443 new_font
= ','.join(props
)
444 self
.global_cola_fontdiff
= new_font
445 self
.notify_observers('global_cola_fontdiff')
447 def commit_diff(self
, sha1
):
448 commit
= self
.git
.show(sha1
)
449 first_newline
= commit
.index('\n')
450 if commit
[first_newline
+1:].startswith('Merge:'):
451 return (core
.decode(commit
) + '\n\n' +
452 core
.decode(self
.diff_helper(commit
=sha1
,
454 suppress_header
=False)))
456 return core
.decode(commit
)
458 def filename(self
, idx
, staged
=True):
461 return self
.staged
[idx
]
463 return self
.unstaged
[idx
]
467 def diff_details(self
, idx
, ref
, staged
=True):
469 Return a "diff" for an entry by index relative to ref.
471 `staged` indicates whether we should consider this as a
472 staged or unstaged entry.
475 filename
= self
.filename(idx
, staged
=staged
)
478 encfilename
= core
.encode(filename
)
480 diff
= self
.diff_helper(filename
=filename
,
484 if os
.path
.isdir(encfilename
):
485 diff
= '\n'.join(os
.listdir(filename
))
487 elif filename
in self
.unmerged
:
488 diff
= ('@@@ Unmerged @@@\n'
489 '- %s is unmerged.\n+ ' % filename
+
490 'Right-click the file to launch "git mergetool".\n'
491 '@@@ Unmerged @@@\n\n')
492 diff
+= self
.diff_helper(filename
=filename
,
494 elif filename
in self
.modified
:
495 diff
= self
.diff_helper(filename
=filename
,
498 diff
= 'SHA1: ' + self
.git
.hash_object(filename
)
499 return (diff
, filename
)
501 def stage_modified(self
):
502 status
, output
= self
.git
.add(v
=True,
507 return (status
, output
)
509 def stage_untracked(self
):
510 status
, output
= self
.git
.add(v
=True,
515 return (status
, output
)
517 def reset(self
, *items
):
518 status
, output
= self
.git
.reset('--',
523 return (status
, output
)
525 def unstage_all(self
):
526 status
, output
= self
.git
.reset(with_stderr
=True,
529 return (status
, output
)
532 status
, output
= self
.git
.add(v
=True,
537 return (status
, output
)
539 def config_set(self
, key
=None, value
=None, local
=True):
540 if key
and value
is not None:
541 # git config category.key value
542 strval
= unicode(value
)
543 if type(value
) is bool:
544 # git uses "true" and "false"
545 strval
= strval
.lower()
547 argv
= [ key
, strval
]
549 argv
= [ '--global', key
, strval
]
550 return self
.git
.config(*argv
)
552 msg
= "oops in config_set(key=%s,value=%s,local=%s)"
553 raise Exception(msg
% (key
, value
, local
))
555 def config_dict(self
, local
=True):
556 """parses the lines from git config --list into a dictionary"""
560 'global': not local
, # global is a python keyword
562 config_lines
= self
.git
.config(**kwargs
).splitlines()
564 for line
in config_lines
:
566 k
, v
= line
.split('=', 1)
568 # the user has an invalid entry in their git config
571 k
= k
.replace('.','_') # git -> model
572 if v
== 'true' or v
== 'false':
573 v
= bool(eval(v
.title()))
581 def commit_with_msg(self
, msg
, amend
=False):
582 """Creates a git commit."""
584 if not msg
.endswith('\n'):
586 # Sure, this is a potential "security risk," but if someone
587 # is trying to intercept/re-write commit messages on your system,
588 # then you probably have bigger problems to worry about.
589 tmpfile
= self
.tmp_filename()
591 # Create the commit message file
592 fh
= open(tmpfile
, 'w')
593 core
.write_nointr(fh
, msg
)
597 status
, out
= self
.git
.commit(F
=tmpfile
, v
=True, amend
=amend
,
604 # Allow TMPDIR/TMP with a fallback to /tmp
605 return os
.environ
.get('TMP', os
.environ
.get('TMPDIR', '/tmp'))
607 def tmp_file_pattern(self
):
608 return os
.path
.join(self
.tmp_dir(), '*.git-cola.%s.*' % os
.getpid())
610 def tmp_filename(self
, prefix
=''):
611 basename
= ((prefix
+'.git-cola.%s.%s'
612 % (os
.getpid(), time
.time())))
613 basename
= basename
.replace('/', '-')
614 basename
= basename
.replace('\\', '-')
615 tmpdir
= self
.tmp_dir()
616 return os
.path
.join(tmpdir
, basename
)
618 def log_helper(self
, all
=False, extra_args
=None):
620 Returns a pair of parallel arrays listing the revision sha1's
621 and commit summaries.
625 regex
= REV_LIST_REGEX
629 output
= self
.git
.log(pretty
='oneline', all
=all
, *args
)
630 for line
in map(core
.decode
, output
.splitlines()):
631 match
= regex
.match(line
)
633 revs
.append(match
.group(1))
634 summaries
.append(match
.group(2))
635 return (revs
, summaries
)
637 def parse_rev_list(self
, raw_revs
):
639 for line
in map(core
.decode
, raw_revs
.splitlines()):
640 match
= REV_LIST_REGEX
.match(line
)
642 rev_id
= match
.group(1)
643 summary
= match
.group(2)
644 revs
.append((rev_id
, summary
,))
647 def rev_list_range(self
, start
, end
):
648 range = '%s..%s' % (start
, end
)
649 raw_revs
= self
.git
.rev_list(range, pretty
='oneline')
650 return self
.parse_rev_list(raw_revs
)
652 def diff_helper(self
,
659 with_diff_header
=False,
660 suppress_header
=True,
662 "Invokes git diff on a filepath."
664 ref
, endref
= commit
+'^', commit
667 argv
.append('%s..%s' % (ref
, endref
))
669 for r
in ref
.strip().split():
676 if type(filename
) is list:
677 argv
.extend(filename
)
679 argv
.append(filename
)
682 del_tag
= 'deleted file mode '
685 deleted
= cached
and not os
.path
.exists(core
.encode(filename
))
687 diffoutput
= self
.git
.diff(R
=reverse
,
691 unified
=self
.diff_context
,
692 with_raw_output
=True,
697 if diffoutput
.startswith('fatal:'):
705 diff
= diffoutput
.split('\n')
706 for line
in map(core
.decode
, diff
):
707 if not start
and '@@' == line
[:2] and '@@' in line
[2:]:
709 if start
or (deleted
and del_tag
in line
):
710 output
.write(core
.encode(line
) + '\n')
713 headers
.append(core
.encode(line
))
714 elif not suppress_header
:
715 output
.write(core
.encode(line
) + '\n')
717 result
= core
.decode(output
.getvalue())
721 return('\n'.join(headers
), result
)
725 def git_repo_path(self
, *subpaths
):
726 paths
= [self
.git
.git_dir()]
727 paths
.extend(subpaths
)
728 return os
.path
.realpath(os
.path
.join(*paths
))
730 def merge_message_path(self
):
731 for basename
in ('MERGE_MSG', 'SQUASH_MSG'):
732 path
= self
.git_repo_path(basename
)
733 if os
.path
.exists(path
):
737 def merge_message(self
):
738 return self
.git
.fmt_merge_msg('--file',
739 self
.git_repo_path('FETCH_HEAD'))
741 def abort_merge(self
):
743 output
= self
.git
.read_tree('HEAD', reset
=True, u
=True, v
=True)
745 merge_head
= self
.git_repo_path('MERGE_HEAD')
746 if os
.path
.exists(merge_head
):
747 os
.unlink(merge_head
)
748 # remove MERGE_MESSAGE, etc.
749 merge_msg_path
= self
.merge_message_path()
750 while merge_msg_path
:
751 os
.unlink(merge_msg_path
)
752 merge_msg_path
= self
.merge_message_path()
754 def _is_modified(self
, name
):
755 status
, out
= self
.git
.diff('--', name
,
762 def _branch_status(self
, branch
):
764 Returns a tuple of staged, unstaged, untracked, and unmerged files
766 This shows only the changes that were introduced in branch
769 status
, output
= self
.git
.diff(name_only
=True,
773 *branch
.strip().split())
775 return ([], [], [], [], [])
777 staged
= map(core
.decode
, [n
for n
in output
.split('\0') if n
])
778 return (staged
, [], [], [], staged
)
780 def worktree_state(self
, head
='HEAD', staged_only
=False):
781 """Return a tuple of files in various states of being
783 Can be staged, unstaged, untracked, unmerged, or changed
787 self
.git
.update_index(refresh
=True)
789 return self
._branch
_status
(head
)
793 upstream_changed_set
= set()
795 (staged
, modified
, unmerged
, untracked
, upstream_changed
) = (
798 output
= self
.git
.diff_index(head
,
801 if output
.startswith('fatal:'):
802 raise errors
.GitInitError('git init')
803 for line
in output
.splitlines():
804 rest
, name
= line
.split('\t', 1)
806 name
= eval_path(name
)
810 # This file will also show up as 'M' without --cached
811 # so by default don't consider it modified unless
812 # it's truly modified
813 modified_set
.add(name
)
814 if not staged_only
and self
._is
_modified
(name
):
815 modified
.append(name
)
822 modified_set
.add(name
)
824 unmerged
.append(name
)
825 modified_set
.add(name
)
827 except errors
.GitInitError
:
829 staged
.extend(gitcmds
.all_files())
832 output
= self
.git
.diff_index(head
, with_stderr
=True)
833 if output
.startswith('fatal:'):
834 raise errors
.GitInitError('git init')
835 for line
in output
.splitlines():
836 info
, name
= line
.split('\t', 1)
837 status
= info
.split()[-1]
838 if status
== 'M' or status
== 'D':
839 name
= eval_path(name
)
840 if name
not in modified_set
:
841 modified
.append(name
)
843 name
= eval_path(name
)
844 # newly-added yet modified
845 if (name
not in modified_set
and not staged_only
and
846 self
._is
_modified
(name
)):
847 modified
.append(name
)
849 except errors
.GitInitError
:
851 ls_files
= (self
.git
.ls_files(modified
=True, z
=True)[:-1]
853 modified
.extend(map(core
.decode
, [f
for f
in ls_files
if f
]))
855 untracked
.extend(gitcmds
.untracked_files())
857 # Look for upstream modified files if this is a tracking branch
858 if self
.trackedbranch
:
860 diff_expr
= self
.merge_base_to(self
.trackedbranch
)
861 output
= self
.git
.diff(diff_expr
,
865 if output
.startswith('fatal:'):
866 raise errors
.GitInitError('git init')
868 for name
in [n
for n
in output
.split('\0') if n
]:
869 name
= core
.decode(name
)
870 upstream_changed
.append(name
)
871 upstream_changed_set
.add(name
)
873 except errors
.GitInitError
:
882 upstream_changed
.sort()
884 return (staged
, modified
, unmerged
, untracked
, upstream_changed
)
886 def reset_helper(self
, args
):
887 """Removes files from the index
889 This handles the git init case, which is why it's not
890 just 'git reset name'. For the git init case this falls
891 back to 'git rm --cached'.
894 # fake the status because 'git reset' returns 1
895 # regardless of success/failure
897 output
= self
.git
.reset('--', with_stderr
=True, *args
)
898 # handle git init: we have to use 'git rm --cached'
899 # detect this condition by checking if the file is still staged
900 state
= self
.worktree_state()
902 rmargs
= [a
for a
in args
if a
in staged
]
904 return (status
, output
)
905 output
+= self
.git
.rm('--', cached
=True, with_stderr
=True, *rmargs
)
906 return (status
, output
)
908 def remote_url(self
, name
):
909 return self
.git
.config('remote.%s.url' % name
, get
=True)
911 def remote_args(self
, remote
,
918 # Swap the branches in push mode (reverse of fetch)
921 local_branch
= remote_branch
924 branch_arg
= '%s:%s' % ( remote_branch
, local_branch
)
926 branch_arg
= '+%s:%s' % ( remote_branch
, local_branch
)
928 if local_branch
and remote_branch
:
929 args
.append(branch_arg
)
931 args
.append(local_branch
)
933 args
.append(remote_branch
)
941 return (args
, kwargs
)
943 def gen_remote_helper(self
, gitaction
, push
=False):
944 """Generates a closure that calls git fetch, push or pull
946 def remote_helper(remote
, **kwargs
):
947 args
, kwargs
= self
.remote_args(remote
, push
=push
, **kwargs
)
948 return gitaction(*args
, **kwargs
)
951 def parse_ls_tree(self
, rev
):
952 """Returns a list of(mode, type, sha1, path) tuples."""
953 lines
= self
.git
.ls_tree(rev
, r
=True).splitlines()
955 regex
= re
.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
957 match
= regex
.match(line
)
959 mode
= match
.group(1)
960 objtype
= match
.group(2)
961 sha1
= match
.group(3)
962 filename
= match
.group(4)
963 output
.append((mode
, objtype
, sha1
, filename
,) )
966 def format_patch_helper(self
, to_export
, revs
, output
='patches'):
967 """writes patches named by to_export to the output directory."""
971 cur_rev
= to_export
[0]
972 cur_master_idx
= revs
.index(cur_rev
)
974 patches_to_export
= [ [cur_rev
] ]
977 # Group the patches into continuous sets
978 for idx
, rev
in enumerate(to_export
[1:]):
979 # Limit the search to the current neighborhood for efficiency
980 master_idx
= revs
[ cur_master_idx
: ].index(rev
)
981 master_idx
+= cur_master_idx
982 if master_idx
== cur_master_idx
+ 1:
983 patches_to_export
[ patchset_idx
].append(rev
)
987 patches_to_export
.append([ rev
])
988 cur_master_idx
= master_idx
991 # Export each patchsets
993 for patchset
in patches_to_export
:
994 newstatus
, out
= self
.export_patchset(patchset
[0],
999 patch_with_stat
=True)
1000 outlines
.append(out
)
1003 return (status
, '\n'.join(outlines
))
1005 def export_patchset(self
, start
, end
, output
="patches", **kwargs
):
1006 revarg
= '%s^..%s' % (start
, end
)
1007 return self
.git
.format_patch('-o', output
, revarg
,
1012 def create_branch(self
, name
, base
, track
=False):
1013 """Create a branch named 'name' from revision 'base'
1015 Pass track=True to create a local tracking branch.
1017 return self
.git
.branch(name
, base
, track
=track
,
1021 def cherry_pick_list(self
, revs
, **kwargs
):
1022 """Cherry-picks each revision into the current branch.
1023 Returns a list of command output strings (1 per cherry pick)"""
1029 newstatus
, out
= self
.git
.cherry_pick(rev
,
1034 cherries
.append(out
)
1035 return (status
, '\n'.join(cherries
))
1037 def parse_stash_list(self
, revids
=False):
1038 """Parses "git stash list" and returns a list of stashes."""
1039 stashes
= self
.git
.stash("list").splitlines()
1041 return [ s
[:s
.index(':')] for s
in stashes
]
1043 return [ s
[s
.index(':')+1:] for s
in stashes
]
1045 def pad(self
, pstr
, num
=22):
1046 topad
= num
-len(pstr
)
1048 return pstr
+ ' '*topad
1052 def describe(self
, revid
, descr
):
1053 version
= self
.git
.describe(revid
, tags
=True, always
=True,
1055 return version
+ ' - ' + descr
1057 def update_revision_lists(self
, filename
=None, show_versions
=False):
1058 num_results
= self
.num_results
1060 rev_list
= self
.git
.log('--', filename
,
1061 max_count
=num_results
,
1064 rev_list
= self
.git
.log(max_count
=num_results
,
1065 pretty
='oneline', all
=True)
1067 commit_list
= self
.parse_rev_list(rev_list
)
1068 commit_list
.reverse()
1069 commits
= map(lambda x
: x
[0], commit_list
)
1070 descriptions
= map(lambda x
: core
.decode(x
[1]), commit_list
)
1072 fancy_descr_list
= map(lambda x
: self
.describe(*x
), commit_list
)
1073 self
.set_descriptions_start(fancy_descr_list
)
1074 self
.set_descriptions_end(fancy_descr_list
)
1076 self
.set_descriptions_start(descriptions
)
1077 self
.set_descriptions_end(descriptions
)
1079 self
.set_revisions_start(commits
)
1080 self
.set_revisions_end(commits
)
1084 def changed_files(self
, start
, end
):
1085 zfiles_str
= self
.git
.diff('%s..%s' % (start
, end
),
1086 name_only
=True, z
=True).strip('\0')
1087 return [core
.decode(enc
) for enc
in zfiles_str
.split('\0') if enc
]
1089 def renamed_files(self
, start
, end
):
1090 difflines
= self
.git
.diff('%s..%s' % (start
, end
),
1092 M
=True).splitlines()
1093 return [ eval_path(r
[12:].rstrip())
1094 for r
in difflines
if r
.startswith('rename from ') ]
1096 def is_commit_published(self
):
1097 head
= self
.git
.rev_parse('HEAD')
1098 return bool(self
.git
.branch(r
=True, contains
=head
))
1100 def merge_base_to(self
, ref
):
1101 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
1102 base
= self
.git
.merge_base('HEAD', ref
)
1103 return '%s..%s' % (base
, ref
)
1105 def everything(self
):
1106 """Returns a sorted list of all files, including untracked files."""
1107 ls_files
= self
.git
.ls_files(z
=True,
1110 exclude_standard
=True)
1111 return sorted(map(core
.decode
, [f
for f
in ls_files
.split('\0') if f
]))
1113 def stage_paths(self
, paths
):
1114 """Stages add/removals to git."""
1117 for path
in set(paths
):
1118 if os
.path
.exists(core
.encode(path
)):
1122 # `git add -u` doesn't work on untracked files
1124 self
.git
.add('--', *add
)
1125 # If a path doesn't exist then that means it should be removed
1126 # from the index. We use `git add -u` for that.
1128 self
.git
.add('--', u
=True, *remove
)
1129 self
.update_status()
1131 def unstage_paths(self
, paths
):
1132 """Unstages paths from the staging area and notifies observers."""
1133 self
.reset_helper(set(paths
))
1134 self
.update_status()
1136 def revert_paths(self
, paths
):
1137 """Revert paths to the content from HEAD."""
1138 self
.git
.checkout('HEAD', '--', *set(paths
))
1139 self
.update_status()
1142 """If we've chosen a directory then use it, otherwise os.getcwd()."""
1144 return self
.directory