CHANGES: update the v4.0.2 release notes draft
[git-cola.git] / cola / cmds.py
blob50305a74bab0d2a706efbd627e5dafc6e5fdedfc
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(model, staged, modified, unmerged, untracked):
1065 if staged:
1066 mode = model.mode_index
1067 elif modified or unmerged:
1068 mode = model.mode_worktree
1069 elif untracked:
1070 mode = model.mode_untracked
1071 else:
1072 mode = model.mode
1073 return mode
1076 class DiffText(EditModel):
1077 """Set the diff type to text"""
1079 def __init__(self, context):
1080 super(DiffText, self).__init__(context)
1081 self.new_file_type = main.Types.TEXT
1082 self.new_diff_type = main.Types.TEXT
1085 class ToggleDiffType(ContextCommand):
1086 """Toggle the diff type between image and text"""
1088 def __init__(self, context):
1089 super(ToggleDiffType, self).__init__(context)
1090 if self.model.diff_type == main.Types.IMAGE:
1091 self.new_diff_type = main.Types.TEXT
1092 self.new_value = False
1093 else:
1094 self.new_diff_type = main.Types.IMAGE
1095 self.new_value = True
1097 def do(self):
1098 diff_type = self.new_diff_type
1099 value = self.new_value
1101 self.model.set_diff_type(diff_type)
1103 filename = self.model.filename
1104 _, ext = os.path.splitext(filename)
1105 if ext.startswith('.'):
1106 cfg = 'cola.imagediff' + ext
1107 self.cfg.set_repo(cfg, value)
1110 class DiffImage(EditModel):
1111 def __init__(
1112 self, context, filename, deleted, staged, modified, unmerged, untracked
1114 super(DiffImage, self).__init__(context)
1116 self.new_filename = filename
1117 self.new_diff_type = self.get_diff_type(filename)
1118 self.new_file_type = main.Types.IMAGE
1119 self.new_mode = get_mode(self.model, staged, modified, unmerged, untracked)
1120 self.staged = staged
1121 self.modified = modified
1122 self.unmerged = unmerged
1123 self.untracked = untracked
1124 self.deleted = deleted
1125 self.annex = self.cfg.is_annex()
1127 def get_diff_type(self, filename):
1128 """Query the diff type to use based on cola.imagediff.<extension>"""
1129 _, ext = os.path.splitext(filename)
1130 if ext.startswith('.'):
1131 # Check eg. "cola.imagediff.svg" to see if we should imagediff.
1132 cfg = 'cola.imagediff' + ext
1133 if self.cfg.get(cfg, True):
1134 result = main.Types.IMAGE
1135 else:
1136 result = main.Types.TEXT
1137 else:
1138 result = main.Types.IMAGE
1139 return result
1141 def do(self):
1142 filename = self.new_filename
1144 if self.staged:
1145 images = self.staged_images()
1146 elif self.modified:
1147 images = self.modified_images()
1148 elif self.unmerged:
1149 images = self.unmerged_images()
1150 elif self.untracked:
1151 images = [(filename, False)]
1152 else:
1153 images = []
1155 self.model.set_images(images)
1156 super(DiffImage, self).do()
1158 def staged_images(self):
1159 context = self.context
1160 git = self.git
1161 head = self.model.head
1162 filename = self.new_filename
1163 annex = self.annex
1165 images = []
1166 index = git.diff_index(head, '--', filename, cached=True)[STDOUT]
1167 if index:
1168 # Example:
1169 # :100644 100644 fabadb8... 4866510... M describe.c
1170 parts = index.split(' ')
1171 if len(parts) > 3:
1172 old_oid = parts[2]
1173 new_oid = parts[3]
1175 if old_oid != MISSING_BLOB_OID:
1176 # First, check if we can get a pre-image from git-annex
1177 annex_image = None
1178 if annex:
1179 annex_image = gitcmds.annex_path(context, head, filename)
1180 if annex_image:
1181 images.append((annex_image, False)) # git annex HEAD
1182 else:
1183 image = gitcmds.write_blob_path(context, head, old_oid, filename)
1184 if image:
1185 images.append((image, True))
1187 if new_oid != MISSING_BLOB_OID:
1188 found_in_annex = False
1189 if annex and core.islink(filename):
1190 status, out, _ = git.annex('status', '--', filename)
1191 if status == 0:
1192 details = out.split(' ')
1193 if details and details[0] == 'A': # newly added file
1194 images.append((filename, False))
1195 found_in_annex = True
1197 if not found_in_annex:
1198 image = gitcmds.write_blob(context, new_oid, filename)
1199 if image:
1200 images.append((image, True))
1202 return images
1204 def unmerged_images(self):
1205 context = self.context
1206 git = self.git
1207 head = self.model.head
1208 filename = self.new_filename
1209 annex = self.annex
1211 candidate_merge_heads = ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1212 merge_heads = [
1213 merge_head
1214 for merge_head in candidate_merge_heads
1215 if core.exists(git.git_path(merge_head))
1218 if annex: # Attempt to find files in git-annex
1219 annex_images = []
1220 for merge_head in merge_heads:
1221 image = gitcmds.annex_path(context, merge_head, filename)
1222 if image:
1223 annex_images.append((image, False))
1224 if annex_images:
1225 annex_images.append((filename, False))
1226 return annex_images
1228 # DIFF FORMAT FOR MERGES
1229 # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1230 # can take -c or --cc option to generate diff output also
1231 # for merge commits. The output differs from the format
1232 # described above in the following way:
1234 # 1. there is a colon for each parent
1235 # 2. there are more "src" modes and "src" sha1
1236 # 3. status is concatenated status characters for each parent
1237 # 4. no optional "score" number
1238 # 5. single path, only for "dst"
1239 # Example:
1240 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1241 # MM describe.c
1242 images = []
1243 index = git.diff_index(head, '--', filename, cached=True, cc=True)[STDOUT]
1244 if index:
1245 parts = index.split(' ')
1246 if len(parts) > 3:
1247 first_mode = parts[0]
1248 num_parents = first_mode.count(':')
1249 # colon for each parent, but for the index, the "parents"
1250 # are really entries in stages 1,2,3 (head, base, remote)
1251 # remote, base, head
1252 for i in range(num_parents):
1253 offset = num_parents + i + 1
1254 oid = parts[offset]
1255 try:
1256 merge_head = merge_heads[i]
1257 except IndexError:
1258 merge_head = 'HEAD'
1259 if oid != MISSING_BLOB_OID:
1260 image = gitcmds.write_blob_path(
1261 context, merge_head, oid, filename
1263 if image:
1264 images.append((image, True))
1266 images.append((filename, False))
1267 return images
1269 def modified_images(self):
1270 context = self.context
1271 git = self.git
1272 head = self.model.head
1273 filename = self.new_filename
1274 annex = self.annex
1276 images = []
1277 annex_image = None
1278 if annex: # Check for a pre-image from git-annex
1279 annex_image = gitcmds.annex_path(context, head, filename)
1280 if annex_image:
1281 images.append((annex_image, False)) # git annex HEAD
1282 else:
1283 worktree = git.diff_files('--', filename)[STDOUT]
1284 parts = worktree.split(' ')
1285 if len(parts) > 3:
1286 oid = parts[2]
1287 if oid != MISSING_BLOB_OID:
1288 image = gitcmds.write_blob_path(context, head, oid, filename)
1289 if image:
1290 images.append((image, True)) # HEAD
1292 images.append((filename, False)) # worktree
1293 return images
1296 class Diff(EditModel):
1297 """Perform a diff and set the model's current text."""
1299 def __init__(self, context, filename, cached=False, deleted=False):
1300 super(Diff, self).__init__(context)
1301 opts = {}
1302 if cached and gitcmds.is_valid_ref(context, self.model.head):
1303 opts['ref'] = self.model.head
1304 self.new_filename = filename
1305 self.new_mode = self.model.mode_worktree
1306 self.new_diff_text = gitcmds.diff_helper(
1307 self.context, filename=filename, cached=cached, deleted=deleted, **opts
1311 class Diffstat(EditModel):
1312 """Perform a diffstat and set the model's diff text."""
1314 def __init__(self, context):
1315 super(Diffstat, self).__init__(context)
1316 cfg = self.cfg
1317 diff_context = cfg.get('diff.context', 3)
1318 diff = self.git.diff(
1319 self.model.head,
1320 unified=diff_context,
1321 no_ext_diff=True,
1322 no_color=True,
1323 M=True,
1324 stat=True,
1325 )[STDOUT]
1326 self.new_diff_text = diff
1327 self.new_diff_type = main.Types.TEXT
1328 self.new_file_type = main.Types.TEXT
1329 self.new_mode = self.model.mode_diffstat
1332 class DiffStaged(Diff):
1333 """Perform a staged diff on a file."""
1335 def __init__(self, context, filename, deleted=None):
1336 super(DiffStaged, self).__init__(
1337 context, filename, cached=True, deleted=deleted
1339 self.new_mode = self.model.mode_index
1342 class DiffStagedSummary(EditModel):
1343 def __init__(self, context):
1344 super(DiffStagedSummary, self).__init__(context)
1345 diff = self.git.diff(
1346 self.model.head,
1347 cached=True,
1348 no_color=True,
1349 no_ext_diff=True,
1350 patch_with_stat=True,
1351 M=True,
1352 )[STDOUT]
1353 self.new_diff_text = diff
1354 self.new_diff_type = main.Types.TEXT
1355 self.new_file_type = main.Types.TEXT
1356 self.new_mode = self.model.mode_index
1359 class Difftool(ContextCommand):
1360 """Run git-difftool limited by path."""
1362 def __init__(self, context, staged, filenames):
1363 super(Difftool, self).__init__(context)
1364 self.staged = staged
1365 self.filenames = filenames
1367 def do(self):
1368 difftool_launch_with_head(
1369 self.context, self.filenames, self.staged, self.model.head
1373 class Edit(ContextCommand):
1374 """Edit a file using the configured gui.editor."""
1376 @staticmethod
1377 def name():
1378 return N_('Launch Editor')
1380 def __init__(self, context, filenames, line_number=None, background_editor=False):
1381 super(Edit, self).__init__(context)
1382 self.filenames = filenames
1383 self.line_number = line_number
1384 self.background_editor = background_editor
1386 def do(self):
1387 context = self.context
1388 if not self.filenames:
1389 return
1390 filename = self.filenames[0]
1391 if not core.exists(filename):
1392 return
1393 if self.background_editor:
1394 editor = prefs.background_editor(context)
1395 else:
1396 editor = prefs.editor(context)
1397 opts = []
1399 if self.line_number is None:
1400 opts = self.filenames
1401 else:
1402 # Single-file w/ line-numbers (likely from grep)
1403 editor_opts = {
1404 '*vim*': [filename, '+%s' % self.line_number],
1405 '*emacs*': ['+%s' % self.line_number, filename],
1406 '*textpad*': ['%s(%s,0)' % (filename, self.line_number)],
1407 '*notepad++*': ['-n%s' % self.line_number, filename],
1408 '*subl*': ['%s:%s' % (filename, self.line_number)],
1411 opts = self.filenames
1412 for pattern, opt in editor_opts.items():
1413 if fnmatch(editor, pattern):
1414 opts = opt
1415 break
1417 try:
1418 core.fork(utils.shell_split(editor) + opts)
1419 except (OSError, ValueError) as e:
1420 message = N_('Cannot exec "%s": please configure your editor') % editor
1421 _, details = utils.format_exception(e)
1422 Interaction.critical(N_('Error Editing File'), message, details)
1425 class FormatPatch(ContextCommand):
1426 """Output a patch series given all revisions and a selected subset."""
1428 def __init__(self, context, to_export, revs, output='patches'):
1429 super(FormatPatch, self).__init__(context)
1430 self.to_export = list(to_export)
1431 self.revs = list(revs)
1432 self.output = output
1434 def do(self):
1435 context = self.context
1436 status, out, err = gitcmds.format_patchsets(
1437 context, self.to_export, self.revs, self.output
1439 Interaction.log_status(status, out, err)
1442 class LaunchDifftool(ContextCommand):
1443 @staticmethod
1444 def name():
1445 return N_('Launch Diff Tool')
1447 def do(self):
1448 s = self.selection.selection()
1449 if s.unmerged:
1450 paths = s.unmerged
1451 if utils.is_win32():
1452 core.fork(['git', 'mergetool', '--no-prompt', '--'] + paths)
1453 else:
1454 cfg = self.cfg
1455 cmd = cfg.terminal()
1456 argv = utils.shell_split(cmd)
1458 terminal = os.path.basename(argv[0])
1459 shellquote_terms = set(['xfce4-terminal'])
1460 shellquote_default = terminal in shellquote_terms
1462 mergetool = ['git', 'mergetool', '--no-prompt', '--']
1463 mergetool.extend(paths)
1464 needs_shellquote = cfg.get(
1465 'cola.terminalshellquote', shellquote_default
1468 if needs_shellquote:
1469 argv.append(core.list2cmdline(mergetool))
1470 else:
1471 argv.extend(mergetool)
1473 core.fork(argv)
1474 else:
1475 difftool_run(self.context)
1478 class LaunchTerminal(ContextCommand):
1479 @staticmethod
1480 def name():
1481 return N_('Launch Terminal')
1483 @staticmethod
1484 def is_available(context):
1485 return context.cfg.terminal() is not None
1487 def __init__(self, context, path):
1488 super(LaunchTerminal, self).__init__(context)
1489 self.path = path
1491 def do(self):
1492 cmd = self.context.cfg.terminal()
1493 if cmd is None:
1494 return
1495 if utils.is_win32():
1496 argv = ['start', '', cmd, '--login']
1497 shell = True
1498 else:
1499 argv = utils.shell_split(cmd)
1500 argv.append(os.getenv('SHELL', '/bin/sh'))
1501 shell = False
1502 core.fork(argv, cwd=self.path, shell=shell)
1505 class LaunchEditor(Edit):
1506 @staticmethod
1507 def name():
1508 return N_('Launch Editor')
1510 def __init__(self, context):
1511 s = context.selection.selection()
1512 filenames = s.staged + s.unmerged + s.modified + s.untracked
1513 super(LaunchEditor, self).__init__(context, filenames, background_editor=True)
1516 class LaunchEditorAtLine(LaunchEditor):
1517 """Launch an editor at the specified line"""
1519 def __init__(self, context):
1520 super(LaunchEditorAtLine, self).__init__(context)
1521 self.line_number = context.selection.line_number
1524 class LoadCommitMessageFromFile(ContextCommand):
1525 """Loads a commit message from a path."""
1527 UNDOABLE = True
1529 def __init__(self, context, path):
1530 super(LoadCommitMessageFromFile, self).__init__(context)
1531 self.path = path
1532 self.old_commitmsg = self.model.commitmsg
1533 self.old_directory = self.model.directory
1535 def do(self):
1536 path = os.path.expanduser(self.path)
1537 if not path or not core.isfile(path):
1538 raise UsageError(
1539 N_('Error: Cannot find commit template'),
1540 N_('%s: No such file or directory.') % path,
1542 self.model.set_directory(os.path.dirname(path))
1543 self.model.set_commitmsg(core.read(path))
1545 def undo(self):
1546 self.model.set_commitmsg(self.old_commitmsg)
1547 self.model.set_directory(self.old_directory)
1550 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile):
1551 """Loads the commit message template specified by commit.template."""
1553 def __init__(self, context):
1554 cfg = context.cfg
1555 template = cfg.get('commit.template')
1556 super(LoadCommitMessageFromTemplate, self).__init__(context, template)
1558 def do(self):
1559 if self.path is None:
1560 raise UsageError(
1561 N_('Error: Unconfigured commit template'),
1563 'A commit template has not been configured.\n'
1564 'Use "git config" to define "commit.template"\n'
1565 'so that it points to a commit template.'
1568 return LoadCommitMessageFromFile.do(self)
1571 class LoadCommitMessageFromOID(ContextCommand):
1572 """Load a previous commit message"""
1574 UNDOABLE = True
1576 def __init__(self, context, oid, prefix=''):
1577 super(LoadCommitMessageFromOID, self).__init__(context)
1578 self.oid = oid
1579 self.old_commitmsg = self.model.commitmsg
1580 self.new_commitmsg = prefix + gitcmds.prev_commitmsg(context, oid)
1582 def do(self):
1583 self.model.set_commitmsg(self.new_commitmsg)
1585 def undo(self):
1586 self.model.set_commitmsg(self.old_commitmsg)
1589 class PrepareCommitMessageHook(ContextCommand):
1590 """Use the cola-prepare-commit-msg hook to prepare the commit message"""
1592 UNDOABLE = True
1594 def __init__(self, context):
1595 super(PrepareCommitMessageHook, self).__init__(context)
1596 self.old_commitmsg = self.model.commitmsg
1598 def get_message(self):
1600 title = N_('Error running prepare-commitmsg hook')
1601 hook = gitcmds.prepare_commit_message_hook(self.context)
1603 if os.path.exists(hook):
1604 filename = self.model.save_commitmsg()
1605 status, out, err = core.run_command([hook, filename])
1607 if status == 0:
1608 result = core.read(filename)
1609 else:
1610 result = self.old_commitmsg
1611 Interaction.command_error(title, hook, status, out, err)
1612 else:
1613 message = N_('A hook must be provided at "%s"') % hook
1614 Interaction.critical(title, message=message)
1615 result = self.old_commitmsg
1617 return result
1619 def do(self):
1620 msg = self.get_message()
1621 self.model.set_commitmsg(msg)
1623 def undo(self):
1624 self.model.set_commitmsg(self.old_commitmsg)
1627 class LoadFixupMessage(LoadCommitMessageFromOID):
1628 """Load a fixup message"""
1630 def __init__(self, context, oid):
1631 super(LoadFixupMessage, self).__init__(context, oid, prefix='fixup! ')
1632 if self.new_commitmsg:
1633 self.new_commitmsg = self.new_commitmsg.splitlines()[0]
1636 class Merge(ContextCommand):
1637 """Merge commits"""
1639 def __init__(self, context, revision, no_commit, squash, no_ff, sign):
1640 super(Merge, self).__init__(context)
1641 self.revision = revision
1642 self.no_ff = no_ff
1643 self.no_commit = no_commit
1644 self.squash = squash
1645 self.sign = sign
1647 def do(self):
1648 squash = self.squash
1649 revision = self.revision
1650 no_ff = self.no_ff
1651 no_commit = self.no_commit
1652 sign = self.sign
1654 status, out, err = self.git.merge(
1655 revision, gpg_sign=sign, no_ff=no_ff, no_commit=no_commit, squash=squash
1657 self.model.update_status()
1658 title = N_('Merge failed. Conflict resolution is required.')
1659 Interaction.command(title, 'git merge', status, out, err)
1661 return status, out, err
1664 class OpenDefaultApp(ContextCommand):
1665 """Open a file using the OS default."""
1667 @staticmethod
1668 def name():
1669 return N_('Open Using Default Application')
1671 def __init__(self, context, filenames):
1672 super(OpenDefaultApp, self).__init__(context)
1673 self.filenames = filenames
1675 def do(self):
1676 if not self.filenames:
1677 return
1678 utils.launch_default_app(self.filenames)
1681 class OpenDir(OpenDefaultApp):
1682 """Open directories using the OS default."""
1684 @staticmethod
1685 def name():
1686 return N_('Open Directory')
1688 @property
1689 def _dirnames(self):
1690 return self.filenames
1692 def do(self):
1693 dirnames = self._dirnames
1694 if not dirnames:
1695 return
1696 # An empty dirname defaults to CWD.
1697 dirs = [(dirname or core.getcwd()) for dirname in dirnames]
1698 utils.launch_default_app(dirs)
1701 class OpenParentDir(OpenDir):
1702 """Open parent directories using the OS default."""
1704 @staticmethod
1705 def name():
1706 return N_('Open Parent Directory')
1708 @property
1709 def _dirnames(self):
1710 dirnames = list(set([os.path.dirname(x) for x in self.filenames]))
1711 return dirnames
1714 class OpenWorktree(OpenDir):
1715 """Open worktree directory using the OS default."""
1717 @staticmethod
1718 def name():
1719 return N_('Open Worktree')
1721 def __init__(self, context, __):
1722 dirnames = [context.git.worktree()]
1723 super(OpenWorktree, self).__init__(context, dirnames)
1726 class OpenNewRepo(ContextCommand):
1727 """Launches git-cola on a repo."""
1729 def __init__(self, context, repo_path):
1730 super(OpenNewRepo, self).__init__(context)
1731 self.repo_path = repo_path
1733 def do(self):
1734 self.model.set_directory(self.repo_path)
1735 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
1738 class OpenRepo(EditModel):
1739 def __init__(self, context, repo_path):
1740 super(OpenRepo, self).__init__(context)
1741 self.repo_path = repo_path
1742 self.new_mode = self.model.mode_none
1743 self.new_diff_text = ''
1744 self.new_diff_type = main.Types.TEXT
1745 self.new_file_type = main.Types.TEXT
1746 self.new_commitmsg = ''
1747 self.new_filename = ''
1749 def do(self):
1750 old_repo = self.git.getcwd()
1751 if self.model.set_worktree(self.repo_path):
1752 self.fsmonitor.stop()
1753 self.fsmonitor.start()
1754 self.model.update_status(reset=True)
1755 # Check if template should be loaded
1756 if self.context.cfg.get(prefs.AUTOTEMPLATE):
1757 template_loader = LoadCommitMessageFromTemplate(self.context)
1758 template_loader.do()
1759 else:
1760 self.model.set_commitmsg(self.new_commitmsg)
1761 settings = self.context.settings
1762 settings.load()
1763 settings.add_recent(self.repo_path, prefs.maxrecent(self.context))
1764 settings.save()
1765 super(OpenRepo, self).do()
1766 else:
1767 self.model.set_worktree(old_repo)
1770 class OpenParentRepo(OpenRepo):
1771 def __init__(self, context):
1772 path = ''
1773 if version.check_git(context, 'show-superproject-working-tree'):
1774 status, out, _ = context.git.rev_parse(show_superproject_working_tree=True)
1775 if status == 0:
1776 path = out
1777 if not path:
1778 path = os.path.dirname(core.getcwd())
1779 super(OpenParentRepo, self).__init__(context, path)
1782 class Clone(ContextCommand):
1783 """Clones a repository and optionally spawns a new cola session."""
1785 def __init__(
1786 self, context, url, new_directory, submodules=False, shallow=False, spawn=True
1788 super(Clone, self).__init__(context)
1789 self.url = url
1790 self.new_directory = new_directory
1791 self.submodules = submodules
1792 self.shallow = shallow
1793 self.spawn = spawn
1794 self.status = -1
1795 self.out = ''
1796 self.err = ''
1798 def do(self):
1799 kwargs = {}
1800 if self.shallow:
1801 kwargs['depth'] = 1
1802 recurse_submodules = self.submodules
1803 shallow_submodules = self.submodules and self.shallow
1805 status, out, err = self.git.clone(
1806 self.url,
1807 self.new_directory,
1808 recurse_submodules=recurse_submodules,
1809 shallow_submodules=shallow_submodules,
1810 **kwargs
1813 self.status = status
1814 self.out = out
1815 self.err = err
1816 if status == 0 and self.spawn:
1817 executable = sys.executable
1818 core.fork([executable, sys.argv[0], '--repo', self.new_directory])
1819 return self
1822 class NewBareRepo(ContextCommand):
1823 """Create a new shared bare repository"""
1825 def __init__(self, context, path):
1826 super(NewBareRepo, self).__init__(context)
1827 self.path = path
1829 def do(self):
1830 path = self.path
1831 status, out, err = self.git.init(path, bare=True, shared=True)
1832 Interaction.command(
1833 N_('Error'), 'git init --bare --shared "%s"' % path, status, out, err
1835 return status == 0
1838 def unix_path(path, is_win32=utils.is_win32):
1839 """Git for Windows requires unix paths, so force them here"""
1840 if is_win32():
1841 path = path.replace('\\', '/')
1842 first = path[0]
1843 second = path[1]
1844 if second == ':': # sanity check, this better be a Windows-style path
1845 path = '/' + first + path[2:]
1847 return path
1850 def sequence_editor():
1851 """Set GIT_SEQUENCE_EDITOR for running git-cola-sequence-editor"""
1852 xbase = unix_path(resources.command('git-cola-sequence-editor'))
1853 if utils.is_win32():
1854 editor = core.list2cmdline([unix_path(sys.executable), xbase])
1855 else:
1856 editor = core.list2cmdline([xbase])
1857 return editor
1860 class SequenceEditorEnvironment(object):
1861 """Set environment variables to enable git-cola-sequence-editor"""
1863 def __init__(self, context, **kwargs):
1864 self.env = {
1865 'GIT_EDITOR': prefs.editor(context),
1866 'GIT_SEQUENCE_EDITOR': sequence_editor(),
1867 'GIT_COLA_SEQ_EDITOR_CANCEL_ACTION': 'save',
1869 self.env.update(kwargs)
1871 def __enter__(self):
1872 for var, value in self.env.items():
1873 compat.setenv(var, value)
1874 return self
1876 def __exit__(self, exc_type, exc_val, exc_tb):
1877 for var in self.env:
1878 compat.unsetenv(var)
1881 class Rebase(ContextCommand):
1882 def __init__(self, context, upstream=None, branch=None, **kwargs):
1883 """Start an interactive rebase session
1885 :param upstream: upstream branch
1886 :param branch: optional branch to checkout
1887 :param kwargs: forwarded directly to `git.rebase()`
1890 super(Rebase, self).__init__(context)
1892 self.upstream = upstream
1893 self.branch = branch
1894 self.kwargs = kwargs
1896 def prepare_arguments(self, upstream):
1897 args = []
1898 kwargs = {}
1900 # Rebase actions must be the only option specified
1901 for action in ('continue', 'abort', 'skip', 'edit_todo'):
1902 if self.kwargs.get(action, False):
1903 kwargs[action] = self.kwargs[action]
1904 return args, kwargs
1906 kwargs['interactive'] = True
1907 kwargs['autosquash'] = self.kwargs.get('autosquash', True)
1908 kwargs.update(self.kwargs)
1910 if upstream:
1911 args.append(upstream)
1912 if self.branch:
1913 args.append(self.branch)
1915 return args, kwargs
1917 def do(self):
1918 (status, out, err) = (1, '', '')
1919 context = self.context
1920 cfg = self.cfg
1921 model = self.model
1923 if not cfg.get('rebase.autostash', False):
1924 if model.staged or model.unmerged or model.modified:
1925 Interaction.information(
1926 N_('Unable to rebase'),
1927 N_('You cannot rebase with uncommitted changes.'),
1929 return status, out, err
1931 upstream = self.upstream or Interaction.choose_ref(
1932 context,
1933 N_('Select New Upstream'),
1934 N_('Interactive Rebase'),
1935 default='@{upstream}',
1937 if not upstream:
1938 return status, out, err
1940 self.model.is_rebasing = True
1941 self.model.emit_updated()
1943 args, kwargs = self.prepare_arguments(upstream)
1944 upstream_title = upstream or '@{upstream}'
1945 with SequenceEditorEnvironment(
1946 self.context,
1947 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase onto %s') % upstream_title,
1948 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
1950 # TODO this blocks the user interface window for the duration
1951 # of git-cola-sequence-editor. We would need to implement
1952 # signals for QProcess and continue running the main thread.
1953 # Alternatively, we can hide the main window while rebasing.
1954 # That doesn't require as much effort.
1955 status, out, err = self.git.rebase(
1956 *args, _no_win32_startupinfo=True, **kwargs
1958 self.model.update_status()
1959 if err.strip() != 'Nothing to do':
1960 title = N_('Rebase stopped')
1961 Interaction.command(title, 'git rebase', status, out, err)
1962 return status, out, err
1965 class RebaseEditTodo(ContextCommand):
1966 def do(self):
1967 (status, out, err) = (1, '', '')
1968 with SequenceEditorEnvironment(
1969 self.context,
1970 GIT_COLA_SEQ_EDITOR_TITLE=N_('Edit Rebase'),
1971 GIT_COLA_SEQ_EDITOR_ACTION=N_('Save'),
1973 status, out, err = self.git.rebase(edit_todo=True)
1974 Interaction.log_status(status, out, err)
1975 self.model.update_status()
1976 return status, out, err
1979 class RebaseContinue(ContextCommand):
1980 def do(self):
1981 (status, out, err) = (1, '', '')
1982 with SequenceEditorEnvironment(
1983 self.context,
1984 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase'),
1985 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
1987 status, out, err = self.git.rebase('--continue')
1988 Interaction.log_status(status, out, err)
1989 self.model.update_status()
1990 return status, out, err
1993 class RebaseSkip(ContextCommand):
1994 def do(self):
1995 (status, out, err) = (1, '', '')
1996 with SequenceEditorEnvironment(
1997 self.context,
1998 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase'),
1999 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
2001 status, out, err = self.git.rebase(skip=True)
2002 Interaction.log_status(status, out, err)
2003 self.model.update_status()
2004 return status, out, err
2007 class RebaseAbort(ContextCommand):
2008 def do(self):
2009 status, out, err = self.git.rebase(abort=True)
2010 Interaction.log_status(status, out, err)
2011 self.model.update_status()
2014 class Rescan(ContextCommand):
2015 """Rescan for changes"""
2017 def do(self):
2018 self.model.update_status()
2021 class Refresh(ContextCommand):
2022 """Update refs, refresh the index, and update config"""
2024 @staticmethod
2025 def name():
2026 return N_('Refresh')
2028 def do(self):
2029 self.model.update_status(update_index=True)
2030 self.cfg.update()
2031 self.fsmonitor.refresh()
2034 class RefreshConfig(ContextCommand):
2035 """Refresh the git config cache"""
2037 def do(self):
2038 self.cfg.update()
2041 class RevertEditsCommand(ConfirmAction):
2042 def __init__(self, context):
2043 super(RevertEditsCommand, self).__init__(context)
2044 self.icon = icons.undo()
2046 def ok_to_run(self):
2047 return self.model.undoable()
2049 # pylint: disable=no-self-use
2050 def checkout_from_head(self):
2051 return False
2053 def checkout_args(self):
2054 args = []
2055 s = self.selection.selection()
2056 if self.checkout_from_head():
2057 args.append(self.model.head)
2058 args.append('--')
2060 if s.staged:
2061 items = s.staged
2062 else:
2063 items = s.modified
2064 args.extend(items)
2066 return args
2068 def action(self):
2069 checkout_args = self.checkout_args()
2070 return self.git.checkout(*checkout_args)
2072 def success(self):
2073 self.model.set_diff_type(main.Types.TEXT)
2074 self.model.update_file_status()
2077 class RevertUnstagedEdits(RevertEditsCommand):
2078 @staticmethod
2079 def name():
2080 return N_('Revert Unstaged Edits...')
2082 def checkout_from_head(self):
2083 # Being in amend mode should not affect the behavior of this command.
2084 # The only sensible thing to do is to checkout from the index.
2085 return False
2087 def confirm(self):
2088 title = N_('Revert Unstaged Changes?')
2089 text = N_(
2090 'This operation removes unstaged edits from selected files.\n'
2091 'These changes cannot be recovered.'
2093 info = N_('Revert the unstaged changes?')
2094 ok_text = N_('Revert Unstaged Changes')
2095 return Interaction.confirm(
2096 title, text, info, ok_text, default=True, icon=self.icon
2100 class RevertUncommittedEdits(RevertEditsCommand):
2101 @staticmethod
2102 def name():
2103 return N_('Revert Uncommitted Edits...')
2105 def checkout_from_head(self):
2106 return True
2108 def confirm(self):
2109 """Prompt for reverting changes"""
2110 title = N_('Revert Uncommitted Changes?')
2111 text = N_(
2112 'This operation removes uncommitted edits from selected files.\n'
2113 'These changes cannot be recovered.'
2115 info = N_('Revert the uncommitted changes?')
2116 ok_text = N_('Revert Uncommitted Changes')
2117 return Interaction.confirm(
2118 title, text, info, ok_text, default=True, icon=self.icon
2122 class RunConfigAction(ContextCommand):
2123 """Run a user-configured action, typically from the "Tools" menu"""
2125 def __init__(self, context, action_name):
2126 super(RunConfigAction, self).__init__(context)
2127 self.action_name = action_name
2129 def do(self):
2130 """Run the user-configured action"""
2131 for env in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
2132 try:
2133 compat.unsetenv(env)
2134 except KeyError:
2135 pass
2136 rev = None
2137 args = None
2138 context = self.context
2139 cfg = self.cfg
2140 opts = cfg.get_guitool_opts(self.action_name)
2141 cmd = opts.get('cmd')
2142 if 'title' not in opts:
2143 opts['title'] = cmd
2145 if 'prompt' not in opts or opts.get('prompt') is True:
2146 prompt = N_('Run "%s"?') % cmd
2147 opts['prompt'] = prompt
2149 if opts.get('needsfile'):
2150 filename = self.selection.filename()
2151 if not filename:
2152 Interaction.information(
2153 N_('Please select a file'),
2154 N_('"%s" requires a selected file.') % cmd,
2156 return False
2157 dirname = utils.dirname(filename, current_dir='.')
2158 compat.setenv('FILENAME', filename)
2159 compat.setenv('DIRNAME', dirname)
2161 if opts.get('revprompt') or opts.get('argprompt'):
2162 while True:
2163 ok = Interaction.confirm_config_action(context, cmd, opts)
2164 if not ok:
2165 return False
2166 rev = opts.get('revision')
2167 args = opts.get('args')
2168 if opts.get('revprompt') and not rev:
2169 title = N_('Invalid Revision')
2170 msg = N_('The revision expression cannot be empty.')
2171 Interaction.critical(title, msg)
2172 continue
2173 break
2175 elif opts.get('confirm'):
2176 title = os.path.expandvars(opts.get('title'))
2177 prompt = os.path.expandvars(opts.get('prompt'))
2178 if not Interaction.question(title, prompt):
2179 return False
2180 if rev:
2181 compat.setenv('REVISION', rev)
2182 if args:
2183 compat.setenv('ARGS', args)
2184 title = os.path.expandvars(cmd)
2185 Interaction.log(N_('Running command: %s') % title)
2186 cmd = ['sh', '-c', cmd]
2188 if opts.get('background'):
2189 core.fork(cmd)
2190 status, out, err = (0, '', '')
2191 elif opts.get('noconsole'):
2192 status, out, err = core.run_command(cmd)
2193 else:
2194 status, out, err = Interaction.run_command(title, cmd)
2196 if not opts.get('background') and not opts.get('norescan'):
2197 self.model.update_status()
2199 title = N_('Error')
2200 Interaction.command(title, cmd, status, out, err)
2202 return status == 0
2205 class SetDefaultRepo(ContextCommand):
2206 """Set the default repository"""
2208 def __init__(self, context, repo):
2209 super(SetDefaultRepo, self).__init__(context)
2210 self.repo = repo
2212 def do(self):
2213 self.cfg.set_user('cola.defaultrepo', self.repo)
2216 class SetDiffText(EditModel):
2217 """Set the diff text"""
2219 UNDOABLE = True
2221 def __init__(self, context, text):
2222 super(SetDiffText, self).__init__(context)
2223 self.new_diff_text = text
2224 self.new_diff_type = main.Types.TEXT
2225 self.new_file_type = main.Types.TEXT
2228 class SetUpstreamBranch(ContextCommand):
2229 """Set the upstream branch"""
2231 def __init__(self, context, branch, remote, remote_branch):
2232 super(SetUpstreamBranch, self).__init__(context)
2233 self.branch = branch
2234 self.remote = remote
2235 self.remote_branch = remote_branch
2237 def do(self):
2238 cfg = self.cfg
2239 remote = self.remote
2240 branch = self.branch
2241 remote_branch = self.remote_branch
2242 cfg.set_repo('branch.%s.remote' % branch, remote)
2243 cfg.set_repo('branch.%s.merge' % branch, 'refs/heads/' + remote_branch)
2246 def format_hex(data):
2247 """Translate binary data into a hex dump"""
2248 hexdigits = '0123456789ABCDEF'
2249 result = ''
2250 offset = 0
2251 byte_offset_to_int = compat.byte_offset_to_int_converter()
2252 while offset < len(data):
2253 result += '%04u |' % offset
2254 textpart = ''
2255 for i in range(0, 16):
2256 if i > 0 and i % 4 == 0:
2257 result += ' '
2258 if offset < len(data):
2259 v = byte_offset_to_int(data[offset])
2260 result += ' ' + hexdigits[v >> 4] + hexdigits[v & 0xF]
2261 textpart += chr(v) if 32 <= v < 127 else '.'
2262 offset += 1
2263 else:
2264 result += ' '
2265 textpart += ' '
2266 result += ' | ' + textpart + ' |\n'
2268 return result
2271 class ShowUntracked(EditModel):
2272 """Show an untracked file."""
2274 def __init__(self, context, filename):
2275 super(ShowUntracked, self).__init__(context)
2276 self.new_filename = filename
2277 if gitcmds.is_binary(context, filename):
2278 self.new_mode = self.model.mode_untracked
2279 self.new_diff_text = self.read(filename)
2280 else:
2281 self.new_mode = self.model.mode_untracked_diff
2282 self.new_diff_text = gitcmds.diff_helper(
2283 self.context, filename=filename, cached=False, untracked=True
2285 self.new_diff_type = main.Types.TEXT
2286 self.new_file_type = main.Types.TEXT
2288 def read(self, filename):
2289 """Read file contents"""
2290 cfg = self.cfg
2291 size = cfg.get('cola.readsize', 2048)
2292 try:
2293 result = core.read(filename, size=size, encoding='bytes')
2294 except (IOError, OSError):
2295 result = ''
2297 truncated = len(result) == size
2299 encoding = cfg.file_encoding(filename) or core.ENCODING
2300 try:
2301 text_result = core.decode_maybe(result, encoding)
2302 except UnicodeError:
2303 text_result = format_hex(result)
2305 if truncated:
2306 text_result += '...'
2307 return text_result
2310 class SignOff(ContextCommand):
2311 """Append a signoff to the commit message"""
2313 UNDOABLE = True
2315 @staticmethod
2316 def name():
2317 return N_('Sign Off')
2319 def __init__(self, context):
2320 super(SignOff, self).__init__(context)
2321 self.old_commitmsg = self.model.commitmsg
2323 def do(self):
2324 """Add a signoff to the commit message"""
2325 signoff = self.signoff()
2326 if signoff in self.model.commitmsg:
2327 return
2328 msg = self.model.commitmsg.rstrip()
2329 self.model.set_commitmsg(msg + '\n' + signoff)
2331 def undo(self):
2332 """Restore the commit message"""
2333 self.model.set_commitmsg(self.old_commitmsg)
2335 def signoff(self):
2336 """Generate the signoff string"""
2337 try:
2338 import pwd # pylint: disable=all
2340 user = pwd.getpwuid(os.getuid()).pw_name
2341 except ImportError:
2342 user = os.getenv('USER', N_('unknown'))
2344 cfg = self.cfg
2345 name = cfg.get('user.name', user)
2346 email = cfg.get('user.email', '%s@%s' % (user, core.node()))
2347 return '\nSigned-off-by: %s <%s>' % (name, email)
2350 def check_conflicts(context, unmerged):
2351 """Check paths for conflicts
2353 Conflicting files can be filtered out one-by-one.
2356 if prefs.check_conflicts(context):
2357 unmerged = [path for path in unmerged if is_conflict_free(path)]
2358 return unmerged
2361 def is_conflict_free(path):
2362 """Return True if `path` contains no conflict markers"""
2363 rgx = re.compile(r'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2364 try:
2365 with core.xopen(path, 'rb') as f:
2366 for line in f:
2367 line = core.decode(line, errors='ignore')
2368 if rgx.match(line):
2369 return should_stage_conflicts(path)
2370 except IOError:
2371 # We can't read this file ~ we may be staging a removal
2372 pass
2373 return True
2376 def should_stage_conflicts(path):
2377 """Inform the user that a file contains merge conflicts
2379 Return `True` if we should stage the path nonetheless.
2382 title = msg = N_('Stage conflicts?')
2383 info = (
2385 '%s appears to contain merge conflicts.\n\n'
2386 'You should probably skip this file.\n'
2387 'Stage it anyways?'
2389 % path
2391 ok_text = N_('Stage conflicts')
2392 cancel_text = N_('Skip')
2393 return Interaction.confirm(
2394 title, msg, info, ok_text, default=False, cancel_text=cancel_text
2398 class Stage(ContextCommand):
2399 """Stage a set of paths."""
2401 @staticmethod
2402 def name():
2403 return N_('Stage')
2405 def __init__(self, context, paths):
2406 super(Stage, self).__init__(context)
2407 self.paths = paths
2409 def do(self):
2410 msg = N_('Staging: %s') % (', '.join(self.paths))
2411 Interaction.log(msg)
2412 return self.stage_paths()
2414 def stage_paths(self):
2415 """Stages add/removals to git."""
2416 context = self.context
2417 paths = self.paths
2418 if not paths:
2419 if self.model.cfg.get('cola.safemode', False):
2420 return (0, '', '')
2421 return self.stage_all()
2423 add = []
2424 remove = []
2426 for path in set(paths):
2427 if core.exists(path) or core.islink(path):
2428 if path.endswith('/'):
2429 path = path.rstrip('/')
2430 add.append(path)
2431 else:
2432 remove.append(path)
2434 self.model.emit_about_to_update()
2436 # `git add -u` doesn't work on untracked files
2437 if add:
2438 status, out, err = gitcmds.add(context, add)
2439 Interaction.command(N_('Error'), 'git add', status, out, err)
2441 # If a path doesn't exist then that means it should be removed
2442 # from the index. We use `git add -u` for that.
2443 if remove:
2444 status, out, err = gitcmds.add(context, remove, u=True)
2445 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2447 self.model.update_files(emit=True)
2448 return status, out, err
2450 def stage_all(self):
2451 """Stage all files"""
2452 status, out, err = self.git.add(v=True, u=True)
2453 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2454 self.model.update_file_status()
2455 return (status, out, err)
2458 class StageCarefully(Stage):
2459 """Only stage when the path list is non-empty
2461 We use "git add -u -- <pathspec>" to stage, and it stages everything by
2462 default when no pathspec is specified, so this class ensures that paths
2463 are specified before calling git.
2465 When no paths are specified, the command does nothing.
2469 def __init__(self, context):
2470 super(StageCarefully, self).__init__(context, None)
2471 self.init_paths()
2473 # pylint: disable=no-self-use
2474 def init_paths(self):
2475 """Initialize path data"""
2476 return
2478 def ok_to_run(self):
2479 """Prevent catch-all "git add -u" from adding unmerged files"""
2480 return self.paths or not self.model.unmerged
2482 def do(self):
2483 """Stage files when ok_to_run() return True"""
2484 if self.ok_to_run():
2485 return super(StageCarefully, self).do()
2486 return (0, '', '')
2489 class StageModified(StageCarefully):
2490 """Stage all modified files."""
2492 @staticmethod
2493 def name():
2494 return N_('Stage Modified')
2496 def init_paths(self):
2497 self.paths = self.model.modified
2500 class StageUnmerged(StageCarefully):
2501 """Stage unmerged files."""
2503 @staticmethod
2504 def name():
2505 return N_('Stage Unmerged')
2507 def init_paths(self):
2508 self.paths = check_conflicts(self.context, self.model.unmerged)
2511 class StageUntracked(StageCarefully):
2512 """Stage all untracked files."""
2514 @staticmethod
2515 def name():
2516 return N_('Stage Untracked')
2518 def init_paths(self):
2519 self.paths = self.model.untracked
2522 class StageModifiedAndUntracked(StageCarefully):
2523 """Stage all untracked files."""
2525 @staticmethod
2526 def name():
2527 return N_('Stage Modified and Untracked')
2529 def init_paths(self):
2530 self.paths = self.model.modified + self.model.untracked
2533 class StageOrUnstageAll(ContextCommand):
2534 """If the selection is staged, unstage it, otherwise stage"""
2536 @staticmethod
2537 def name():
2538 return N_('Stage / Unstage All')
2540 def do(self):
2541 if self.model.staged:
2542 do(Unstage, self.context, self.model.staged)
2543 else:
2544 if self.cfg.get('cola.safemode', False):
2545 unstaged = self.model.modified
2546 else:
2547 unstaged = self.model.modified + self.model.untracked
2548 do(Stage, self.context, unstaged)
2551 class StageOrUnstage(ContextCommand):
2552 """If the selection is staged, unstage it, otherwise stage"""
2554 @staticmethod
2555 def name():
2556 return N_('Stage / Unstage')
2558 def do(self):
2559 s = self.selection.selection()
2560 if s.staged:
2561 do(Unstage, self.context, s.staged)
2563 unstaged = []
2564 unmerged = check_conflicts(self.context, s.unmerged)
2565 if unmerged:
2566 unstaged.extend(unmerged)
2567 if s.modified:
2568 unstaged.extend(s.modified)
2569 if s.untracked:
2570 unstaged.extend(s.untracked)
2571 if unstaged:
2572 do(Stage, self.context, unstaged)
2575 class Tag(ContextCommand):
2576 """Create a tag object."""
2578 def __init__(self, context, name, revision, sign=False, message=''):
2579 super(Tag, self).__init__(context)
2580 self._name = name
2581 self._message = message
2582 self._revision = revision
2583 self._sign = sign
2585 def do(self):
2586 result = False
2587 git = self.git
2588 revision = self._revision
2589 tag_name = self._name
2590 tag_message = self._message
2592 if not revision:
2593 Interaction.critical(
2594 N_('Missing Revision'), N_('Please specify a revision to tag.')
2596 return result
2598 if not tag_name:
2599 Interaction.critical(
2600 N_('Missing Name'), N_('Please specify a name for the new tag.')
2602 return result
2604 title = N_('Missing Tag Message')
2605 message = N_('Tag-signing was requested but the tag message is empty.')
2606 info = N_(
2607 'An unsigned, lightweight tag will be created instead.\n'
2608 'Create an unsigned tag?'
2610 ok_text = N_('Create Unsigned Tag')
2611 sign = self._sign
2612 if sign and not tag_message:
2613 # We require a message in order to sign the tag, so if they
2614 # choose to create an unsigned tag we have to clear the sign flag.
2615 if not Interaction.confirm(
2616 title, message, info, ok_text, default=False, icon=icons.save()
2618 return result
2619 sign = False
2621 opts = {}
2622 tmp_file = None
2623 try:
2624 if tag_message:
2625 tmp_file = utils.tmp_filename('tag-message')
2626 opts['file'] = tmp_file
2627 core.write(tmp_file, tag_message)
2629 if sign:
2630 opts['sign'] = True
2631 if tag_message:
2632 opts['annotate'] = True
2633 status, out, err = git.tag(tag_name, revision, **opts)
2634 finally:
2635 if tmp_file:
2636 core.unlink(tmp_file)
2638 title = N_('Error: could not create tag "%s"') % tag_name
2639 Interaction.command(title, 'git tag', status, out, err)
2641 if status == 0:
2642 result = True
2643 self.model.update_status()
2644 Interaction.information(
2645 N_('Tag Created'),
2646 N_('Created a new tag named "%s"') % tag_name,
2647 details=tag_message or None,
2650 return result
2653 class Unstage(ContextCommand):
2654 """Unstage a set of paths."""
2656 @staticmethod
2657 def name():
2658 return N_('Unstage')
2660 def __init__(self, context, paths):
2661 super(Unstage, self).__init__(context)
2662 self.paths = paths
2664 def do(self):
2665 """Unstage paths"""
2666 context = self.context
2667 head = self.model.head
2668 paths = self.paths
2670 msg = N_('Unstaging: %s') % (', '.join(paths))
2671 Interaction.log(msg)
2672 if not paths:
2673 return unstage_all(context)
2674 status, out, err = gitcmds.unstage_paths(context, paths, head=head)
2675 Interaction.command(N_('Error'), 'git reset', status, out, err)
2676 self.model.update_file_status()
2677 return (status, out, err)
2680 class UnstageAll(ContextCommand):
2681 """Unstage all files; resets the index."""
2683 def do(self):
2684 return unstage_all(self.context)
2687 def unstage_all(context):
2688 """Unstage all files, even while amending"""
2689 model = context.model
2690 git = context.git
2691 head = model.head
2692 status, out, err = git.reset(head, '--', '.')
2693 Interaction.command(N_('Error'), 'git reset', status, out, err)
2694 model.update_file_status()
2695 return (status, out, err)
2698 class StageSelected(ContextCommand):
2699 """Stage selected files, or all files if no selection exists."""
2701 def do(self):
2702 context = self.context
2703 paths = self.selection.unstaged
2704 if paths:
2705 do(Stage, context, paths)
2706 elif self.cfg.get('cola.safemode', False):
2707 do(StageModified, context)
2710 class UnstageSelected(Unstage):
2711 """Unstage selected files."""
2713 def __init__(self, context):
2714 staged = context.selection.staged
2715 super(UnstageSelected, self).__init__(context, staged)
2718 class Untrack(ContextCommand):
2719 """Unstage a set of paths."""
2721 def __init__(self, context, paths):
2722 super(Untrack, self).__init__(context)
2723 self.paths = paths
2725 def do(self):
2726 msg = N_('Untracking: %s') % (', '.join(self.paths))
2727 Interaction.log(msg)
2728 status, out, err = self.model.untrack_paths(self.paths)
2729 Interaction.log_status(status, out, err)
2732 class UntrackedSummary(EditModel):
2733 """List possible .gitignore rules as the diff text."""
2735 def __init__(self, context):
2736 super(UntrackedSummary, self).__init__(context)
2737 untracked = self.model.untracked
2738 suffix = 's' if untracked else ''
2739 io = StringIO()
2740 io.write('# %s untracked file%s\n' % (len(untracked), suffix))
2741 if untracked:
2742 io.write('# possible .gitignore rule%s:\n' % suffix)
2743 for u in untracked:
2744 io.write('/' + u + '\n')
2745 self.new_diff_text = io.getvalue()
2746 self.new_diff_type = main.Types.TEXT
2747 self.new_file_type = main.Types.TEXT
2748 self.new_mode = self.model.mode_untracked
2751 class VisualizeAll(ContextCommand):
2752 """Visualize all branches."""
2754 def do(self):
2755 context = self.context
2756 browser = utils.shell_split(prefs.history_browser(context))
2757 launch_history_browser(browser + ['--all'])
2760 class VisualizeCurrent(ContextCommand):
2761 """Visualize all branches."""
2763 def do(self):
2764 context = self.context
2765 browser = utils.shell_split(prefs.history_browser(context))
2766 launch_history_browser(browser + [self.model.currentbranch] + ['--'])
2769 class VisualizePaths(ContextCommand):
2770 """Path-limited visualization."""
2772 def __init__(self, context, paths):
2773 super(VisualizePaths, self).__init__(context)
2774 context = self.context
2775 browser = utils.shell_split(prefs.history_browser(context))
2776 if paths:
2777 self.argv = browser + ['--'] + list(paths)
2778 else:
2779 self.argv = browser
2781 def do(self):
2782 launch_history_browser(self.argv)
2785 class VisualizeRevision(ContextCommand):
2786 """Visualize a specific revision."""
2788 def __init__(self, context, revision, paths=None):
2789 super(VisualizeRevision, self).__init__(context)
2790 self.revision = revision
2791 self.paths = paths
2793 def do(self):
2794 context = self.context
2795 argv = utils.shell_split(prefs.history_browser(context))
2796 if self.revision:
2797 argv.append(self.revision)
2798 if self.paths:
2799 argv.append('--')
2800 argv.extend(self.paths)
2801 launch_history_browser(argv)
2804 class SubmoduleAdd(ConfirmAction):
2805 """Add specified submodules"""
2807 def __init__(self, context, url, path, branch, depth, reference):
2808 super(SubmoduleAdd, self).__init__(context)
2809 self.url = url
2810 self.path = path
2811 self.branch = branch
2812 self.depth = depth
2813 self.reference = reference
2815 def confirm(self):
2816 title = N_('Add Submodule...')
2817 question = N_('Add this submodule?')
2818 info = N_('The submodule will be added using\n' '"%s"' % self.command())
2819 ok_txt = N_('Add Submodule')
2820 return Interaction.confirm(title, question, info, ok_txt, icon=icons.ok())
2822 def action(self):
2823 context = self.context
2824 args = self.get_args()
2825 return context.git.submodule('add', *args)
2827 def success(self):
2828 self.model.update_file_status()
2829 self.model.update_submodules_list()
2831 def error_message(self):
2832 return N_('Error updating submodule %s' % self.path)
2834 def command(self):
2835 cmd = ['git', 'submodule', 'add']
2836 cmd.extend(self.get_args())
2837 return core.list2cmdline(cmd)
2839 def get_args(self):
2840 args = []
2841 if self.branch:
2842 args.extend(['--branch', self.branch])
2843 if self.reference:
2844 args.extend(['--reference', self.reference])
2845 if self.depth:
2846 args.extend(['--depth', '%d' % self.depth])
2847 args.extend(['--', self.url])
2848 if self.path:
2849 args.append(self.path)
2850 return args
2853 class SubmoduleUpdate(ConfirmAction):
2854 """Update specified submodule"""
2856 def __init__(self, context, path):
2857 super(SubmoduleUpdate, self).__init__(context)
2858 self.path = path
2860 def confirm(self):
2861 title = N_('Update Submodule...')
2862 question = N_('Update this submodule?')
2863 info = N_('The submodule will be updated using\n' '"%s"' % self.command())
2864 ok_txt = N_('Update Submodule')
2865 return Interaction.confirm(
2866 title, question, info, ok_txt, default=False, icon=icons.pull()
2869 def action(self):
2870 context = self.context
2871 args = self.get_args()
2872 return context.git.submodule(*args)
2874 def success(self):
2875 self.model.update_file_status()
2877 def error_message(self):
2878 return N_('Error updating submodule %s' % self.path)
2880 def command(self):
2881 cmd = ['git', 'submodule']
2882 cmd.extend(self.get_args())
2883 return core.list2cmdline(cmd)
2885 def get_args(self):
2886 cmd = ['update']
2887 if version.check_git(self.context, 'submodule-update-recursive'):
2888 cmd.append('--recursive')
2889 cmd.extend(['--', self.path])
2890 return cmd
2893 class SubmodulesUpdate(ConfirmAction):
2894 """Update all submodules"""
2896 def confirm(self):
2897 title = N_('Update submodules...')
2898 question = N_('Update all submodules?')
2899 info = N_('All submodules will be updated using\n' '"%s"' % self.command())
2900 ok_txt = N_('Update Submodules')
2901 return Interaction.confirm(
2902 title, question, info, ok_txt, default=False, icon=icons.pull()
2905 def action(self):
2906 context = self.context
2907 args = self.get_args()
2908 return context.git.submodule(*args)
2910 def success(self):
2911 self.model.update_file_status()
2913 def error_message(self):
2914 return N_('Error updating submodules')
2916 def command(self):
2917 cmd = ['git', 'submodule']
2918 cmd.extend(self.get_args())
2919 return core.list2cmdline(cmd)
2921 def get_args(self):
2922 cmd = ['update']
2923 if version.check_git(self.context, 'submodule-update-recursive'):
2924 cmd.append('--recursive')
2925 return cmd
2928 def launch_history_browser(argv):
2929 """Launch the configured history browser"""
2930 try:
2931 core.fork(argv)
2932 except OSError as e:
2933 _, details = utils.format_exception(e)
2934 title = N_('Error Launching History Browser')
2935 msg = N_('Cannot exec "%s": please configure a history browser') % ' '.join(
2936 argv
2938 Interaction.critical(title, message=msg, details=details)
2941 def run(cls, *args, **opts):
2943 Returns a callback that runs a command
2945 If the caller of run() provides args or opts then those are
2946 used instead of the ones provided by the invoker of the callback.
2950 def runner(*local_args, **local_opts):
2951 """Closure return by run() which runs the command"""
2952 if args or opts:
2953 do(cls, *args, **opts)
2954 else:
2955 do(cls, *local_args, **local_opts)
2957 return runner
2960 def do(cls, *args, **opts):
2961 """Run a command in-place"""
2962 try:
2963 cmd = cls(*args, **opts)
2964 return cmd.do()
2965 except Exception as e: # pylint: disable=broad-except
2966 msg, details = utils.format_exception(e)
2967 if hasattr(cls, '__name__'):
2968 msg = '%s exception:\n%s' % (cls.__name__, msg)
2969 Interaction.critical(N_('Error'), message=msg, details=details)
2970 return None
2973 def difftool_run(context):
2974 """Start a default difftool session"""
2975 selection = context.selection
2976 files = selection.group()
2977 if not files:
2978 return
2979 s = selection.selection()
2980 head = context.model.head
2981 difftool_launch_with_head(context, files, bool(s.staged), head)
2984 def difftool_launch_with_head(context, filenames, staged, head):
2985 """Launch difftool against the provided head"""
2986 if head == 'HEAD':
2987 left = None
2988 else:
2989 left = head
2990 difftool_launch(context, left=left, staged=staged, paths=filenames)
2993 def difftool_launch(
2994 context,
2995 left=None,
2996 right=None,
2997 paths=None,
2998 staged=False,
2999 dir_diff=False,
3000 left_take_magic=False,
3001 left_take_parent=False,
3003 """Launches 'git difftool' with given parameters
3005 :param left: first argument to difftool
3006 :param right: second argument to difftool_args
3007 :param paths: paths to diff
3008 :param staged: activate `git difftool --staged`
3009 :param dir_diff: activate `git difftool --dir-diff`
3010 :param left_take_magic: whether to append the magic ^! diff expression
3011 :param left_take_parent: whether to append the first-parent ~ for diffing
3015 difftool_args = ['git', 'difftool', '--no-prompt']
3016 if staged:
3017 difftool_args.append('--cached')
3018 if dir_diff:
3019 difftool_args.append('--dir-diff')
3021 if left:
3022 if left_take_parent or left_take_magic:
3023 suffix = '^!' if left_take_magic else '~'
3024 # Check root commit (no parents and thus cannot execute '~')
3025 git = context.git
3026 status, out, err = git.rev_list(left, parents=True, n=1)
3027 Interaction.log_status(status, out, err)
3028 if status:
3029 raise OSError('git rev-list command failed')
3031 if len(out.split()) >= 2:
3032 # Commit has a parent, so we can take its child as requested
3033 left += suffix
3034 else:
3035 # No parent, assume it's the root commit, so we have to diff
3036 # against the empty tree.
3037 left = EMPTY_TREE_OID
3038 if not right and left_take_magic:
3039 right = left
3040 difftool_args.append(left)
3042 if right:
3043 difftool_args.append(right)
3045 if paths:
3046 difftool_args.append('--')
3047 difftool_args.extend(paths)
3049 runtask = context.runtask
3050 if runtask:
3051 Interaction.async_command(N_('Difftool'), difftool_args, runtask)
3052 else:
3053 core.fork(difftool_args)