branch: set the current item when restoring state
[git-cola.git] / cola / cmds.py
blobe1b906fb52b43fabf0400ce7d625e552052461d7
1 """Editor commands"""
2 from __future__ import absolute_import, division, print_function, unicode_literals
3 import os
4 import re
5 import sys
6 from fnmatch import fnmatch
7 from io import StringIO
9 try:
10 from send2trash import send2trash
11 except ImportError:
12 send2trash = None
14 from . import compat
15 from . import core
16 from . import gitcmds
17 from . import icons
18 from . import resources
19 from . import textwrap
20 from . import utils
21 from . import version
22 from .cmd import ContextCommand
23 from .diffparse import DiffParser
24 from .git import STDOUT
25 from .git import EMPTY_TREE_OID
26 from .git import MISSING_BLOB_OID
27 from .i18n import N_
28 from .interaction import Interaction
29 from .models import main
30 from .models import prefs
33 class UsageError(Exception):
34 """Exception class for usage errors."""
36 def __init__(self, title, message):
37 Exception.__init__(self, message)
38 self.title = title
39 self.msg = message
42 class EditModel(ContextCommand):
43 """Commands that mutate the main model diff data"""
45 UNDOABLE = True
47 def __init__(self, context):
48 """Common edit operations on the main model"""
49 super(EditModel, self).__init__(context)
51 self.old_diff_text = self.model.diff_text
52 self.old_filename = self.model.filename
53 self.old_mode = self.model.mode
54 self.old_diff_type = self.model.diff_type
55 self.old_file_type = self.model.file_type
57 self.new_diff_text = self.old_diff_text
58 self.new_filename = self.old_filename
59 self.new_mode = self.old_mode
60 self.new_diff_type = self.old_diff_type
61 self.new_file_type = self.old_file_type
63 def do(self):
64 """Perform the operation."""
65 self.model.filename = self.new_filename
66 self.model.set_mode(self.new_mode)
67 self.model.set_diff_text(self.new_diff_text)
68 self.model.set_diff_type(self.new_diff_type)
69 self.model.set_file_type(self.new_file_type)
71 def undo(self):
72 """Undo the operation."""
73 self.model.filename = self.old_filename
74 self.model.set_mode(self.old_mode)
75 self.model.set_diff_text(self.old_diff_text)
76 self.model.set_diff_type(self.old_diff_type)
77 self.model.set_file_type(self.old_file_type)
80 class ConfirmAction(ContextCommand):
81 """Confirm an action before running it"""
83 # pylint: disable=no-self-use
84 def ok_to_run(self):
85 """Return True when the command is ok to run"""
86 return True
88 # pylint: disable=no-self-use
89 def confirm(self):
90 """Prompt for confirmation"""
91 return True
93 # pylint: disable=no-self-use
94 def action(self):
95 """Run the command and return (status, out, err)"""
96 return (-1, '', '')
98 # pylint: disable=no-self-use
99 def success(self):
100 """Callback run on success"""
101 return
103 # pylint: disable=no-self-use
104 def command(self):
105 """Command name, for error messages"""
106 return 'git'
108 # pylint: disable=no-self-use
109 def error_message(self):
110 """Command error message"""
111 return ''
113 def do(self):
114 """Prompt for confirmation before running a command"""
115 status = -1
116 out = err = ''
117 ok = self.ok_to_run() and self.confirm()
118 if ok:
119 status, out, err = self.action()
120 if status == 0:
121 self.success()
122 title = self.error_message()
123 cmd = self.command()
124 Interaction.command(title, cmd, status, out, err)
126 return ok, status, out, err
129 class AbortMerge(ConfirmAction):
130 """Reset an in-progress merge back to HEAD"""
132 def confirm(self):
133 title = N_('Abort Merge...')
134 question = N_('Aborting the current merge?')
135 info = N_(
136 'Aborting the current merge will cause '
137 '*ALL* uncommitted changes to be lost.\n'
138 'Recovering uncommitted changes is not possible.'
140 ok_txt = N_('Abort Merge')
141 return Interaction.confirm(
142 title, question, info, ok_txt, default=False, icon=icons.undo()
145 def action(self):
146 status, out, err = gitcmds.abort_merge(self.context)
147 self.model.update_file_status()
148 return status, out, err
150 def success(self):
151 self.model.set_commitmsg('')
153 def error_message(self):
154 return N_('Error')
156 def command(self):
157 return 'git merge'
160 class AmendMode(EditModel):
161 """Try to amend a commit."""
163 UNDOABLE = True
164 LAST_MESSAGE = None
166 @staticmethod
167 def name():
168 return N_('Amend')
170 def __init__(self, context, amend=True):
171 super(AmendMode, self).__init__(context)
172 self.skip = False
173 self.amending = amend
174 self.old_commitmsg = self.model.commitmsg
175 self.old_mode = self.model.mode
177 if self.amending:
178 self.new_mode = self.model.mode_amend
179 self.new_commitmsg = gitcmds.prev_commitmsg(context)
180 AmendMode.LAST_MESSAGE = self.model.commitmsg
181 return
182 # else, amend unchecked, regular commit
183 self.new_mode = self.model.mode_none
184 self.new_diff_text = ''
185 self.new_commitmsg = self.model.commitmsg
186 # If we're going back into new-commit-mode then search the
187 # undo stack for a previous amend-commit-mode and grab the
188 # commit message at that point in time.
189 if AmendMode.LAST_MESSAGE is not None:
190 self.new_commitmsg = AmendMode.LAST_MESSAGE
191 AmendMode.LAST_MESSAGE = None
193 def do(self):
194 """Leave/enter amend mode."""
195 # Attempt to enter amend mode. Do not allow this when merging.
196 if self.amending:
197 if self.model.is_merging:
198 self.skip = True
199 self.model.set_mode(self.old_mode)
200 Interaction.information(
201 N_('Cannot Amend'),
203 'You are in the middle of a merge.\n'
204 'Cannot amend while merging.'
207 return
208 self.skip = False
209 super(AmendMode, self).do()
210 self.model.set_commitmsg(self.new_commitmsg)
211 self.model.update_file_status()
213 def undo(self):
214 if self.skip:
215 return
216 self.model.set_commitmsg(self.old_commitmsg)
217 super(AmendMode, self).undo()
218 self.model.update_file_status()
221 class AnnexAdd(ContextCommand):
222 """Add to Git Annex"""
224 def __init__(self, context):
225 super(AnnexAdd, self).__init__(context)
226 self.filename = self.selection.filename()
228 def do(self):
229 status, out, err = self.git.annex('add', self.filename)
230 Interaction.command(N_('Error'), 'git annex add', status, out, err)
231 self.model.update_status()
234 class AnnexInit(ContextCommand):
235 """Initialize Git Annex"""
237 def do(self):
238 status, out, err = self.git.annex('init')
239 Interaction.command(N_('Error'), 'git annex init', status, out, err)
240 self.model.cfg.reset()
241 self.model.emit_updated()
244 class LFSTrack(ContextCommand):
245 """Add a file to git lfs"""
247 def __init__(self, context):
248 super(LFSTrack, self).__init__(context)
249 self.filename = self.selection.filename()
250 self.stage_cmd = Stage(context, [self.filename])
252 def do(self):
253 status, out, err = self.git.lfs('track', self.filename)
254 Interaction.command(N_('Error'), 'git lfs track', status, out, err)
255 if status == 0:
256 self.stage_cmd.do()
259 class LFSInstall(ContextCommand):
260 """Initialize git lfs"""
262 def do(self):
263 status, out, err = self.git.lfs('install')
264 Interaction.command(N_('Error'), 'git lfs install', status, out, err)
265 self.model.update_config(reset=True, emit=True)
268 class ApplyDiffSelection(ContextCommand):
269 """Apply the selected diff to the worktree or index"""
271 def __init__(
272 self,
273 context,
274 first_line_idx,
275 last_line_idx,
276 has_selection,
277 reverse,
278 apply_to_worktree,
280 super(ApplyDiffSelection, self).__init__(context)
281 self.first_line_idx = first_line_idx
282 self.last_line_idx = last_line_idx
283 self.has_selection = has_selection
284 self.reverse = reverse
285 self.apply_to_worktree = apply_to_worktree
287 def do(self):
288 context = self.context
289 cfg = self.context.cfg
290 diff_text = self.model.diff_text
292 parser = DiffParser(self.model.filename, diff_text)
293 if self.has_selection:
294 patch = parser.generate_patch(
295 self.first_line_idx, self.last_line_idx, reverse=self.reverse
297 else:
298 patch = parser.generate_hunk_patch(
299 self.first_line_idx, reverse=self.reverse
301 if patch is None:
302 return
304 if isinstance(diff_text, core.UStr):
305 # original encoding must prevail
306 encoding = diff_text.encoding
307 else:
308 encoding = cfg.file_encoding(self.model.filename)
310 tmp_file = utils.tmp_filename('patch')
311 try:
312 core.write(tmp_file, patch, encoding=encoding)
313 if self.apply_to_worktree:
314 status, out, err = gitcmds.apply_diff_to_worktree(context, tmp_file)
315 else:
316 status, out, err = gitcmds.apply_diff(context, tmp_file)
317 finally:
318 core.unlink(tmp_file)
320 Interaction.log_status(status, out, err)
321 self.model.update_file_status(update_index=True)
324 class ApplyPatches(ContextCommand):
325 """Apply patches using the "git am" command"""
327 def __init__(self, context, patches):
328 super(ApplyPatches, self).__init__(context)
329 self.patches = patches
331 def do(self):
332 status, out, err = self.git.am('-3', *self.patches)
333 Interaction.log_status(status, out, err)
335 # Display a diffstat
336 self.model.update_file_status()
338 patch_basenames = [os.path.basename(p) for p in self.patches]
339 if len(patch_basenames) > 25:
340 patch_basenames = patch_basenames[:25]
341 patch_basenames.append('...')
343 basenames = '\n'.join(patch_basenames)
344 Interaction.information(
345 N_('Patch(es) Applied'),
346 (N_('%d patch(es) applied.') + '\n\n%s') % (len(self.patches), basenames),
350 class Archive(ContextCommand):
351 """ "Export archives using the "git archive" command"""
353 def __init__(self, context, ref, fmt, prefix, filename):
354 super(Archive, self).__init__(context)
355 self.ref = ref
356 self.fmt = fmt
357 self.prefix = prefix
358 self.filename = filename
360 def do(self):
361 fp = core.xopen(self.filename, 'wb')
362 cmd = ['git', 'archive', '--format=' + self.fmt]
363 if self.fmt in ('tgz', 'tar.gz'):
364 cmd.append('-9')
365 if self.prefix:
366 cmd.append('--prefix=' + self.prefix)
367 cmd.append(self.ref)
368 proc = core.start_command(cmd, stdout=fp)
369 out, err = proc.communicate()
370 fp.close()
371 status = proc.returncode
372 Interaction.log_status(status, out or '', err or '')
375 class Checkout(EditModel):
376 """A command object for git-checkout.
378 'argv' is handed off directly to git.
382 def __init__(self, context, argv, checkout_branch=False):
383 super(Checkout, self).__init__(context)
384 self.argv = argv
385 self.checkout_branch = checkout_branch
386 self.new_diff_text = ''
387 self.new_diff_type = main.Types.TEXT
388 self.new_file_type = main.Types.TEXT
390 def do(self):
391 super(Checkout, self).do()
392 status, out, err = self.git.checkout(*self.argv)
393 if self.checkout_branch:
394 self.model.update_status()
395 else:
396 self.model.update_file_status()
397 Interaction.command(N_('Error'), 'git checkout', status, out, err)
400 class BlamePaths(ContextCommand):
401 """Blame view for paths."""
403 @staticmethod
404 def name():
405 return N_('Blame...')
407 def __init__(self, context, paths=None):
408 super(BlamePaths, self).__init__(context)
409 if not paths:
410 paths = context.selection.union()
411 viewer = utils.shell_split(prefs.blame_viewer(context))
412 self.argv = viewer + list(paths)
414 def do(self):
415 try:
416 core.fork(self.argv)
417 except OSError as e:
418 _, details = utils.format_exception(e)
419 title = N_('Error Launching Blame Viewer')
420 msg = N_('Cannot exec "%s": please configure a blame viewer') % ' '.join(
421 self.argv
423 Interaction.critical(title, message=msg, details=details)
426 class CheckoutBranch(Checkout):
427 """Checkout a branch."""
429 def __init__(self, context, branch):
430 args = [branch]
431 super(CheckoutBranch, self).__init__(context, args, checkout_branch=True)
434 class CherryPick(ContextCommand):
435 """Cherry pick commits into the current branch."""
437 def __init__(self, context, commits):
438 super(CherryPick, self).__init__(context)
439 self.commits = commits
441 def do(self):
442 self.model.cherry_pick_list(self.commits)
443 self.model.update_file_status()
446 class Revert(ContextCommand):
447 """Cherry pick commits into the current branch."""
449 def __init__(self, context, oid):
450 super(Revert, self).__init__(context)
451 self.oid = oid
453 def do(self):
454 self.git.revert(self.oid, no_edit=True)
455 self.model.update_file_status()
458 class ResetMode(EditModel):
459 """Reset the mode and clear the model's diff text."""
461 def __init__(self, context):
462 super(ResetMode, self).__init__(context)
463 self.new_mode = self.model.mode_none
464 self.new_diff_text = ''
465 self.new_diff_type = main.Types.TEXT
466 self.new_file_type = main.Types.TEXT
467 self.new_filename = ''
469 def do(self):
470 super(ResetMode, self).do()
471 self.model.update_file_status()
474 class ResetCommand(ConfirmAction):
475 """Reset state using the "git reset" command"""
477 def __init__(self, context, ref):
478 super(ResetCommand, self).__init__(context)
479 self.ref = ref
481 def action(self):
482 return self.reset()
484 def command(self):
485 return 'git reset'
487 def error_message(self):
488 return N_('Error')
490 def success(self):
491 self.model.update_file_status()
493 def confirm(self):
494 raise NotImplementedError('confirm() must be overridden')
496 def reset(self):
497 raise NotImplementedError('reset() must be overridden')
500 class ResetMixed(ResetCommand):
501 @staticmethod
502 def tooltip(ref):
503 tooltip = N_('The branch will be reset using "git reset --mixed %s"')
504 return tooltip % ref
506 def confirm(self):
507 title = N_('Reset Branch and Stage (Mixed)')
508 question = N_('Point the current branch head to a new commit?')
509 info = self.tooltip(self.ref)
510 ok_text = N_('Reset Branch')
511 return Interaction.confirm(title, question, info, ok_text)
513 def reset(self):
514 return self.git.reset(self.ref, '--', mixed=True)
517 class ResetKeep(ResetCommand):
518 @staticmethod
519 def tooltip(ref):
520 tooltip = N_('The repository will be reset using "git reset --keep %s"')
521 return tooltip % ref
523 def confirm(self):
524 title = N_('Restore Worktree and Reset All (Keep Unstaged Changes)')
525 question = N_('Restore worktree, reset, and preserve unstaged edits?')
526 info = self.tooltip(self.ref)
527 ok_text = N_('Reset and Restore')
528 return Interaction.confirm(title, question, info, ok_text)
530 def reset(self):
531 return self.git.reset(self.ref, '--', keep=True)
534 class ResetMerge(ResetCommand):
535 @staticmethod
536 def tooltip(ref):
537 tooltip = N_('The repository will be reset using "git reset --merge %s"')
538 return tooltip % ref
540 def confirm(self):
541 title = N_('Restore Worktree and Reset All (Merge)')
542 question = N_('Reset Worktree and Reset All?')
543 info = self.tooltip(self.ref)
544 ok_text = N_('Reset and Restore')
545 return Interaction.confirm(title, question, info, ok_text)
547 def reset(self):
548 return self.git.reset(self.ref, '--', merge=True)
551 class ResetSoft(ResetCommand):
552 @staticmethod
553 def tooltip(ref):
554 tooltip = N_('The branch will be reset using "git reset --soft %s"')
555 return tooltip % ref
557 def confirm(self):
558 title = N_('Reset Branch (Soft)')
559 question = N_('Reset branch?')
560 info = self.tooltip(self.ref)
561 ok_text = N_('Reset Branch')
562 return Interaction.confirm(title, question, info, ok_text)
564 def reset(self):
565 return self.git.reset(self.ref, '--', soft=True)
568 class ResetHard(ResetCommand):
569 @staticmethod
570 def tooltip(ref):
571 tooltip = N_('The repository will be reset using "git reset --hard %s"')
572 return tooltip % ref
574 def confirm(self):
575 title = N_('Restore Worktree and Reset All (Hard)')
576 question = N_('Restore Worktree and Reset All?')
577 info = self.tooltip(self.ref)
578 ok_text = N_('Reset and Restore')
579 return Interaction.confirm(title, question, info, ok_text)
581 def reset(self):
582 return self.git.reset(self.ref, '--', hard=True)
585 class RestoreWorktree(ConfirmAction):
586 """Reset the worktree using the "git read-tree" command"""
588 @staticmethod
589 def tooltip(ref):
590 tooltip = N_(
591 'The worktree will be restored using "git read-tree --reset -u %s"'
593 return tooltip % ref
595 def __init__(self, context, ref):
596 super(RestoreWorktree, self).__init__(context)
597 self.ref = ref
599 def action(self):
600 return self.git.read_tree(self.ref, reset=True, u=True)
602 def command(self):
603 return 'git read-tree --reset -u %s' % self.ref
605 def error_message(self):
606 return N_('Error')
608 def success(self):
609 self.model.update_file_status()
611 def confirm(self):
612 title = N_('Restore Worktree')
613 question = N_('Restore Worktree to %s?') % self.ref
614 info = self.tooltip(self.ref)
615 ok_text = N_('Restore Worktree')
616 return Interaction.confirm(title, question, info, ok_text)
619 class UndoLastCommit(ResetCommand):
620 """Undo the last commit"""
622 # NOTE: this is the similar to ResetSoft() with an additional check for
623 # published commits and different messages.
624 def __init__(self, context):
625 super(UndoLastCommit, self).__init__(context, 'HEAD^')
627 def confirm(self):
628 check_published = prefs.check_published_commits(self.context)
629 if check_published and self.model.is_commit_published():
630 return Interaction.confirm(
631 N_('Rewrite Published Commit?'),
633 'This commit has already been published.\n'
634 'This operation will rewrite published history.\n'
635 'You probably don\'t want to do this.'
637 N_('Undo the published commit?'),
638 N_('Undo Last Commit'),
639 default=False,
640 icon=icons.save(),
643 title = N_('Undo Last Commit')
644 question = N_('Undo last commit?')
645 info = N_('The branch will be reset using "git reset --soft %s"')
646 ok_text = N_('Undo Last Commit')
647 info_text = info % self.ref
648 return Interaction.confirm(title, question, info_text, ok_text)
650 def reset(self):
651 return self.git.reset('HEAD^', '--', soft=True)
654 class Commit(ResetMode):
655 """Attempt to create a new commit."""
657 def __init__(self, context, amend, msg, sign, no_verify=False):
658 super(Commit, self).__init__(context)
659 self.amend = amend
660 self.msg = msg
661 self.sign = sign
662 self.no_verify = no_verify
663 self.old_commitmsg = self.model.commitmsg
664 self.new_commitmsg = ''
666 def do(self):
667 # Create the commit message file
668 context = self.context
669 comment_char = prefs.comment_char(context)
670 msg = self.strip_comments(self.msg, comment_char=comment_char)
671 tmp_file = utils.tmp_filename('commit-message')
672 try:
673 core.write(tmp_file, msg)
674 # Run 'git commit'
675 status, out, err = self.git.commit(
676 F=tmp_file,
677 v=True,
678 gpg_sign=self.sign,
679 amend=self.amend,
680 no_verify=self.no_verify,
682 finally:
683 core.unlink(tmp_file)
684 if status == 0:
685 super(Commit, self).do()
686 if context.cfg.get(prefs.AUTOTEMPLATE):
687 template_loader = LoadCommitMessageFromTemplate(context)
688 template_loader.do()
689 else:
690 self.model.set_commitmsg(self.new_commitmsg)
692 title = N_('Commit failed')
693 Interaction.command(title, 'git commit', status, out, err)
695 return status, out, err
697 @staticmethod
698 def strip_comments(msg, comment_char='#'):
699 # Strip off comments
700 message_lines = [
701 line for line in msg.split('\n') if not line.startswith(comment_char)
703 msg = '\n'.join(message_lines)
704 if not msg.endswith('\n'):
705 msg += '\n'
707 return msg
710 class CycleReferenceSort(ContextCommand):
711 """Choose the next reference sort type"""
713 def do(self):
714 self.model.cycle_ref_sort()
717 class Ignore(ContextCommand):
718 """Add files to an exclusion file"""
720 def __init__(self, context, filenames, local=False):
721 super(Ignore, self).__init__(context)
722 self.filenames = list(filenames)
723 self.local = local
725 def do(self):
726 if not self.filenames:
727 return
728 new_additions = '\n'.join(self.filenames) + '\n'
729 for_status = new_additions
730 if self.local:
731 filename = os.path.join('.git', 'info', 'exclude')
732 else:
733 filename = '.gitignore'
734 if core.exists(filename):
735 current_list = core.read(filename)
736 new_additions = current_list.rstrip() + '\n' + new_additions
737 core.write(filename, new_additions)
738 Interaction.log_status(0, 'Added to %s:\n%s' % (filename, for_status), '')
739 self.model.update_file_status()
742 def file_summary(files):
743 txt = core.list2cmdline(files)
744 if len(txt) > 768:
745 txt = txt[:768].rstrip() + '...'
746 wrap = textwrap.TextWrapper()
747 return '\n'.join(wrap.wrap(txt))
750 class RemoteCommand(ConfirmAction):
751 def __init__(self, context, remote):
752 super(RemoteCommand, self).__init__(context)
753 self.remote = remote
755 def success(self):
756 self.cfg.reset()
757 self.model.update_remotes()
760 class RemoteAdd(RemoteCommand):
761 def __init__(self, context, remote, url):
762 super(RemoteAdd, self).__init__(context, remote)
763 self.url = url
765 def action(self):
766 return self.git.remote('add', self.remote, self.url)
768 def error_message(self):
769 return N_('Error creating remote "%s"') % self.remote
771 def command(self):
772 return 'git remote add "%s" "%s"' % (self.remote, self.url)
775 class RemoteRemove(RemoteCommand):
776 def confirm(self):
777 title = N_('Delete Remote')
778 question = N_('Delete remote?')
779 info = N_('Delete remote "%s"') % self.remote
780 ok_text = N_('Delete')
781 return Interaction.confirm(title, question, info, ok_text)
783 def action(self):
784 return self.git.remote('rm', self.remote)
786 def error_message(self):
787 return N_('Error deleting remote "%s"') % self.remote
789 def command(self):
790 return 'git remote rm "%s"' % self.remote
793 class RemoteRename(RemoteCommand):
794 def __init__(self, context, remote, new_name):
795 super(RemoteRename, self).__init__(context, remote)
796 self.new_name = new_name
798 def confirm(self):
799 title = N_('Rename Remote')
800 text = N_('Rename remote "%(current)s" to "%(new)s"?') % dict(
801 current=self.remote, new=self.new_name
803 info_text = ''
804 ok_text = title
805 return Interaction.confirm(title, text, info_text, ok_text)
807 def action(self):
808 return self.git.remote('rename', self.remote, self.new_name)
810 def error_message(self):
811 return N_('Error renaming "%(name)s" to "%(new_name)s"') % dict(
812 name=self.remote, new_name=self.new_name
815 def command(self):
816 return 'git remote rename "%s" "%s"' % (self.remote, self.new_name)
819 class RemoteSetURL(RemoteCommand):
820 def __init__(self, context, remote, url):
821 super(RemoteSetURL, self).__init__(context, remote)
822 self.url = url
824 def action(self):
825 return self.git.remote('set-url', self.remote, self.url)
827 def error_message(self):
828 return N_('Unable to set URL for "%(name)s" to "%(url)s"') % dict(
829 name=self.remote, url=self.url
832 def command(self):
833 return 'git remote set-url "%s" "%s"' % (self.remote, self.url)
836 class RemoteEdit(ContextCommand):
837 """Combine RemoteRename and RemoteSetURL"""
839 def __init__(self, context, old_name, remote, url):
840 super(RemoteEdit, self).__init__(context)
841 self.rename = RemoteRename(context, old_name, remote)
842 self.set_url = RemoteSetURL(context, remote, url)
844 def do(self):
845 result = self.rename.do()
846 name_ok = result[0]
847 url_ok = False
848 if name_ok:
849 result = self.set_url.do()
850 url_ok = result[0]
851 return name_ok, url_ok
854 class RemoveFromSettings(ConfirmAction):
855 def __init__(self, context, repo, entry, icon=None):
856 super(RemoveFromSettings, self).__init__(context)
857 self.context = context
858 self.repo = repo
859 self.entry = entry
860 self.icon = icon
862 def success(self):
863 self.context.settings.save()
866 class RemoveBookmark(RemoveFromSettings):
867 def confirm(self):
868 entry = self.entry
869 title = msg = N_('Delete Bookmark?')
870 info = N_('%s will be removed from your bookmarks.') % entry
871 ok_text = N_('Delete Bookmark')
872 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
874 def action(self):
875 self.context.settings.remove_bookmark(self.repo, self.entry)
876 return (0, '', '')
879 class RemoveRecent(RemoveFromSettings):
880 def confirm(self):
881 repo = self.repo
882 title = msg = N_('Remove %s from the recent list?') % repo
883 info = N_('%s will be removed from your recent repositories.') % repo
884 ok_text = N_('Remove')
885 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
887 def action(self):
888 self.context.settings.remove_recent(self.repo)
889 return (0, '', '')
892 class RemoveFiles(ContextCommand):
893 """Removes files"""
895 def __init__(self, context, remover, filenames):
896 super(RemoveFiles, self).__init__(context)
897 if remover is None:
898 remover = os.remove
899 self.remover = remover
900 self.filenames = filenames
901 # We could git-hash-object stuff and provide undo-ability
902 # as an option. Heh.
904 def do(self):
905 files = self.filenames
906 if not files:
907 return
909 rescan = False
910 bad_filenames = []
911 remove = self.remover
912 for filename in files:
913 if filename:
914 try:
915 remove(filename)
916 rescan = True
917 except OSError:
918 bad_filenames.append(filename)
920 if bad_filenames:
921 Interaction.information(
922 N_('Error'), N_('Deleting "%s" failed') % file_summary(bad_filenames)
925 if rescan:
926 self.model.update_file_status()
929 class Delete(RemoveFiles):
930 """Delete files."""
932 def __init__(self, context, filenames):
933 super(Delete, self).__init__(context, os.remove, filenames)
935 def do(self):
936 files = self.filenames
937 if not files:
938 return
940 title = N_('Delete Files?')
941 msg = N_('The following files will be deleted:') + '\n\n'
942 msg += file_summary(files)
943 info_txt = N_('Delete %d file(s)?') % len(files)
944 ok_txt = N_('Delete Files')
946 if Interaction.confirm(
947 title, msg, info_txt, ok_txt, default=True, icon=icons.remove()
949 super(Delete, self).do()
952 class MoveToTrash(RemoveFiles):
953 """Move files to the trash using send2trash"""
955 AVAILABLE = send2trash is not None
957 def __init__(self, context, filenames):
958 super(MoveToTrash, self).__init__(context, send2trash, filenames)
961 class DeleteBranch(ConfirmAction):
962 """Delete a git branch."""
964 def __init__(self, context, branch):
965 super(DeleteBranch, self).__init__(context)
966 self.branch = branch
968 def confirm(self):
969 title = N_('Delete Branch')
970 question = N_('Delete branch "%s"?') % self.branch
971 info = N_('The branch will be no longer available.')
972 ok_txt = N_('Delete Branch')
973 return Interaction.confirm(
974 title, question, info, ok_txt, default=True, icon=icons.discard()
977 def action(self):
978 return self.model.delete_branch(self.branch)
980 def error_message(self):
981 return N_('Error deleting branch "%s"' % self.branch)
983 def command(self):
984 command = 'git branch -D %s'
985 return command % self.branch
988 class Rename(ContextCommand):
989 """Rename a set of paths."""
991 def __init__(self, context, paths):
992 super(Rename, self).__init__(context)
993 self.paths = paths
995 def do(self):
996 msg = N_('Untracking: %s') % (', '.join(self.paths))
997 Interaction.log(msg)
999 for path in self.paths:
1000 ok = self.rename(path)
1001 if not ok:
1002 return
1004 self.model.update_status()
1006 def rename(self, path):
1007 git = self.git
1008 title = N_('Rename "%s"') % path
1010 if os.path.isdir(path):
1011 base_path = os.path.dirname(path)
1012 else:
1013 base_path = path
1014 new_path = Interaction.save_as(base_path, title)
1015 if not new_path:
1016 return False
1018 status, out, err = git.mv(path, new_path, force=True, verbose=True)
1019 Interaction.command(N_('Error'), 'git mv', status, out, err)
1020 return status == 0
1023 class RenameBranch(ContextCommand):
1024 """Rename a git branch."""
1026 def __init__(self, context, branch, new_branch):
1027 super(RenameBranch, self).__init__(context)
1028 self.branch = branch
1029 self.new_branch = new_branch
1031 def do(self):
1032 branch = self.branch
1033 new_branch = self.new_branch
1034 status, out, err = self.model.rename_branch(branch, new_branch)
1035 Interaction.log_status(status, out, err)
1038 class DeleteRemoteBranch(DeleteBranch):
1039 """Delete a remote git branch."""
1041 def __init__(self, context, remote, branch):
1042 super(DeleteRemoteBranch, self).__init__(context, branch)
1043 self.remote = remote
1045 def action(self):
1046 return self.git.push(self.remote, self.branch, delete=True)
1048 def success(self):
1049 self.model.update_status()
1050 Interaction.information(
1051 N_('Remote Branch Deleted'),
1052 N_('"%(branch)s" has been deleted from "%(remote)s".')
1053 % dict(branch=self.branch, remote=self.remote),
1056 def error_message(self):
1057 return N_('Error Deleting Remote Branch')
1059 def command(self):
1060 command = 'git push --delete %s %s'
1061 return command % (self.remote, self.branch)
1064 def get_mode(context, filename, staged, modified, unmerged, untracked):
1065 model = context.model
1066 if staged:
1067 mode = model.mode_index
1068 elif modified or unmerged:
1069 mode = model.mode_worktree
1070 elif untracked:
1071 if gitcmds.is_binary(context, filename):
1072 mode = model.mode_untracked
1073 else:
1074 mode = model.mode_untracked_diff
1075 else:
1076 mode = model.mode
1077 return mode
1080 class DiffText(EditModel):
1081 """Set the diff type to text"""
1083 def __init__(self, context):
1084 super(DiffText, self).__init__(context)
1085 self.new_file_type = main.Types.TEXT
1086 self.new_diff_type = main.Types.TEXT
1089 class ToggleDiffType(ContextCommand):
1090 """Toggle the diff type between image and text"""
1092 def __init__(self, context):
1093 super(ToggleDiffType, self).__init__(context)
1094 if self.model.diff_type == main.Types.IMAGE:
1095 self.new_diff_type = main.Types.TEXT
1096 self.new_value = False
1097 else:
1098 self.new_diff_type = main.Types.IMAGE
1099 self.new_value = True
1101 def do(self):
1102 diff_type = self.new_diff_type
1103 value = self.new_value
1105 self.model.set_diff_type(diff_type)
1107 filename = self.model.filename
1108 _, ext = os.path.splitext(filename)
1109 if ext.startswith('.'):
1110 cfg = 'cola.imagediff' + ext
1111 self.cfg.set_repo(cfg, value)
1114 class DiffImage(EditModel):
1115 def __init__(
1116 self, context, filename, deleted, staged, modified, unmerged, untracked
1118 super(DiffImage, self).__init__(context)
1120 self.new_filename = filename
1121 self.new_diff_type = self.get_diff_type(filename)
1122 self.new_file_type = main.Types.IMAGE
1123 self.new_mode = get_mode(
1124 context, filename, staged, modified, unmerged, untracked
1126 self.staged = staged
1127 self.modified = modified
1128 self.unmerged = unmerged
1129 self.untracked = untracked
1130 self.deleted = deleted
1131 self.annex = self.cfg.is_annex()
1133 def get_diff_type(self, filename):
1134 """Query the diff type to use based on cola.imagediff.<extension>"""
1135 _, ext = os.path.splitext(filename)
1136 if ext.startswith('.'):
1137 # Check eg. "cola.imagediff.svg" to see if we should imagediff.
1138 cfg = 'cola.imagediff' + ext
1139 if self.cfg.get(cfg, True):
1140 result = main.Types.IMAGE
1141 else:
1142 result = main.Types.TEXT
1143 else:
1144 result = main.Types.IMAGE
1145 return result
1147 def do(self):
1148 filename = self.new_filename
1150 if self.staged:
1151 images = self.staged_images()
1152 elif self.modified:
1153 images = self.modified_images()
1154 elif self.unmerged:
1155 images = self.unmerged_images()
1156 elif self.untracked:
1157 images = [(filename, False)]
1158 else:
1159 images = []
1161 self.model.set_images(images)
1162 super(DiffImage, self).do()
1164 def staged_images(self):
1165 context = self.context
1166 git = self.git
1167 head = self.model.head
1168 filename = self.new_filename
1169 annex = self.annex
1171 images = []
1172 index = git.diff_index(head, '--', filename, cached=True)[STDOUT]
1173 if index:
1174 # Example:
1175 # :100644 100644 fabadb8... 4866510... M describe.c
1176 parts = index.split(' ')
1177 if len(parts) > 3:
1178 old_oid = parts[2]
1179 new_oid = parts[3]
1181 if old_oid != MISSING_BLOB_OID:
1182 # First, check if we can get a pre-image from git-annex
1183 annex_image = None
1184 if annex:
1185 annex_image = gitcmds.annex_path(context, head, filename)
1186 if annex_image:
1187 images.append((annex_image, False)) # git annex HEAD
1188 else:
1189 image = gitcmds.write_blob_path(context, head, old_oid, filename)
1190 if image:
1191 images.append((image, True))
1193 if new_oid != MISSING_BLOB_OID:
1194 found_in_annex = False
1195 if annex and core.islink(filename):
1196 status, out, _ = git.annex('status', '--', filename)
1197 if status == 0:
1198 details = out.split(' ')
1199 if details and details[0] == 'A': # newly added file
1200 images.append((filename, False))
1201 found_in_annex = True
1203 if not found_in_annex:
1204 image = gitcmds.write_blob(context, new_oid, filename)
1205 if image:
1206 images.append((image, True))
1208 return images
1210 def unmerged_images(self):
1211 context = self.context
1212 git = self.git
1213 head = self.model.head
1214 filename = self.new_filename
1215 annex = self.annex
1217 candidate_merge_heads = ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1218 merge_heads = [
1219 merge_head
1220 for merge_head in candidate_merge_heads
1221 if core.exists(git.git_path(merge_head))
1224 if annex: # Attempt to find files in git-annex
1225 annex_images = []
1226 for merge_head in merge_heads:
1227 image = gitcmds.annex_path(context, merge_head, filename)
1228 if image:
1229 annex_images.append((image, False))
1230 if annex_images:
1231 annex_images.append((filename, False))
1232 return annex_images
1234 # DIFF FORMAT FOR MERGES
1235 # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1236 # can take -c or --cc option to generate diff output also
1237 # for merge commits. The output differs from the format
1238 # described above in the following way:
1240 # 1. there is a colon for each parent
1241 # 2. there are more "src" modes and "src" sha1
1242 # 3. status is concatenated status characters for each parent
1243 # 4. no optional "score" number
1244 # 5. single path, only for "dst"
1245 # Example:
1246 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1247 # MM describe.c
1248 images = []
1249 index = git.diff_index(head, '--', filename, cached=True, cc=True)[STDOUT]
1250 if index:
1251 parts = index.split(' ')
1252 if len(parts) > 3:
1253 first_mode = parts[0]
1254 num_parents = first_mode.count(':')
1255 # colon for each parent, but for the index, the "parents"
1256 # are really entries in stages 1,2,3 (head, base, remote)
1257 # remote, base, head
1258 for i in range(num_parents):
1259 offset = num_parents + i + 1
1260 oid = parts[offset]
1261 try:
1262 merge_head = merge_heads[i]
1263 except IndexError:
1264 merge_head = 'HEAD'
1265 if oid != MISSING_BLOB_OID:
1266 image = gitcmds.write_blob_path(
1267 context, merge_head, oid, filename
1269 if image:
1270 images.append((image, True))
1272 images.append((filename, False))
1273 return images
1275 def modified_images(self):
1276 context = self.context
1277 git = self.git
1278 head = self.model.head
1279 filename = self.new_filename
1280 annex = self.annex
1282 images = []
1283 annex_image = None
1284 if annex: # Check for a pre-image from git-annex
1285 annex_image = gitcmds.annex_path(context, head, filename)
1286 if annex_image:
1287 images.append((annex_image, False)) # git annex HEAD
1288 else:
1289 worktree = git.diff_files('--', filename)[STDOUT]
1290 parts = worktree.split(' ')
1291 if len(parts) > 3:
1292 oid = parts[2]
1293 if oid != MISSING_BLOB_OID:
1294 image = gitcmds.write_blob_path(context, head, oid, filename)
1295 if image:
1296 images.append((image, True)) # HEAD
1298 images.append((filename, False)) # worktree
1299 return images
1302 class Diff(EditModel):
1303 """Perform a diff and set the model's current text."""
1305 def __init__(self, context, filename, cached=False, deleted=False):
1306 super(Diff, self).__init__(context)
1307 opts = {}
1308 if cached and gitcmds.is_valid_ref(context, self.model.head):
1309 opts['ref'] = self.model.head
1310 self.new_filename = filename
1311 self.new_mode = self.model.mode_worktree
1312 self.new_diff_text = gitcmds.diff_helper(
1313 self.context, filename=filename, cached=cached, deleted=deleted, **opts
1317 class Diffstat(EditModel):
1318 """Perform a diffstat and set the model's diff text."""
1320 def __init__(self, context):
1321 super(Diffstat, self).__init__(context)
1322 cfg = self.cfg
1323 diff_context = cfg.get('diff.context', 3)
1324 diff = self.git.diff(
1325 self.model.head,
1326 unified=diff_context,
1327 no_ext_diff=True,
1328 no_color=True,
1329 M=True,
1330 stat=True,
1331 )[STDOUT]
1332 self.new_diff_text = diff
1333 self.new_diff_type = main.Types.TEXT
1334 self.new_file_type = main.Types.TEXT
1335 self.new_mode = self.model.mode_diffstat
1338 class DiffStaged(Diff):
1339 """Perform a staged diff on a file."""
1341 def __init__(self, context, filename, deleted=None):
1342 super(DiffStaged, self).__init__(
1343 context, filename, cached=True, deleted=deleted
1345 self.new_mode = self.model.mode_index
1348 class DiffStagedSummary(EditModel):
1349 def __init__(self, context):
1350 super(DiffStagedSummary, self).__init__(context)
1351 diff = self.git.diff(
1352 self.model.head,
1353 cached=True,
1354 no_color=True,
1355 no_ext_diff=True,
1356 patch_with_stat=True,
1357 M=True,
1358 )[STDOUT]
1359 self.new_diff_text = diff
1360 self.new_diff_type = main.Types.TEXT
1361 self.new_file_type = main.Types.TEXT
1362 self.new_mode = self.model.mode_index
1365 class Difftool(ContextCommand):
1366 """Run git-difftool limited by path."""
1368 def __init__(self, context, staged, filenames):
1369 super(Difftool, self).__init__(context)
1370 self.staged = staged
1371 self.filenames = filenames
1373 def do(self):
1374 difftool_launch_with_head(
1375 self.context, self.filenames, self.staged, self.model.head
1379 class Edit(ContextCommand):
1380 """Edit a file using the configured gui.editor."""
1382 @staticmethod
1383 def name():
1384 return N_('Launch Editor')
1386 def __init__(self, context, filenames, line_number=None, background_editor=False):
1387 super(Edit, self).__init__(context)
1388 self.filenames = filenames
1389 self.line_number = line_number
1390 self.background_editor = background_editor
1392 def do(self):
1393 context = self.context
1394 if not self.filenames:
1395 return
1396 filename = self.filenames[0]
1397 if not core.exists(filename):
1398 return
1399 if self.background_editor:
1400 editor = prefs.background_editor(context)
1401 else:
1402 editor = prefs.editor(context)
1403 opts = []
1405 if self.line_number is None:
1406 opts = self.filenames
1407 else:
1408 # Single-file w/ line-numbers (likely from grep)
1409 editor_opts = {
1410 '*vim*': [filename, '+%s' % self.line_number],
1411 '*emacs*': ['+%s' % self.line_number, filename],
1412 '*textpad*': ['%s(%s,0)' % (filename, self.line_number)],
1413 '*notepad++*': ['-n%s' % self.line_number, filename],
1414 '*subl*': ['%s:%s' % (filename, self.line_number)],
1417 opts = self.filenames
1418 for pattern, opt in editor_opts.items():
1419 if fnmatch(editor, pattern):
1420 opts = opt
1421 break
1423 try:
1424 core.fork(utils.shell_split(editor) + opts)
1425 except (OSError, ValueError) as e:
1426 message = N_('Cannot exec "%s": please configure your editor') % editor
1427 _, details = utils.format_exception(e)
1428 Interaction.critical(N_('Error Editing File'), message, details)
1431 class FormatPatch(ContextCommand):
1432 """Output a patch series given all revisions and a selected subset."""
1434 def __init__(self, context, to_export, revs, output='patches'):
1435 super(FormatPatch, self).__init__(context)
1436 self.to_export = list(to_export)
1437 self.revs = list(revs)
1438 self.output = output
1440 def do(self):
1441 context = self.context
1442 status, out, err = gitcmds.format_patchsets(
1443 context, self.to_export, self.revs, self.output
1445 Interaction.log_status(status, out, err)
1448 class LaunchDifftool(ContextCommand):
1449 @staticmethod
1450 def name():
1451 return N_('Launch Diff Tool')
1453 def do(self):
1454 s = self.selection.selection()
1455 if s.unmerged:
1456 paths = s.unmerged
1457 if utils.is_win32():
1458 core.fork(['git', 'mergetool', '--no-prompt', '--'] + paths)
1459 else:
1460 cfg = self.cfg
1461 cmd = cfg.terminal()
1462 argv = utils.shell_split(cmd)
1464 terminal = os.path.basename(argv[0])
1465 shellquote_terms = set(['xfce4-terminal'])
1466 shellquote_default = terminal in shellquote_terms
1468 mergetool = ['git', 'mergetool', '--no-prompt', '--']
1469 mergetool.extend(paths)
1470 needs_shellquote = cfg.get(
1471 'cola.terminalshellquote', shellquote_default
1474 if needs_shellquote:
1475 argv.append(core.list2cmdline(mergetool))
1476 else:
1477 argv.extend(mergetool)
1479 core.fork(argv)
1480 else:
1481 difftool_run(self.context)
1484 class LaunchTerminal(ContextCommand):
1485 @staticmethod
1486 def name():
1487 return N_('Launch Terminal')
1489 @staticmethod
1490 def is_available(context):
1491 return context.cfg.terminal() is not None
1493 def __init__(self, context, path):
1494 super(LaunchTerminal, self).__init__(context)
1495 self.path = path
1497 def do(self):
1498 cmd = self.context.cfg.terminal()
1499 if cmd is None:
1500 return
1501 if utils.is_win32():
1502 argv = ['start', '', cmd, '--login']
1503 shell = True
1504 else:
1505 argv = utils.shell_split(cmd)
1506 argv.append(os.getenv('SHELL', '/bin/sh'))
1507 shell = False
1508 core.fork(argv, cwd=self.path, shell=shell)
1511 class LaunchEditor(Edit):
1512 @staticmethod
1513 def name():
1514 return N_('Launch Editor')
1516 def __init__(self, context):
1517 s = context.selection.selection()
1518 filenames = s.staged + s.unmerged + s.modified + s.untracked
1519 super(LaunchEditor, self).__init__(context, filenames, background_editor=True)
1522 class LaunchEditorAtLine(LaunchEditor):
1523 """Launch an editor at the specified line"""
1525 def __init__(self, context):
1526 super(LaunchEditorAtLine, self).__init__(context)
1527 self.line_number = context.selection.line_number
1530 class LoadCommitMessageFromFile(ContextCommand):
1531 """Loads a commit message from a path."""
1533 UNDOABLE = True
1535 def __init__(self, context, path):
1536 super(LoadCommitMessageFromFile, self).__init__(context)
1537 self.path = path
1538 self.old_commitmsg = self.model.commitmsg
1539 self.old_directory = self.model.directory
1541 def do(self):
1542 path = os.path.expanduser(self.path)
1543 if not path or not core.isfile(path):
1544 raise UsageError(
1545 N_('Error: Cannot find commit template'),
1546 N_('%s: No such file or directory.') % path,
1548 self.model.set_directory(os.path.dirname(path))
1549 self.model.set_commitmsg(core.read(path))
1551 def undo(self):
1552 self.model.set_commitmsg(self.old_commitmsg)
1553 self.model.set_directory(self.old_directory)
1556 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile):
1557 """Loads the commit message template specified by commit.template."""
1559 def __init__(self, context):
1560 cfg = context.cfg
1561 template = cfg.get('commit.template')
1562 super(LoadCommitMessageFromTemplate, self).__init__(context, template)
1564 def do(self):
1565 if self.path is None:
1566 raise UsageError(
1567 N_('Error: Unconfigured commit template'),
1569 'A commit template has not been configured.\n'
1570 'Use "git config" to define "commit.template"\n'
1571 'so that it points to a commit template.'
1574 return LoadCommitMessageFromFile.do(self)
1577 class LoadCommitMessageFromOID(ContextCommand):
1578 """Load a previous commit message"""
1580 UNDOABLE = True
1582 def __init__(self, context, oid, prefix=''):
1583 super(LoadCommitMessageFromOID, self).__init__(context)
1584 self.oid = oid
1585 self.old_commitmsg = self.model.commitmsg
1586 self.new_commitmsg = prefix + gitcmds.prev_commitmsg(context, oid)
1588 def do(self):
1589 self.model.set_commitmsg(self.new_commitmsg)
1591 def undo(self):
1592 self.model.set_commitmsg(self.old_commitmsg)
1595 class PrepareCommitMessageHook(ContextCommand):
1596 """Use the cola-prepare-commit-msg hook to prepare the commit message"""
1598 UNDOABLE = True
1600 def __init__(self, context):
1601 super(PrepareCommitMessageHook, self).__init__(context)
1602 self.old_commitmsg = self.model.commitmsg
1604 def get_message(self):
1606 title = N_('Error running prepare-commitmsg hook')
1607 hook = gitcmds.prepare_commit_message_hook(self.context)
1609 if os.path.exists(hook):
1610 filename = self.model.save_commitmsg()
1611 status, out, err = core.run_command([hook, filename])
1613 if status == 0:
1614 result = core.read(filename)
1615 else:
1616 result = self.old_commitmsg
1617 Interaction.command_error(title, hook, status, out, err)
1618 else:
1619 message = N_('A hook must be provided at "%s"') % hook
1620 Interaction.critical(title, message=message)
1621 result = self.old_commitmsg
1623 return result
1625 def do(self):
1626 msg = self.get_message()
1627 self.model.set_commitmsg(msg)
1629 def undo(self):
1630 self.model.set_commitmsg(self.old_commitmsg)
1633 class LoadFixupMessage(LoadCommitMessageFromOID):
1634 """Load a fixup message"""
1636 def __init__(self, context, oid):
1637 super(LoadFixupMessage, self).__init__(context, oid, prefix='fixup! ')
1638 if self.new_commitmsg:
1639 self.new_commitmsg = self.new_commitmsg.splitlines()[0]
1642 class Merge(ContextCommand):
1643 """Merge commits"""
1645 def __init__(self, context, revision, no_commit, squash, no_ff, sign):
1646 super(Merge, self).__init__(context)
1647 self.revision = revision
1648 self.no_ff = no_ff
1649 self.no_commit = no_commit
1650 self.squash = squash
1651 self.sign = sign
1653 def do(self):
1654 squash = self.squash
1655 revision = self.revision
1656 no_ff = self.no_ff
1657 no_commit = self.no_commit
1658 sign = self.sign
1660 status, out, err = self.git.merge(
1661 revision, gpg_sign=sign, no_ff=no_ff, no_commit=no_commit, squash=squash
1663 self.model.update_status()
1664 title = N_('Merge failed. Conflict resolution is required.')
1665 Interaction.command(title, 'git merge', status, out, err)
1667 return status, out, err
1670 class OpenDefaultApp(ContextCommand):
1671 """Open a file using the OS default."""
1673 @staticmethod
1674 def name():
1675 return N_('Open Using Default Application')
1677 def __init__(self, context, filenames):
1678 super(OpenDefaultApp, self).__init__(context)
1679 self.filenames = filenames
1681 def do(self):
1682 if not self.filenames:
1683 return
1684 utils.launch_default_app(self.filenames)
1687 class OpenDir(OpenDefaultApp):
1688 """Open directories using the OS default."""
1690 @staticmethod
1691 def name():
1692 return N_('Open Directory')
1694 @property
1695 def _dirnames(self):
1696 return self.filenames
1698 def do(self):
1699 dirnames = self._dirnames
1700 if not dirnames:
1701 return
1702 # An empty dirname defaults to CWD.
1703 dirs = [(dirname or core.getcwd()) for dirname in dirnames]
1704 utils.launch_default_app(dirs)
1707 class OpenParentDir(OpenDir):
1708 """Open parent directories using the OS default."""
1710 @staticmethod
1711 def name():
1712 return N_('Open Parent Directory')
1714 @property
1715 def _dirnames(self):
1716 dirnames = list(set([os.path.dirname(x) for x in self.filenames]))
1717 return dirnames
1720 class OpenWorktree(OpenDir):
1721 """Open worktree directory using the OS default."""
1723 @staticmethod
1724 def name():
1725 return N_('Open Worktree')
1727 # The _unused parameter is needed by worktree_dir_action() -> common.cmd_action().
1728 def __init__(self, context, _unused=None):
1729 dirnames = [context.git.worktree()]
1730 super(OpenWorktree, self).__init__(context, dirnames)
1733 class OpenNewRepo(ContextCommand):
1734 """Launches git-cola on a repo."""
1736 def __init__(self, context, repo_path):
1737 super(OpenNewRepo, self).__init__(context)
1738 self.repo_path = repo_path
1740 def do(self):
1741 self.model.set_directory(self.repo_path)
1742 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
1745 class OpenRepo(EditModel):
1746 def __init__(self, context, repo_path):
1747 super(OpenRepo, self).__init__(context)
1748 self.repo_path = repo_path
1749 self.new_mode = self.model.mode_none
1750 self.new_diff_text = ''
1751 self.new_diff_type = main.Types.TEXT
1752 self.new_file_type = main.Types.TEXT
1753 self.new_commitmsg = ''
1754 self.new_filename = ''
1756 def do(self):
1757 old_repo = self.git.getcwd()
1758 if self.model.set_worktree(self.repo_path):
1759 self.fsmonitor.stop()
1760 self.fsmonitor.start()
1761 self.model.update_status(reset=True)
1762 # Check if template should be loaded
1763 if self.context.cfg.get(prefs.AUTOTEMPLATE):
1764 template_loader = LoadCommitMessageFromTemplate(self.context)
1765 template_loader.do()
1766 else:
1767 self.model.set_commitmsg(self.new_commitmsg)
1768 settings = self.context.settings
1769 settings.load()
1770 settings.add_recent(self.repo_path, prefs.maxrecent(self.context))
1771 settings.save()
1772 super(OpenRepo, self).do()
1773 else:
1774 self.model.set_worktree(old_repo)
1777 class OpenParentRepo(OpenRepo):
1778 def __init__(self, context):
1779 path = ''
1780 if version.check_git(context, 'show-superproject-working-tree'):
1781 status, out, _ = context.git.rev_parse(show_superproject_working_tree=True)
1782 if status == 0:
1783 path = out
1784 if not path:
1785 path = os.path.dirname(core.getcwd())
1786 super(OpenParentRepo, self).__init__(context, path)
1789 class Clone(ContextCommand):
1790 """Clones a repository and optionally spawns a new cola session."""
1792 def __init__(
1793 self, context, url, new_directory, submodules=False, shallow=False, spawn=True
1795 super(Clone, self).__init__(context)
1796 self.url = url
1797 self.new_directory = new_directory
1798 self.submodules = submodules
1799 self.shallow = shallow
1800 self.spawn = spawn
1801 self.status = -1
1802 self.out = ''
1803 self.err = ''
1805 def do(self):
1806 kwargs = {}
1807 if self.shallow:
1808 kwargs['depth'] = 1
1809 recurse_submodules = self.submodules
1810 shallow_submodules = self.submodules and self.shallow
1812 status, out, err = self.git.clone(
1813 self.url,
1814 self.new_directory,
1815 recurse_submodules=recurse_submodules,
1816 shallow_submodules=shallow_submodules,
1817 **kwargs
1820 self.status = status
1821 self.out = out
1822 self.err = err
1823 if status == 0 and self.spawn:
1824 executable = sys.executable
1825 core.fork([executable, sys.argv[0], '--repo', self.new_directory])
1826 return self
1829 class NewBareRepo(ContextCommand):
1830 """Create a new shared bare repository"""
1832 def __init__(self, context, path):
1833 super(NewBareRepo, self).__init__(context)
1834 self.path = path
1836 def do(self):
1837 path = self.path
1838 status, out, err = self.git.init(path, bare=True, shared=True)
1839 Interaction.command(
1840 N_('Error'), 'git init --bare --shared "%s"' % path, status, out, err
1842 return status == 0
1845 def unix_path(path, is_win32=utils.is_win32):
1846 """Git for Windows requires unix paths, so force them here"""
1847 if is_win32():
1848 path = path.replace('\\', '/')
1849 first = path[0]
1850 second = path[1]
1851 if second == ':': # sanity check, this better be a Windows-style path
1852 path = '/' + first + path[2:]
1854 return path
1857 def sequence_editor():
1858 """Set GIT_SEQUENCE_EDITOR for running git-cola-sequence-editor"""
1859 xbase = unix_path(resources.command('git-cola-sequence-editor'))
1860 if utils.is_win32():
1861 editor = core.list2cmdline([unix_path(sys.executable), xbase])
1862 else:
1863 editor = core.list2cmdline([xbase])
1864 return editor
1867 class SequenceEditorEnvironment(object):
1868 """Set environment variables to enable git-cola-sequence-editor"""
1870 def __init__(self, context, **kwargs):
1871 self.env = {
1872 'GIT_EDITOR': prefs.editor(context),
1873 'GIT_SEQUENCE_EDITOR': sequence_editor(),
1874 'GIT_COLA_SEQ_EDITOR_CANCEL_ACTION': 'save',
1876 self.env.update(kwargs)
1878 def __enter__(self):
1879 for var, value in self.env.items():
1880 compat.setenv(var, value)
1881 return self
1883 def __exit__(self, exc_type, exc_val, exc_tb):
1884 for var in self.env:
1885 compat.unsetenv(var)
1888 class Rebase(ContextCommand):
1889 def __init__(self, context, upstream=None, branch=None, **kwargs):
1890 """Start an interactive rebase session
1892 :param upstream: upstream branch
1893 :param branch: optional branch to checkout
1894 :param kwargs: forwarded directly to `git.rebase()`
1897 super(Rebase, self).__init__(context)
1899 self.upstream = upstream
1900 self.branch = branch
1901 self.kwargs = kwargs
1903 def prepare_arguments(self, upstream):
1904 args = []
1905 kwargs = {}
1907 # Rebase actions must be the only option specified
1908 for action in ('continue', 'abort', 'skip', 'edit_todo'):
1909 if self.kwargs.get(action, False):
1910 kwargs[action] = self.kwargs[action]
1911 return args, kwargs
1913 kwargs['interactive'] = True
1914 kwargs['autosquash'] = self.kwargs.get('autosquash', True)
1915 kwargs.update(self.kwargs)
1917 if upstream:
1918 args.append(upstream)
1919 if self.branch:
1920 args.append(self.branch)
1922 return args, kwargs
1924 def do(self):
1925 (status, out, err) = (1, '', '')
1926 context = self.context
1927 cfg = self.cfg
1928 model = self.model
1930 if not cfg.get('rebase.autostash', False):
1931 if model.staged or model.unmerged or model.modified:
1932 Interaction.information(
1933 N_('Unable to rebase'),
1934 N_('You cannot rebase with uncommitted changes.'),
1936 return status, out, err
1938 upstream = self.upstream or Interaction.choose_ref(
1939 context,
1940 N_('Select New Upstream'),
1941 N_('Interactive Rebase'),
1942 default='@{upstream}',
1944 if not upstream:
1945 return status, out, err
1947 self.model.is_rebasing = True
1948 self.model.emit_updated()
1950 args, kwargs = self.prepare_arguments(upstream)
1951 upstream_title = upstream or '@{upstream}'
1952 with SequenceEditorEnvironment(
1953 self.context,
1954 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase onto %s') % upstream_title,
1955 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
1957 # TODO this blocks the user interface window for the duration
1958 # of git-cola-sequence-editor. We would need to implement
1959 # signals for QProcess and continue running the main thread.
1960 # Alternatively, we can hide the main window while rebasing.
1961 # That doesn't require as much effort.
1962 status, out, err = self.git.rebase(
1963 *args, _no_win32_startupinfo=True, **kwargs
1965 self.model.update_status()
1966 if err.strip() != 'Nothing to do':
1967 title = N_('Rebase stopped')
1968 Interaction.command(title, 'git rebase', status, out, err)
1969 return status, out, err
1972 class RebaseEditTodo(ContextCommand):
1973 def do(self):
1974 (status, out, err) = (1, '', '')
1975 with SequenceEditorEnvironment(
1976 self.context,
1977 GIT_COLA_SEQ_EDITOR_TITLE=N_('Edit Rebase'),
1978 GIT_COLA_SEQ_EDITOR_ACTION=N_('Save'),
1980 status, out, err = self.git.rebase(edit_todo=True)
1981 Interaction.log_status(status, out, err)
1982 self.model.update_status()
1983 return status, out, err
1986 class RebaseContinue(ContextCommand):
1987 def do(self):
1988 (status, out, err) = (1, '', '')
1989 with SequenceEditorEnvironment(
1990 self.context,
1991 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase'),
1992 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
1994 status, out, err = self.git.rebase('--continue')
1995 Interaction.log_status(status, out, err)
1996 self.model.update_status()
1997 return status, out, err
2000 class RebaseSkip(ContextCommand):
2001 def do(self):
2002 (status, out, err) = (1, '', '')
2003 with SequenceEditorEnvironment(
2004 self.context,
2005 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase'),
2006 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
2008 status, out, err = self.git.rebase(skip=True)
2009 Interaction.log_status(status, out, err)
2010 self.model.update_status()
2011 return status, out, err
2014 class RebaseAbort(ContextCommand):
2015 def do(self):
2016 status, out, err = self.git.rebase(abort=True)
2017 Interaction.log_status(status, out, err)
2018 self.model.update_status()
2021 class Rescan(ContextCommand):
2022 """Rescan for changes"""
2024 def do(self):
2025 self.model.update_status()
2028 class Refresh(ContextCommand):
2029 """Update refs, refresh the index, and update config"""
2031 @staticmethod
2032 def name():
2033 return N_('Refresh')
2035 def do(self):
2036 self.model.update_status(update_index=True)
2037 self.cfg.update()
2038 self.fsmonitor.refresh()
2041 class RefreshConfig(ContextCommand):
2042 """Refresh the git config cache"""
2044 def do(self):
2045 self.cfg.update()
2048 class RevertEditsCommand(ConfirmAction):
2049 def __init__(self, context):
2050 super(RevertEditsCommand, self).__init__(context)
2051 self.icon = icons.undo()
2053 def ok_to_run(self):
2054 return self.model.undoable()
2056 # pylint: disable=no-self-use
2057 def checkout_from_head(self):
2058 return False
2060 def checkout_args(self):
2061 args = []
2062 s = self.selection.selection()
2063 if self.checkout_from_head():
2064 args.append(self.model.head)
2065 args.append('--')
2067 if s.staged:
2068 items = s.staged
2069 else:
2070 items = s.modified
2071 args.extend(items)
2073 return args
2075 def action(self):
2076 checkout_args = self.checkout_args()
2077 return self.git.checkout(*checkout_args)
2079 def success(self):
2080 self.model.set_diff_type(main.Types.TEXT)
2081 self.model.update_file_status()
2084 class RevertUnstagedEdits(RevertEditsCommand):
2085 @staticmethod
2086 def name():
2087 return N_('Revert Unstaged Edits...')
2089 def checkout_from_head(self):
2090 # Being in amend mode should not affect the behavior of this command.
2091 # The only sensible thing to do is to checkout from the index.
2092 return False
2094 def confirm(self):
2095 title = N_('Revert Unstaged Changes?')
2096 text = N_(
2097 'This operation removes unstaged edits from selected files.\n'
2098 'These changes cannot be recovered.'
2100 info = N_('Revert the unstaged changes?')
2101 ok_text = N_('Revert Unstaged Changes')
2102 return Interaction.confirm(
2103 title, text, info, ok_text, default=True, icon=self.icon
2107 class RevertUncommittedEdits(RevertEditsCommand):
2108 @staticmethod
2109 def name():
2110 return N_('Revert Uncommitted Edits...')
2112 def checkout_from_head(self):
2113 return True
2115 def confirm(self):
2116 """Prompt for reverting changes"""
2117 title = N_('Revert Uncommitted Changes?')
2118 text = N_(
2119 'This operation removes uncommitted edits from selected files.\n'
2120 'These changes cannot be recovered.'
2122 info = N_('Revert the uncommitted changes?')
2123 ok_text = N_('Revert Uncommitted Changes')
2124 return Interaction.confirm(
2125 title, text, info, ok_text, default=True, icon=self.icon
2129 class RunConfigAction(ContextCommand):
2130 """Run a user-configured action, typically from the "Tools" menu"""
2132 def __init__(self, context, action_name):
2133 super(RunConfigAction, self).__init__(context)
2134 self.action_name = action_name
2136 def do(self):
2137 """Run the user-configured action"""
2138 for env in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
2139 try:
2140 compat.unsetenv(env)
2141 except KeyError:
2142 pass
2143 rev = None
2144 args = None
2145 context = self.context
2146 cfg = self.cfg
2147 opts = cfg.get_guitool_opts(self.action_name)
2148 cmd = opts.get('cmd')
2149 if 'title' not in opts:
2150 opts['title'] = cmd
2152 if 'prompt' not in opts or opts.get('prompt') is True:
2153 prompt = N_('Run "%s"?') % cmd
2154 opts['prompt'] = prompt
2156 if opts.get('needsfile'):
2157 filename = self.selection.filename()
2158 if not filename:
2159 Interaction.information(
2160 N_('Please select a file'),
2161 N_('"%s" requires a selected file.') % cmd,
2163 return False
2164 dirname = utils.dirname(filename, current_dir='.')
2165 compat.setenv('FILENAME', filename)
2166 compat.setenv('DIRNAME', dirname)
2168 if opts.get('revprompt') or opts.get('argprompt'):
2169 while True:
2170 ok = Interaction.confirm_config_action(context, cmd, opts)
2171 if not ok:
2172 return False
2173 rev = opts.get('revision')
2174 args = opts.get('args')
2175 if opts.get('revprompt') and not rev:
2176 title = N_('Invalid Revision')
2177 msg = N_('The revision expression cannot be empty.')
2178 Interaction.critical(title, msg)
2179 continue
2180 break
2182 elif opts.get('confirm'):
2183 title = os.path.expandvars(opts.get('title'))
2184 prompt = os.path.expandvars(opts.get('prompt'))
2185 if not Interaction.question(title, prompt):
2186 return False
2187 if rev:
2188 compat.setenv('REVISION', rev)
2189 if args:
2190 compat.setenv('ARGS', args)
2191 title = os.path.expandvars(cmd)
2192 Interaction.log(N_('Running command: %s') % title)
2193 cmd = ['sh', '-c', cmd]
2195 if opts.get('background'):
2196 core.fork(cmd)
2197 status, out, err = (0, '', '')
2198 elif opts.get('noconsole'):
2199 status, out, err = core.run_command(cmd)
2200 else:
2201 status, out, err = Interaction.run_command(title, cmd)
2203 if not opts.get('background') and not opts.get('norescan'):
2204 self.model.update_status()
2206 title = N_('Error')
2207 Interaction.command(title, cmd, status, out, err)
2209 return status == 0
2212 class SetDefaultRepo(ContextCommand):
2213 """Set the default repository"""
2215 def __init__(self, context, repo):
2216 super(SetDefaultRepo, self).__init__(context)
2217 self.repo = repo
2219 def do(self):
2220 self.cfg.set_user('cola.defaultrepo', self.repo)
2223 class SetDiffText(EditModel):
2224 """Set the diff text"""
2226 UNDOABLE = True
2228 def __init__(self, context, text):
2229 super(SetDiffText, self).__init__(context)
2230 self.new_diff_text = text
2231 self.new_diff_type = main.Types.TEXT
2232 self.new_file_type = main.Types.TEXT
2235 class SetUpstreamBranch(ContextCommand):
2236 """Set the upstream branch"""
2238 def __init__(self, context, branch, remote, remote_branch):
2239 super(SetUpstreamBranch, self).__init__(context)
2240 self.branch = branch
2241 self.remote = remote
2242 self.remote_branch = remote_branch
2244 def do(self):
2245 cfg = self.cfg
2246 remote = self.remote
2247 branch = self.branch
2248 remote_branch = self.remote_branch
2249 cfg.set_repo('branch.%s.remote' % branch, remote)
2250 cfg.set_repo('branch.%s.merge' % branch, 'refs/heads/' + remote_branch)
2253 def format_hex(data):
2254 """Translate binary data into a hex dump"""
2255 hexdigits = '0123456789ABCDEF'
2256 result = ''
2257 offset = 0
2258 byte_offset_to_int = compat.byte_offset_to_int_converter()
2259 while offset < len(data):
2260 result += '%04u |' % offset
2261 textpart = ''
2262 for i in range(0, 16):
2263 if i > 0 and i % 4 == 0:
2264 result += ' '
2265 if offset < len(data):
2266 v = byte_offset_to_int(data[offset])
2267 result += ' ' + hexdigits[v >> 4] + hexdigits[v & 0xF]
2268 textpart += chr(v) if 32 <= v < 127 else '.'
2269 offset += 1
2270 else:
2271 result += ' '
2272 textpart += ' '
2273 result += ' | ' + textpart + ' |\n'
2275 return result
2278 class ShowUntracked(EditModel):
2279 """Show an untracked file."""
2281 def __init__(self, context, filename):
2282 super(ShowUntracked, self).__init__(context)
2283 self.new_filename = filename
2284 if gitcmds.is_binary(context, filename):
2285 self.new_mode = self.model.mode_untracked
2286 self.new_diff_text = self.read(filename)
2287 else:
2288 self.new_mode = self.model.mode_untracked_diff
2289 self.new_diff_text = gitcmds.diff_helper(
2290 self.context, filename=filename, cached=False, untracked=True
2292 self.new_diff_type = main.Types.TEXT
2293 self.new_file_type = main.Types.TEXT
2295 def read(self, filename):
2296 """Read file contents"""
2297 cfg = self.cfg
2298 size = cfg.get('cola.readsize', 2048)
2299 try:
2300 result = core.read(filename, size=size, encoding='bytes')
2301 except (IOError, OSError):
2302 result = ''
2304 truncated = len(result) == size
2306 encoding = cfg.file_encoding(filename) or core.ENCODING
2307 try:
2308 text_result = core.decode_maybe(result, encoding)
2309 except UnicodeError:
2310 text_result = format_hex(result)
2312 if truncated:
2313 text_result += '...'
2314 return text_result
2317 class SignOff(ContextCommand):
2318 """Append a signoff to the commit message"""
2320 UNDOABLE = True
2322 @staticmethod
2323 def name():
2324 return N_('Sign Off')
2326 def __init__(self, context):
2327 super(SignOff, self).__init__(context)
2328 self.old_commitmsg = self.model.commitmsg
2330 def do(self):
2331 """Add a signoff to the commit message"""
2332 signoff = self.signoff()
2333 if signoff in self.model.commitmsg:
2334 return
2335 msg = self.model.commitmsg.rstrip()
2336 self.model.set_commitmsg(msg + '\n' + signoff)
2338 def undo(self):
2339 """Restore the commit message"""
2340 self.model.set_commitmsg(self.old_commitmsg)
2342 def signoff(self):
2343 """Generate the signoff string"""
2344 try:
2345 import pwd # pylint: disable=all
2347 user = pwd.getpwuid(os.getuid()).pw_name
2348 except ImportError:
2349 user = os.getenv('USER', N_('unknown'))
2351 cfg = self.cfg
2352 name = cfg.get('user.name', user)
2353 email = cfg.get('user.email', '%s@%s' % (user, core.node()))
2354 return '\nSigned-off-by: %s <%s>' % (name, email)
2357 def check_conflicts(context, unmerged):
2358 """Check paths for conflicts
2360 Conflicting files can be filtered out one-by-one.
2363 if prefs.check_conflicts(context):
2364 unmerged = [path for path in unmerged if is_conflict_free(path)]
2365 return unmerged
2368 def is_conflict_free(path):
2369 """Return True if `path` contains no conflict markers"""
2370 rgx = re.compile(r'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2371 try:
2372 with core.xopen(path, 'rb') as f:
2373 for line in f:
2374 line = core.decode(line, errors='ignore')
2375 if rgx.match(line):
2376 return should_stage_conflicts(path)
2377 except IOError:
2378 # We can't read this file ~ we may be staging a removal
2379 pass
2380 return True
2383 def should_stage_conflicts(path):
2384 """Inform the user that a file contains merge conflicts
2386 Return `True` if we should stage the path nonetheless.
2389 title = msg = N_('Stage conflicts?')
2390 info = (
2392 '%s appears to contain merge conflicts.\n\n'
2393 'You should probably skip this file.\n'
2394 'Stage it anyways?'
2396 % path
2398 ok_text = N_('Stage conflicts')
2399 cancel_text = N_('Skip')
2400 return Interaction.confirm(
2401 title, msg, info, ok_text, default=False, cancel_text=cancel_text
2405 class Stage(ContextCommand):
2406 """Stage a set of paths."""
2408 @staticmethod
2409 def name():
2410 return N_('Stage')
2412 def __init__(self, context, paths):
2413 super(Stage, self).__init__(context)
2414 self.paths = paths
2416 def do(self):
2417 msg = N_('Staging: %s') % (', '.join(self.paths))
2418 Interaction.log(msg)
2419 return self.stage_paths()
2421 def stage_paths(self):
2422 """Stages add/removals to git."""
2423 context = self.context
2424 paths = self.paths
2425 if not paths:
2426 if self.model.cfg.get('cola.safemode', False):
2427 return (0, '', '')
2428 return self.stage_all()
2430 add = []
2431 remove = []
2432 status = 0
2433 out = ''
2434 err = ''
2436 for path in set(paths):
2437 if core.exists(path) or core.islink(path):
2438 if path.endswith('/'):
2439 path = path.rstrip('/')
2440 add.append(path)
2441 else:
2442 remove.append(path)
2444 self.model.emit_about_to_update()
2446 # `git add -u` doesn't work on untracked files
2447 if add:
2448 status, out, err = gitcmds.add(context, add)
2449 Interaction.command(N_('Error'), 'git add', status, out, err)
2451 # If a path doesn't exist then that means it should be removed
2452 # from the index. We use `git add -u` for that.
2453 if remove:
2454 status, out, err = gitcmds.add(context, remove, u=True)
2455 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2457 self.model.update_files(emit=True)
2458 return status, out, err
2460 def stage_all(self):
2461 """Stage all files"""
2462 status, out, err = self.git.add(v=True, u=True)
2463 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2464 self.model.update_file_status()
2465 return (status, out, err)
2468 class StageCarefully(Stage):
2469 """Only stage when the path list is non-empty
2471 We use "git add -u -- <pathspec>" to stage, and it stages everything by
2472 default when no pathspec is specified, so this class ensures that paths
2473 are specified before calling git.
2475 When no paths are specified, the command does nothing.
2479 def __init__(self, context):
2480 super(StageCarefully, self).__init__(context, None)
2481 self.init_paths()
2483 # pylint: disable=no-self-use
2484 def init_paths(self):
2485 """Initialize path data"""
2486 return
2488 def ok_to_run(self):
2489 """Prevent catch-all "git add -u" from adding unmerged files"""
2490 return self.paths or not self.model.unmerged
2492 def do(self):
2493 """Stage files when ok_to_run() return True"""
2494 if self.ok_to_run():
2495 return super(StageCarefully, self).do()
2496 return (0, '', '')
2499 class StageModified(StageCarefully):
2500 """Stage all modified files."""
2502 @staticmethod
2503 def name():
2504 return N_('Stage Modified')
2506 def init_paths(self):
2507 self.paths = self.model.modified
2510 class StageUnmerged(StageCarefully):
2511 """Stage unmerged files."""
2513 @staticmethod
2514 def name():
2515 return N_('Stage Unmerged')
2517 def init_paths(self):
2518 self.paths = check_conflicts(self.context, self.model.unmerged)
2521 class StageUntracked(StageCarefully):
2522 """Stage all untracked files."""
2524 @staticmethod
2525 def name():
2526 return N_('Stage Untracked')
2528 def init_paths(self):
2529 self.paths = self.model.untracked
2532 class StageModifiedAndUntracked(StageCarefully):
2533 """Stage all untracked files."""
2535 @staticmethod
2536 def name():
2537 return N_('Stage Modified and Untracked')
2539 def init_paths(self):
2540 self.paths = self.model.modified + self.model.untracked
2543 class StageOrUnstageAll(ContextCommand):
2544 """If the selection is staged, unstage it, otherwise stage"""
2546 @staticmethod
2547 def name():
2548 return N_('Stage / Unstage All')
2550 def do(self):
2551 if self.model.staged:
2552 do(Unstage, self.context, self.model.staged)
2553 else:
2554 if self.cfg.get('cola.safemode', False):
2555 unstaged = self.model.modified
2556 else:
2557 unstaged = self.model.modified + self.model.untracked
2558 do(Stage, self.context, unstaged)
2561 class StageOrUnstage(ContextCommand):
2562 """If the selection is staged, unstage it, otherwise stage"""
2564 @staticmethod
2565 def name():
2566 return N_('Stage / Unstage')
2568 def do(self):
2569 s = self.selection.selection()
2570 if s.staged:
2571 do(Unstage, self.context, s.staged)
2573 unstaged = []
2574 unmerged = check_conflicts(self.context, s.unmerged)
2575 if unmerged:
2576 unstaged.extend(unmerged)
2577 if s.modified:
2578 unstaged.extend(s.modified)
2579 if s.untracked:
2580 unstaged.extend(s.untracked)
2581 if unstaged:
2582 do(Stage, self.context, unstaged)
2585 class Tag(ContextCommand):
2586 """Create a tag object."""
2588 def __init__(self, context, name, revision, sign=False, message=''):
2589 super(Tag, self).__init__(context)
2590 self._name = name
2591 self._message = message
2592 self._revision = revision
2593 self._sign = sign
2595 def do(self):
2596 result = False
2597 git = self.git
2598 revision = self._revision
2599 tag_name = self._name
2600 tag_message = self._message
2602 if not revision:
2603 Interaction.critical(
2604 N_('Missing Revision'), N_('Please specify a revision to tag.')
2606 return result
2608 if not tag_name:
2609 Interaction.critical(
2610 N_('Missing Name'), N_('Please specify a name for the new tag.')
2612 return result
2614 title = N_('Missing Tag Message')
2615 message = N_('Tag-signing was requested but the tag message is empty.')
2616 info = N_(
2617 'An unsigned, lightweight tag will be created instead.\n'
2618 'Create an unsigned tag?'
2620 ok_text = N_('Create Unsigned Tag')
2621 sign = self._sign
2622 if sign and not tag_message:
2623 # We require a message in order to sign the tag, so if they
2624 # choose to create an unsigned tag we have to clear the sign flag.
2625 if not Interaction.confirm(
2626 title, message, info, ok_text, default=False, icon=icons.save()
2628 return result
2629 sign = False
2631 opts = {}
2632 tmp_file = None
2633 try:
2634 if tag_message:
2635 tmp_file = utils.tmp_filename('tag-message')
2636 opts['file'] = tmp_file
2637 core.write(tmp_file, tag_message)
2639 if sign:
2640 opts['sign'] = True
2641 if tag_message:
2642 opts['annotate'] = True
2643 status, out, err = git.tag(tag_name, revision, **opts)
2644 finally:
2645 if tmp_file:
2646 core.unlink(tmp_file)
2648 title = N_('Error: could not create tag "%s"') % tag_name
2649 Interaction.command(title, 'git tag', status, out, err)
2651 if status == 0:
2652 result = True
2653 self.model.update_status()
2654 Interaction.information(
2655 N_('Tag Created'),
2656 N_('Created a new tag named "%s"') % tag_name,
2657 details=tag_message or None,
2660 return result
2663 class Unstage(ContextCommand):
2664 """Unstage a set of paths."""
2666 @staticmethod
2667 def name():
2668 return N_('Unstage')
2670 def __init__(self, context, paths):
2671 super(Unstage, self).__init__(context)
2672 self.paths = paths
2674 def do(self):
2675 """Unstage paths"""
2676 context = self.context
2677 head = self.model.head
2678 paths = self.paths
2680 msg = N_('Unstaging: %s') % (', '.join(paths))
2681 Interaction.log(msg)
2682 if not paths:
2683 return unstage_all(context)
2684 status, out, err = gitcmds.unstage_paths(context, paths, head=head)
2685 Interaction.command(N_('Error'), 'git reset', status, out, err)
2686 self.model.update_file_status()
2687 return (status, out, err)
2690 class UnstageAll(ContextCommand):
2691 """Unstage all files; resets the index."""
2693 def do(self):
2694 return unstage_all(self.context)
2697 def unstage_all(context):
2698 """Unstage all files, even while amending"""
2699 model = context.model
2700 git = context.git
2701 head = model.head
2702 status, out, err = git.reset(head, '--', '.')
2703 Interaction.command(N_('Error'), 'git reset', status, out, err)
2704 model.update_file_status()
2705 return (status, out, err)
2708 class StageSelected(ContextCommand):
2709 """Stage selected files, or all files if no selection exists."""
2711 def do(self):
2712 context = self.context
2713 paths = self.selection.unstaged
2714 if paths:
2715 do(Stage, context, paths)
2716 elif self.cfg.get('cola.safemode', False):
2717 do(StageModified, context)
2720 class UnstageSelected(Unstage):
2721 """Unstage selected files."""
2723 def __init__(self, context):
2724 staged = context.selection.staged
2725 super(UnstageSelected, self).__init__(context, staged)
2728 class Untrack(ContextCommand):
2729 """Unstage a set of paths."""
2731 def __init__(self, context, paths):
2732 super(Untrack, self).__init__(context)
2733 self.paths = paths
2735 def do(self):
2736 msg = N_('Untracking: %s') % (', '.join(self.paths))
2737 Interaction.log(msg)
2738 status, out, err = self.model.untrack_paths(self.paths)
2739 Interaction.log_status(status, out, err)
2742 class UntrackedSummary(EditModel):
2743 """List possible .gitignore rules as the diff text."""
2745 def __init__(self, context):
2746 super(UntrackedSummary, self).__init__(context)
2747 untracked = self.model.untracked
2748 suffix = 's' if untracked else ''
2749 io = StringIO()
2750 io.write('# %s untracked file%s\n' % (len(untracked), suffix))
2751 if untracked:
2752 io.write('# possible .gitignore rule%s:\n' % suffix)
2753 for u in untracked:
2754 io.write('/' + u + '\n')
2755 self.new_diff_text = io.getvalue()
2756 self.new_diff_type = main.Types.TEXT
2757 self.new_file_type = main.Types.TEXT
2758 self.new_mode = self.model.mode_display
2761 class VisualizeAll(ContextCommand):
2762 """Visualize all branches."""
2764 def do(self):
2765 context = self.context
2766 browser = utils.shell_split(prefs.history_browser(context))
2767 launch_history_browser(browser + ['--all'])
2770 class VisualizeCurrent(ContextCommand):
2771 """Visualize all branches."""
2773 def do(self):
2774 context = self.context
2775 browser = utils.shell_split(prefs.history_browser(context))
2776 launch_history_browser(browser + [self.model.currentbranch] + ['--'])
2779 class VisualizePaths(ContextCommand):
2780 """Path-limited visualization."""
2782 def __init__(self, context, paths):
2783 super(VisualizePaths, self).__init__(context)
2784 context = self.context
2785 browser = utils.shell_split(prefs.history_browser(context))
2786 if paths:
2787 self.argv = browser + ['--'] + list(paths)
2788 else:
2789 self.argv = browser
2791 def do(self):
2792 launch_history_browser(self.argv)
2795 class VisualizeRevision(ContextCommand):
2796 """Visualize a specific revision."""
2798 def __init__(self, context, revision, paths=None):
2799 super(VisualizeRevision, self).__init__(context)
2800 self.revision = revision
2801 self.paths = paths
2803 def do(self):
2804 context = self.context
2805 argv = utils.shell_split(prefs.history_browser(context))
2806 if self.revision:
2807 argv.append(self.revision)
2808 if self.paths:
2809 argv.append('--')
2810 argv.extend(self.paths)
2811 launch_history_browser(argv)
2814 class SubmoduleAdd(ConfirmAction):
2815 """Add specified submodules"""
2817 def __init__(self, context, url, path, branch, depth, reference):
2818 super(SubmoduleAdd, self).__init__(context)
2819 self.url = url
2820 self.path = path
2821 self.branch = branch
2822 self.depth = depth
2823 self.reference = reference
2825 def confirm(self):
2826 title = N_('Add Submodule...')
2827 question = N_('Add this submodule?')
2828 info = N_('The submodule will be added using\n' '"%s"' % self.command())
2829 ok_txt = N_('Add Submodule')
2830 return Interaction.confirm(title, question, info, ok_txt, icon=icons.ok())
2832 def action(self):
2833 context = self.context
2834 args = self.get_args()
2835 return context.git.submodule('add', *args)
2837 def success(self):
2838 self.model.update_file_status()
2839 self.model.update_submodules_list()
2841 def error_message(self):
2842 return N_('Error updating submodule %s' % self.path)
2844 def command(self):
2845 cmd = ['git', 'submodule', 'add']
2846 cmd.extend(self.get_args())
2847 return core.list2cmdline(cmd)
2849 def get_args(self):
2850 args = []
2851 if self.branch:
2852 args.extend(['--branch', self.branch])
2853 if self.reference:
2854 args.extend(['--reference', self.reference])
2855 if self.depth:
2856 args.extend(['--depth', '%d' % self.depth])
2857 args.extend(['--', self.url])
2858 if self.path:
2859 args.append(self.path)
2860 return args
2863 class SubmoduleUpdate(ConfirmAction):
2864 """Update specified submodule"""
2866 def __init__(self, context, path):
2867 super(SubmoduleUpdate, self).__init__(context)
2868 self.path = path
2870 def confirm(self):
2871 title = N_('Update Submodule...')
2872 question = N_('Update this submodule?')
2873 info = N_('The submodule will be updated using\n' '"%s"' % self.command())
2874 ok_txt = N_('Update Submodule')
2875 return Interaction.confirm(
2876 title, question, info, ok_txt, default=False, icon=icons.pull()
2879 def action(self):
2880 context = self.context
2881 args = self.get_args()
2882 return context.git.submodule(*args)
2884 def success(self):
2885 self.model.update_file_status()
2887 def error_message(self):
2888 return N_('Error updating submodule %s' % self.path)
2890 def command(self):
2891 cmd = ['git', 'submodule']
2892 cmd.extend(self.get_args())
2893 return core.list2cmdline(cmd)
2895 def get_args(self):
2896 cmd = ['update']
2897 if version.check_git(self.context, 'submodule-update-recursive'):
2898 cmd.append('--recursive')
2899 cmd.extend(['--', self.path])
2900 return cmd
2903 class SubmodulesUpdate(ConfirmAction):
2904 """Update all submodules"""
2906 def confirm(self):
2907 title = N_('Update submodules...')
2908 question = N_('Update all submodules?')
2909 info = N_('All submodules will be updated using\n' '"%s"' % self.command())
2910 ok_txt = N_('Update Submodules')
2911 return Interaction.confirm(
2912 title, question, info, ok_txt, default=False, icon=icons.pull()
2915 def action(self):
2916 context = self.context
2917 args = self.get_args()
2918 return context.git.submodule(*args)
2920 def success(self):
2921 self.model.update_file_status()
2923 def error_message(self):
2924 return N_('Error updating submodules')
2926 def command(self):
2927 cmd = ['git', 'submodule']
2928 cmd.extend(self.get_args())
2929 return core.list2cmdline(cmd)
2931 def get_args(self):
2932 cmd = ['update']
2933 if version.check_git(self.context, 'submodule-update-recursive'):
2934 cmd.append('--recursive')
2935 return cmd
2938 def launch_history_browser(argv):
2939 """Launch the configured history browser"""
2940 try:
2941 core.fork(argv)
2942 except OSError as e:
2943 _, details = utils.format_exception(e)
2944 title = N_('Error Launching History Browser')
2945 msg = N_('Cannot exec "%s": please configure a history browser') % ' '.join(
2946 argv
2948 Interaction.critical(title, message=msg, details=details)
2951 def run(cls, *args, **opts):
2953 Returns a callback that runs a command
2955 If the caller of run() provides args or opts then those are
2956 used instead of the ones provided by the invoker of the callback.
2960 def runner(*local_args, **local_opts):
2961 """Closure return by run() which runs the command"""
2962 if args or opts:
2963 do(cls, *args, **opts)
2964 else:
2965 do(cls, *local_args, **local_opts)
2967 return runner
2970 def do(cls, *args, **opts):
2971 """Run a command in-place"""
2972 try:
2973 cmd = cls(*args, **opts)
2974 return cmd.do()
2975 except Exception as e: # pylint: disable=broad-except
2976 msg, details = utils.format_exception(e)
2977 if hasattr(cls, '__name__'):
2978 msg = '%s exception:\n%s' % (cls.__name__, msg)
2979 Interaction.critical(N_('Error'), message=msg, details=details)
2980 return None
2983 def difftool_run(context):
2984 """Start a default difftool session"""
2985 selection = context.selection
2986 files = selection.group()
2987 if not files:
2988 return
2989 s = selection.selection()
2990 head = context.model.head
2991 difftool_launch_with_head(context, files, bool(s.staged), head)
2994 def difftool_launch_with_head(context, filenames, staged, head):
2995 """Launch difftool against the provided head"""
2996 if head == 'HEAD':
2997 left = None
2998 else:
2999 left = head
3000 difftool_launch(context, left=left, staged=staged, paths=filenames)
3003 def difftool_launch(
3004 context,
3005 left=None,
3006 right=None,
3007 paths=None,
3008 staged=False,
3009 dir_diff=False,
3010 left_take_magic=False,
3011 left_take_parent=False,
3013 """Launches 'git difftool' with given parameters
3015 :param left: first argument to difftool
3016 :param right: second argument to difftool_args
3017 :param paths: paths to diff
3018 :param staged: activate `git difftool --staged`
3019 :param dir_diff: activate `git difftool --dir-diff`
3020 :param left_take_magic: whether to append the magic ^! diff expression
3021 :param left_take_parent: whether to append the first-parent ~ for diffing
3025 difftool_args = ['git', 'difftool', '--no-prompt']
3026 if staged:
3027 difftool_args.append('--cached')
3028 if dir_diff:
3029 difftool_args.append('--dir-diff')
3031 if left:
3032 if left_take_parent or left_take_magic:
3033 suffix = '^!' if left_take_magic else '~'
3034 # Check root commit (no parents and thus cannot execute '~')
3035 git = context.git
3036 status, out, err = git.rev_list(left, parents=True, n=1)
3037 Interaction.log_status(status, out, err)
3038 if status:
3039 raise OSError('git rev-list command failed')
3041 if len(out.split()) >= 2:
3042 # Commit has a parent, so we can take its child as requested
3043 left += suffix
3044 else:
3045 # No parent, assume it's the root commit, so we have to diff
3046 # against the empty tree.
3047 left = EMPTY_TREE_OID
3048 if not right and left_take_magic:
3049 right = left
3050 difftool_args.append(left)
3052 if right:
3053 difftool_args.append(right)
3055 if paths:
3056 difftool_args.append('--')
3057 difftool_args.extend(paths)
3059 runtask = context.runtask
3060 if runtask:
3061 Interaction.async_command(N_('Difftool'), difftool_args, runtask)
3062 else:
3063 core.fork(difftool_args)