commands: Use prompt_user() instead of signalling information
[git-cola.git] / cola / commands.py
blobc1e19c5c7ec572ffc22d710ef795a4c3b2daf21b
1 import os
2 import sys
4 from cStringIO import StringIO
6 import cola
7 from cola import i18n
8 from cola import core
9 from cola import gitcfg
10 from cola import gitcmds
11 from cola import utils
12 from cola import signals
13 from cola import cmdfactory
14 from cola import difftool
15 from cola import version
16 from cola.diffparse import DiffParser
17 from cola.models import selection
19 _notifier = cola.notifier()
20 _factory = cmdfactory.factory()
21 _config = gitcfg.instance()
24 class Command(object):
25 """Base class for all commands; provides the command pattern."""
26 def __init__(self, update=False):
27 """Initialize the command and stash away values for use in do()"""
28 # These are commonly used so let's make it easier to write new commands.
29 self.undoable = False
30 self.model = cola.model()
31 self.update = update
33 self.old_diff_text = self.model.diff_text
34 self.old_filename = self.model.filename
35 self.old_mode = self.model.mode
36 self.old_head = self.model.head
38 self.new_diff_text = self.old_diff_text
39 self.new_filename = self.old_filename
40 self.new_head = self.old_head
41 self.new_mode = self.old_mode
43 def do(self):
44 """Perform the operation."""
45 self.model.set_diff_text(self.new_diff_text)
46 self.model.set_filename(self.new_filename)
47 self.model.set_head(self.new_head)
48 self.model.set_mode(self.new_mode)
49 if self.update:
50 self.model.update_status()
52 def is_undoable(self):
53 """Can this be undone?"""
54 return self.undoable
56 def undo(self):
57 """Undo the operation."""
58 self.model.set_diff_text(self.old_diff_text)
59 self.model.set_filename(self.old_filename)
60 self.model.set_head(self.old_head)
61 self.model.set_mode(self.old_mode)
62 if self.update:
63 self.model.update_status()
65 def name(self):
66 """Return this command's name."""
67 return self.__class__.__name__
70 class AddSignoff(Command):
71 """Add a signed-off-by to the commit message."""
72 def __init__(self):
73 Command.__init__(self)
74 self.undoable = True
75 self.old_commitmsg = self.model.commitmsg
76 self.new_commitmsg = self.old_commitmsg
77 signoff = ('\nSigned-off-by: %s <%s>\n' %
78 (self.model.local_user_name, self.model.local_user_email))
79 if signoff not in self.new_commitmsg:
80 self.new_commitmsg += ('\n' + signoff)
82 def do(self):
83 self.model.set_commitmsg(self.new_commitmsg)
85 def undo(self):
86 self.model.set_commitmsg(self.old_commitmsg)
89 class AmendMode(Command):
90 """Try to amend a commit."""
91 def __init__(self, amend):
92 Command.__init__(self, update=True)
93 self.undoable = True
94 self.skip = False
95 self.amending = amend
96 self.old_commitmsg = self.model.commitmsg
98 if self.amending:
99 self.new_mode = self.model.mode_amend
100 self.new_head = 'HEAD^'
101 self.new_commitmsg = self.model.prev_commitmsg()
102 return
103 # else, amend unchecked, regular commit
104 self.new_mode = self.model.mode_none
105 self.new_head = 'HEAD'
106 self.new_commitmsg = self.model.commitmsg
107 # If we're going back into new-commit-mode then search the
108 # undo stack for a previous amend-commit-mode and grab the
109 # commit message at that point in time.
110 if not _factory.undostack:
111 return
112 undo_count = len(_factory.undostack)
113 for i in xrange(undo_count):
114 # Find the latest AmendMode command
115 idx = undo_count - i - 1
116 cmdobj = _factory.undostack[idx]
117 if type(cmdobj) is not AmendMode:
118 continue
119 if cmdobj.amending:
120 self.new_commitmsg = cmdobj.old_commitmsg
121 break
123 def do(self):
124 """Leave/enter amend mode."""
125 """Attempt to enter amend mode. Do not allow this when merging."""
126 if self.amending:
127 if os.path.exists(self.model.git.git_path('MERGE_HEAD')):
128 self.skip = True
129 _notifier.broadcast(signals.amend, False)
130 _factory.prompt_user(signals.information,
131 'Oops! Unmerged',
132 'You are in the middle of a merge.\n'
133 'You cannot amend while merging.')
134 return
135 self.skip = False
136 _notifier.broadcast(signals.amend, self.amending)
137 self.model.set_commitmsg(self.new_commitmsg)
138 Command.do(self)
140 def undo(self):
141 if self.skip:
142 return
143 self.model.set_commitmsg(self.old_commitmsg)
144 Command.undo(self)
147 class ApplyDiffSelection(Command):
148 def __init__(self, staged, selected, offset, selection, apply_to_worktree):
149 Command.__init__(self, update=True)
150 self.staged = staged
151 self.selected = selected
152 self.offset = offset
153 self.selection = selection
154 self.apply_to_worktree = apply_to_worktree
156 def do(self):
157 if self.model.mode == self.model.mode_branch:
158 # We're applying changes from a different branch!
159 parser = DiffParser(self.model,
160 filename=self.model.filename,
161 cached=False,
162 branch=self.model.head)
163 parser.process_diff_selection(self.selected,
164 self.offset,
165 self.selection,
166 apply_to_worktree=True)
167 else:
168 # The normal worktree vs index scenario
169 parser = DiffParser(self.model,
170 filename=self.model.filename,
171 cached=self.staged,
172 reverse=self.apply_to_worktree)
173 parser.process_diff_selection(self.selected,
174 self.offset,
175 self.selection,
176 apply_to_worktree=
177 self.apply_to_worktree)
178 # Redo the diff to show changes
179 if self.staged:
180 diffcmd = DiffStaged([self.model.filename])
181 else:
182 diffcmd = Diff([self.model.filename])
183 diffcmd.do()
184 self.model.update_status()
186 class ApplyPatches(Command):
187 def __init__(self, patches):
188 Command.__init__(self)
189 patches.sort()
190 self.patches = patches
192 def do(self):
193 diff_text = ''
194 num_patches = len(self.patches)
195 orig_head = self.model.git.rev_parse('HEAD')
197 for idx, patch in enumerate(self.patches):
198 status, output = self.model.git.am(patch,
199 with_status=True,
200 with_stderr=True)
201 # Log the git-am command
202 _notifier.broadcast(signals.log_cmd, status, output)
204 if num_patches > 1:
205 diff = self.model.git.diff('HEAD^!', stat=True)
206 diff_text += 'Patch %d/%d - ' % (idx+1, num_patches)
207 diff_text += '%s:\n%s\n\n' % (os.path.basename(patch), diff)
209 diff_text += 'Summary:\n'
210 diff_text += self.model.git.diff(orig_head, stat=True)
212 # Display a diffstat
213 self.model.set_diff_text(diff_text)
215 _factory.prompt_user(signals.information,
216 'Patch(es) Applied',
217 '%d patch(es) applied:\n\n%s' %
218 (len(self.patches),
219 '\n'.join(map(os.path.basename, self.patches))))
222 class HeadChangeCommand(Command):
223 """Changes the model's current head."""
224 def __init__(self, treeish):
225 Command.__init__(self, update=True)
226 self.new_head = treeish
227 self.new_diff_text = ''
230 class BranchMode(HeadChangeCommand):
231 """Enter into diff-branch mode."""
232 def __init__(self, treeish, filename):
233 HeadChangeCommand.__init__(self, treeish)
234 self.old_filename = self.model.filename
235 self.new_filename = filename
236 self.new_mode = self.model.mode_branch
237 self.new_diff_text = gitcmds.diff_helper(filename=filename,
238 cached=False,
239 reverse=True,
240 branch=treeish)
241 class Checkout(Command):
243 A command object for git-checkout.
245 'argv' is handed off directly to git.
248 def __init__(self, argv):
249 Command.__init__(self)
250 self.argv = argv
252 def do(self):
253 status, output = self.model.git.checkout(with_stderr=True,
254 with_status=True, *self.argv)
255 _notifier.broadcast(signals.log_cmd, status, output)
256 self.model.set_diff_text('')
257 self.model.update_status()
260 class CheckoutBranch(Checkout):
261 """Checkout a branch."""
262 def __init__(self, branch):
263 Checkout.__init__(self, [branch])
266 class CherryPick(Command):
267 """Cherry pick commits into the current branch."""
268 def __init__(self, commits):
269 Command.__init__(self)
270 self.commits = commits
272 def do(self):
273 self.model.cherry_pick_list(self.commits)
276 class ResetMode(Command):
277 """Reset the mode and clear the model's diff text."""
278 def __init__(self):
279 Command.__init__(self, update=True)
280 self.new_mode = self.model.mode_none
281 self.new_head = 'HEAD'
282 self.new_diff_text = ''
285 class Commit(ResetMode):
286 """Attempt to create a new commit."""
287 def __init__(self, amend, msg):
288 ResetMode.__init__(self)
289 self.amend = amend
290 self.msg = core.encode(msg)
291 self.old_commitmsg = self.model.commitmsg
292 self.new_commitmsg = ''
294 def do(self):
295 status, output = self.model.commit_with_msg(self.msg, amend=self.amend)
296 if status == 0:
297 ResetMode.do(self)
298 self.model.set_commitmsg(self.new_commitmsg)
299 title = 'Commit: '
300 else:
301 title = 'Commit failed: '
302 _notifier.broadcast(signals.log_cmd, status, title+output)
305 class Delete(Command):
306 """Simply delete files."""
307 def __init__(self, filenames):
308 Command.__init__(self)
309 self.filenames = filenames
310 # We could git-hash-object stuff and provide undo-ability
311 # as an option. Heh.
312 def do(self):
313 rescan = False
314 for filename in self.filenames:
315 if filename:
316 try:
317 os.remove(filename)
318 rescan=True
319 except:
320 _factory.prompt_user(signals.information,
321 'Error'
322 'Deleting "%s" failed.' % filename)
323 if rescan:
324 self.model.update_status()
326 class DeleteBranch(Command):
327 """Delete a git branch."""
328 def __init__(self, branch):
329 Command.__init__(self)
330 self.branch = branch
332 def do(self):
333 status, output = self.model.delete_branch(self.branch)
334 title = ''
335 if output.startswith('error:'):
336 output = 'E' + output[1:]
337 else:
338 title = 'Info: '
339 _notifier.broadcast(signals.log_cmd, status, title + output)
342 class Diff(Command):
343 """Perform a diff and set the model's current text."""
344 def __init__(self, filenames, cached=False):
345 Command.__init__(self)
346 opts = {}
347 if cached:
348 cached = not self.model.read_only()
349 opts = dict(ref=self.model.head)
351 self.new_filename = filenames[0]
352 self.old_filename = self.model.filename
353 if not self.model.read_only():
354 if self.model.mode != self.model.mode_amend:
355 self.new_mode = self.model.mode_worktree
356 self.new_diff_text = gitcmds.diff_helper(filename=self.new_filename,
357 cached=cached, **opts)
360 class DiffMode(HeadChangeCommand):
361 """Enter diff mode and clear the model's diff text."""
362 def __init__(self, treeish):
363 HeadChangeCommand.__init__(self, treeish)
364 self.new_mode = self.model.mode_diff
367 class DiffExprMode(HeadChangeCommand):
368 """Enter diff-expr mode and clear the model's diff text."""
369 def __init__(self, treeish):
370 HeadChangeCommand.__init__(self, treeish)
371 self.new_mode = self.model.mode_diff_expr
374 class Diffstat(Command):
375 """Perform a diffstat and set the model's diff text."""
376 def __init__(self):
377 Command.__init__(self)
378 diff = self.model.git.diff(self.model.head,
379 unified=_config.get('diff.context', 3),
380 no_color=True,
381 M=True,
382 stat=True)
383 self.new_diff_text = core.decode(diff)
384 self.new_mode = self.model.mode_worktree
387 class DiffStaged(Diff):
388 """Perform a staged diff on a file."""
389 def __init__(self, filenames):
390 Diff.__init__(self, filenames, cached=True)
391 if not self.model.read_only():
392 if self.model.mode != self.model.mode_amend:
393 self.new_mode = self.model.mode_index
396 class DiffStagedSummary(Command):
397 def __init__(self):
398 Command.__init__(self)
399 cached = not self.model.read_only()
400 diff = self.model.git.diff(self.model.head,
401 cached=cached,
402 no_color=True,
403 patch_with_stat=True,
404 M=True)
405 self.new_diff_text = core.decode(diff)
406 if not self.model.read_only():
407 if self.model.mode != self.model.mode_amend:
408 self.new_mode = self.model.mode_index
411 class Difftool(Command):
412 """Run git-difftool limited by path."""
413 def __init__(self, staged, filenames):
414 Command.__init__(self)
415 self.staged = staged
416 self.filenames = filenames
418 def do(self):
419 if not self.filenames:
420 return
421 args = []
422 if self.staged and not self.model.read_only():
423 args.append('--cached')
424 args.extend([self.model.head, '--'])
425 args.extend(self.filenames)
426 difftool.launch(args)
429 class Edit(Command):
430 """Edit a file using the configured gui.editor."""
431 def __init__(self, filenames, line_number=None):
432 Command.__init__(self)
433 self.filenames = filenames
434 self.line_number = line_number
436 def do(self):
437 filename = self.filenames[0]
438 if not os.path.exists(filename):
439 return
440 editor = self.model.editor()
441 if 'vi' in editor and self.line_number:
442 utils.fork([editor, filename, '+'+self.line_number])
443 else:
444 utils.fork([editor, filename])
447 class FormatPatch(Command):
448 """Output a patch series given all revisions and a selected subset."""
449 def __init__(self, to_export, revs):
450 Command.__init__(self)
451 self.to_export = to_export
452 self.revs = revs
454 def do(self):
455 status, output = gitcmds.format_patchsets(self.to_export, self.revs)
456 _notifier.broadcast(signals.log_cmd, status, output)
459 class GrepMode(Command):
460 def __init__(self, txt):
461 """Perform a git-grep."""
462 Command.__init__(self)
463 self.new_mode = self.model.mode_grep
464 self.new_diff_text = self.model.git.grep(txt, n=True)
466 class LoadCommitMessage(Command):
467 """Loads a commit message from a path."""
468 def __init__(self, path):
469 Command.__init__(self)
470 if not path or not os.path.exists(path):
471 raise OSError('error: "%s" does not exist' % path)
472 self.undoable = True
473 self.path = path
474 self.old_commitmsg = self.model.commitmsg
475 self.old_directory = self.model.directory
477 def do(self):
478 self.model.set_directory(os.path.dirname(self.path))
479 self.model.set_commitmsg(utils.slurp(self.path))
481 def undo(self):
482 self.model.set_commitmsg(self.old_commitmsg)
483 self.model.set_directory(self.old_directory)
486 class LoadCommitTemplate(LoadCommitMessage):
487 """Loads the commit message template specified by commit.template."""
488 def __init__(self):
489 LoadCommitMessage.__init__(self, _config.get('commit.template'))
492 class Mergetool(Command):
493 """Launch git-mergetool on a list of paths."""
494 def __init__(self, paths):
495 Command.__init__(self)
496 self.paths = paths
498 def do(self):
499 if not self.paths:
500 return
501 if version.check('mergetool-no-prompt',
502 self.model.git.version().split()[-1]):
503 utils.fork(['git', 'mergetool', '--no-prompt', '--'] + self.paths)
504 else:
505 utils.fork(['xterm', '-e', 'git', 'mergetool', '--'] + self.paths)
508 class OpenRepo(Command):
509 """Launches git-cola on a repo."""
510 def __init__(self, dirname):
511 Command.__init__(self)
512 self.new_directory = utils.quote_repopath(dirname)
514 def do(self):
515 self.model.set_directory(self.new_directory)
516 utils.fork(['python', sys.argv[0], '--repo', self.new_directory])
519 class Clone(Command):
520 """Clones a repository and optionally spawns a new cola session."""
521 def __init__(self, url, destdir, spawn=True):
522 Command.__init__(self)
523 self.url = url
524 self.new_directory = utils.quote_repopath(destdir)
525 self.spawn = spawn
527 def do(self):
528 self.model.git.clone(self.url, self.new_directory,
529 with_stderr=True, with_status=True)
530 if self.spawn:
531 utils.fork(['python', sys.argv[0], '--repo', self.new_directory])
534 class Rescan(Command):
535 """Rescans for changes."""
536 def __init__(self):
537 Command.__init__(self, update=True)
540 class ReviewBranchMode(Command):
541 """Enter into review-branch mode."""
542 def __init__(self, branch):
543 Command.__init__(self, update=True)
544 self.new_mode = self.model.mode_review
545 self.new_head = gitcmds.merge_base_parent(branch)
546 self.new_diff_text = ''
549 class RunConfigAction(Command):
550 """Run a user-configured action, typically from the "Tools" menu"""
551 def __init__(self, name):
552 Command.__init__(self)
553 self.name = name
554 self.model = cola.model()
556 def do(self):
557 for env in ('FILENAME', 'REVISION', 'ARGS'):
558 try:
559 del os.environ[env]
560 except KeyError:
561 pass
562 rev = None
563 args = None
564 opts = _config.get_guitool_opts(self.name)
565 cmd = opts.get('cmd')
566 if 'title' not in opts:
567 opts['title'] = cmd
569 if 'prompt' not in opts:
570 prompt = i18n.gettext('Are you sure you want to run %s?') % cmd
571 opts['prompt'] = prompt
573 if opts.get('needsfile'):
574 filename = selection.filename()
575 if not filename:
576 _notifier.broadcast(signals.information,
577 'Please select a file',
578 '"%s" requires a selected file' % cmd)
579 return
580 os.environ['FILENAME'] = utils.shell_quote(filename)
582 if opts.get('revprompt') or opts.get('argprompt'):
583 ok = _factory.prompt_user(signals.run_config_action, cmd, opts)
584 if not ok:
585 return
586 rev = opts.get('revision')
587 args = opts.get('args')
588 if opts.get('revprompt') and not rev:
589 return
591 elif opts.get('confirm'):
592 title = opts.get('title')
593 prompt = opts.get('prompt')
594 title = os.path.expandvars(title)
595 prompt = os.path.expandvars(prompt)
596 if not _factory.prompt_user(signals.question, title, prompt):
597 return
598 if rev:
599 os.environ['REVISION'] = rev
600 if args:
601 os.environ['ARGS'] = args
602 cmdexpand = os.path.expandvars(cmd)
603 status = os.system(cmdexpand)
604 _notifier.broadcast(signals.log_cmd, status, 'Running: ' + cmdexpand)
605 if not opts.get('norescan'):
606 self.model.update_status()
607 return status
610 class ShowUntracked(Command):
611 """Show an untracked file."""
612 # We don't actually do anything other than set the mode right now.
613 # TODO check the mimetype for the file and handle things
614 # generically.
615 def __init__(self, filenames):
616 Command.__init__(self)
617 self.new_mode = self.model.mode_worktree
618 # TODO new_diff_text = utils.file_preview(filenames[0])
621 class Stage(Command):
622 """Stage a set of paths."""
623 def __init__(self, paths):
624 Command.__init__(self)
625 self.paths = paths
627 def do(self):
628 msg = 'Staging: %s' % (', '.join(self.paths))
629 _notifier.broadcast(signals.log_cmd, 0, msg)
630 self.model.stage_paths(self.paths)
633 class StageModified(Stage):
634 """Stage all modified files."""
635 def __init__(self):
636 Stage.__init__(self, None)
637 self.paths = self.model.modified
640 class StageUntracked(Stage):
641 """Stage all untracked files."""
642 def __init__(self):
643 Stage.__init__(self, None)
644 self.paths = self.model.untracked
646 class Tag(Command):
647 """Create a tag object."""
648 def __init__(self, name, revision, sign=False, message=''):
649 Command.__init__(self)
650 self._name = name
651 self._message = core.encode(message)
652 self._revision = revision
653 self._sign = sign
655 def do(self):
656 log_msg = 'Tagging: "%s" as "%s"' % (self._revision, self._name)
657 if self._sign:
658 log_msg += ', GPG-signed'
659 path = self.model.tmp_filename()
660 utils.write(path, self._message)
661 status, output = self.model.git.tag(self._name,
662 self._revision,
663 s=True,
664 F=path,
665 with_status=True,
666 with_stderr=True)
667 os.unlink(path)
668 else:
669 status, output = self.model.git.tag(self._name,
670 self._revision,
671 with_status=True,
672 with_stderr=True)
673 if output:
674 log_msg += '\nOutput:\n%s' % output
676 _notifier.broadcast(signals.log_cmd, status, log_msg)
677 if status == 0:
678 self.model.update_status()
681 class Unstage(Command):
682 """Unstage a set of paths."""
683 def __init__(self, paths):
684 Command.__init__(self)
685 self.paths = paths
687 def do(self):
688 msg = 'Unstaging: %s' % (', '.join(self.paths))
689 _notifier.broadcast(signals.log_cmd, 0, msg)
690 gitcmds.unstage_paths(self.paths)
691 self.model.update_status()
694 class UnstageAll(Command):
695 """Unstage all files; resets the index."""
696 def __init__(self):
697 Command.__init__(self, update=True)
699 def do(self):
700 self.model.unstage_all()
703 class UnstageSelected(Unstage):
704 """Unstage selected files."""
705 def __init__(self):
706 Unstage.__init__(self, cola.selection_model().staged)
709 class UntrackedSummary(Command):
710 """List possible .gitignore rules as the diff text."""
711 def __init__(self):
712 Command.__init__(self)
713 untracked = self.model.untracked
714 suffix = len(untracked) > 1 and 's' or ''
715 io = StringIO()
716 io.write('# %s untracked file%s\n' % (len(untracked), suffix))
717 if untracked:
718 io.write('# possible .gitignore rule%s:\n' % suffix)
719 for u in untracked:
720 io.write('/%s\n' % u)
721 self.new_diff_text = io.getvalue()
724 class VisualizeAll(Command):
725 """Visualize all branches."""
726 def do(self):
727 browser = self.model.history_browser()
728 utils.fork([browser, '--all'])
731 class VisualizeCurrent(Command):
732 """Visualize all branches."""
733 def do(self):
734 browser = self.model.history_browser()
735 utils.fork([browser, self.model.currentbranch])
738 class VisualizePaths(Command):
739 """Path-limited visualization."""
740 def __init__(self, paths):
741 Command.__init__(self)
742 browser = self.model.history_browser()
743 if paths:
744 self.argv = [browser] + paths
745 else:
746 self.argv = [browser]
748 def do(self):
749 utils.fork(self.argv)
752 def register():
754 Register signal mappings with the factory.
756 These commands are automatically created and run when
757 their corresponding signal is broadcast by the notifier.
760 signal_to_command_map = {
761 signals.add_signoff: AddSignoff,
762 signals.amend_mode: AmendMode,
763 signals.apply_diff_selection: ApplyDiffSelection,
764 signals.apply_patches: ApplyPatches,
765 signals.branch_mode: BranchMode,
766 signals.clone: Clone,
767 signals.checkout: Checkout,
768 signals.checkout_branch: CheckoutBranch,
769 signals.cherry_pick: CherryPick,
770 signals.commit: Commit,
771 signals.delete: Delete,
772 signals.delete_branch: DeleteBranch,
773 signals.diff: Diff,
774 signals.diff_mode: DiffMode,
775 signals.diff_expr_mode: DiffExprMode,
776 signals.diff_staged: DiffStaged,
777 signals.diffstat: Diffstat,
778 signals.difftool: Difftool,
779 signals.edit: Edit,
780 signals.format_patch: FormatPatch,
781 signals.grep: GrepMode,
782 signals.load_commit_message: LoadCommitMessage,
783 signals.load_commit_template: LoadCommitTemplate,
784 signals.modified_summary: Diffstat,
785 signals.mergetool: Mergetool,
786 signals.open_repo: OpenRepo,
787 signals.rescan: Rescan,
788 signals.reset_mode: ResetMode,
789 signals.review_branch_mode: ReviewBranchMode,
790 signals.run_config_action: RunConfigAction,
791 signals.show_untracked: ShowUntracked,
792 signals.stage: Stage,
793 signals.stage_modified: StageModified,
794 signals.stage_untracked: StageUntracked,
795 signals.staged_summary: DiffStagedSummary,
796 signals.tag: Tag,
797 signals.unstage: Unstage,
798 signals.unstage_all: UnstageAll,
799 signals.unstage_selected: UnstageSelected,
800 signals.untracked_summary: UntrackedSummary,
801 signals.visualize_all: VisualizeAll,
802 signals.visualize_current: VisualizeCurrent,
803 signals.visualize_paths: VisualizePaths,
806 for signal, cmd in signal_to_command_map.iteritems():
807 _factory.add_command(signal, cmd)