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'
47 message_paths_staged
= 'paths_staged'
48 message_paths_unstaged
= 'paths_unstaged'
49 message_paths_reverted
= 'paths_reverted'
52 mode_none
= 'none' # Default: nothing's happened, do nothing
53 mode_worktree
= 'worktree' # Comparing index to worktree
54 mode_index
= 'index' # Comparing index to last commit
55 mode_amend
= 'amend' # Amending a commit
56 mode_grep
= 'grep' # We ran Search -> Grep
57 mode_branch
= 'branch' # Applying changes from a branch
58 mode_diff
= 'diff' # Diffing against an arbitrary branch
59 mode_diff_expr
= 'diff_expr' # Diffing using arbitrary expression
60 mode_review
= 'review' # Reviewing a branch
62 # Modes where we don't do anything like staging, etc.
63 modes_read_only
= (mode_branch
, mode_grep
,
64 mode_diff
, mode_diff_expr
, mode_review
)
65 # Modes where we can checkout files from the $head
66 modes_undoable
= (mode_none
, mode_index
, mode_worktree
)
68 def __init__(self
, cwd
=None):
69 """Reads git repository settings and sets several methods
70 so that they refer to the git module. This object
71 encapsulates cola's interaction with git."""
72 ObservableModel
.__init
__(self
)
74 # Initialize the git command object
75 self
.git
= gitcola
.GitCola()
77 #####################################################
79 self
.mode
= self
.mode_none
82 self
.currentbranch
= ''
83 self
.trackedbranch
= ''
85 self
.git_version
= self
.git
.version()
88 self
.local_branch
= ''
89 self
.remote_branch
= ''
91 #####################################################
99 self
.upstream_changed
= []
101 #####################################################
104 self
.local_branches
= []
105 self
.remote_branches
= []
110 # These are parallel lists
116 self
.directories
= []
117 self
.directory_entries
= {}
120 self
.subtree_types
= []
121 self
.subtree_sha1s
= []
122 self
.subtree_names
= []
124 self
.fetch_helper
= None
125 self
.push_helper
= None
126 self
.pull_helper
= None
127 self
.generate_remote_helpers()
129 self
.use_worktree(cwd
)
132 return self
.mode
in self
.modes_read_only
135 """Whether we can checkout files from the $head."""
136 return self
.mode
in self
.modes_undoable
138 def enable_staging(self
):
139 """Whether staging should be allowed."""
140 return self
.mode
== self
.mode_worktree
143 """Returns the names of all files in the repository"""
144 return [core
.decode(f
)
145 for f
in self
.git
.ls_files(z
=True)
146 .strip('\0').split('\0') if f
]
148 def generate_remote_helpers(self
):
149 """Generates helper methods for fetch, push and pull"""
150 self
.push_helper
= self
.gen_remote_helper(self
.git
.push
, push
=True)
151 self
.fetch_helper
= self
.gen_remote_helper(self
.git
.fetch
)
152 self
.pull_helper
= self
.gen_remote_helper(self
.git
.pull
)
154 def use_worktree(self
, worktree
):
155 self
.git
.load_worktree(worktree
)
156 is_valid
= self
.git
.is_valid()
158 self
._init
_config
_data
()
159 self
.set_project(os
.path
.basename(self
.git
.worktree()))
162 def _init_config_data(self
):
163 """Reads git config --list and creates parameters
165 # These parameters are saved in .gitconfig,
166 # so ideally these should be as short as possible.
168 # config items that are controllable globally
170 self
._local
_and
_global
_defaults
= {
173 'merge_summary': False,
174 'merge_diffstat': True,
175 'merge_verbosity': 2,
176 'gui_diffcontext': 3,
177 'gui_pruneduringfetch': False,
179 # config items that are purely git config --global settings
180 self
.__global
_defaults
= {
183 'cola_fontdiff_size': 12,
184 'cola_savewindowsettings': False,
185 'cola_showoutput': 'errors',
187 'merge_keepbackup': True,
188 'diff_tool': os
.getenv('GIT_DIFF_TOOL', 'xxdiff'),
189 'merge_tool': os
.getenv('GIT_MERGE_TOOL', 'xxdiff'),
190 'gui_editor': os
.getenv('EDITOR', 'gvim'),
191 'gui_historybrowser': 'gitk',
194 local_dict
= self
.config_dict(local
=True)
195 global_dict
= self
.config_dict(local
=False)
197 for k
,v
in local_dict
.iteritems():
198 self
.set_param('local_'+k
, v
)
199 for k
,v
in global_dict
.iteritems():
200 self
.set_param('global_'+k
, v
)
201 if k
not in local_dict
:
203 self
.set_param('local_'+k
, v
)
205 # Bootstrap the internal font*size variables
206 for param
in ('global_cola_fontdiff'):
208 if hasattr(self
, param
):
209 font
= getattr(self
, param
)
212 size
= int(font
.split(',')[1])
213 self
.set_param(param
+'_size', size
)
214 param
= param
[len('global_'):]
215 global_dict
[param
] = font
216 global_dict
[param
+'_size'] = size
218 # Load defaults for all undefined items
219 local_and_global_defaults
= self
._local
_and
_global
_defaults
220 for k
,v
in local_and_global_defaults
.iteritems():
221 if k
not in local_dict
:
222 self
.set_param('local_'+k
, v
)
223 if k
not in global_dict
:
224 self
.set_param('global_'+k
, v
)
226 global_defaults
= self
.__global
_defaults
227 for k
,v
in global_defaults
.iteritems():
228 if k
not in global_dict
:
229 self
.set_param('global_'+k
, v
)
231 # Load the diff context
232 self
.diff_context
= self
.local_config('gui.diffcontext', 3)
234 def global_config(self
, key
, default
=None):
235 return self
.param('global_'+key
.replace('.', '_'),
238 def local_config(self
, key
, default
=None):
239 return self
.param('local_'+key
.replace('.', '_'),
242 def cola_config(self
, key
):
243 return getattr(self
, 'global_cola_'+key
)
245 def gui_config(self
, key
):
246 return getattr(self
, 'global_gui_'+key
)
248 def default_remote(self
):
249 branch
= self
.currentbranch
250 branchconfig
= 'branch.%s.remote' % branch
251 return self
.local_config(branchconfig
, 'origin')
253 def corresponding_remote_ref(self
):
254 remote
= self
.default_remote()
255 branch
= self
.currentbranch
256 best_match
= '%s/%s' % (remote
, branch
)
257 remote_branches
= self
.remote_branches
258 if not remote_branches
:
260 for rb
in remote_branches
:
263 return remote_branches
[0]
265 def diff_filenames(self
, arg
):
266 """Returns a list of filenames that have been modified"""
267 diff_zstr
= self
.git
.diff(arg
, name_only
=True, z
=True).rstrip('\0')
268 return [core
.decode(f
) for f
in diff_zstr
.split('\0') if f
]
270 def branch_list(self
, remote
=False):
271 """Returns a list of local or remote branches
273 This explicitly removes HEAD from the list of remote branches.
275 branches
= map(lambda x
: x
.lstrip('* '),
276 self
.git
.branch(r
=remote
).splitlines())
278 return [b
for b
in branches
if b
.find('/HEAD') == -1]
281 def config_params(self
):
283 params
.extend(map(lambda x
: 'local_' + x
,
284 self
._local
_and
_global
_defaults
.keys()))
285 params
.extend(map(lambda x
: 'global_' + x
,
286 self
._local
_and
_global
_defaults
.keys()))
287 params
.extend(map(lambda x
: 'global_' + x
,
288 self
.__global
_defaults
.keys()))
289 return [ p
for p
in params
if not p
.endswith('_size') ]
291 def save_config_param(self
, param
):
292 if param
not in self
.config_params():
294 value
= getattr(self
, param
)
295 if param
== 'local_gui_diffcontext':
296 self
.diff_context
= value
297 if param
.startswith('local_'):
298 param
= param
[len('local_'):]
300 elif param
.startswith('global_'):
301 param
= param
[len('global_'):]
304 raise Exception("Invalid param '%s' passed to " % param
305 +'save_config_param()')
306 param
= param
.replace('_', '.') # model -> git
307 return self
.config_set(param
, value
, local
=is_local
)
309 def init_browser_data(self
):
310 """This scans over self.(names, sha1s, types) to generate
311 directories, directory_entries, and subtree_*"""
313 # Collect data for the model
314 if not self
.currentbranch
:
317 self
.subtree_types
= []
318 self
.subtree_sha1s
= []
319 self
.subtree_names
= []
320 self
.directories
= []
321 self
.directory_entries
= {}
323 # Lookup the tree info
324 tree_info
= self
.parse_ls_tree(self
.currentbranch
)
326 self
.set_types(map(lambda(x
): x
[1], tree_info
))
327 self
.set_sha1s(map(lambda(x
): x
[2], tree_info
))
328 self
.set_names(map(lambda(x
): x
[3], tree_info
))
330 if self
.directory
: self
.directories
.append('..')
332 dir_entries
= self
.directory_entries
333 dir_regex
= re
.compile('([^/]+)/')
337 for idx
, name
in enumerate(self
.names
):
338 if not name
.startswith(self
.directory
):
340 name
= name
[ len(self
.directory
): ]
342 # This is a directory...
343 match
= dir_regex
.match(name
)
346 dirent
= match
.group(1) + '/'
347 if dirent
not in self
.directory_entries
:
348 self
.directory_entries
[dirent
] = []
350 if dirent
not in dirs_seen
:
351 dirs_seen
[dirent
] = True
352 self
.directories
.append(dirent
)
354 entry
= name
.replace(dirent
, '')
355 entry_match
= dir_regex
.match(entry
)
357 subdir
= entry_match
.group(1) + '/'
358 if subdir
in subdirs_seen
:
360 subdirs_seen
[subdir
] = True
361 dir_entries
[dirent
].append(subdir
)
363 dir_entries
[dirent
].append(entry
)
365 self
.subtree_types
.append(self
.types
[idx
])
366 self
.subtree_sha1s
.append(self
.sha1s
[idx
])
367 self
.subtree_names
.append(name
)
369 def add_or_remove(self
, to_process
):
370 """Invokes 'git add' to index the filenames in to_process that exist
371 and 'git rm' for those that do not exist."""
374 return 'No files to add or remove.'
379 for filename
in to_process
:
380 encfilename
= core
.encode(filename
)
381 if os
.path
.exists(encfilename
):
382 to_add
.append(filename
)
386 newstatus
, output
= self
.git
.add(v
=True,
394 if len(to_add
) == len(to_process
):
395 # to_process only contained unremoved files --
396 # short-circuit the removal checks
397 return (status
, output
)
399 # Process files to remote
400 for filename
in to_process
:
401 if not os
.path
.exists(filename
):
402 to_remove
.append(filename
)
403 newstatus
, out
= self
.git
.rm(with_stderr
=True,
408 output
+ '\n\n' + out
409 return (status
, output
)
412 return self
.gui_config('editor')
414 def history_browser(self
):
415 return self
.gui_config('historybrowser')
417 def remember_gui_settings(self
):
418 return self
.cola_config('savewindowsettings')
420 def subtree_node(self
, idx
):
421 return (self
.subtree_types
[idx
],
422 self
.subtree_sha1s
[idx
],
423 self
.subtree_names
[idx
])
425 def all_branches(self
):
426 return (self
.local_branches
+ self
.remote_branches
)
428 def set_remote(self
, remote
):
431 self
.set_param('remote', remote
)
432 branches
= utils
.grep('%s/\S+$' % remote
,
433 self
.branch_list(remote
=True),
435 self
.set_remote_branches(branches
)
437 def apply_diff(self
, filename
):
438 return self
.git
.apply(filename
, index
=True, cached
=True)
440 def apply_diff_to_worktree(self
, filename
):
441 return self
.git
.apply(filename
)
443 def load_commitmsg(self
, path
):
445 contents
= core
.decode(core
.read_nointr(fh
))
447 self
.set_commitmsg(contents
)
449 def prev_commitmsg(self
):
450 """Queries git for the latest commit message."""
451 return core
.decode(self
.git
.log('-1', pretty
='format:%s%n%n%b'))
453 def load_commitmsg_template(self
):
454 template
= self
.global_config('commit.template')
456 self
.load_commitmsg(template
)
458 def update_status(self
):
459 # Give observers a chance to respond
460 self
.notify_message_observers(self
.message_about_to_update
)
461 # This allows us to defer notification until the
462 # we finish processing data
463 staged_only
= self
.read_only()
465 notify_enabled
= self
.notification_enabled
466 self
.notification_enabled
= False
472 self
.upstream_changed
) = self
.worktree_state(head
=head
,
473 staged_only
=staged_only
)
474 # NOTE: the model's unstaged list holds an aggregate of the
475 # the modified, unmerged, and untracked file lists.
476 self
.set_unstaged(self
.modified
+ self
.unmerged
+ self
.untracked
)
477 self
.set_currentbranch(self
.current_branch())
478 self
.set_remotes(self
.git
.remote().splitlines())
479 self
.set_remote_branches(self
.branch_list(remote
=True))
480 self
.set_trackedbranch(self
.tracked_branch())
481 self
.set_local_branches(self
.branch_list(remote
=False))
482 self
.set_tags(self
.git
.tag().splitlines())
483 self
.set_revision('')
484 self
.set_local_branch('')
485 self
.set_remote_branch('')
486 # Re-enable notifications and emit changes
487 self
.notification_enabled
= notify_enabled
489 self
.read_font_sizes()
490 self
.notify_observers('staged','unstaged')
491 self
.notify_message_observers(self
.message_updated
)
493 def read_font_sizes(self
):
494 """Read font sizes from the configuration."""
495 value
= self
.cola_config('fontdiff')
498 items
= value
.split(',')
501 self
.global_cola_fontdiff_size
= int(items
[1])
503 def set_diff_font(self
, fontstr
):
504 """Set the diff font string."""
505 self
.global_cola_fontdiff
= fontstr
506 self
.read_font_sizes()
508 def delete_branch(self
, branch
):
509 return self
.git
.branch(branch
,
514 def revision_sha1(self
, idx
):
515 return self
.revisions
[idx
]
517 def apply_diff_font_size(self
, default
):
518 old_font
= self
.cola_config('fontdiff')
521 size
= self
.cola_config('fontdiff_size')
522 props
= old_font
.split(',')
524 new_font
= ','.join(props
)
525 self
.global_cola_fontdiff
= new_font
526 self
.notify_observers('global_cola_fontdiff')
528 def commit_diff(self
, sha1
):
529 commit
= self
.git
.show(sha1
)
530 first_newline
= commit
.index('\n')
531 if commit
[first_newline
+1:].startswith('Merge:'):
532 return (core
.decode(commit
) + '\n\n' +
533 core
.decode(self
.diff_helper(commit
=sha1
,
535 suppress_header
=False)))
537 return core
.decode(commit
)
539 def filename(self
, idx
, staged
=True):
542 return self
.staged
[idx
]
544 return self
.unstaged
[idx
]
548 def diff_details(self
, idx
, ref
, staged
=True):
550 Return a "diff" for an entry by index relative to ref.
552 `staged` indicates whether we should consider this as a
553 staged or unstaged entry.
556 filename
= self
.filename(idx
, staged
=staged
)
559 encfilename
= core
.encode(filename
)
561 diff
= self
.diff_helper(filename
=filename
,
565 if os
.path
.isdir(encfilename
):
566 diff
= '\n'.join(os
.listdir(filename
))
568 elif filename
in self
.unmerged
:
569 diff
= ('@@@ Unmerged @@@\n'
570 '- %s is unmerged.\n+ ' % filename
+
571 'Right-click the file to launch "git mergetool".\n'
572 '@@@ Unmerged @@@\n\n')
573 diff
+= self
.diff_helper(filename
=filename
,
575 elif filename
in self
.modified
:
576 diff
= self
.diff_helper(filename
=filename
,
579 diff
= 'SHA1: ' + self
.git
.hash_object(filename
)
580 return (diff
, filename
)
582 def stage_modified(self
):
583 status
, output
= self
.git
.add(v
=True,
588 return (status
, output
)
590 def stage_untracked(self
):
591 status
, output
= self
.git
.add(v
=True,
596 return (status
, output
)
598 def reset(self
, *items
):
599 status
, output
= self
.git
.reset('--',
604 return (status
, output
)
606 def unstage_all(self
):
607 status
, output
= self
.git
.reset(with_stderr
=True,
610 return (status
, output
)
613 status
, output
= self
.git
.add(v
=True,
618 return (status
, output
)
620 def config_set(self
, key
=None, value
=None, local
=True):
621 if key
and value
is not None:
622 # git config category.key value
623 strval
= unicode(value
)
624 if type(value
) is bool:
625 # git uses "true" and "false"
626 strval
= strval
.lower()
628 argv
= [ key
, strval
]
630 argv
= [ '--global', key
, strval
]
631 return self
.git
.config(*argv
)
633 msg
= "oops in config_set(key=%s,value=%s,local=%s)"
634 raise Exception(msg
% (key
, value
, local
))
636 def config_dict(self
, local
=True):
637 """parses the lines from git config --list into a dictionary"""
641 'global': not local
, # global is a python keyword
643 config_lines
= self
.git
.config(**kwargs
).splitlines()
645 for line
in config_lines
:
647 k
, v
= line
.split('=', 1)
649 # the user has an invalid entry in their git config
652 k
= k
.replace('.','_') # git -> model
653 if v
== 'true' or v
== 'false':
654 v
= bool(eval(v
.title()))
662 def commit_with_msg(self
, msg
, amend
=False):
663 """Creates a git commit."""
665 if not msg
.endswith('\n'):
667 # Sure, this is a potential "security risk," but if someone
668 # is trying to intercept/re-write commit messages on your system,
669 # then you probably have bigger problems to worry about.
670 tmpfile
= self
.tmp_filename()
672 # Create the commit message file
673 fh
= open(tmpfile
, 'w')
674 core
.write_nointr(fh
, msg
)
678 status
, out
= self
.git
.commit(F
=tmpfile
, v
=True, amend
=amend
,
685 # Allow TMPDIR/TMP with a fallback to /tmp
686 return os
.environ
.get('TMP', os
.environ
.get('TMPDIR', '/tmp'))
688 def tmp_file_pattern(self
):
689 return os
.path
.join(self
.tmp_dir(), '*.git-cola.%s.*' % os
.getpid())
691 def tmp_filename(self
, prefix
=''):
692 basename
= ((prefix
+'.git-cola.%s.%s'
693 % (os
.getpid(), time
.time())))
694 basename
= basename
.replace('/', '-')
695 basename
= basename
.replace('\\', '-')
696 tmpdir
= self
.tmp_dir()
697 return os
.path
.join(tmpdir
, basename
)
699 def log_helper(self
, all
=False, extra_args
=None):
701 Returns a pair of parallel arrays listing the revision sha1's
702 and commit summaries.
706 regex
= REV_LIST_REGEX
710 output
= self
.git
.log(pretty
='oneline', all
=all
, *args
)
711 for line
in map(core
.decode
, 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 map(core
.decode
, 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 "Invokes git diff on a filepath."
745 ref
, endref
= commit
+'^', commit
748 argv
.append('%s..%s' % (ref
, endref
))
750 for r
in ref
.strip().split():
757 if type(filename
) is list:
758 argv
.extend(filename
)
760 argv
.append(filename
)
763 del_tag
= 'deleted file mode '
766 deleted
= cached
and not os
.path
.exists(core
.encode(filename
))
768 diffoutput
= self
.git
.diff(R
=reverse
,
772 unified
=self
.diff_context
,
773 with_raw_output
=True,
778 if diffoutput
.startswith('fatal:'):
786 diff
= diffoutput
.split('\n')
787 for line
in map(core
.decode
, diff
):
788 if not start
and '@@' == line
[:2] and '@@' in line
[2:]:
790 if start
or (deleted
and del_tag
in line
):
791 output
.write(core
.encode(line
) + '\n')
794 headers
.append(core
.encode(line
))
795 elif not suppress_header
:
796 output
.write(core
.encode(line
) + '\n')
798 result
= core
.decode(output
.getvalue())
802 return('\n'.join(headers
), result
)
806 def git_repo_path(self
, *subpaths
):
807 paths
= [self
.git
.git_dir()]
808 paths
.extend(subpaths
)
809 return os
.path
.realpath(os
.path
.join(*paths
))
811 def merge_message_path(self
):
812 for file in ('MERGE_MSG', 'SQUASH_MSG'):
813 path
= self
.git_repo_path(file)
814 if os
.path
.exists(path
):
818 def merge_message(self
):
819 return self
.git
.fmt_merge_msg('--file',
820 self
.git_repo_path('FETCH_HEAD'))
822 def abort_merge(self
):
824 output
= self
.git
.read_tree('HEAD', reset
=True, u
=True, v
=True)
826 merge_head
= self
.git_repo_path('MERGE_HEAD')
827 if os
.path
.exists(merge_head
):
828 os
.unlink(merge_head
)
829 # remove MERGE_MESSAGE, etc.
830 merge_msg_path
= self
.merge_message_path()
831 while merge_msg_path
:
832 os
.unlink(merge_msg_path
)
833 merge_msg_path
= self
.merge_message_path()
835 def _is_modified(self
, name
):
836 status
, out
= self
.git
.diff('--', name
,
843 def _branch_status(self
, branch
):
845 Returns a tuple of staged, unstaged, untracked, and unmerged files
847 This shows only the changes that were introduced in branch
850 status
, output
= self
.git
.diff(name_only
=True,
854 *branch
.strip().split())
856 return ([], [], [], [], [])
858 for name
in output
.strip('\0').split('\0'):
861 staged
.append(core
.decode(name
))
863 return (staged
, [], [], [], staged
)
865 def worktree_state(self
, head
='HEAD', staged_only
=False):
866 """Return a tuple of files in various states of being
868 Can be staged, unstaged, untracked, unmerged, or changed
872 self
.git
.update_index(refresh
=True)
874 return self
._branch
_status
(head
)
878 upstream_changed_set
= set()
880 (staged
, modified
, unmerged
, untracked
, upstream_changed
) = (
883 output
= self
.git
.diff_index(head
,
886 if output
.startswith('fatal:'):
887 raise errors
.GitInitError('git init')
888 for line
in output
.splitlines():
889 rest
, name
= line
.split('\t', 1)
891 name
= eval_path(name
)
895 # This file will also show up as 'M' without --cached
896 # so by default don't consider it modified unless
897 # it's truly modified
898 modified_set
.add(name
)
899 if not staged_only
and self
._is
_modified
(name
):
900 modified
.append(name
)
907 modified_set
.add(name
)
909 unmerged
.append(name
)
910 modified_set
.add(name
)
912 except errors
.GitInitError
:
914 staged
.extend(self
.all_files())
917 output
= self
.git
.diff_index(head
, with_stderr
=True)
918 if output
.startswith('fatal:'):
919 raise errors
.GitInitError('git init')
920 for line
in output
.splitlines():
921 info
, name
= line
.split('\t', 1)
922 status
= info
.split()[-1]
923 if status
== 'M' or status
== 'D':
924 name
= eval_path(name
)
925 if name
not in modified_set
:
926 modified
.append(name
)
928 name
= eval_path(name
)
929 # newly-added yet modified
930 if (name
not in modified_set
and not staged_only
and
931 self
._is
_modified
(name
)):
932 modified
.append(name
)
934 except errors
.GitInitError
:
936 for name
in (self
.git
.ls_files(modified
=True, z
=True)
939 modified
.append(core
.decode(name
))
941 for name
in self
.git
.ls_files(others
=True, exclude_standard
=True,
944 untracked
.append(core
.decode(name
))
946 # Look for upstream modified files if this is a tracking branch
947 if self
.trackedbranch
:
949 output
= self
.git
.diff('..'+self
.trackedbranch
,
950 name_only
=True, z
=True)
951 if output
.startswith('fatal:'):
952 raise errors
.GitInitError('git init')
953 for name
in output
.split('\0'):
956 name
= core
.decode(name
)
957 upstream_changed
.append(name
)
958 upstream_changed_set
.add(name
)
960 except errors
.GitInitError
:
969 upstream_changed
.sort()
971 return (staged
, modified
, unmerged
, untracked
, upstream_changed
)
973 def reset_helper(self
, args
):
974 """Removes files from the index
976 This handles the git init case, which is why it's not
977 just 'git reset name'. For the git init case this falls
978 back to 'git rm --cached'.
981 # fake the status because 'git reset' returns 1
982 # regardless of success/failure
984 output
= self
.git
.reset('--', with_stderr
=True, *args
)
985 # handle git init: we have to use 'git rm --cached'
986 # detect this condition by checking if the file is still staged
987 state
= self
.worktree_state()
989 rmargs
= [a
for a
in args
if a
in staged
]
991 return (status
, output
)
992 output
+= self
.git
.rm('--', cached
=True, with_stderr
=True, *rmargs
)
993 return (status
, output
)
995 def remote_url(self
, name
):
996 return self
.git
.config('remote.%s.url' % name
, get
=True)
998 def remote_args(self
, remote
,
1005 # Swap the branches in push mode (reverse of fetch)
1008 local_branch
= remote_branch
1011 branch_arg
= '%s:%s' % ( remote_branch
, local_branch
)
1013 branch_arg
= '+%s:%s' % ( remote_branch
, local_branch
)
1015 if local_branch
and remote_branch
:
1016 args
.append(branch_arg
)
1018 args
.append(local_branch
)
1020 args
.append(remote_branch
)
1025 'with_stderr': True,
1026 'with_status': True,
1028 return (args
, kwargs
)
1030 def gen_remote_helper(self
, gitaction
, push
=False):
1031 """Generates a closure that calls git fetch, push or pull
1033 def remote_helper(remote
, **kwargs
):
1034 args
, kwargs
= self
.remote_args(remote
, push
=push
, **kwargs
)
1035 return gitaction(*args
, **kwargs
)
1036 return remote_helper
1038 def parse_ls_tree(self
, rev
):
1039 """Returns a list of(mode, type, sha1, path) tuples."""
1040 lines
= self
.git
.ls_tree(rev
, r
=True).splitlines()
1042 regex
= re
.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
1044 match
= regex
.match(line
)
1046 mode
= match
.group(1)
1047 objtype
= match
.group(2)
1048 sha1
= match
.group(3)
1049 filename
= match
.group(4)
1050 output
.append((mode
, objtype
, sha1
, filename
,) )
1053 def format_patch_helper(self
, to_export
, revs
, output
='patches'):
1054 """writes patches named by to_export to the output directory."""
1058 cur_rev
= to_export
[0]
1059 cur_master_idx
= revs
.index(cur_rev
)
1061 patches_to_export
= [ [cur_rev
] ]
1064 # Group the patches into continuous sets
1065 for idx
, rev
in enumerate(to_export
[1:]):
1066 # Limit the search to the current neighborhood for efficiency
1067 master_idx
= revs
[ cur_master_idx
: ].index(rev
)
1068 master_idx
+= cur_master_idx
1069 if master_idx
== cur_master_idx
+ 1:
1070 patches_to_export
[ patchset_idx
].append(rev
)
1074 patches_to_export
.append([ rev
])
1075 cur_master_idx
= master_idx
1078 # Export each patchsets
1080 for patchset
in patches_to_export
:
1081 newstatus
, out
= self
.export_patchset(patchset
[0],
1084 n
=len(patchset
) > 1,
1086 patch_with_stat
=True)
1087 outlines
.append(out
)
1090 return (status
, '\n'.join(outlines
))
1092 def export_patchset(self
, start
, end
, output
="patches", **kwargs
):
1093 revarg
= '%s^..%s' % (start
, end
)
1094 return self
.git
.format_patch('-o', output
, revarg
,
1099 def current_branch(self
):
1100 """Parses 'git symbolic-ref' to find the current branch."""
1101 headref
= self
.git
.symbolic_ref('HEAD', with_stderr
=True)
1102 if headref
.startswith('refs/heads/'):
1104 elif headref
.startswith('fatal:'):
1108 def tracked_branch(self
):
1109 """The name of the branch that current branch is tracking"""
1110 remote
= self
.git
.config('branch.'+self
.currentbranch
+'.remote',
1111 get
=True, with_stderr
=True)
1114 headref
= self
.git
.config('branch.'+self
.currentbranch
+'.merge',
1115 get
=True, with_stderr
=True)
1116 if headref
.startswith('refs/heads/'):
1117 tracked_branch
= headref
[11:]
1118 return remote
+ '/' + tracked_branch
1121 def create_branch(self
, name
, base
, track
=False):
1122 """Create a branch named 'name' from revision 'base'
1124 Pass track=True to create a local tracking branch.
1126 return self
.git
.branch(name
, base
, track
=track
,
1130 def cherry_pick_list(self
, revs
, **kwargs
):
1131 """Cherry-picks each revision into the current branch.
1132 Returns a list of command output strings (1 per cherry pick)"""
1138 newstatus
, out
= self
.git
.cherry_pick(rev
,
1143 cherries
.append(out
)
1144 return (status
, '\n'.join(cherries
))
1146 def parse_stash_list(self
, revids
=False):
1147 """Parses "git stash list" and returns a list of stashes."""
1148 stashes
= self
.git
.stash("list").splitlines()
1150 return [ s
[:s
.index(':')] for s
in stashes
]
1152 return [ s
[s
.index(':')+1:] for s
in stashes
]
1154 def pad(self
, pstr
, num
=22):
1155 topad
= num
-len(pstr
)
1157 return pstr
+ ' '*topad
1161 def describe(self
, revid
, descr
):
1162 version
= self
.git
.describe(revid
, tags
=True, always
=True,
1164 return version
+ ' - ' + descr
1166 def update_revision_lists(self
, filename
=None, show_versions
=False):
1167 num_results
= self
.num_results
1169 rev_list
= self
.git
.log('--', filename
,
1170 max_count
=num_results
,
1173 rev_list
= self
.git
.log(max_count
=num_results
,
1174 pretty
='oneline', all
=True)
1176 commit_list
= self
.parse_rev_list(rev_list
)
1177 commit_list
.reverse()
1178 commits
= map(lambda x
: x
[0], commit_list
)
1179 descriptions
= map(lambda x
: core
.decode(x
[1]), commit_list
)
1181 fancy_descr_list
= map(lambda x
: self
.describe(*x
), commit_list
)
1182 self
.set_descriptions_start(fancy_descr_list
)
1183 self
.set_descriptions_end(fancy_descr_list
)
1185 self
.set_descriptions_start(descriptions
)
1186 self
.set_descriptions_end(descriptions
)
1188 self
.set_revisions_start(commits
)
1189 self
.set_revisions_end(commits
)
1193 def changed_files(self
, start
, end
):
1194 zfiles_str
= self
.git
.diff('%s..%s' % (start
, end
),
1195 name_only
=True, z
=True).strip('\0')
1196 return [core
.decode(enc
) for enc
in zfiles_str
.split('\0') if enc
]
1198 def renamed_files(self
, start
, end
):
1199 difflines
= self
.git
.diff('%s..%s' % (start
, end
),
1201 M
=True).splitlines()
1202 return [ eval_path(r
[12:].rstrip())
1203 for r
in difflines
if r
.startswith('rename from ') ]
1205 def is_commit_published(self
):
1206 head
= self
.git
.rev_parse('HEAD')
1207 return bool(self
.git
.branch(r
=True, contains
=head
))
1209 def merge_base_to(self
, ref
):
1210 """Given `ref`, return $(git merge-base ref HEAD)..ref."""
1211 base
= self
.git
.merge_base('HEAD', ref
)
1212 return '%s..%s' % (base
, ref
)
1214 def everything(self
):
1215 """Returns a sorted list of all files, including untracked files."""
1216 files
= self
.all_files() + self
.untracked
1220 def stage_paths(self
, paths
):
1221 """Adds paths to git and notifies observers."""
1223 # Grab the old lists of untracked + modified files
1224 self
.update_status()
1225 old_modified
= set(self
.modified
)
1226 old_untracked
= set(self
.untracked
)
1228 # Add paths and scan for changes
1231 # If a path doesn't exist then that means it should be removed
1232 # from the index. We use `git add -u` for that.
1233 # GITBUG: `git add -u` doesn't on untracked files.
1234 if os
.path
.exists(core
.encode(path
)):
1235 self
.git
.add('--', path
)
1237 self
.git
.add('--', path
, u
=True)
1238 self
.update_status()
1240 # Grab the new lists of untracked + modified files
1241 new_modified
= set(self
.modified
)
1242 new_untracked
= set(self
.untracked
)
1244 # Handle 'git add' on a directory
1245 newly_not_modified
= utils
.add_parents(old_modified
- new_modified
)
1246 newly_not_untracked
= utils
.add_parents(old_untracked
- new_untracked
)
1247 for path
in newly_not_modified
.union(newly_not_untracked
):
1250 self
.notify_message_observers(self
.message_paths_staged
, paths
=paths
)
1252 def unstage_paths(self
, paths
):
1253 """Unstages paths from the staging area and notifies observers."""
1256 # Grab the old list of staged files
1257 self
.update_status()
1258 old_staged
= set(self
.staged
)
1260 # Reset and scan for new changes
1261 self
.reset_helper(paths
)
1262 self
.update_status()
1264 # Grab the new list of staged file
1265 new_staged
= set(self
.staged
)
1267 # Handle 'git reset' on a directory
1268 newly_unstaged
= utils
.add_parents(old_staged
- new_staged
)
1269 for path
in newly_unstaged
:
1272 self
.notify_message_observers(self
.message_paths_unstaged
, paths
=paths
)
1274 def revert_paths(self
, paths
):
1275 """Revert paths to the content from HEAD."""
1278 # Grab the old set of changed files
1279 self
.update_status()
1280 old_modified
= set(self
.modified
)
1281 old_staged
= set(self
.staged
)
1282 old_changed
= old_modified
.union(old_staged
)
1284 # Checkout and scan for changes
1285 self
.git
.checkout('HEAD', '--', *paths
)
1286 self
.update_status()
1288 # Grab the new set of changed files
1289 new_modified
= set(self
.modified
)
1290 new_staged
= set(self
.staged
)
1291 new_changed
= new_modified
.union(new_staged
)
1293 # Handle 'git checkout' on a directory
1294 newly_reverted
= utils
.add_parents(old_changed
- new_changed
)
1296 for path
in newly_reverted
:
1299 self
.notify_message_observers(self
.message_paths_reverted
, paths
=paths
)
1302 """If we've chosen a directory then use it, otherwise os.getcwd()."""
1304 return self
.directory