doc: Document the cola.qt module
[git-cola.git] / cola / commands.py
blobd22939c2f96fe4e51f43dae60080f41056e57118
1 import os
2 import sys
4 from cStringIO import StringIO
6 import cola
7 from cola import core
8 from cola import gitcfg
9 from cola import gitcmds
10 from cola import utils
11 from cola import signals
12 from cola import cmdfactory
13 from cola import difftool
14 from cola import version
15 from cola.diffparse import DiffParser
17 _notifier = cola.notifier()
18 _factory = cmdfactory.factory()
19 _config = gitcfg.instance()
22 class Command(object):
23 """Base class for all commands; provides the command pattern."""
24 def __init__(self, update=False):
25 """Initialize the command and stash away values for use in do()"""
26 # These are commonly used so let's make it easier to write new commands.
27 self.undoable = False
28 self.model = cola.model()
29 self.update = update
31 self.old_diff_text = self.model.diff_text
32 self.old_filename = self.model.filename
33 self.old_mode = self.model.mode
34 self.old_head = self.model.head
36 self.new_diff_text = self.old_diff_text
37 self.new_filename = self.old_filename
38 self.new_head = self.old_head
39 self.new_mode = self.old_mode
41 def do(self):
42 """Perform the operation."""
43 self.model.set_diff_text(self.new_diff_text)
44 self.model.set_filename(self.new_filename)
45 self.model.set_head(self.new_head)
46 self.model.set_mode(self.new_mode)
47 if self.update:
48 self.model.update_status()
50 def is_undoable(self):
51 """Can this be undone?"""
52 return self.undoable
54 def undo(self):
55 """Undo the operation."""
56 self.model.set_diff_text(self.old_diff_text)
57 self.model.set_filename(self.old_filename)
58 self.model.set_head(self.old_head)
59 self.model.set_mode(self.old_mode)
60 if self.update:
61 self.model.update_status()
63 def name(self):
64 """Return this command's name."""
65 return self.__class__.__name__
68 class AddSignoff(Command):
69 """Add a signed-off-by to the commit message."""
70 def __init__(self):
71 Command.__init__(self)
72 self.undoable = True
73 self.old_commitmsg = self.model.commitmsg
74 self.new_commitmsg = self.old_commitmsg
75 signoff = ('\nSigned-off-by: %s <%s>\n' %
76 (self.model.local_user_name, self.model.local_user_email))
77 if signoff not in self.new_commitmsg:
78 self.new_commitmsg += ('\n' + signoff)
80 def do(self):
81 self.model.set_commitmsg(self.new_commitmsg)
83 def undo(self):
84 self.model.set_commitmsg(self.old_commitmsg)
87 class AmendMode(Command):
88 """Try to amend a commit."""
89 def __init__(self, amend):
90 Command.__init__(self, update=True)
91 self.undoable = True
92 self.skip = False
93 self.amending = amend
94 self.old_commitmsg = self.model.commitmsg
96 if self.amending:
97 self.new_mode = self.model.mode_amend
98 self.new_head = 'HEAD^'
99 self.new_commitmsg = self.model.prev_commitmsg()
100 return
101 # else, amend unchecked, regular commit
102 self.new_mode = self.model.mode_none
103 self.new_head = 'HEAD'
104 self.new_commitmsg = self.model.commitmsg
105 # If we're going back into new-commit-mode then search the
106 # undo stack for a previous amend-commit-mode and grab the
107 # commit message at that point in time.
108 if not _factory.undostack:
109 return
110 undo_count = len(_factory.undostack)
111 for i in xrange(undo_count):
112 # Find the latest AmendMode command
113 idx = undo_count - i - 1
114 cmdobj = _factory.undostack[idx]
115 if type(cmdobj) is not AmendMode:
116 continue
117 if cmdobj.amending:
118 self.new_commitmsg = cmdobj.old_commitmsg
119 break
121 def do(self):
122 """Leave/enter amend mode."""
123 """Attempt to enter amend mode. Do not allow this when merging."""
124 if self.amending:
125 if os.path.exists(self.model.git.git_path('MERGE_HEAD')):
126 self.skip = True
127 _notifier.broadcast(signals.amend, False)
128 _notifier.broadcast(signals.information,
129 'Oops! Unmerged',
130 'You are in the middle of a merge.\n'
131 'You cannot amend while merging.')
132 return
133 self.skip = False
134 _notifier.broadcast(signals.amend, self.amending)
135 self.model.set_commitmsg(self.new_commitmsg)
136 Command.do(self)
138 def undo(self):
139 if self.skip:
140 return
141 self.model.set_commitmsg(self.old_commitmsg)
142 Command.undo(self)
145 class ApplyDiffSelection(Command):
146 def __init__(self, staged, selected, offset, selection, apply_to_worktree):
147 Command.__init__(self, update=True)
148 self.staged = staged
149 self.selected = selected
150 self.offset = offset
151 self.selection = selection
152 self.apply_to_worktree = apply_to_worktree
154 def do(self):
155 if self.model.mode == self.model.mode_branch:
156 # We're applying changes from a different branch!
157 parser = DiffParser(self.model,
158 filename=self.model.filename,
159 cached=False,
160 branch=self.model.head)
161 parser.process_diff_selection(self.selected,
162 self.offset,
163 self.selection,
164 apply_to_worktree=True)
165 else:
166 # The normal worktree vs index scenario
167 parser = DiffParser(self.model,
168 filename=self.model.filename,
169 cached=self.staged,
170 reverse=self.apply_to_worktree)
171 parser.process_diff_selection(self.selected,
172 self.offset,
173 self.selection,
174 apply_to_worktree=
175 self.apply_to_worktree)
176 # Redo the diff to show changes
177 if self.staged:
178 diffcmd = DiffStaged([self.model.filename])
179 else:
180 diffcmd = Diff([self.model.filename])
181 diffcmd.do()
182 self.model.update_status()
184 class ApplyPatches(Command):
185 def __init__(self, patches):
186 Command.__init__(self)
187 patches.sort()
188 self.patches = patches
190 def do(self):
191 diff_text = ''
192 num_patches = len(self.patches)
193 orig_head = self.model.git.rev_parse('HEAD')
195 for idx, patch in enumerate(self.patches):
196 status, output = self.model.git.am(patch,
197 with_status=True,
198 with_stderr=True)
199 # Log the git-am command
200 _notifier.broadcast(signals.log_cmd, status, output)
202 if num_patches > 1:
203 diff = self.model.git.diff('HEAD^!', stat=True)
204 diff_text += 'Patch %d/%d - ' % (idx+1, num_patches)
205 diff_text += '%s:\n%s\n\n' % (os.path.basename(patch), diff)
207 diff_text += 'Summary:\n'
208 diff_text += self.model.git.diff(orig_head, stat=True)
210 # Display a diffstat
211 self.model.set_diff_text(diff_text)
213 _notifier.broadcast(signals.information,
214 'Patch(es) Applied',
215 '%d patch(es) applied:\n\n%s' %
216 (len(self.patches),
217 '\n'.join(map(os.path.basename, self.patches))))
220 class HeadChangeCommand(Command):
221 """Changes the model's current head."""
222 def __init__(self, treeish):
223 Command.__init__(self, update=True)
224 self.new_head = treeish
225 self.new_diff_text = ''
228 class BranchMode(HeadChangeCommand):
229 """Enter into diff-branch mode."""
230 def __init__(self, treeish, filename):
231 HeadChangeCommand.__init__(self, treeish)
232 self.old_filename = self.model.filename
233 self.new_filename = filename
234 self.new_mode = self.model.mode_branch
235 self.new_diff_text = gitcmds.diff_helper(filename=filename,
236 cached=False,
237 reverse=True,
238 branch=treeish)
239 class Checkout(Command):
241 A command object for git-checkout.
243 'argv' is handed off directly to git.
246 def __init__(self, argv):
247 Command.__init__(self)
248 self.argv = argv
250 def do(self):
251 status, output = self.model.git.checkout(with_stderr=True,
252 with_status=True, *self.argv)
253 _notifier.broadcast(signals.log_cmd, status, output)
254 self.model.set_diff_text('')
255 self.model.update_status()
258 class CheckoutBranch(Checkout):
259 """Checkout a branch."""
260 def __init__(self, branch):
261 Checkout.__init__(self, [branch])
264 class CherryPick(Command):
265 """Cherry pick commits into the current branch."""
266 def __init__(self, commits):
267 Command.__init__(self)
268 self.commits = commits
270 def do(self):
271 self.model.cherry_pick_list(self.commits)
274 class ResetMode(Command):
275 """Reset the mode and clear the model's diff text."""
276 def __init__(self):
277 Command.__init__(self, update=True)
278 self.new_mode = self.model.mode_none
279 self.new_head = 'HEAD'
280 self.new_diff_text = ''
283 class Commit(ResetMode):
284 """Attempt to create a new commit."""
285 def __init__(self, amend, msg):
286 ResetMode.__init__(self)
287 self.amend = amend
288 self.msg = core.encode(msg)
289 self.old_commitmsg = self.model.commitmsg
290 self.new_commitmsg = ''
292 def do(self):
293 status, output = self.model.commit_with_msg(self.msg, amend=self.amend)
294 if status == 0:
295 ResetMode.do(self)
296 self.model.set_commitmsg(self.new_commitmsg)
297 title = 'Commit: '
298 else:
299 title = 'Commit failed: '
300 _notifier.broadcast(signals.log_cmd, status, title+output)
303 class Delete(Command):
304 """Simply delete files."""
305 def __init__(self, filenames):
306 Command.__init__(self)
307 self.filenames = filenames
308 # We could git-hash-object stuff and provide undo-ability
309 # as an option. Heh.
310 def do(self):
311 rescan = False
312 for filename in self.filenames:
313 if filename:
314 try:
315 os.remove(filename)
316 rescan=True
317 except:
318 _notifier.broadcast(signals.information,
319 'Error'
320 'Deleting "%s" failed.' % filename)
321 if rescan:
322 self.model.update_status()
324 class DeleteBranch(Command):
325 """Delete a git branch."""
326 def __init__(self, branch):
327 Command.__init__(self)
328 self.branch = branch
330 def do(self):
331 status, output = self.model.delete_branch(self.branch)
332 title = ''
333 if output.startswith('error:'):
334 output = 'E' + output[1:]
335 else:
336 title = 'Info: '
337 _notifier.broadcast(signals.log_cmd, status, title + output)
340 class Diff(Command):
341 """Perform a diff and set the model's current text."""
342 def __init__(self, filenames, cached=False):
343 Command.__init__(self)
344 opts = {}
345 if cached:
346 cached = not self.model.read_only()
347 opts = dict(ref=self.model.head)
349 self.new_filename = filenames[0]
350 self.old_filename = self.model.filename
351 if not self.model.read_only():
352 if self.model.mode != self.model.mode_amend:
353 self.new_mode = self.model.mode_worktree
354 self.new_diff_text = gitcmds.diff_helper(filename=self.new_filename,
355 cached=cached, **opts)
358 class DiffMode(HeadChangeCommand):
359 """Enter diff mode and clear the model's diff text."""
360 def __init__(self, treeish):
361 HeadChangeCommand.__init__(self, treeish)
362 self.new_mode = self.model.mode_diff
365 class DiffExprMode(HeadChangeCommand):
366 """Enter diff-expr mode and clear the model's diff text."""
367 def __init__(self, treeish):
368 HeadChangeCommand.__init__(self, treeish)
369 self.new_mode = self.model.mode_diff_expr
372 class Diffstat(Command):
373 """Perform a diffstat and set the model's diff text."""
374 def __init__(self):
375 Command.__init__(self)
376 diff = self.model.git.diff(self.model.head,
377 unified=_config.get('diff.context', 3),
378 no_color=True,
379 M=True,
380 stat=True)
381 self.new_diff_text = core.decode(diff)
382 self.new_mode = self.model.mode_worktree
385 class DiffStaged(Diff):
386 """Perform a staged diff on a file."""
387 def __init__(self, filenames):
388 Diff.__init__(self, filenames, cached=True)
389 if not self.model.read_only():
390 if self.model.mode != self.model.mode_amend:
391 self.new_mode = self.model.mode_index
394 class DiffStagedSummary(Command):
395 def __init__(self):
396 Command.__init__(self)
397 cached = not self.model.read_only()
398 diff = self.model.git.diff(self.model.head,
399 cached=cached,
400 no_color=True,
401 patch_with_stat=True,
402 M=True)
403 self.new_diff_text = core.decode(diff)
404 if not self.model.read_only():
405 if self.model.mode != self.model.mode_amend:
406 self.new_mode = self.model.mode_index
409 class Difftool(Command):
410 """Run git-difftool limited by path."""
411 def __init__(self, staged, filenames):
412 Command.__init__(self)
413 self.staged = staged
414 self.filenames = filenames
416 def do(self):
417 if not self.filenames:
418 return
419 args = []
420 if self.staged and not self.model.read_only():
421 args.append('--cached')
422 args.extend([self.model.head, '--'])
423 args.extend(self.filenames)
424 difftool.launch(args)
427 class Edit(Command):
428 """Edit a file using the configured gui.editor."""
429 def __init__(self, filenames, line_number=None):
430 Command.__init__(self)
431 self.filenames = filenames
432 self.line_number = line_number
434 def do(self):
435 filename = self.filenames[0]
436 if not os.path.exists(filename):
437 return
438 editor = self.model.editor()
439 if 'vi' in editor and self.line_number:
440 utils.fork([editor, filename, '+'+self.line_number])
441 else:
442 utils.fork([editor, filename])
445 class FormatPatch(Command):
446 """Output a patch series given all revisions and a selected subset."""
447 def __init__(self, to_export, revs):
448 Command.__init__(self)
449 self.to_export = to_export
450 self.revs = revs
452 def do(self):
453 status, output = gitcmds.format_patchsets(self.to_export, self.revs)
454 _notifier.broadcast(signals.log_cmd, status, output)
457 class GrepMode(Command):
458 def __init__(self, txt):
459 """Perform a git-grep."""
460 Command.__init__(self)
461 self.new_mode = self.model.mode_grep
462 self.new_diff_text = self.model.git.grep(txt, n=True)
464 class LoadCommitMessage(Command):
465 """Loads a commit message from a path."""
466 def __init__(self, path):
467 Command.__init__(self)
468 if not path or not os.path.exists(path):
469 raise OSError('error: "%s" does not exist' % path)
470 self.undoable = True
471 self.path = path
472 self.old_commitmsg = self.model.commitmsg
473 self.old_directory = self.model.directory
475 def do(self):
476 self.model.set_directory(os.path.dirname(self.path))
477 self.model.set_commitmsg(utils.slurp(self.path))
479 def undo(self):
480 self.model.set_commitmsg(self.old_commitmsg)
481 self.model.set_directory(self.old_directory)
484 class LoadCommitTemplate(LoadCommitMessage):
485 """Loads the commit message template specified by commit.template."""
486 def __init__(self):
487 LoadCommitMessage.__init__(self, _config.get('commit.template'))
490 class Mergetool(Command):
491 """Launch git-mergetool on a list of paths."""
492 def __init__(self, paths):
493 Command.__init__(self)
494 self.paths = paths
496 def do(self):
497 if not self.paths:
498 return
499 if version.check('mergetool-no-prompt',
500 self.model.git.version().split()[-1]):
501 utils.fork(['git', 'mergetool', '--no-prompt', '--'] + self.paths)
502 else:
503 utils.fork(['xterm', '-e', 'git', 'mergetool', '--'] + self.paths)
506 class OpenRepo(Command):
507 """Launches git-cola on a repo."""
508 def __init__(self, dirname):
509 Command.__init__(self)
510 self.new_directory = utils.quote_repopath(dirname)
512 def do(self):
513 self.model.set_directory(self.new_directory)
514 utils.fork(['python', sys.argv[0], '--repo', self.new_directory])
517 class Clone(Command):
518 """Clones a repository and optionally spawns a new cola session."""
519 def __init__(self, url, destdir, spawn=True):
520 Command.__init__(self)
521 self.url = url
522 self.new_directory = utils.quote_repopath(destdir)
523 self.spawn = spawn
525 def do(self):
526 self.model.git.clone(self.url, self.new_directory,
527 with_stderr=True, with_status=True)
528 if self.spawn:
529 utils.fork(['python', sys.argv[0], '--repo', self.new_directory])
532 class Rescan(Command):
533 """Rescans for changes."""
534 def __init__(self):
535 Command.__init__(self, update=True)
538 class ReviewBranchMode(Command):
539 """Enter into review-branch mode."""
540 def __init__(self, branch):
541 Command.__init__(self, update=True)
542 self.new_mode = self.model.mode_review
543 self.new_head = gitcmds.merge_base_parent(branch)
544 self.new_diff_text = ''
547 class ShowUntracked(Command):
548 """Show an untracked file."""
549 # We don't actually do anything other than set the mode right now.
550 # We could probably check the mimetype for the file and handle things
551 # generically.
552 def __init__(self, filenames):
553 Command.__init__(self)
554 self.new_mode = self.model.mode_worktree
555 # TODO new_diff_text = utils.file_preview(filenames[0])
558 class Stage(Command):
559 """Stage a set of paths."""
560 def __init__(self, paths):
561 Command.__init__(self)
562 self.paths = paths
564 def do(self):
565 msg = 'Staging: %s' % (', '.join(self.paths))
566 _notifier.broadcast(signals.log_cmd, 0, msg)
567 self.model.stage_paths(self.paths)
570 class StageModified(Stage):
571 """Stage all modified files."""
572 def __init__(self):
573 Stage.__init__(self, None)
574 self.paths = self.model.modified
577 class StageUntracked(Stage):
578 """Stage all untracked files."""
579 def __init__(self):
580 Stage.__init__(self, None)
581 self.paths = self.model.untracked
583 class Tag(Command):
584 """Create a tag object."""
585 def __init__(self, name, revision, sign=False, message=''):
586 Command.__init__(self)
587 self._name = name
588 self._message = core.encode(message)
589 self._revision = revision
590 self._sign = sign
592 def do(self):
593 log_msg = 'Tagging: "%s" as "%s"' % (self._revision, self._name)
594 if self._sign:
595 log_msg += ', GPG-signed'
596 path = self.model.tmp_filename()
597 utils.write(path, self._message)
598 status, output = self.model.git.tag(self._name,
599 self._revision,
600 s=True,
601 F=path,
602 with_status=True,
603 with_stderr=True)
604 os.unlink(path)
605 else:
606 status, output = self.model.git.tag(self._name,
607 self._revision,
608 with_status=True,
609 with_stderr=True)
610 if output:
611 log_msg += '\nOutput:\n%s' % output
613 _notifier.broadcast(signals.log_cmd, status, log_msg)
614 if status == 0:
615 self.model.update_status()
618 class Unstage(Command):
619 """Unstage a set of paths."""
620 def __init__(self, paths):
621 Command.__init__(self)
622 self.paths = paths
624 def do(self):
625 msg = 'Unstaging: %s' % (', '.join(self.paths))
626 _notifier.broadcast(signals.log_cmd, 0, msg)
627 gitcmds.unstage_paths(self.paths)
628 self.model.update_status()
631 class UnstageAll(Command):
632 """Unstage all files; resets the index."""
633 def __init__(self):
634 Command.__init__(self, update=True)
636 def do(self):
637 self.model.unstage_all()
640 class UnstageSelected(Unstage):
641 """Unstage selected files."""
642 def __init__(self):
643 Unstage.__init__(self, cola.selection_model().staged)
646 class UntrackedSummary(Command):
647 """List possible .gitignore rules as the diff text."""
648 def __init__(self):
649 Command.__init__(self)
650 untracked = self.model.untracked
651 suffix = len(untracked) > 1 and 's' or ''
652 io = StringIO()
653 io.write('# %s untracked file%s\n' % (len(untracked), suffix))
654 if untracked:
655 io.write('# possible .gitignore rule%s:\n' % suffix)
656 for u in untracked:
657 io.write('/%s\n' % u)
658 self.new_diff_text = io.getvalue()
661 class VisualizeAll(Command):
662 """Visualize all branches."""
663 def do(self):
664 browser = self.model.history_browser()
665 utils.fork([browser, '--all'])
668 class VisualizeCurrent(Command):
669 """Visualize all branches."""
670 def do(self):
671 browser = self.model.history_browser()
672 utils.fork([browser, self.model.currentbranch])
675 class VisualizePaths(Command):
676 """Path-limited visualization."""
677 def __init__(self, paths):
678 Command.__init__(self)
679 browser = self.model.history_browser()
680 if paths:
681 self.argv = [browser] + paths
682 else:
683 self.argv = [browser]
685 def do(self):
686 utils.fork(self.argv)
689 def register():
691 Register signal mappings with the factory.
693 These commands are automatically created and run when
694 their corresponding signal is broadcast by the notifier.
697 signal_to_command_map = {
698 signals.add_signoff: AddSignoff,
699 signals.amend_mode: AmendMode,
700 signals.apply_diff_selection: ApplyDiffSelection,
701 signals.apply_patches: ApplyPatches,
702 signals.branch_mode: BranchMode,
703 signals.clone: Clone,
704 signals.checkout: Checkout,
705 signals.checkout_branch: CheckoutBranch,
706 signals.cherry_pick: CherryPick,
707 signals.commit: Commit,
708 signals.delete: Delete,
709 signals.delete_branch: DeleteBranch,
710 signals.diff: Diff,
711 signals.diff_mode: DiffMode,
712 signals.diff_expr_mode: DiffExprMode,
713 signals.diff_staged: DiffStaged,
714 signals.diffstat: Diffstat,
715 signals.difftool: Difftool,
716 signals.edit: Edit,
717 signals.format_patch: FormatPatch,
718 signals.grep: GrepMode,
719 signals.load_commit_message: LoadCommitMessage,
720 signals.load_commit_template: LoadCommitTemplate,
721 signals.modified_summary: Diffstat,
722 signals.mergetool: Mergetool,
723 signals.open_repo: OpenRepo,
724 signals.rescan: Rescan,
725 signals.reset_mode: ResetMode,
726 signals.review_branch_mode: ReviewBranchMode,
727 signals.show_untracked: ShowUntracked,
728 signals.stage: Stage,
729 signals.stage_modified: StageModified,
730 signals.stage_untracked: StageUntracked,
731 signals.staged_summary: DiffStagedSummary,
732 signals.tag: Tag,
733 signals.unstage: Unstage,
734 signals.unstage_all: UnstageAll,
735 signals.unstage_selected: UnstageSelected,
736 signals.untracked_summary: UntrackedSummary,
737 signals.visualize_all: VisualizeAll,
738 signals.visualize_current: VisualizeCurrent,
739 signals.visualize_paths: VisualizePaths,
742 for signal, cmd in signal_to_command_map.iteritems():
743 _factory.add_command(signal, cmd)