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