1 # Copyright (c) 2008 David Aguilar
2 """This module provides the central cola model.
10 from cStringIO
import StringIO
12 from cola
import gitcola
14 from cola
import utils
15 from cola
import errors
16 from cola
.models
.observable
import ObservableModel
18 #+-------------------------------------------------------------------------
19 #+ A regex for matching the output of git(log|rev-list) --pretty=oneline
20 REV_LIST_REGEX
= re
.compile('([0-9a-f]+)\W(.*)')
22 # Provides access to a global MainModel instance
25 """Returns the main model singleton"""
29 _instance
= MainModel()
34 """handles quoted paths."""
35 if path
.startswith('"') and path
.endswith('"'):
36 return core
.decode(eval(path
))
41 class MainModel(ObservableModel
):
42 """Provides a friendly wrapper for doing common git operations."""
45 message_updated
= 'updated'
46 message_about_to_update
= 'about_to_update'
49 mode_none
= 'none' # Default: nothing's happened, do nothing
50 mode_worktree
= 'worktree' # Comparing index to worktree
51 mode_index
= 'index' # Comparing index to last commit
52 mode_amend
= 'amend' # Amending a commit
53 mode_grep
= 'grep' # We ran Search -> Grep
54 mode_branch
= 'branch' # Applying changes from a branch
55 mode_diff
= 'diff' # Diffing against an arbitrary branch
56 mode_diff_expr
= 'diff_expr' # Diffing using arbitrary expression
57 mode_review
= 'review' # Reviewing a branch
59 # Modes where we don't do anything like staging, etc.
60 modes_read_only
= (mode_branch
, mode_grep
,
61 mode_diff
, mode_diff_expr
, mode_review
)
62 # Modes where we can checkout files from the $head
63 modes_undoable
= (mode_none
, mode_index
, mode_worktree
)
65 def __init__(self
, cwd
=None):
66 """Reads git repository settings and sets several methods
67 so that they refer to the git module. This object
68 encapsulates cola's interaction with git."""
69 ObservableModel
.__init
__(self
)
71 # Initialize the git command object
72 self
.git
= gitcola
.GitCola()
74 #####################################################
76 self
.mode
= self
.mode_none
79 self
.currentbranch
= ''
80 self
.trackedbranch
= ''
82 self
.git_version
= self
.git
.version()
85 self
.local_branch
= ''
86 self
.remote_branch
= ''
88 #####################################################
96 self
.upstream_changed
= []
98 #####################################################
101 self
.local_branches
= []
102 self
.remote_branches
= []
107 # These are parallel lists
113 self
.directories
= []
114 self
.directory_entries
= {}
117 self
.subtree_types
= []
118 self
.subtree_sha1s
= []
119 self
.subtree_names
= []
121 self
.fetch_helper
= None
122 self
.push_helper
= None
123 self
.pull_helper
= None
124 self
.generate_remote_helpers()
126 self
.use_worktree(cwd
)
129 return self
.mode
in self
.modes_read_only
132 """Whether we can checkout files from the $head."""
133 return self
.mode
in self
.modes_undoable
135 def enable_staging(self
):
136 """Whether staging should be allowed."""
137 return self
.mode
== self
.mode_worktree
140 """Returns the names of all files in the repository"""
141 return [core
.decode(f
)
142 for f
in self
.git
.ls_files(z
=True)
143 .strip('\0').split('\0') if f
]
145 def generate_remote_helpers(self
):
146 """Generates helper methods for fetch, push and pull"""
147 self
.push_helper
= self
.gen_remote_helper(self
.git
.push
, push
=True)
148 self
.fetch_helper
= self
.gen_remote_helper(self
.git
.fetch
)
149 self
.pull_helper
= self
.gen_remote_helper(self
.git
.pull
)
151 def use_worktree(self
, worktree
):
152 self
.git
.load_worktree(worktree
)
153 is_valid
= self
.git
.is_valid()
155 self
._init
_config
_data
()
156 self
.set_project(os
.path
.basename(self
.git
.worktree()))
159 def _init_config_data(self
):
160 """Reads git config --list and creates parameters
162 # These parameters are saved in .gitconfig,
163 # so ideally these should be as short as possible.
165 # config items that are controllable globally
167 self
._local
_and
_global
_defaults
= {
170 'merge_summary': False,
171 'merge_diffstat': True,
172 'merge_verbosity': 2,
173 'gui_diffcontext': 3,
174 'gui_pruneduringfetch': False,
176 # config items that are purely git config --global settings
177 self
._global
_defaults
= {
180 'cola_fontdiff_size': 12,
181 'cola_savewindowsettings': False,
182 'cola_showoutput': 'errors',
184 'merge_keepbackup': True,
185 'diff_tool': os
.getenv('GIT_DIFF_TOOL', 'xxdiff'),
186 'merge_tool': os
.getenv('GIT_MERGE_TOOL', 'xxdiff'),
187 'gui_editor': os
.getenv('EDITOR', 'gvim'),
188 'gui_historybrowser': 'gitk',
191 local_dict
= self
.config_dict(local
=True)
192 global_dict
= self
.config_dict(local
=False)
194 for k
,v
in local_dict
.iteritems():
195 self
.set_param('local_'+k
, v
)
196 for k
,v
in global_dict
.iteritems():
197 self
.set_param('global_'+k
, v
)
198 if k
not in local_dict
:
200 self
.set_param('local_'+k
, v
)
202 # Bootstrap the internal font*size variables
203 for param
in ('global_cola_fontdiff'):
205 if hasattr(self
, param
):
206 font
= getattr(self
, param
)
209 size
= int(font
.split(',')[1])
210 self
.set_param(param
+'_size', size
)
211 param
= param
[len('global_'):]
212 global_dict
[param
] = font
213 global_dict
[param
+'_size'] = size
215 # Load defaults for all undefined items
216 local_and_global_defaults
= self
._local
_and
_global
_defaults
217 for k
,v
in local_and_global_defaults
.iteritems():
218 if k
not in local_dict
:
219 self
.set_param('local_'+k
, v
)
220 if k
not in global_dict
:
221 self
.set_param('global_'+k
, v
)
223 global_defaults
= self
._global
_defaults
224 for k
,v
in global_defaults
.iteritems():
225 if k
not in global_dict
:
226 self
.set_param('global_'+k
, v
)
228 # Load the diff context
229 self
.diff_context
= self
.local_config('gui.diffcontext', 3)
231 def global_config(self
, key
, default
=None):
232 return self
.param('global_'+key
.replace('.', '_'),
235 def local_config(self
, key
, default
=None):
236 return self
.param('local_'+key
.replace('.', '_'),
239 def cola_config(self
, key
):
240 return getattr(self
, 'global_cola_'+key
)
242 def gui_config(self
, key
):
243 return getattr(self
, 'global_gui_'+key
)
245 def branch_list(self
, remote
=False):
246 """Returns a list of local or remote branches
248 This explicitly removes HEAD from the list of remote branches.
250 branches
= map(lambda x
: x
.lstrip('* '),
251 self
.git
.branch(r
=remote
).splitlines())
253 return [b
for b
in branches
if b
.find('/HEAD') == -1]
256 def config_params(self
):
258 params
.extend(map(lambda x
: 'local_' + x
,
259 self
._local
_and
_global
_defaults
.keys()))
260 params
.extend(map(lambda x
: 'global_' + x
,
261 self
._local
_and
_global
_defaults
.keys()))
262 params
.extend(map(lambda x
: 'global_' + x
,
263 self
._global
_defaults
.keys()))
264 return [ p
for p
in params
if not p
.endswith('_size') ]
266 def save_config_param(self
, param
):
267 if param
not in self
.config_params():
269 value
= getattr(self
, param
)
270 if param
== 'local_gui_diffcontext':
271 self
.diff_context
= value
272 if param
.startswith('local_'):
273 param
= param
[len('local_'):]
275 elif param
.startswith('global_'):
276 param
= param
[len('global_'):]
279 raise Exception("Invalid param '%s' passed to " % param
280 +'save_config_param()')
281 param
= param
.replace('_', '.') # model -> git
282 return self
.config_set(param
, value
, local
=is_local
)
284 def init_browser_data(self
):
285 """This scans over self.(names, sha1s, types) to generate
286 directories, directory_entries, and subtree_*"""
288 # Collect data for the model
289 if not self
.currentbranch
:
292 self
.subtree_types
= []
293 self
.subtree_sha1s
= []
294 self
.subtree_names
= []
295 self
.directories
= []
296 self
.directory_entries
= {}
298 # Lookup the tree info
299 tree_info
= self
.parse_ls_tree(self
.currentbranch
)
301 self
.set_types(map(lambda(x
): x
[1], tree_info
))
302 self
.set_sha1s(map(lambda(x
): x
[2], tree_info
))
303 self
.set_names(map(lambda(x
): x
[3], tree_info
))
305 if self
.directory
: self
.directories
.append('..')
307 dir_entries
= self
.directory_entries
308 dir_regex
= re
.compile('([^/]+)/')
312 for idx
, name
in enumerate(self
.names
):
313 if not name
.startswith(self
.directory
):
315 name
= name
[ len(self
.directory
): ]
317 # This is a directory...
318 match
= dir_regex
.match(name
)
321 dirent
= match
.group(1) + '/'
322 if dirent
not in self
.directory_entries
:
323 self
.directory_entries
[dirent
] = []
325 if dirent
not in dirs_seen
:
326 dirs_seen
[dirent
] = True
327 self
.directories
.append(dirent
)
329 entry
= name
.replace(dirent
, '')
330 entry_match
= dir_regex
.match(entry
)
332 subdir
= entry_match
.group(1) + '/'
333 if subdir
in subdirs_seen
:
335 subdirs_seen
[subdir
] = True
336 dir_entries
[dirent
].append(subdir
)
338 dir_entries
[dirent
].append(entry
)
340 self
.subtree_types
.append(self
.types
[idx
])
341 self
.subtree_sha1s
.append(self
.sha1s
[idx
])
342 self
.subtree_names
.append(name
)
344 def add_or_remove(self
, to_process
):
345 """Invokes 'git add' to index the filenames in to_process that exist
346 and 'git rm' for those that do not exist."""
349 return 'No files to add or remove.'
354 for filename
in to_process
:
355 encfilename
= core
.encode(filename
)
356 if os
.path
.exists(encfilename
):
357 to_add
.append(filename
)
361 newstatus
, output
= self
.git
.add(v
=True,
369 if len(to_add
) == len(to_process
):
370 # to_process only contained unremoved files --
371 # short-circuit the removal checks
372 return (status
, output
)
374 # Process files to remote
375 for filename
in to_process
:
376 if not os
.path
.exists(filename
):
377 to_remove
.append(filename
)
378 newstatus
, out
= self
.git
.rm(with_stderr
=True,
383 output
+ '\n\n' + out
384 return (status
, output
)
387 return self
.gui_config('editor')
389 def history_browser(self
):
390 return self
.gui_config('historybrowser')
392 def remember_gui_settings(self
):
393 return self
.cola_config('savewindowsettings')
395 def subtree_node(self
, idx
):
396 return (self
.subtree_types
[idx
],
397 self
.subtree_sha1s
[idx
],
398 self
.subtree_names
[idx
])
400 def all_branches(self
):
401 return (self
.local_branches
+ self
.remote_branches
)
403 def set_remote(self
, remote
):
406 self
.set_param('remote', remote
)
407 branches
= utils
.grep('%s/\S+$' % remote
,
408 self
.branch_list(remote
=True),
410 self
.set_remote_branches(branches
)
412 def apply_diff(self
, filename
):
413 return self
.git
.apply(filename
, index
=True, cached
=True)
415 def apply_diff_to_worktree(self
, filename
):
416 return self
.git
.apply(filename
)
418 def load_commitmsg(self
, path
):
420 contents
= core
.decode(core
.read_nointr(fh
))
422 self
.set_commitmsg(contents
)
424 def prev_commitmsg(self
):
425 """Queries git for the latest commit message."""
426 return core
.decode(self
.git
.log('-1', pretty
='format:%s%n%n%b'))
428 def load_commitmsg_template(self
):
429 template
= self
.global_config('commit.template')
431 self
.load_commitmsg(template
)
433 def update_status(self
):
434 # Give observers a chance to respond
435 self
.notify_message_observers(self
.message_about_to_update
)
436 # This allows us to defer notification until the
437 # we finish processing data
438 staged_only
= self
.read_only()
440 notify_enabled
= self
.notification_enabled
441 self
.notification_enabled
= False
443 # Set these early since they are used to calculate 'upstream_changed'.
444 self
.set_currentbranch(self
.current_branch())
445 self
.set_trackedbranch(self
.tracked_branch())
451 self
.upstream_changed
) = self
.worktree_state(head
=head
,
452 staged_only
=staged_only
)
453 # NOTE: the model's unstaged list holds an aggregate of the
454 # the modified, unmerged, and untracked file lists.
455 self
.set_unstaged(self
.modified
+ self
.unmerged
+ self
.untracked
)
456 self
.set_remotes(self
.git
.remote().splitlines())
457 self
.set_remote_branches(self
.branch_list(remote
=True))
458 self
.set_local_branches(self
.branch_list(remote
=False))
459 self
.set_tags(self
.git
.tag().splitlines())
460 self
.set_revision('')
461 self
.set_local_branch('')
462 self
.set_remote_branch('')
463 # Re-enable notifications and emit changes
464 self
.notification_enabled
= notify_enabled
466 self
.read_font_sizes()
467 self
.notify_observers('staged','unstaged')
468 self
.notify_message_observers(self
.message_updated
)
470 def read_font_sizes(self
):
471 """Read font sizes from the configuration."""
472 value
= self
.cola_config('fontdiff')
475 items
= value
.split(',')
478 self
.global_cola_fontdiff_size
= int(float(items
[1]))
480 def set_diff_font(self
, fontstr
):
481 """Set the diff font string."""
482 self
.global_cola_fontdiff
= fontstr
483 self
.read_font_sizes()
485 def delete_branch(self
, branch
):
486 return self
.git
.branch(branch
,
491 def revision_sha1(self
, idx
):
492 return self
.revisions
[idx
]
494 def apply_diff_font_size(self
, default
):
495 old_font
= self
.cola_config('fontdiff')
498 size
= self
.cola_config('fontdiff_size')
499 props
= old_font
.split(',')
501 new_font
= ','.join(props
)
502 self
.global_cola_fontdiff
= new_font
503 self
.notify_observers('global_cola_fontdiff')
505 def commit_diff(self
, sha1
):
506 commit
= self
.git
.show(sha1
)
507 first_newline
= commit
.index('\n')
508 if commit
[first_newline
+1:].startswith('Merge:'):
509 return (core
.decode(commit
) + '\n\n' +
510 core
.decode(self
.diff_helper(commit
=sha1
,
512 suppress_header
=False)))
514 return core
.decode(commit
)
516 def filename(self
, idx
, staged
=True):
519 return self
.staged
[idx
]
521 return self
.unstaged
[idx
]
525 def diff_details(self
, idx
, ref
, staged
=True):
527 Return a "diff" for an entry by index relative to ref.
529 `staged` indicates whether we should consider this as a
530 staged or unstaged entry.
533 filename
= self
.filename(idx
, staged
=staged
)
536 encfilename
= core
.encode(filename
)
538 diff
= self
.diff_helper(filename
=filename
,
542 if os
.path
.isdir(encfilename
):
543 diff
= '\n'.join(os
.listdir(filename
))
545 elif filename
in self
.unmerged
:
546 diff
= ('@@@ Unmerged @@@\n'
547 '- %s is unmerged.\n+ ' % filename
+
548 'Right-click the file to launch "git mergetool".\n'
549 '@@@ Unmerged @@@\n\n')
550 diff
+= self
.diff_helper(filename
=filename
,
552 elif filename
in self
.modified
:
553 diff
= self
.diff_helper(filename
=filename
,
556 diff
= 'SHA1: ' + self
.git
.hash_object(filename
)
557 return (diff
, filename
)
559 def stage_modified(self
):
560 status
, output
= self
.git
.add(v
=True,
565 return (status
, output
)
567 def stage_untracked(self
):
568 status
, output
= self
.git
.add(v
=True,
573 return (status
, output
)
575 def reset(self
, *items
):
576 status
, output
= self
.git
.reset('--',
581 return (status
, output
)
583 def unstage_all(self
):
584 status
, output
= self
.git
.reset(with_stderr
=True,
587 return (status
, output
)
590 status
, output
= self
.git
.add(v
=True,
595 return (status
, output
)
597 def config_set(self
, key
=None, value
=None, local
=True):
598 if key
and value
is not None:
599 # git config category.key value
600 strval
= unicode(value
)
601 if type(value
) is bool:
602 # git uses "true" and "false"
603 strval
= strval
.lower()
605 argv
= [ key
, strval
]
607 argv
= [ '--global', key
, strval
]
608 return self
.git
.config(*argv
)
610 msg
= "oops in config_set(key=%s,value=%s,local=%s)"
611 raise Exception(msg
% (key
, value
, local
))
613 def config_dict(self
, local
=True):
614 """parses the lines from git config --list into a dictionary"""
618 'global': not local
, # global is a python keyword
620 config_lines
= self
.git
.config(**kwargs
).splitlines()
622 for line
in config_lines
:
624 k
, v
= line
.split('=', 1)
626 # the user has an invalid entry in their git config
629 k
= k
.replace('.','_') # git -> model
630 if v
== 'true' or v
== 'false':
631 v
= bool(eval(v
.title()))
639 def commit_with_msg(self
, msg
, amend
=False):
640 """Creates a git commit."""
642 if not msg
.endswith('\n'):
644 # Sure, this is a potential "security risk," but if someone
645 # is trying to intercept/re-write commit messages on your system,
646 # then you probably have bigger problems to worry about.
647 tmpfile
= self
.tmp_filename()
649 # Create the commit message file
650 fh
= open(tmpfile
, 'w')
651 core
.write_nointr(fh
, msg
)
655 status
, out
= self
.git
.commit(F
=tmpfile
, v
=True, amend
=amend
,
662 # Allow TMPDIR/TMP with a fallback to /tmp
663 return os
.environ
.get('TMP', os
.environ
.get('TMPDIR', '/tmp'))
665 def tmp_file_pattern(self
):
666 return os
.path
.join(self
.tmp_dir(), '*.git-cola.%s.*' % os
.getpid())
668 def tmp_filename(self
, prefix
=''):
669 basename
= ((prefix
+'.git-cola.%s.%s'
670 % (os
.getpid(), time
.time())))
671 basename
= basename
.replace('/', '-')
672 basename
= basename
.replace('\\', '-')
673 tmpdir
= self
.tmp_dir()
674 return os
.path
.join(tmpdir
, basename
)
676 def log_helper(self
, all
=False, extra_args
=None):
678 Returns a pair of parallel arrays listing the revision sha1's
679 and commit summaries.
683 regex
= REV_LIST_REGEX
687 output
= self
.git
.log(pretty
='oneline', all
=all
, *args
)
688 for line
in map(core
.decode
, output
.splitlines()):
689 match
= regex
.match(line
)
691 revs
.append(match
.group(1))
692 summaries
.append(match
.group(2))
693 return (revs
, summaries
)
695 def parse_rev_list(self
, raw_revs
):
697 for line
in map(core
.decode
, raw_revs
.splitlines()):
698 match
= REV_LIST_REGEX
.match(line
)
700 rev_id
= match
.group(1)
701 summary
= match
.group(2)
702 revs
.append((rev_id
, summary
,))
705 def rev_list_range(self
, start
, end
):
706 range = '%s..%s' % (start
, end
)
707 raw_revs
= self
.git
.rev_list(range, pretty
='oneline')
708 return self
.parse_rev_list(raw_revs
)
710 def diff_helper(self
,
717 with_diff_header
=False,
718 suppress_header
=True,
720 "Invokes git diff on a filepath."
722 ref
, endref
= commit
+'^', commit
725 argv
.append('%s..%s' % (ref
, endref
))
727 for r
in ref
.strip().split():
734 if type(filename
) is list:
735 argv
.extend(filename
)
737 argv
.append(filename
)
740 del_tag
= 'deleted file mode '
743 deleted
= cached
and not os
.path
.exists(core
.encode(filename
))
745 diffoutput
= self
.git
.diff(R
=reverse
,
749 unified
=self
.diff_context
,
750 with_raw_output
=True,
755 if diffoutput
.startswith('fatal:'):
763 diff
= diffoutput
.split('\n')
764 for line
in map(core
.decode
, diff
):
765 if not start
and '@@' == line
[:2] and '@@' in line
[2:]:
767 if start
or (deleted
and del_tag
in line
):
768 output
.write(core
.encode(line
) + '\n')
771 headers
.append(core
.encode(line
))
772 elif not suppress_header
:
773 output
.write(core
.encode(line
) + '\n')
775 result
= core
.decode(output
.getvalue())
779 return('\n'.join(headers
), result
)
783 def git_repo_path(self
, *subpaths
):
784 paths
= [self
.git
.git_dir()]
785 paths
.extend(subpaths
)
786 return os
.path
.realpath(os
.path
.join(*paths
))
788 def merge_message_path(self
):
789 for basename
in ('MERGE_MSG', 'SQUASH_MSG'):
790 path
= self
.git_repo_path(basename
)
791 if os
.path
.exists(path
):
795 def merge_message(self
):
796 return self
.git
.fmt_merge_msg('--file',
797 self
.git_repo_path('FETCH_HEAD'))
799 def abort_merge(self
):
801 output
= self
.git
.read_tree('HEAD', reset
=True, u
=True, v
=True)
803 merge_head
= self
.git_repo_path('MERGE_HEAD')
804 if os
.path
.exists(merge_head
):
805 os
.unlink(merge_head
)
806 # remove MERGE_MESSAGE, etc.
807 merge_msg_path
= self
.merge_message_path()
808 while merge_msg_path
:
809 os
.unlink(merge_msg_path
)
810 merge_msg_path
= self
.merge_message_path()
812 def _is_modified(self
, name
):
813 status
, out
= self
.git
.diff('--', name
,
820 def _branch_status(self
, branch
):
822 Returns a tuple of staged, unstaged, untracked, and unmerged files
824 This shows only the changes that were introduced in branch
827 status
, output
= self
.git
.diff(name_only
=True,
831 *branch
.strip().split())
833 return ([], [], [], [], [])
835 staged
= map(core
.decode
, [n
for n
in output
.split('\0') if n
])
836 return (staged
, [], [], [], staged
)
838 def worktree_state(self
, head
='HEAD', staged_only
=False):
839 """Return a tuple of files in various states of being
841 Can be staged, unstaged, untracked, unmerged, or changed
845 self
.git
.update_index(refresh
=True)
847 return self
._branch
_status
(head
)
851 upstream_changed_set
= set()
853 (staged
, modified
, unmerged
, untracked
, upstream_changed
) = (
856 output
= self
.git
.diff_index(head
,
859 if output
.startswith('fatal:'):
860 raise errors
.GitInitError('git init')
861 for line
in output
.splitlines():
862 rest
, name
= line
.split('\t', 1)
864 name
= eval_path(name
)
868 # This file will also show up as 'M' without --cached
869 # so by default don't consider it modified unless
870 # it's truly modified
871 modified_set
.add(name
)
872 if not staged_only
and self
._is
_modified
(name
):
873 modified
.append(name
)
880 modified_set
.add(name
)
882 unmerged
.append(name
)
883 modified_set
.add(name
)
885 except errors
.GitInitError
:
887 staged
.extend(self
.all_files())
890 output
= self
.git
.diff_index(head
, with_stderr
=True)
891 if output
.startswith('fatal:'):
892 raise errors
.GitInitError('git init')
893 for line
in output
.splitlines():
894 info
, name
= line
.split('\t', 1)
895 status
= info
.split()[-1]
896 if status
== 'M' or status
== 'D':
897 name
= eval_path(name
)
898 if name
not in modified_set
:
899 modified
.append(name
)
901 name
= eval_path(name
)
902 # newly-added yet modified
903 if (name
not in modified_set
and not staged_only
and
904 self
._is
_modified
(name
)):
905 modified
.append(name
)
907 except errors
.GitInitError
:
909 ls_files
= (self
.git
.ls_files(modified
=True, z
=True)[:-1]
911 modified
.extend(map(core
.decode
, [f
for f
in ls_files
if f
]))
913 untracked
.extend(self
.untracked_files())
915 # Look for upstream modified files if this is a tracking branch
916 if self
.trackedbranch
:
918 diff_expr
= self
.merge_base_to(self
.trackedbranch
)
919 output
= self
.git
.diff(diff_expr
,
923 if output
.startswith('fatal:'):
924 raise errors
.GitInitError('git init')
926 for name
in [n
for n
in output
.split('\0') if n
]:
927 name
= core
.decode(name
)
928 upstream_changed
.append(name
)
929 upstream_changed_set
.add(name
)
931 except errors
.GitInitError
:
940 upstream_changed
.sort()
942 return (staged
, modified
, unmerged
, untracked
, upstream_changed
)
944 def reset_helper(self
, args
):
945 """Removes files from the index
947 This handles the git init case, which is why it's not
948 just 'git reset name'. For the git init case this falls
949 back to 'git rm --cached'.
952 # fake the status because 'git reset' returns 1
953 # regardless of success/failure
955 output
= self
.git
.reset('--', with_stderr
=True, *args
)
956 # handle git init: we have to use 'git rm --cached'
957 # detect this condition by checking if the file is still staged
958 state
= self
.worktree_state()
960 rmargs
= [a
for a
in args
if a
in staged
]
962 return (status
, output
)
963 output
+= self
.git
.rm('--', cached
=True, with_stderr
=True, *rmargs
)
964 return (status
, output
)
966 def remote_url(self
, name
):
967 return self
.git
.config('remote.%s.url' % name
, get
=True)
969 def remote_args(self
, remote
,
976 # Swap the branches in push mode (reverse of fetch)
979 local_branch
= remote_branch
982 branch_arg
= '%s:%s' % ( remote_branch
, local_branch
)
984 branch_arg
= '+%s:%s' % ( remote_branch
, local_branch
)
986 if local_branch
and remote_branch
:
987 args
.append(branch_arg
)
989 args
.append(local_branch
)
991 args
.append(remote_branch
)
999 return (args
, kwargs
)
1001 def gen_remote_helper(self
, gitaction
, push
=False):
1002 """Generates a closure that calls git fetch, push or pull
1004 def remote_helper(remote
, **kwargs
):
1005 args
, kwargs
= self
.remote_args(remote
, push
=push
, **kwargs
)
1006 return gitaction(*args
, **kwargs
)
1007 return remote_helper
1009 def parse_ls_tree(self
, rev
):
1010 """Returns a list of(mode, type, sha1, path) tuples."""
1011 lines
= self
.git
.ls_tree(rev
, r
=True).splitlines()
1013 regex
= re
.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
1015 match
= regex
.match(line
)
1017 mode
= match
.group(1)
1018 objtype
= match
.group(2)
1019 sha1
= match
.group(3)
1020 filename
= match
.group(4)
1021 output
.append((mode
, objtype
, sha1
, filename
,) )
1024 def format_patch_helper(self
, to_export
, revs
, output
='patches'):
1025 """writes patches named by to_export to the output directory."""
1029 cur_rev
= to_export
[0]
1030 cur_master_idx
= revs
.index(cur_rev
)
1032 patches_to_export
= [ [cur_rev
] ]
1035 # Group the patches into continuous sets
1036 for idx
, rev
in enumerate(to_export
[1:]):
1037 # Limit the search to the current neighborhood for efficiency
1038 master_idx
= revs
[ cur_master_idx
: ].index(rev
)
1039 master_idx
+= cur_master_idx
1040 if master_idx
== cur_master_idx
+ 1:
1041 patches_to_export
[ patchset_idx
].append(rev
)
1045 patches_to_export
.append([ rev
])
1046 cur_master_idx
= master_idx
1049 # Export each patchsets
1051 for patchset
in patches_to_export
:
1052 newstatus
, out
= self
.export_patchset(patchset
[0],
1055 n
=len(patchset
) > 1,
1057 patch_with_stat
=True)
1058 outlines
.append(out
)
1061 return (status
, '\n'.join(outlines
))
1063 def export_patchset(self
, start
, end
, output
="patches", **kwargs
):
1064 revarg
= '%s^..%s' % (start
, end
)
1065 return self
.git
.format_patch('-o', output
, revarg
,
1070 def current_branch(self
):
1071 """Parses 'git symbolic-ref' to find the current branch."""
1072 headref
= self
.git
.symbolic_ref('HEAD', with_stderr
=True)
1073 if headref
.startswith('refs/heads/'):
1075 elif headref
.startswith('fatal:'):
1079 def tracked_branch(self
):
1080 """The name of the branch that current branch is tracking"""
1081 remote
= self
.git
.config('branch.'+self
.currentbranch
+'.remote',
1082 get
=True, with_stderr
=True)
1085 headref
= self
.git
.config('branch.'+self
.currentbranch
+'.merge',
1086 get
=True, with_stderr
=True)
1087 if headref
.startswith('refs/heads/'):
1088 tracked_branch
= headref
[11:]
1089 return remote
+ '/' + tracked_branch
1092 def create_branch(self
, name
, base
, track
=False):
1093 """Create a branch named 'name' from revision 'base'
1095 Pass track=True to create a local tracking branch.
1097 return self
.git
.branch(name
, base
, track
=track
,
1101 def cherry_pick_list(self
, revs
, **kwargs
):
1102 """Cherry-picks each revision into the current branch.
1103 Returns a list of command output strings (1 per cherry pick)"""
1109 newstatus
, out
= self
.git
.cherry_pick(rev
,
1114 cherries
.append(out
)
1115 return (status
, '\n'.join(cherries
))
1117 def parse_stash_list(self
, revids
=False):
1118 """Parses "git stash list" and returns a list of stashes."""
1119 stashes
= self
.git
.stash("list").splitlines()
1121 return [ s
[:s
.index(':')] for s
in stashes
]
1123 return [ s
[s
.index(':')+1:] for s
in stashes
]
1125 def pad(self
, pstr
, num
=22):
1126 topad
= num
-len(pstr
)
1128 return pstr
+ ' '*topad
1132 def describe(self
, revid
, descr
):
1133 version
= self
.git
.describe(revid
, tags
=True, always
=True,
1135 return version
+ ' - ' + descr
1137 def update_revision_lists(self
, filename
=None, show_versions
=False):
1138 num_results
= self
.num_results
1140 rev_list
= self
.git
.log('--', filename
,
1141 max_count
=num_results
,
1144 rev_list
= self
.git
.log(max_count
=num_results
,
1145 pretty
='oneline', all
=True)
1147 commit_list
= self
.parse_rev_list(rev_list
)
1148 commit_list
.reverse()
1149 commits
= map(lambda x
: x
[0], commit_list
)
1150 descriptions
= map(lambda x
: core
.decode(x
[1]), commit_list
)
1152 fancy_descr_list
= map(lambda x
: self
.describe(*x
), commit_list
)
1153 self
.set_descriptions_start(fancy_descr_list
)
1154 self
.set_descriptions_end(fancy_descr_list
)
1156 self
.set_descriptions_start(descriptions
)
1157 self
.set_descriptions_end(descriptions
)
1159 self
.set_revisions_start(commits
)
1160 self
.set_revisions_end(commits
)
1164 def changed_files(self
, start
, end
):
1165 zfiles_str
= self
.git
.diff('%s..%s' % (start
, end
),
1166 name_only
=True, z
=True).strip('\0')
1167 return [core
.decode(enc
) for enc
in zfiles_str
.split('\0') if enc
]
1169 def renamed_files(self
, start
, end
):
1170 difflines
= self
.git
.diff('%s..%s' % (start
, end
),
1172 M
=True).splitlines()
1173 return [ eval_path(r
[12:].rstrip())
1174 for r
in difflines
if r
.startswith('rename from ') ]
1176 def is_commit_published(self
):
1177 head
= self
.git
.rev_parse('HEAD')
1178 return bool(self
.git
.branch(r
=True, contains
=head
))
1180 def merge_base_to(self
, ref
):
1181 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
1182 base
= self
.git
.merge_base('HEAD', ref
)
1183 return '%s..%s' % (base
, ref
)
1185 def everything(self
):
1186 """Returns a sorted list of all files, including untracked files."""
1187 ls_files
= self
.git
.ls_files(z
=True,
1190 exclude_standard
=True)
1191 return sorted(map(core
.decode
, [f
for f
in ls_files
.split('\0') if f
]))
1193 def untracked_files(self
):
1194 """Returns a sorted list of all files, including untracked files."""
1195 # -1 for trailing NULL
1196 ls_files
= self
.git
.ls_files(z
=True,
1198 exclude_standard
=True)
1199 return map(core
.decode
, [f
for f
in ls_files
.split('\0') if f
])
1201 def stage_paths(self
, paths
):
1202 """Stages add/removals to git."""
1205 for path
in set(paths
):
1206 if os
.path
.exists(core
.encode(path
)):
1210 # `git add -u` doesn't work on untracked files
1212 self
.git
.add('--', *add
)
1213 # If a path doesn't exist then that means it should be removed
1214 # from the index. We use `git add -u` for that.
1216 self
.git
.add('--', u
=True, *remove
)
1217 self
.update_status()
1219 def unstage_paths(self
, paths
):
1220 """Unstages paths from the staging area and notifies observers."""
1221 self
.reset_helper(set(paths
))
1222 self
.update_status()
1224 def revert_paths(self
, paths
):
1225 """Revert paths to the content from HEAD."""
1226 self
.git
.checkout('HEAD', '--', *set(paths
))
1227 self
.update_status()
1230 """If we've chosen a directory then use it, otherwise os.getcwd()."""
1232 return self
.directory