6 from cStringIO
import StringIO
8 # GitPython http://gitorious.org/projects/git-python
11 from cola
import utils
12 from cola
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()
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
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
38 class Model(model
.Model
):
39 """Provides a friendly wrapper for doing commit git operations."""
42 """Reads git repository settings and sets several methods
43 so that they refer to the git module. This object is
44 encapsulates cola's interaction with git."""
46 # chdir to the root of the git tree.
47 # This keeps paths relative.
49 os
.chdir( self
.git
.git_dir
)
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
))
59 #####################################################
60 # Used in various places
67 git_version
= self
.git
.version(),
69 #####################################################
70 # Used primarily by the main UI
71 project
= os
.path
.basename(os
.getcwd()),
77 window_geom
= utils
.parse_geom(
78 self
.get_global_cola_geometry()),
80 #####################################################
81 # Used by the create branch dialog
87 #####################################################
88 # Used by the commit/repo browser
93 # These are parallel lists
98 # All items below here are re-calculated in
101 directory_entries
= {},
103 # These are also parallel lists
109 def __init_config_data(self
):
110 """Reads git config --list and creates parameters
112 # These parameters are saved in .gitconfig,
113 # so ideally these should be as short as possible.
115 # config items that are controllable globally
117 self
.__local
_and
_global
_defaults
= {
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
= {
130 'cola_fontui_size':12,
132 'cola_fontdiff_size':12,
133 'cola_savewindowsettings': False,
134 'cola_saveatexit': False,
135 'gui_editor': 'gvim',
136 'gui_diffeditor': 'xxdiff',
137 'gui_historybrowser': 'gitk',
140 local_dict
= self
.config_dict(local
=True)
141 global_dict
= self
.config_dict(local
=False)
143 for k
,v
in local_dict
.iteritems():
144 self
.set_param('local_'+k
, v
)
145 for k
,v
in global_dict
.iteritems():
146 self
.set_param('global_'+k
, v
)
147 if k
not in local_dict
:
149 self
.set_param('local_'+k
, v
)
151 # Bootstrap the internal font*_size variables
152 for param
in ('global_cola_fontui', 'global_cola_fontdiff'):
153 if hasattr(self
, param
):
154 font
= self
.get_param(param
)
156 size
= int(font
.split(',')[1])
157 self
.set_param(param
+'_size', size
)
158 param
= param
[len('global_'):]
159 global_dict
[param
] = font
160 global_dict
[param
+'_size'] = size
162 # Load defaults for all undefined items
163 local_and_global_defaults
= self
.__local
_and
_global
_defaults
164 for k
,v
in local_and_global_defaults
.iteritems():
165 if k
not in local_dict
:
166 self
.set_param('local_'+k
, v
)
167 if k
not in global_dict
:
168 self
.set_param('global_'+k
, v
)
170 global_defaults
= self
.__global
_defaults
171 for k
,v
in global_defaults
.iteritems():
172 if k
not in global_dict
:
173 self
.set_param('global_'+k
, v
)
175 # Allow EDITOR/DIFF_EDITOR environment variable overrides
176 self
.global_gui_editor
= os
.getenv("GUI_EDITOR", self
.global_gui_editor
)
177 self
.global_gui_diffeditor
= os
.getenv("DIFF_EDITOR", self
.global_gui_diffeditor
)
179 # Load the diff context
180 self
.diff_context
= self
.local_gui_diffcontext
182 def branch_list(self
, remote
=False):
183 branches
= map(lambda x
: x
.lstrip('* '),
184 self
.git
.branch(r
=remote
).splitlines())
187 for branch
in branches
:
188 if branch
.endswith('/HEAD'):
190 remotes
.append(branch
)
194 def get_config_params(self
):
196 params
.extend(map(lambda x
: 'local_' + x
,
197 self
.__local
_and
_global
_defaults
.keys()))
198 params
.extend(map(lambda x
: 'global_' + x
,
199 self
.__local
_and
_global
_defaults
.keys()))
200 params
.extend(map(lambda x
: 'global_' + x
,
201 self
.__global
_defaults
.keys()))
204 def save_config_param(self
, param
):
205 if param
not in self
.get_config_params():
207 value
= self
.get_param(param
)
208 if param
== 'local_gui_diffcontext':
209 self
.diff_context
= value
210 if param
.startswith('local_'):
211 param
= param
[len('local_'):]
213 elif param
.startswith('global_'):
214 param
= param
[len('global_'):]
217 raise Exception("Invalid param '%s' passed to " % param
218 + "save_config_param()")
219 param
= param
.replace('_','.') # model -> git
220 return self
.config_set(param
, value
, local
=is_local
)
222 def init_browser_data(self
):
223 '''This scans over self.(names, sha1s, types) to generate
224 directories, directory_entries, and subtree_*'''
226 # Collect data for the model
227 if not self
.get_currentbranch(): return
229 self
.subtree_types
= []
230 self
.subtree_sha1s
= []
231 self
.subtree_names
= []
232 self
.directories
= []
233 self
.directory_entries
= {}
235 # Lookup the tree info
236 tree_info
= self
.parse_ls_tree(self
.get_currentbranch())
238 self
.set_types(map( lambda(x
): x
[1], tree_info
))
239 self
.set_sha1s(map( lambda(x
): x
[2], tree_info
))
240 self
.set_names(map( lambda(x
): x
[3], tree_info
))
242 if self
.directory
: self
.directories
.append('..')
244 dir_entries
= self
.directory_entries
245 dir_regex
= re
.compile('([^/]+)/')
249 for idx
, name
in enumerate(self
.names
):
251 if not name
.startswith(self
.directory
): continue
252 name
= name
[ len(self
.directory
): ]
255 # This is a directory...
256 match
= dir_regex
.match(name
)
257 if not match
: continue
259 dirent
= match
.group(1) + '/'
260 if dirent
not in self
.directory_entries
:
261 self
.directory_entries
[dirent
] = []
263 if dirent
not in dirs_seen
:
264 dirs_seen
[dirent
] = True
265 self
.directories
.append(dirent
)
267 entry
= name
.replace(dirent
, '')
268 entry_match
= dir_regex
.match(entry
)
270 subdir
= entry_match
.group(1) + '/'
271 if subdir
in subdirs_seen
: continue
272 subdirs_seen
[subdir
] = True
273 dir_entries
[dirent
].append(subdir
)
275 dir_entries
[dirent
].append(entry
)
277 self
.subtree_types
.append(self
.types
[idx
])
278 self
.subtree_sha1s
.append(self
.sha1s
[idx
])
279 self
.subtree_names
.append(name
)
281 def add_or_remove(self
, *to_process
):
282 """Invokes 'git add' to index the filenames in to_process that exist
283 and 'git rm' for those that do not exist."""
286 return 'No files to add or remove.'
291 for filename
in to_process
:
292 if os
.path
.exists(filename
):
293 to_add
.append(filename
)
295 output
= self
.git
.add(v
=True, *to_add
)
297 if len(to_add
) == len(to_process
):
298 # to_process only contained unremoved files --
299 # short-circuit the removal checks
302 # Process files to remote
303 for filename
in to_process
:
304 if not os
.path
.exists(filename
):
305 to_remove
.append(filename
)
306 output
+ '\n\n' + self
.git
.rm(*to_remove
)
308 def get_editor(self
):
309 return self
.global_gui_editor
311 def get_diffeditor(self
):
312 return self
.global_gui_diffeditor
314 def get_history_browser(self
):
315 return self
.global_gui_historybrowser
317 def remember_gui_settings(self
):
318 return self
.global_cola_savewindowsettings
320 def save_at_exit(self
):
321 return self
.global_cola_saveatexit
323 def get_tree_node(self
, idx
):
324 return (self
.get_types()[idx
],
325 self
.get_sha1s()[idx
],
326 self
.get_names()[idx
] )
328 def get_subtree_node(self
, idx
):
329 return (self
.get_subtree_types()[idx
],
330 self
.get_subtree_sha1s()[idx
],
331 self
.get_subtree_names()[idx
] )
333 def get_all_branches(self
):
334 return (self
.get_local_branches() + self
.get_remote_branches())
336 def set_remote(self
, remote
):
337 if not remote
: return
338 self
.set_param('remote', remote
)
339 branches
= utils
.grep( '%s/\S+$' % remote
,
340 self
.branch_list(remote
=True), squash
=False)
341 self
.set_remote_branches(branches
)
343 def add_signoff(self
,*rest
):
344 '''Adds a standard Signed-off by: tag to the end
345 of the current commit message.'''
347 msg
= self
.get_commitmsg()
348 signoff
=('\n\nSigned-off-by: %s <%s>\n' % (
349 self
.get_local_user_name(),
350 self
.get_local_user_email()))
352 if signoff
not in msg
:
353 self
.set_commitmsg(msg
+ signoff
)
355 def apply_diff(self
, filename
):
356 return self
.git
.apply(filename
, index
=True, cached
=True)
358 def load_commitmsg(self
, path
):
359 file = open(path
, 'r')
360 contents
= file.read()
362 self
.set_commitmsg(contents
)
364 def get_prev_commitmsg(self
,*rest
):
365 '''Queries git for the latest commit message and sets it in
368 commit_lines
= self
.git
.show('HEAD').split('\n')
369 for idx
, msg
in enumerate(commit_lines
):
372 if msg
.startswith('diff --git'):
375 commit_msg
.append(msg
)
376 self
.set_commitmsg('\n'.join(commit_msg
).rstrip())
378 def update_status(self
):
379 # This allows us to defer notification until the
380 # we finish processing data
381 notify_enabled
= self
.get_notify()
382 self
.set_notify(False)
384 # Reset the staged and unstaged model lists
385 # NOTE: the model's unstaged list is used to
386 # hold both modified and untracked files.
391 # Read git status items
394 untracked_items
) = self
.parse_status()
396 # Gather items to be committed
397 for staged
in staged_items
:
398 if staged
not in self
.get_staged():
399 self
.add_staged(staged
)
401 # Gather unindexed items
402 for modified
in modified_items
:
403 if modified
not in self
.get_modified():
404 self
.add_modified(modified
)
406 # Gather untracked items
407 for untracked
in untracked_items
:
408 if untracked
not in self
.get_untracked():
409 self
.add_untracked(untracked
)
411 self
.set_currentbranch(self
.current_branch())
412 self
.set_unstaged(self
.get_modified() + self
.get_untracked())
413 self
.set_remotes(self
.git
.remote().splitlines())
414 self
.set_remote_branches(self
.branch_list(remote
=True))
415 self
.set_local_branches(self
.branch_list(remote
=False))
416 self
.set_tags(self
.git
.tag().splitlines())
417 self
.set_revision('')
418 self
.set_local_branch('')
419 self
.set_remote_branch('')
420 # Re-enable notifications and emit changes
421 self
.set_notify(notify_enabled
)
422 self
.notify_observers('staged','unstaged')
424 def delete_branch(self
, branch
):
425 return self
.git
.branch(branch
, D
=True)
427 def get_revision_sha1(self
, idx
):
428 return self
.get_revisions()[idx
]
430 def apply_font_size(self
, param
, default
):
431 old_font
= self
.get_param(param
)
435 size
= self
.get_param(param
+'_size')
436 props
= old_font
.split(',')
438 new_font
= ','.join(props
)
440 self
.set_param(param
, new_font
)
442 def read_font_size(self
, param
, new_font
):
443 new_size
= int(new_font
.split(',')[1])
444 self
.set_param(param
, new_size
)
446 def get_commit_diff(self
, sha1
):
447 commit
= self
.git
.show(sha1
)
448 first_newline
= commit
.index('\n')
449 if commit
[first_newline
+1:].startswith('Merge:'):
455 suppress_header
=False,
461 def get_diff_and_status(self
, idx
, staged
=True):
463 filename
= self
.get_staged()[idx
]
464 if os
.path
.exists(filename
):
465 status
= 'Staged for commit'
467 status
= 'Staged for removal'
468 diff
= self
.diff_helper(
473 filename
= self
.get_unstaged()[idx
]
474 if os
.path
.isdir(filename
):
475 status
= 'Untracked directory'
476 diff
= '\n'.join(os
.listdir(filename
))
477 elif filename
in self
.get_modified():
478 status
= 'Modified, not staged'
479 diff
= self
.diff_helper(
484 status
= 'Untracked, not staged'
486 file_type
= utils
.run_cmd('file', '-b', filename
)
487 if 'binary' in file_type
or 'data' in file_type
:
488 diff
= utils
.run_cmd('hexdump', '-C', filename
)
490 if os
.path
.exists(filename
):
491 file = open(filename
, 'r')
498 def stage_modified(self
):
499 output
= self
.git
.add(self
.get_modified())
503 def stage_untracked(self
):
504 output
= self
.git
.add(self
.get_untracked())
508 def reset(self
, *items
):
509 output
= self
.git
.reset('--', *items
)
513 def unstage_all(self
):
514 self
.git
.reset('--', *self
.get_staged())
517 def save_gui_settings(self
):
518 self
.config_set('cola.geometry', utils
.get_geom(), local
=False)
520 def config_set(self
, key
=None, value
=None, local
=True):
521 if key
and value
is not None:
522 # git config category.key value
524 if type(value
) is bool:
525 # git uses "true" and "false"
526 strval
= strval
.lower()
528 argv
= [ key
, strval
]
530 argv
= [ '--global', key
, strval
]
531 return self
.git
.config(*argv
)
533 msg
= "oops in config_set(key=%s,value=%s,local=%s"
534 raise Exception(msg
% (key
, value
, local
))
536 def config_dict(self
, local
=True):
537 """parses the lines from git config --list into a dictionary"""
543 config_lines
= self
.git
.config(**kwargs
).splitlines()
545 for line
in config_lines
:
546 k
, v
= line
.split('=', 1)
547 k
= k
.replace('.','_') # git -> model
548 if v
== 'true' or v
== 'false':
549 v
= bool(eval(v
.title()))
557 def commit_with_msg(self
, msg
, amend
=False):
558 """Creates a git commit."""
560 if not msg
.endswith('\n'):
562 # Sure, this is a potential "security risk," but if someone
563 # is trying to intercept/re-write commit messages on your system,
564 # then you probably have bigger problems to worry about.
565 tmpfile
= self
.get_tmp_filename()
567 # Create the commit message file
568 file = open(tmpfile
, 'w')
573 output
= self
.git
.commit(F
=tmpfile
, amend
=amend
)
576 return ('git commit -F %s --amend %s\n\n%s'
577 % ( tmpfile
, amend
, output
))
581 return self
.git
.diff(
582 unified
=self
.diff_context
,
587 def get_tmp_filename(self
):
588 # Allow TMPDIR/TMP with a fallback to /tmp
590 basename
= '.git.%s.%s' % ( os
.getpid(), time
.time() )
591 tmpdir
= env
.get('TMP', env
.get('TMPDIR', '/tmp'))
592 return os
.path
.join( tmpdir
, basename
)
594 def log_helper(self
, all
=False):
595 """Returns a pair of parallel arrays listing the revision sha1's
596 and commit summaries."""
599 regex
= REV_LIST_REGEX
600 output
= self
.git
.log(pretty
='oneline', all
=all
)
601 for line
in output
.splitlines():
602 match
= regex
.match(line
)
604 revs
.append(match
.group(1))
605 summaries
.append(match
.group(2))
606 return( revs
, summaries
)
608 def parse_rev_list(self
, raw_revs
):
610 for line
in raw_revs
.splitlines():
611 match
= REV_LIST_REGEX
.match(line
)
613 rev_id
= match
.group(1)
614 summary
= match
.group(2)
615 revs
.append((rev_id
, summary
,) )
618 def rev_list_range(self
, start
, end
):
619 range = '%s..%s' % ( start
, end
)
620 raw_revs
= self
.git
.rev_list(range, pretty
='oneline')
621 return self
.parse_rev_list(raw_revs
)
623 def diff_helper(self
,
628 with_diff_header
=False,
629 suppress_header
=True,
631 "Invokes git diff on a filepath."
635 argv
.append('%s^..%s' % (commit
, commit
))
639 if type(filename
) is list:
640 argv
.extend(filename
)
642 argv
.append(filename
)
644 diff
= self
.git
.diff(
649 unified
=self
.diff_context
,
655 del_tag
= 'deleted file mode '
658 deleted
= cached
and not os
.path
.exists(filename
)
660 if not start
and '@@ ' in line
and ' @@' in line
:
662 if start
or(deleted
and del_tag
in line
):
663 output
.write(line
+ '\n')
667 elif not suppress_header
:
668 output
.write(line
+ '\n')
669 result
= output
.getvalue()
672 return('\n'.join(headers
), result
)
676 def git_repo_path(self
, *subpaths
):
677 paths
= [ self
.git
.rev_parse(git_dir
=True) ]
678 paths
.extend(subpaths
)
679 return os
.path
.realpath(os
.path
.join(*paths
))
681 def get_merge_message_path(self
):
682 for file in ('MERGE_MSG', 'SQUASH_MSG'):
683 path
= self
.git_repo_path(file)
684 if os
.path
.exists(path
):
688 def get_merge_message(self
):
689 return self
.git
.fmt_merge_msg(
690 '--file', self
.git_repo_path('FETCH_HEAD')
693 def abort_merge(self
):
695 output
= self
.git
.read_tree("HEAD", reset
=True, u
=True, v
=True)
697 merge_head
= self
.git_repo_path('MERGE_HEAD')
698 if os
.path
.exists(merge_head
):
699 os
.unlink(merge_head
)
700 # remove MERGE_MESSAGE, etc.
701 merge_msg_path
= self
.get_merge_message_path()
702 while merge_msg_path
:
703 os
.unlink(merge_msg_path
)
704 merge_msg_path
= self
.get_merge_message_path()
707 def parse_status(self
):
708 """RETURNS: A tuple of staged, unstaged and untracked file lists.
711 """handles quoted paths."""
712 if path
.startswith('"') and path
.endswith('"'):
717 MODIFIED_TAG
= '# Changed but not updated:'
718 UNTRACKED_TAG
= '# Untracked files:'
719 RGX_RENAMED
= re
.compile(
723 RGX_MODIFIED
= re
.compile(
736 current_dest
= staged
739 for status_line
in self
.git
.status().splitlines():
740 if status_line
== MODIFIED_TAG
:
742 current_dest
= unstaged
744 elif status_line
== UNTRACKED_TAG
:
745 mode
= UNTRACKED_MODE
746 current_dest
= untracked
748 # Staged/unstaged modified/renamed/deleted files
749 if mode
is STAGED_MODE
or mode
is UNSTAGED_MODE
:
750 match
= RGX_MODIFIED
.match(status_line
)
753 filename
= status_line
.replace(tag
, '')
754 current_dest
.append(eval_path(filename
))
756 match
= RGX_RENAMED
.match(status_line
)
758 oldname
= match
.group(2)
759 newname
= match
.group(3)
760 current_dest
.append(eval_path(oldname
))
761 current_dest
.append(eval_path(newname
))
764 elif mode
is UNTRACKED_MODE
:
765 if status_line
.startswith('#\t'):
766 current_dest
.append(eval_path(status_line
[2:]))
768 return( staged
, unstaged
, untracked
)
770 def reset_helper(self
, *args
, **kwargs
):
771 return self
.git
.reset('--', *args
, **kwargs
)
773 def remote_url(self
, name
):
774 return self
.git
.config('remote.%s.url' % name
, get
=True)
776 def get_remote_args(self
, remote
,
777 local_branch
='', remote_branch
='',
778 ffwd
=True, tags
=False):
780 branch_arg
= '%s:%s' % ( remote_branch
, local_branch
)
782 branch_arg
= '+%s:%s' % ( remote_branch
, local_branch
)
784 if local_branch
and remote_branch
:
785 args
.append(branch_arg
)
786 kwargs
= { "with_stderr": True, "with_status": True, "tags": tags
}
787 return (args
, kwargs
)
789 def fetch_helper(self
, *args
, **kwargs
):
791 Fetches remote_branch to local_branch only if
792 remote_branch and local_branch are both supplied.
793 If either is ommitted, "git fetch <remote>" is performed instead.
794 Returns (status,output)
796 args
, kwargs
= self
.get_remote_args(*args
, **kwargs
)
797 return self
.git
.fetch(v
=True, *args
, **kwargs
)
799 def push_helper(self
, *args
, **kwargs
):
801 Pushes local_branch to remote's remote_branch only if
802 remote_branch and local_branch both are supplied.
803 If either is ommitted, "git push <remote>" is performed instead.
804 Returns (status,output)
806 args
, kwargs
= self
.get_remote_args(*args
, **kwargs
)
807 return self
.git
.push(*args
, **kwargs
)
809 def pull_helper(self
, *args
, **kwargs
):
811 Pushes branches. If local_branch or remote_branch is ommitted,
812 "git pull <remote>" is performed instead of
813 "git pull <remote> <remote_branch>:<local_branch>
814 Returns (status,output)
816 args
, kwargs
= self
.get_remote_args(*args
, **kwargs
)
817 return self
.git
.pull(v
=True, *args
, **kwargs
)
820 def parse_ls_tree(self
, rev
):
821 """Returns a list of(mode, type, sha1, path) tuples."""
822 lines
= self
.git
.ls_tree(rev
, r
=True).splitlines()
824 regex
= re
.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
826 match
= regex
.match(line
)
828 mode
= match
.group(1)
829 objtype
= match
.group(2)
830 sha1
= match
.group(3)
831 filename
= match
.group(4)
832 output
.append((mode
, objtype
, sha1
, filename
,) )
835 def format_patch_helper(self
, to_export
, revs
, output
='patches'):
836 """writes patches named by to_export to the output directory."""
840 cur_rev
= to_export
[0]
841 cur_master_idx
= revs
.index(cur_rev
)
843 patches_to_export
= [ [cur_rev
] ]
846 # Group the patches into continuous sets
847 for idx
, rev
in enumerate(to_export
[1:]):
848 # Limit the search to the current neighborhood for efficiency
849 master_idx
= revs
[ cur_master_idx
: ].index(rev
)
850 master_idx
+= cur_master_idx
851 if master_idx
== cur_master_idx
+ 1:
852 patches_to_export
[ patchset_idx
].append(rev
)
856 patches_to_export
.append([ rev
])
857 cur_master_idx
= master_idx
860 # Export each patchsets
861 for patchset
in patches_to_export
:
862 cmdoutput
= self
.export_patchset(
868 patch_with_stat
=True,
870 outlines
.append(cmdoutput
)
871 return '\n'.join(outlines
)
873 def export_patchset(self
, start
, end
, output
="patches", **kwargs
):
874 revarg
= '%s^..%s' % (start
, end
)
875 return self
.git
.format_patch("-o", output
, revarg
, **kwargs
)
877 def current_branch(self
):
878 """Parses 'git branch' to find the current branch."""
879 branches
= self
.git
.branch().splitlines()
880 for branch
in branches
:
881 if branch
.startswith('* '):
882 return branch
.lstrip('* ')
883 return 'Detached HEAD'
885 def create_branch(self
, name
, base
, track
=False):
886 """Creates a branch starting from base. Pass track=True
887 to create a remote tracking branch."""
888 return self
.git
.branch(name
, base
, track
=track
)
890 def cherry_pick_list(self
, revs
, **kwargs
):
891 """Cherry-picks each revision into the current branch.
892 Returns a list of command output strings (1 per cherry pick)"""
897 cherries
.append(self
.git
.cherry_pick(rev
, **kwargs
))
898 return '\n'.join(cherries
)
900 def parse_stash_list(self
, revids
=False):
901 """Parses "git stash list" and returns a list of stashes."""
902 stashes
= self
.stash("list").splitlines()
904 return [ s
[:s
.index(':')] for s
in stashes
]
906 return [ s
[s
.index(':')+1:] for s
in stashes
]