gitcfg: Use st_mtime as the cache key
[git-cola.git] / cola / commands.py
blobcc7d60d5d9f67809aefb810f80aaee8c50d74e98
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)
465 class LoadCommitMessage(Command):
466 """Loads a commit message from a path."""
467 def __init__(self, path):
468 Command.__init__(self)
469 self.undoable = True
470 self.path = path
471 self.old_commitmsg = self.model.commitmsg
472 self.old_directory = self.model.directory
474 def do(self):
475 self.model.set_directory(os.path.dirname(self.path))
476 fh = open(self.path, 'r')
477 contents = core.decode(core.read_nointr(fh))
478 fh.close()
479 self.model.set_commitmsg(contents)
481 def undo(self):
482 self.model.set_commitmsg(self.old_commitmsg)
483 self.model.set_directory(self.old_directory)
486 class Mergetool(Command):
487 """Launch git-mergetool on a list of paths."""
488 def __init__(self, paths):
489 Command.__init__(self)
490 self.paths = paths
492 def do(self):
493 if not self.paths:
494 return
495 if version.check('mergetool-no-prompt',
496 self.model.git.version().split()[-1]):
497 utils.fork(['git', 'mergetool', '--no-prompt', '--'] + self.paths)
498 else:
499 utils.fork(['xterm', '-e', 'git', 'mergetool', '--'] + self.paths)
502 class OpenRepo(Command):
503 """Launches git-cola on a repo."""
504 def __init__(self, dirname):
505 Command.__init__(self)
506 self.new_directory = utils.quote_repopath(dirname)
508 def do(self):
509 self.model.set_directory(self.new_directory)
510 utils.fork(['python', sys.argv[0], '--repo', self.new_directory])
513 class Clone(Command):
514 """Clones a repository and optionally spawns a new cola session."""
515 def __init__(self, url, destdir, spawn=True):
516 Command.__init__(self)
517 self.url = url
518 self.new_directory = utils.quote_repopath(destdir)
519 self.spawn = spawn
521 def do(self):
522 self.model.git.clone(self.url, self.new_directory,
523 with_stderr=True, with_status=True)
524 if self.spawn:
525 utils.fork(['python', sys.argv[0], '--repo', self.new_directory])
528 class Rescan(Command):
529 """Rescans for changes."""
530 def __init__(self):
531 Command.__init__(self, update=True)
534 class ReviewBranchMode(Command):
535 """Enter into review-branch mode."""
536 def __init__(self, branch):
537 Command.__init__(self, update=True)
538 self.new_mode = self.model.mode_review
539 self.new_head = gitcmds.merge_base_to(branch)
540 self.new_diff_text = ''
543 class ShowUntracked(Command):
544 """Show an untracked file."""
545 # We don't actually do anything other than set the mode right now.
546 # We could probably check the mimetype for the file and handle things
547 # generically.
548 def __init__(self, filenames):
549 Command.__init__(self)
550 self.new_mode = self.model.mode_worktree
551 # TODO new_diff_text = utils.file_preview(filenames[0])
554 class Stage(Command):
555 """Stage a set of paths."""
556 def __init__(self, paths):
557 Command.__init__(self)
558 self.paths = paths
560 def do(self):
561 msg = 'Staging: %s' % (', '.join(self.paths))
562 _notifier.broadcast(signals.log_cmd, 0, msg)
563 self.model.stage_paths(self.paths)
566 class StageModified(Stage):
567 """Stage all modified files."""
568 def __init__(self):
569 Stage.__init__(self, None)
570 self.paths = self.model.modified
573 class StageUntracked(Stage):
574 """Stage all untracked files."""
575 def __init__(self):
576 Stage.__init__(self, None)
577 self.paths = self.model.untracked
579 class Tag(Command):
580 """Create a tag object."""
581 def __init__(self, name, revision, sign=False, message=''):
582 Command.__init__(self)
583 self._name = name
584 self._message = core.encode(message)
585 self._revision = revision
586 self._sign = sign
588 def do(self):
589 log_msg = 'Tagging: "%s" as "%s"' % (self._revision, self._name)
590 if self._sign:
591 log_msg += ', GPG-signed'
592 path = self.model.tmp_filename()
593 utils.write(path, self._message)
594 status, output = self.model.git.tag(self._name,
595 self._revision,
596 s=True,
597 F=path,
598 with_status=True,
599 with_stderr=True)
600 os.unlink(path)
601 else:
602 status, output = self.model.git.tag(self._name,
603 self._revision,
604 with_status=True,
605 with_stderr=True)
606 if output:
607 log_msg += '\nOutput:\n%s' % output
609 _notifier.broadcast(signals.log_cmd, status, log_msg)
610 if status == 0:
611 self.model.update_status()
614 class Unstage(Command):
615 """Unstage a set of paths."""
616 def __init__(self, paths):
617 Command.__init__(self)
618 self.paths = paths
620 def do(self):
621 msg = 'Unstaging: %s' % (', '.join(self.paths))
622 _notifier.broadcast(signals.log_cmd, 0, msg)
623 gitcmds.unstage_paths(self.paths)
624 self.model.update_status()
627 class UnstageAll(Command):
628 """Unstage all files; resets the index."""
629 def __init__(self):
630 Command.__init__(self, update=True)
632 def do(self):
633 self.model.unstage_all()
636 class UnstageSelected(Unstage):
637 """Unstage selected files."""
638 def __init__(self):
639 Unstage.__init__(self, cola.selection_model().staged)
642 class UntrackedSummary(Command):
643 """List possible .gitignore rules as the diff text."""
644 def __init__(self):
645 Command.__init__(self)
646 untracked = self.model.untracked
647 suffix = len(untracked) > 1 and 's' or ''
648 io = StringIO()
649 io.write('# %s untracked file%s\n' % (len(untracked), suffix))
650 if untracked:
651 io.write('# possible .gitignore rule%s:\n' % suffix)
652 for u in untracked:
653 io.write('/%s\n' % u)
654 self.new_diff_text = io.getvalue()
657 class VisualizeAll(Command):
658 """Visualize all branches."""
659 def do(self):
660 browser = self.model.history_browser()
661 utils.fork([browser, '--all'])
664 class VisualizeCurrent(Command):
665 """Visualize all branches."""
666 def do(self):
667 browser = self.model.history_browser()
668 utils.fork([browser, self.model.currentbranch])
671 class VisualizePaths(Command):
672 """Path-limited visualization."""
673 def __init__(self, paths):
674 Command.__init__(self)
675 browser = self.model.history_browser()
676 if paths:
677 self.argv = [browser] + paths
678 else:
679 self.argv = [browser]
681 def do(self):
682 utils.fork(self.argv)
685 def register():
687 Register signal mappings with the factory.
689 These commands are automatically created and run when
690 their corresponding signal is broadcast by the notifier.
693 signal_to_command_map = {
694 signals.add_signoff: AddSignoff,
695 signals.amend_mode: AmendMode,
696 signals.apply_diff_selection: ApplyDiffSelection,
697 signals.apply_patches: ApplyPatches,
698 signals.branch_mode: BranchMode,
699 signals.clone: Clone,
700 signals.checkout: Checkout,
701 signals.checkout_branch: CheckoutBranch,
702 signals.cherry_pick: CherryPick,
703 signals.commit: Commit,
704 signals.delete: Delete,
705 signals.delete_branch: DeleteBranch,
706 signals.diff: Diff,
707 signals.diff_mode: DiffMode,
708 signals.diff_expr_mode: DiffExprMode,
709 signals.diff_staged: DiffStaged,
710 signals.diffstat: Diffstat,
711 signals.difftool: Difftool,
712 signals.edit: Edit,
713 signals.format_patch: FormatPatch,
714 signals.grep: GrepMode,
715 signals.load_commit_message: LoadCommitMessage,
716 signals.modified_summary: Diffstat,
717 signals.mergetool: Mergetool,
718 signals.open_repo: OpenRepo,
719 signals.rescan: Rescan,
720 signals.reset_mode: ResetMode,
721 signals.review_branch_mode: ReviewBranchMode,
722 signals.show_untracked: ShowUntracked,
723 signals.stage: Stage,
724 signals.stage_modified: StageModified,
725 signals.stage_untracked: StageUntracked,
726 signals.staged_summary: DiffStagedSummary,
727 signals.tag: Tag,
728 signals.unstage: Unstage,
729 signals.unstage_all: UnstageAll,
730 signals.unstage_selected: UnstageSelected,
731 signals.untracked_summary: UntrackedSummary,
732 signals.visualize_all: VisualizeAll,
733 signals.visualize_current: VisualizeCurrent,
734 signals.visualize_paths: VisualizePaths,
737 for signal, cmd in signal_to_command_map.iteritems():
738 _factory.add_command(signal, cmd)