icons: add style icons for apply, discard and reset
[git-cola.git] / cola / cmds.py
blob70c5021cd0de7704c8425ae2d5e7841d91847b80
1 """Editor commands"""
2 from __future__ import division, absolute_import, 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.set_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.set_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 ResetBranchHead(ResetCommand):
501 def confirm(self):
502 title = N_('Reset Branch')
503 question = N_('Point the current branch head to a new commit?')
504 info = N_('The branch will be reset using "git reset --mixed %s"')
505 ok_text = N_('Reset Branch')
506 info = info % self.ref
507 return Interaction.confirm(title, question, info, ok_text)
509 def reset(self):
510 return self.git.reset(self.ref, '--', mixed=True)
513 class ResetWorktree(ResetCommand):
514 def confirm(self):
515 title = N_('Reset Worktree')
516 question = N_('Reset worktree?')
517 info = N_('The worktree will be reset using "git reset --keep %s"')
518 ok_text = N_('Reset Worktree')
519 info = info % self.ref
520 return Interaction.confirm(title, question, info, ok_text)
522 def reset(self):
523 return self.git.reset(self.ref, '--', keep=True)
526 class ResetMerge(ResetCommand):
527 def confirm(self):
528 title = N_('Reset Merge')
529 question = N_('Reset merge?')
530 info = N_('The branch will be reset using "git reset --merge %s"')
531 ok_text = N_('Reset Merge')
532 info = info % self.ref
533 return Interaction.confirm(title, question, info, ok_text)
535 def reset(self):
536 return self.git.reset(self.ref, '--', merge=True)
539 class ResetSoft(ResetCommand):
540 def confirm(self):
541 title = N_('Reset Soft')
542 question = N_('Reset soft?')
543 info = N_('The branch will be reset using "git reset --soft %s"')
544 ok_text = N_('Reset Soft')
545 info = info % self.ref
546 return Interaction.confirm(title, question, info, ok_text)
548 def reset(self):
549 return self.git.reset(self.ref, '--', soft=True)
552 class ResetHard(ResetCommand):
553 def confirm(self):
554 title = N_('Reset Hard')
555 question = N_('Reset hard?')
556 info = N_('The branch will be reset using "git reset --hard %s"')
557 ok_text = N_('Reset Hard')
558 info = info % self.ref
559 return Interaction.confirm(title, question, info, ok_text)
561 def reset(self):
562 return self.git.reset(self.ref, '--', hard=True)
565 class UndoLastCommit(ResetCommand):
566 """Undo the last commit"""
567 # NOTE: this is the similar to ResetSoft() with an additional check for
568 # published commits and different messages.
569 def __init__(self, context):
570 super(UndoLastCommit, self).__init__(context, 'HEAD^')
572 def confirm(self):
573 check_published = prefs.check_published_commits(self.context)
574 if check_published and self.model.is_commit_published():
575 return Interaction.confirm(
576 N_('Rewrite Published Commit?'),
578 'This commit has already been published.\n'
579 'This operation will rewrite published history.\n'
580 'You probably don\'t want to do this.'
582 N_('Undo the published commit?'),
583 N_('Undo Last Commit'),
584 default=False,
585 icon=icons.save(),
588 title = N_('Undo Last Commit')
589 question = N_('Undo last commit?')
590 info = N_('The branch will be reset using "git reset --soft %s"')
591 ok_text = N_('Undo Last Commit')
592 info_text = info % self.ref
593 return Interaction.confirm(title, question, info_text, ok_text)
595 def reset(self):
596 return self.git.reset('HEAD^', '--', soft=True)
599 class Commit(ResetMode):
600 """Attempt to create a new commit."""
602 def __init__(self, context, amend, msg, sign, no_verify=False):
603 super(Commit, self).__init__(context)
604 self.amend = amend
605 self.msg = msg
606 self.sign = sign
607 self.no_verify = no_verify
608 self.old_commitmsg = self.model.commitmsg
609 self.new_commitmsg = ''
611 def do(self):
612 # Create the commit message file
613 context = self.context
614 comment_char = prefs.comment_char(context)
615 msg = self.strip_comments(self.msg, comment_char=comment_char)
616 tmp_file = utils.tmp_filename('commit-message')
617 try:
618 core.write(tmp_file, msg)
619 # Run 'git commit'
620 status, out, err = self.git.commit(
621 F=tmp_file,
622 v=True,
623 gpg_sign=self.sign,
624 amend=self.amend,
625 no_verify=self.no_verify,
627 finally:
628 core.unlink(tmp_file)
629 if status == 0:
630 super(Commit, self).do()
631 if context.cfg.get(prefs.AUTOTEMPLATE):
632 template_loader = LoadCommitMessageFromTemplate(context)
633 template_loader.do()
634 else:
635 self.model.set_commitmsg(self.new_commitmsg)
637 title = N_('Commit failed')
638 Interaction.command(title, 'git commit', status, out, err)
640 return status, out, err
642 @staticmethod
643 def strip_comments(msg, comment_char='#'):
644 # Strip off comments
645 message_lines = [
646 line for line in msg.split('\n') if not line.startswith(comment_char)
648 msg = '\n'.join(message_lines)
649 if not msg.endswith('\n'):
650 msg += '\n'
652 return msg
655 class CycleReferenceSort(ContextCommand):
656 """Choose the next reference sort type"""
658 def do(self):
659 self.model.cycle_ref_sort()
662 class Ignore(ContextCommand):
663 """Add files to an exclusion file"""
665 def __init__(self, context, filenames, local=False):
666 super(Ignore, self).__init__(context)
667 self.filenames = list(filenames)
668 self.local = local
670 def do(self):
671 if not self.filenames:
672 return
673 new_additions = '\n'.join(self.filenames) + '\n'
674 for_status = new_additions
675 if self.local:
676 filename = os.path.join('.git', 'info', 'exclude')
677 else:
678 filename = '.gitignore'
679 if core.exists(filename):
680 current_list = core.read(filename)
681 new_additions = current_list.rstrip() + '\n' + new_additions
682 core.write(filename, new_additions)
683 Interaction.log_status(0, 'Added to %s:\n%s' % (filename, for_status), '')
684 self.model.update_file_status()
687 def file_summary(files):
688 txt = core.list2cmdline(files)
689 if len(txt) > 768:
690 txt = txt[:768].rstrip() + '...'
691 wrap = textwrap.TextWrapper()
692 return '\n'.join(wrap.wrap(txt))
695 class RemoteCommand(ConfirmAction):
696 def __init__(self, context, remote):
697 super(RemoteCommand, self).__init__(context)
698 self.remote = remote
700 def success(self):
701 self.cfg.reset()
702 self.model.update_remotes()
705 class RemoteAdd(RemoteCommand):
706 def __init__(self, context, remote, url):
707 super(RemoteAdd, self).__init__(context, remote)
708 self.url = url
710 def action(self):
711 return self.git.remote('add', self.remote, self.url)
713 def error_message(self):
714 return N_('Error creating remote "%s"') % self.remote
716 def command(self):
717 return 'git remote add "%s" "%s"' % (self.remote, self.url)
720 class RemoteRemove(RemoteCommand):
721 def confirm(self):
722 title = N_('Delete Remote')
723 question = N_('Delete remote?')
724 info = N_('Delete remote "%s"') % self.remote
725 ok_text = N_('Delete')
726 return Interaction.confirm(title, question, info, ok_text)
728 def action(self):
729 return self.git.remote('rm', self.remote)
731 def error_message(self):
732 return N_('Error deleting remote "%s"') % self.remote
734 def command(self):
735 return 'git remote rm "%s"' % self.remote
738 class RemoteRename(RemoteCommand):
739 def __init__(self, context, remote, new_name):
740 super(RemoteRename, self).__init__(context, remote)
741 self.new_name = new_name
743 def confirm(self):
744 title = N_('Rename Remote')
745 text = N_('Rename remote "%(current)s" to "%(new)s"?') % dict(
746 current=self.remote, new=self.new_name
748 info_text = ''
749 ok_text = title
750 return Interaction.confirm(title, text, info_text, ok_text)
752 def action(self):
753 return self.git.remote('rename', self.remote, self.new_name)
755 def error_message(self):
756 return N_('Error renaming "%(name)s" to "%(new_name)s"') % dict(
757 name=self.remote, new_name=self.new_name
760 def command(self):
761 return 'git remote rename "%s" "%s"' % (self.remote, self.new_name)
764 class RemoteSetURL(RemoteCommand):
765 def __init__(self, context, remote, url):
766 super(RemoteSetURL, self).__init__(context, remote)
767 self.url = url
769 def action(self):
770 return self.git.remote('set-url', self.remote, self.url)
772 def error_message(self):
773 return N_('Unable to set URL for "%(name)s" to "%(url)s"') % dict(
774 name=self.remote, url=self.url
777 def command(self):
778 return 'git remote set-url "%s" "%s"' % (self.remote, self.url)
781 class RemoteEdit(ContextCommand):
782 """Combine RemoteRename and RemoteSetURL"""
784 def __init__(self, context, old_name, remote, url):
785 super(RemoteEdit, self).__init__(context)
786 self.rename = RemoteRename(context, old_name, remote)
787 self.set_url = RemoteSetURL(context, remote, url)
789 def do(self):
790 result = self.rename.do()
791 name_ok = result[0]
792 url_ok = False
793 if name_ok:
794 result = self.set_url.do()
795 url_ok = result[0]
796 return name_ok, url_ok
799 class RemoveFromSettings(ConfirmAction):
801 def __init__(self, context, repo, entry, icon=None):
802 super(RemoveFromSettings, self).__init__(context)
803 self.context = context
804 self.repo = repo
805 self.entry = entry
806 self.icon = icon
808 def success(self):
809 self.context.settings.save()
812 class RemoveBookmark(RemoveFromSettings):
814 def confirm(self):
815 entry = self.entry
816 title = msg = N_('Delete Bookmark?')
817 info = N_('%s will be removed from your bookmarks.') % entry
818 ok_text = N_('Delete Bookmark')
819 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
821 def action(self):
822 self.context.settings.remove_bookmark(self.repo, self.entry)
823 return (0, '', '')
826 class RemoveRecent(RemoveFromSettings):
827 def confirm(self):
828 repo = self.repo
829 title = msg = N_('Remove %s from the recent list?') % repo
830 info = N_('%s will be removed from your recent repositories.') % repo
831 ok_text = N_('Remove')
832 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
834 def action(self):
835 self.context.settings.remove_recent(self.repo)
836 return (0, '', '')
839 class RemoveFiles(ContextCommand):
840 """Removes files"""
842 def __init__(self, context, remover, filenames):
843 super(RemoveFiles, self).__init__(context)
844 if remover is None:
845 remover = os.remove
846 self.remover = remover
847 self.filenames = filenames
848 # We could git-hash-object stuff and provide undo-ability
849 # as an option. Heh.
851 def do(self):
852 files = self.filenames
853 if not files:
854 return
856 rescan = False
857 bad_filenames = []
858 remove = self.remover
859 for filename in files:
860 if filename:
861 try:
862 remove(filename)
863 rescan = True
864 except OSError:
865 bad_filenames.append(filename)
867 if bad_filenames:
868 Interaction.information(
869 N_('Error'), N_('Deleting "%s" failed') % file_summary(bad_filenames)
872 if rescan:
873 self.model.update_file_status()
876 class Delete(RemoveFiles):
877 """Delete files."""
879 def __init__(self, context, filenames):
880 super(Delete, self).__init__(context, os.remove, filenames)
882 def do(self):
883 files = self.filenames
884 if not files:
885 return
887 title = N_('Delete Files?')
888 msg = N_('The following files will be deleted:') + '\n\n'
889 msg += file_summary(files)
890 info_txt = N_('Delete %d file(s)?') % len(files)
891 ok_txt = N_('Delete Files')
893 if Interaction.confirm(
894 title, msg, info_txt, ok_txt, default=True, icon=icons.remove()
896 super(Delete, self).do()
899 class MoveToTrash(RemoveFiles):
900 """Move files to the trash using send2trash"""
902 AVAILABLE = send2trash is not None
904 def __init__(self, context, filenames):
905 super(MoveToTrash, self).__init__(context, send2trash, filenames)
908 class DeleteBranch(ConfirmAction):
909 """Delete a git branch."""
911 def __init__(self, context, branch):
912 super(DeleteBranch, self).__init__(context)
913 self.branch = branch
915 def confirm(self):
916 title = N_('Delete Branch')
917 question = N_('Delete branch "%s"?') % self.branch
918 info = N_('The branch will be no longer available.')
919 ok_txt = N_('Delete Branch')
920 return Interaction.confirm(
921 title, question, info, ok_txt, default=True, icon=icons.discard()
924 def action(self):
925 return self.model.delete_branch(self.branch)
927 def error_message(self):
928 return N_('Error deleting branch "%s"' % self.branch)
930 def command(self):
931 command = 'git branch -D %s'
932 return command % self.branch
935 class Rename(ContextCommand):
936 """Rename a set of paths."""
938 def __init__(self, context, paths):
939 super(Rename, self).__init__(context)
940 self.paths = paths
942 def do(self):
943 msg = N_('Untracking: %s') % (', '.join(self.paths))
944 Interaction.log(msg)
946 for path in self.paths:
947 ok = self.rename(path)
948 if not ok:
949 return
951 self.model.update_status()
953 def rename(self, path):
954 git = self.git
955 title = N_('Rename "%s"') % path
957 if os.path.isdir(path):
958 base_path = os.path.dirname(path)
959 else:
960 base_path = path
961 new_path = Interaction.save_as(base_path, title)
962 if not new_path:
963 return False
965 status, out, err = git.mv(path, new_path, force=True, verbose=True)
966 Interaction.command(N_('Error'), 'git mv', status, out, err)
967 return status == 0
970 class RenameBranch(ContextCommand):
971 """Rename a git branch."""
973 def __init__(self, context, branch, new_branch):
974 super(RenameBranch, self).__init__(context)
975 self.branch = branch
976 self.new_branch = new_branch
978 def do(self):
979 branch = self.branch
980 new_branch = self.new_branch
981 status, out, err = self.model.rename_branch(branch, new_branch)
982 Interaction.log_status(status, out, err)
985 class DeleteRemoteBranch(DeleteBranch):
986 """Delete a remote git branch."""
988 def __init__(self, context, remote, branch):
989 super(DeleteRemoteBranch, self).__init__(context, branch)
990 self.remote = remote
992 def action(self):
993 return self.git.push(self.remote, self.branch, delete=True)
995 def success(self):
996 self.model.update_status()
997 Interaction.information(
998 N_('Remote Branch Deleted'),
999 N_('"%(branch)s" has been deleted from "%(remote)s".')
1000 % dict(branch=self.branch, remote=self.remote),
1003 def error_message(self):
1004 return N_('Error Deleting Remote Branch')
1006 def command(self):
1007 command = 'git push --delete %s %s'
1008 return command % (self.remote, self.branch)
1011 def get_mode(model, staged, modified, unmerged, untracked):
1012 if staged:
1013 mode = model.mode_index
1014 elif modified or unmerged:
1015 mode = model.mode_worktree
1016 elif untracked:
1017 mode = model.mode_untracked
1018 else:
1019 mode = model.mode
1020 return mode
1023 class DiffText(EditModel):
1024 """Set the diff type to text"""
1026 def __init__(self, context):
1027 super(DiffText, self).__init__(context)
1028 self.new_file_type = main.Types.TEXT
1029 self.new_diff_type = main.Types.TEXT
1032 class ToggleDiffType(ContextCommand):
1033 """Toggle the diff type between image and text"""
1035 def __init__(self, context):
1036 super(ToggleDiffType, self).__init__(context)
1037 if self.model.diff_type == main.Types.IMAGE:
1038 self.new_diff_type = main.Types.TEXT
1039 self.new_value = False
1040 else:
1041 self.new_diff_type = main.Types.IMAGE
1042 self.new_value = True
1044 def do(self):
1045 diff_type = self.new_diff_type
1046 value = self.new_value
1048 self.model.set_diff_type(diff_type)
1050 filename = self.model.filename
1051 _, ext = os.path.splitext(filename)
1052 if ext.startswith('.'):
1053 cfg = 'cola.imagediff' + ext
1054 self.cfg.set_repo(cfg, value)
1057 class DiffImage(EditModel):
1058 def __init__(
1059 self, context, filename, deleted, staged, modified, unmerged, untracked
1061 super(DiffImage, self).__init__(context)
1063 self.new_filename = filename
1064 self.new_diff_type = self.get_diff_type(filename)
1065 self.new_file_type = main.Types.IMAGE
1066 self.new_mode = get_mode(self.model, staged, modified, unmerged, untracked)
1067 self.staged = staged
1068 self.modified = modified
1069 self.unmerged = unmerged
1070 self.untracked = untracked
1071 self.deleted = deleted
1072 self.annex = self.cfg.is_annex()
1074 def get_diff_type(self, filename):
1075 """Query the diff type to use based on cola.imagediff.<extension>"""
1076 _, ext = os.path.splitext(filename)
1077 if ext.startswith('.'):
1078 # Check eg. "cola.imagediff.svg" to see if we should imagediff.
1079 cfg = 'cola.imagediff' + ext
1080 if self.cfg.get(cfg, True):
1081 result = main.Types.IMAGE
1082 else:
1083 result = main.Types.TEXT
1084 else:
1085 result = main.Types.IMAGE
1086 return result
1088 def do(self):
1089 filename = self.new_filename
1091 if self.staged:
1092 images = self.staged_images()
1093 elif self.modified:
1094 images = self.modified_images()
1095 elif self.unmerged:
1096 images = self.unmerged_images()
1097 elif self.untracked:
1098 images = [(filename, False)]
1099 else:
1100 images = []
1102 self.model.set_images(images)
1103 super(DiffImage, self).do()
1105 def staged_images(self):
1106 context = self.context
1107 git = self.git
1108 head = self.model.head
1109 filename = self.new_filename
1110 annex = self.annex
1112 images = []
1113 index = git.diff_index(head, '--', filename, cached=True)[STDOUT]
1114 if index:
1115 # Example:
1116 # :100644 100644 fabadb8... 4866510... M describe.c
1117 parts = index.split(' ')
1118 if len(parts) > 3:
1119 old_oid = parts[2]
1120 new_oid = parts[3]
1122 if old_oid != MISSING_BLOB_OID:
1123 # First, check if we can get a pre-image from git-annex
1124 annex_image = None
1125 if annex:
1126 annex_image = gitcmds.annex_path(context, head, filename)
1127 if annex_image:
1128 images.append((annex_image, False)) # git annex HEAD
1129 else:
1130 image = gitcmds.write_blob_path(context, head, old_oid, filename)
1131 if image:
1132 images.append((image, True))
1134 if new_oid != MISSING_BLOB_OID:
1135 found_in_annex = False
1136 if annex and core.islink(filename):
1137 status, out, _ = git.annex('status', '--', filename)
1138 if status == 0:
1139 details = out.split(' ')
1140 if details and details[0] == 'A': # newly added file
1141 images.append((filename, False))
1142 found_in_annex = True
1144 if not found_in_annex:
1145 image = gitcmds.write_blob(context, new_oid, filename)
1146 if image:
1147 images.append((image, True))
1149 return images
1151 def unmerged_images(self):
1152 context = self.context
1153 git = self.git
1154 head = self.model.head
1155 filename = self.new_filename
1156 annex = self.annex
1158 candidate_merge_heads = ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1159 merge_heads = [
1160 merge_head
1161 for merge_head in candidate_merge_heads
1162 if core.exists(git.git_path(merge_head))
1165 if annex: # Attempt to find files in git-annex
1166 annex_images = []
1167 for merge_head in merge_heads:
1168 image = gitcmds.annex_path(context, merge_head, filename)
1169 if image:
1170 annex_images.append((image, False))
1171 if annex_images:
1172 annex_images.append((filename, False))
1173 return annex_images
1175 # DIFF FORMAT FOR MERGES
1176 # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1177 # can take -c or --cc option to generate diff output also
1178 # for merge commits. The output differs from the format
1179 # described above in the following way:
1181 # 1. there is a colon for each parent
1182 # 2. there are more "src" modes and "src" sha1
1183 # 3. status is concatenated status characters for each parent
1184 # 4. no optional "score" number
1185 # 5. single path, only for "dst"
1186 # Example:
1187 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1188 # MM describe.c
1189 images = []
1190 index = git.diff_index(head, '--', filename, cached=True, cc=True)[STDOUT]
1191 if index:
1192 parts = index.split(' ')
1193 if len(parts) > 3:
1194 first_mode = parts[0]
1195 num_parents = first_mode.count(':')
1196 # colon for each parent, but for the index, the "parents"
1197 # are really entries in stages 1,2,3 (head, base, remote)
1198 # remote, base, head
1199 for i in range(num_parents):
1200 offset = num_parents + i + 1
1201 oid = parts[offset]
1202 try:
1203 merge_head = merge_heads[i]
1204 except IndexError:
1205 merge_head = 'HEAD'
1206 if oid != MISSING_BLOB_OID:
1207 image = gitcmds.write_blob_path(
1208 context, merge_head, oid, filename
1210 if image:
1211 images.append((image, True))
1213 images.append((filename, False))
1214 return images
1216 def modified_images(self):
1217 context = self.context
1218 git = self.git
1219 head = self.model.head
1220 filename = self.new_filename
1221 annex = self.annex
1223 images = []
1224 annex_image = None
1225 if annex: # Check for a pre-image from git-annex
1226 annex_image = gitcmds.annex_path(context, head, filename)
1227 if annex_image:
1228 images.append((annex_image, False)) # git annex HEAD
1229 else:
1230 worktree = git.diff_files('--', filename)[STDOUT]
1231 parts = worktree.split(' ')
1232 if len(parts) > 3:
1233 oid = parts[2]
1234 if oid != MISSING_BLOB_OID:
1235 image = gitcmds.write_blob_path(context, head, oid, filename)
1236 if image:
1237 images.append((image, True)) # HEAD
1239 images.append((filename, False)) # worktree
1240 return images
1243 class Diff(EditModel):
1244 """Perform a diff and set the model's current text."""
1246 def __init__(self, context, filename, cached=False, deleted=False):
1247 super(Diff, self).__init__(context)
1248 opts = {}
1249 if cached:
1250 opts['ref'] = self.model.head
1251 self.new_filename = filename
1252 self.new_mode = self.model.mode_worktree
1253 self.new_diff_text = gitcmds.diff_helper(
1254 self.context, filename=filename, cached=cached, deleted=deleted, **opts
1258 class Diffstat(EditModel):
1259 """Perform a diffstat and set the model's diff text."""
1261 def __init__(self, context):
1262 super(Diffstat, self).__init__(context)
1263 cfg = self.cfg
1264 diff_context = cfg.get('diff.context', 3)
1265 diff = self.git.diff(
1266 self.model.head,
1267 unified=diff_context,
1268 no_ext_diff=True,
1269 no_color=True,
1270 M=True,
1271 stat=True,
1272 )[STDOUT]
1273 self.new_diff_text = diff
1274 self.new_diff_type = main.Types.TEXT
1275 self.new_file_type = main.Types.TEXT
1276 self.new_mode = self.model.mode_diffstat
1279 class DiffStaged(Diff):
1280 """Perform a staged diff on a file."""
1282 def __init__(self, context, filename, deleted=None):
1283 super(DiffStaged, self).__init__(
1284 context, filename, cached=True, deleted=deleted
1286 self.new_mode = self.model.mode_index
1289 class DiffStagedSummary(EditModel):
1290 def __init__(self, context):
1291 super(DiffStagedSummary, self).__init__(context)
1292 diff = self.git.diff(
1293 self.model.head,
1294 cached=True,
1295 no_color=True,
1296 no_ext_diff=True,
1297 patch_with_stat=True,
1298 M=True,
1299 )[STDOUT]
1300 self.new_diff_text = diff
1301 self.new_diff_type = main.Types.TEXT
1302 self.new_file_type = main.Types.TEXT
1303 self.new_mode = self.model.mode_index
1306 class Difftool(ContextCommand):
1307 """Run git-difftool limited by path."""
1309 def __init__(self, context, staged, filenames):
1310 super(Difftool, self).__init__(context)
1311 self.staged = staged
1312 self.filenames = filenames
1314 def do(self):
1315 difftool_launch_with_head(
1316 self.context, self.filenames, self.staged, self.model.head
1320 class Edit(ContextCommand):
1321 """Edit a file using the configured gui.editor."""
1323 @staticmethod
1324 def name():
1325 return N_('Launch Editor')
1327 def __init__(self, context, filenames, line_number=None, background_editor=False):
1328 super(Edit, self).__init__(context)
1329 self.filenames = filenames
1330 self.line_number = line_number
1331 self.background_editor = background_editor
1333 def do(self):
1334 context = self.context
1335 if not self.filenames:
1336 return
1337 filename = self.filenames[0]
1338 if not core.exists(filename):
1339 return
1340 if self.background_editor:
1341 editor = prefs.background_editor(context)
1342 else:
1343 editor = prefs.editor(context)
1344 opts = []
1346 if self.line_number is None:
1347 opts = self.filenames
1348 else:
1349 # Single-file w/ line-numbers (likely from grep)
1350 editor_opts = {
1351 '*vim*': [filename, '+%s' % self.line_number],
1352 '*emacs*': ['+%s' % self.line_number, filename],
1353 '*textpad*': ['%s(%s,0)' % (filename, self.line_number)],
1354 '*notepad++*': ['-n%s' % self.line_number, filename],
1355 '*subl*': ['%s:%s' % (filename, self.line_number)],
1358 opts = self.filenames
1359 for pattern, opt in editor_opts.items():
1360 if fnmatch(editor, pattern):
1361 opts = opt
1362 break
1364 try:
1365 core.fork(utils.shell_split(editor) + opts)
1366 except (OSError, ValueError) as e:
1367 message = N_('Cannot exec "%s": please configure your editor') % editor
1368 _, details = utils.format_exception(e)
1369 Interaction.critical(N_('Error Editing File'), message, details)
1372 class FormatPatch(ContextCommand):
1373 """Output a patch series given all revisions and a selected subset."""
1375 def __init__(self, context, to_export, revs, output='patches'):
1376 super(FormatPatch, self).__init__(context)
1377 self.to_export = list(to_export)
1378 self.revs = list(revs)
1379 self.output = output
1381 def do(self):
1382 context = self.context
1383 status, out, err = gitcmds.format_patchsets(
1384 context, self.to_export, self.revs, self.output
1386 Interaction.log_status(status, out, err)
1389 class LaunchDifftool(ContextCommand):
1390 @staticmethod
1391 def name():
1392 return N_('Launch Diff Tool')
1394 def do(self):
1395 s = self.selection.selection()
1396 if s.unmerged:
1397 paths = s.unmerged
1398 if utils.is_win32():
1399 core.fork(['git', 'mergetool', '--no-prompt', '--'] + paths)
1400 else:
1401 cfg = self.cfg
1402 cmd = cfg.terminal()
1403 argv = utils.shell_split(cmd)
1405 terminal = os.path.basename(argv[0])
1406 shellquote_terms = set(['xfce4-terminal'])
1407 shellquote_default = terminal in shellquote_terms
1409 mergetool = ['git', 'mergetool', '--no-prompt', '--']
1410 mergetool.extend(paths)
1411 needs_shellquote = cfg.get(
1412 'cola.terminalshellquote', shellquote_default
1415 if needs_shellquote:
1416 argv.append(core.list2cmdline(mergetool))
1417 else:
1418 argv.extend(mergetool)
1420 core.fork(argv)
1421 else:
1422 difftool_run(self.context)
1425 class LaunchTerminal(ContextCommand):
1426 @staticmethod
1427 def name():
1428 return N_('Launch Terminal')
1430 @staticmethod
1431 def is_available(context):
1432 return context.cfg.terminal() is not None
1434 def __init__(self, context, path):
1435 super(LaunchTerminal, self).__init__(context)
1436 self.path = path
1438 def do(self):
1439 cmd = self.context.cfg.terminal()
1440 if cmd is None:
1441 return
1442 if utils.is_win32():
1443 argv = ['start', '', cmd, '--login']
1444 shell = True
1445 else:
1446 argv = utils.shell_split(cmd)
1447 argv.append(os.getenv('SHELL', '/bin/sh'))
1448 shell = False
1449 core.fork(argv, cwd=self.path, shell=shell)
1452 class LaunchEditor(Edit):
1453 @staticmethod
1454 def name():
1455 return N_('Launch Editor')
1457 def __init__(self, context):
1458 s = context.selection.selection()
1459 filenames = s.staged + s.unmerged + s.modified + s.untracked
1460 super(LaunchEditor, self).__init__(context, filenames, background_editor=True)
1463 class LaunchEditorAtLine(LaunchEditor):
1464 """Launch an editor at the specified line"""
1466 def __init__(self, context):
1467 super(LaunchEditorAtLine, self).__init__(context)
1468 self.line_number = context.selection.line_number
1471 class LoadCommitMessageFromFile(ContextCommand):
1472 """Loads a commit message from a path."""
1474 UNDOABLE = True
1476 def __init__(self, context, path):
1477 super(LoadCommitMessageFromFile, self).__init__(context)
1478 self.path = path
1479 self.old_commitmsg = self.model.commitmsg
1480 self.old_directory = self.model.directory
1482 def do(self):
1483 path = os.path.expanduser(self.path)
1484 if not path or not core.isfile(path):
1485 raise UsageError(
1486 N_('Error: Cannot find commit template'),
1487 N_('%s: No such file or directory.') % path,
1489 self.model.set_directory(os.path.dirname(path))
1490 self.model.set_commitmsg(core.read(path))
1492 def undo(self):
1493 self.model.set_commitmsg(self.old_commitmsg)
1494 self.model.set_directory(self.old_directory)
1497 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile):
1498 """Loads the commit message template specified by commit.template."""
1500 def __init__(self, context):
1501 cfg = context.cfg
1502 template = cfg.get('commit.template')
1503 super(LoadCommitMessageFromTemplate, self).__init__(context, template)
1505 def do(self):
1506 if self.path is None:
1507 raise UsageError(
1508 N_('Error: Unconfigured commit template'),
1510 'A commit template has not been configured.\n'
1511 'Use "git config" to define "commit.template"\n'
1512 'so that it points to a commit template.'
1515 return LoadCommitMessageFromFile.do(self)
1518 class LoadCommitMessageFromOID(ContextCommand):
1519 """Load a previous commit message"""
1521 UNDOABLE = True
1523 def __init__(self, context, oid, prefix=''):
1524 super(LoadCommitMessageFromOID, self).__init__(context)
1525 self.oid = oid
1526 self.old_commitmsg = self.model.commitmsg
1527 self.new_commitmsg = prefix + gitcmds.prev_commitmsg(context, oid)
1529 def do(self):
1530 self.model.set_commitmsg(self.new_commitmsg)
1532 def undo(self):
1533 self.model.set_commitmsg(self.old_commitmsg)
1536 class PrepareCommitMessageHook(ContextCommand):
1537 """Use the cola-prepare-commit-msg hook to prepare the commit message
1540 UNDOABLE = True
1542 def __init__(self, context):
1543 super(PrepareCommitMessageHook, self).__init__(context)
1544 self.old_commitmsg = self.model.commitmsg
1546 def get_message(self):
1548 title = N_('Error running prepare-commitmsg hook')
1549 hook = gitcmds.prepare_commit_message_hook(self.context)
1551 if os.path.exists(hook):
1552 filename = self.model.save_commitmsg()
1553 status, out, err = core.run_command([hook, filename])
1555 if status == 0:
1556 result = core.read(filename)
1557 else:
1558 result = self.old_commitmsg
1559 Interaction.command_error(title, hook, status, out, err)
1560 else:
1561 message = N_('A hook must be provided at "%s"') % hook
1562 Interaction.critical(title, message=message)
1563 result = self.old_commitmsg
1565 return result
1567 def do(self):
1568 msg = self.get_message()
1569 self.model.set_commitmsg(msg)
1571 def undo(self):
1572 self.model.set_commitmsg(self.old_commitmsg)
1575 class LoadFixupMessage(LoadCommitMessageFromOID):
1576 """Load a fixup message"""
1578 def __init__(self, context, oid):
1579 super(LoadFixupMessage, self).__init__(context, oid, prefix='fixup! ')
1580 if self.new_commitmsg:
1581 self.new_commitmsg = self.new_commitmsg.splitlines()[0]
1584 class Merge(ContextCommand):
1585 """Merge commits"""
1587 def __init__(self, context, revision, no_commit, squash, no_ff, sign):
1588 super(Merge, self).__init__(context)
1589 self.revision = revision
1590 self.no_ff = no_ff
1591 self.no_commit = no_commit
1592 self.squash = squash
1593 self.sign = sign
1595 def do(self):
1596 squash = self.squash
1597 revision = self.revision
1598 no_ff = self.no_ff
1599 no_commit = self.no_commit
1600 sign = self.sign
1602 status, out, err = self.git.merge(
1603 revision, gpg_sign=sign, no_ff=no_ff, no_commit=no_commit, squash=squash
1605 self.model.update_status()
1606 title = N_('Merge failed. Conflict resolution is required.')
1607 Interaction.command(title, 'git merge', status, out, err)
1609 return status, out, err
1612 class OpenDefaultApp(ContextCommand):
1613 """Open a file using the OS default."""
1615 @staticmethod
1616 def name():
1617 return N_('Open Using Default Application')
1619 def __init__(self, context, filenames):
1620 super(OpenDefaultApp, self).__init__(context)
1621 if utils.is_darwin():
1622 launcher = 'open'
1623 else:
1624 launcher = 'xdg-open'
1625 self.launcher = launcher
1626 self.filenames = filenames
1628 def do(self):
1629 if not self.filenames:
1630 return
1631 core.fork([self.launcher] + self.filenames)
1634 class OpenParentDir(OpenDefaultApp):
1635 """Open parent directories using the OS default."""
1637 @staticmethod
1638 def name():
1639 return N_('Open Parent Directory')
1641 def __init__(self, context, filenames):
1642 OpenDefaultApp.__init__(self, context, filenames)
1644 def do(self):
1645 if not self.filenames:
1646 return
1647 dirnames = list(set([os.path.dirname(x) for x in self.filenames]))
1648 # os.path.dirname() can return an empty string so we fallback to
1649 # the current directory
1650 dirs = [(dirname or core.getcwd()) for dirname in dirnames]
1651 core.fork([self.launcher] + dirs)
1654 class OpenNewRepo(ContextCommand):
1655 """Launches git-cola on a repo."""
1657 def __init__(self, context, repo_path):
1658 super(OpenNewRepo, self).__init__(context)
1659 self.repo_path = repo_path
1661 def do(self):
1662 self.model.set_directory(self.repo_path)
1663 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
1666 class OpenRepo(EditModel):
1667 def __init__(self, context, repo_path):
1668 super(OpenRepo, self).__init__(context)
1669 self.repo_path = repo_path
1670 self.new_mode = self.model.mode_none
1671 self.new_diff_text = ''
1672 self.new_diff_type = main.Types.TEXT
1673 self.new_file_type = main.Types.TEXT
1674 self.new_commitmsg = ''
1675 self.new_filename = ''
1677 def do(self):
1678 old_repo = self.git.getcwd()
1679 if self.model.set_worktree(self.repo_path):
1680 self.fsmonitor.stop()
1681 self.fsmonitor.start()
1682 self.model.update_status()
1683 # Check if template should be loaded
1684 if self.context.cfg.get(prefs.AUTOTEMPLATE):
1685 template_loader = LoadCommitMessageFromTemplate(self.context)
1686 template_loader.do()
1687 else:
1688 self.model.set_commitmsg(self.new_commitmsg)
1689 settings = self.context.settings
1690 settings.load()
1691 settings.add_recent(self.repo_path, prefs.maxrecent(self.context))
1692 settings.save()
1693 super(OpenRepo, self).do()
1694 else:
1695 self.model.set_worktree(old_repo)
1698 class OpenParentRepo(OpenRepo):
1699 def __init__(self, context):
1700 path = ''
1701 if version.check_git(context, 'show-superproject-working-tree'):
1702 status, out, _ = context.git.rev_parse(show_superproject_working_tree=True)
1703 if status == 0:
1704 path = out
1705 if not path:
1706 path = os.path.dirname(core.getcwd())
1707 super(OpenParentRepo, self).__init__(context, path)
1710 class Clone(ContextCommand):
1711 """Clones a repository and optionally spawns a new cola session."""
1713 def __init__(
1714 self, context, url, new_directory, submodules=False, shallow=False, spawn=True
1716 super(Clone, self).__init__(context)
1717 self.url = url
1718 self.new_directory = new_directory
1719 self.submodules = submodules
1720 self.shallow = shallow
1721 self.spawn = spawn
1722 self.status = -1
1723 self.out = ''
1724 self.err = ''
1726 def do(self):
1727 kwargs = {}
1728 if self.shallow:
1729 kwargs['depth'] = 1
1730 recurse_submodules = self.submodules
1731 shallow_submodules = self.submodules and self.shallow
1733 status, out, err = self.git.clone(
1734 self.url,
1735 self.new_directory,
1736 recurse_submodules=recurse_submodules,
1737 shallow_submodules=shallow_submodules,
1738 **kwargs
1741 self.status = status
1742 self.out = out
1743 self.err = err
1744 if status == 0 and self.spawn:
1745 executable = sys.executable
1746 core.fork([executable, sys.argv[0], '--repo', self.new_directory])
1747 return self
1750 class NewBareRepo(ContextCommand):
1751 """Create a new shared bare repository"""
1753 def __init__(self, context, path):
1754 super(NewBareRepo, self).__init__(context)
1755 self.path = path
1757 def do(self):
1758 path = self.path
1759 status, out, err = self.git.init(path, bare=True, shared=True)
1760 Interaction.command(
1761 N_('Error'), 'git init --bare --shared "%s"' % path, status, out, err
1763 return status == 0
1766 def unix_path(path, is_win32=utils.is_win32):
1767 """Git for Windows requires unix paths, so force them here
1769 if is_win32():
1770 path = path.replace('\\', '/')
1771 first = path[0]
1772 second = path[1]
1773 if second == ':': # sanity check, this better be a Windows-style path
1774 path = '/' + first + path[2:]
1776 return path
1779 def sequence_editor():
1780 """Set GIT_SEQUENCE_EDITOR for running git-cola-sequence-editor"""
1781 xbase = unix_path(resources.command('git-cola-sequence-editor'))
1782 editor = core.list2cmdline([unix_path(sys.executable), xbase])
1783 return editor
1786 class SequenceEditorEnvironment(object):
1787 """Set environment variables to enable git-cola-sequence-editor"""
1789 def __init__(self, context, **kwargs):
1790 self.env = {
1791 'GIT_EDITOR': prefs.editor(context),
1792 'GIT_SEQUENCE_EDITOR': sequence_editor(),
1793 'GIT_COLA_SEQ_EDITOR_CANCEL_ACTION': 'save',
1795 self.env.update(kwargs)
1797 def __enter__(self):
1798 for var, value in self.env.items():
1799 compat.setenv(var, value)
1800 return self
1802 def __exit__(self, exc_type, exc_val, exc_tb):
1803 for var in self.env:
1804 compat.unsetenv(var)
1807 class Rebase(ContextCommand):
1808 def __init__(self, context, upstream=None, branch=None, **kwargs):
1809 """Start an interactive rebase session
1811 :param upstream: upstream branch
1812 :param branch: optional branch to checkout
1813 :param kwargs: forwarded directly to `git.rebase()`
1816 super(Rebase, self).__init__(context)
1818 self.upstream = upstream
1819 self.branch = branch
1820 self.kwargs = kwargs
1822 def prepare_arguments(self, upstream):
1823 args = []
1824 kwargs = {}
1826 # Rebase actions must be the only option specified
1827 for action in ('continue', 'abort', 'skip', 'edit_todo'):
1828 if self.kwargs.get(action, False):
1829 kwargs[action] = self.kwargs[action]
1830 return args, kwargs
1832 kwargs['interactive'] = True
1833 kwargs['autosquash'] = self.kwargs.get('autosquash', True)
1834 kwargs.update(self.kwargs)
1836 if upstream:
1837 args.append(upstream)
1838 if self.branch:
1839 args.append(self.branch)
1841 return args, kwargs
1843 def do(self):
1844 (status, out, err) = (1, '', '')
1845 context = self.context
1846 cfg = self.cfg
1847 model = self.model
1849 if not cfg.get('rebase.autostash', False):
1850 if model.staged or model.unmerged or model.modified:
1851 Interaction.information(
1852 N_('Unable to rebase'),
1853 N_('You cannot rebase with uncommitted changes.'),
1855 return status, out, err
1857 upstream = self.upstream or Interaction.choose_ref(
1858 context,
1859 N_('Select New Upstream'),
1860 N_('Interactive Rebase'),
1861 default='@{upstream}',
1863 if not upstream:
1864 return status, out, err
1866 self.model.is_rebasing = True
1867 self.model.emit_updated()
1869 args, kwargs = self.prepare_arguments(upstream)
1870 upstream_title = upstream or '@{upstream}'
1871 with SequenceEditorEnvironment(
1872 self.context,
1873 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase onto %s') % upstream_title,
1874 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
1876 # TODO this blocks the user interface window for the duration
1877 # of git-cola-sequence-editor. We would need to implement
1878 # signals for QProcess and continue running the main thread.
1879 # Alternatively, we can hide the main window while rebasing.
1880 # That doesn't require as much effort.
1881 status, out, err = self.git.rebase(
1882 *args, _no_win32_startupinfo=True, **kwargs
1884 self.model.update_status()
1885 if err.strip() != 'Nothing to do':
1886 title = N_('Rebase stopped')
1887 Interaction.command(title, 'git rebase', status, out, err)
1888 return status, out, err
1891 class RebaseEditTodo(ContextCommand):
1892 def do(self):
1893 (status, out, err) = (1, '', '')
1894 with SequenceEditorEnvironment(
1895 self.context,
1896 GIT_COLA_SEQ_EDITOR_TITLE=N_('Edit Rebase'),
1897 GIT_COLA_SEQ_EDITOR_ACTION=N_('Save'),
1899 status, out, err = self.git.rebase(edit_todo=True)
1900 Interaction.log_status(status, out, err)
1901 self.model.update_status()
1902 return status, out, err
1905 class RebaseContinue(ContextCommand):
1906 def do(self):
1907 (status, out, err) = (1, '', '')
1908 with SequenceEditorEnvironment(
1909 self.context,
1910 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase'),
1911 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
1913 status, out, err = self.git.rebase('--continue')
1914 Interaction.log_status(status, out, err)
1915 self.model.update_status()
1916 return status, out, err
1919 class RebaseSkip(ContextCommand):
1920 def do(self):
1921 (status, out, err) = (1, '', '')
1922 with SequenceEditorEnvironment(
1923 self.context,
1924 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase'),
1925 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
1927 status, out, err = self.git.rebase(skip=True)
1928 Interaction.log_status(status, out, err)
1929 self.model.update_status()
1930 return status, out, err
1933 class RebaseAbort(ContextCommand):
1934 def do(self):
1935 status, out, err = self.git.rebase(abort=True)
1936 Interaction.log_status(status, out, err)
1937 self.model.update_status()
1940 class Rescan(ContextCommand):
1941 """Rescan for changes"""
1943 def do(self):
1944 self.model.update_status()
1947 class Refresh(ContextCommand):
1948 """Update refs, refresh the index, and update config"""
1950 @staticmethod
1951 def name():
1952 return N_('Refresh')
1954 def do(self):
1955 self.model.update_status(update_index=True)
1956 self.cfg.update()
1957 self.fsmonitor.refresh()
1960 class RefreshConfig(ContextCommand):
1961 """Refresh the git config cache"""
1963 def do(self):
1964 self.cfg.update()
1967 class RevertEditsCommand(ConfirmAction):
1968 def __init__(self, context):
1969 super(RevertEditsCommand, self).__init__(context)
1970 self.icon = icons.undo()
1972 def ok_to_run(self):
1973 return self.model.undoable()
1975 # pylint: disable=no-self-use
1976 def checkout_from_head(self):
1977 return False
1979 def checkout_args(self):
1980 args = []
1981 s = self.selection.selection()
1982 if self.checkout_from_head():
1983 args.append(self.model.head)
1984 args.append('--')
1986 if s.staged:
1987 items = s.staged
1988 else:
1989 items = s.modified
1990 args.extend(items)
1992 return args
1994 def action(self):
1995 checkout_args = self.checkout_args()
1996 return self.git.checkout(*checkout_args)
1998 def success(self):
1999 self.model.set_diff_type(main.Types.TEXT)
2000 self.model.update_file_status()
2003 class RevertUnstagedEdits(RevertEditsCommand):
2004 @staticmethod
2005 def name():
2006 return N_('Revert Unstaged Edits...')
2008 def checkout_from_head(self):
2009 # Being in amend mode should not affect the behavior of this command.
2010 # The only sensible thing to do is to checkout from the index.
2011 return False
2013 def confirm(self):
2014 title = N_('Revert Unstaged Changes?')
2015 text = N_(
2016 'This operation removes unstaged edits from selected files.\n'
2017 'These changes cannot be recovered.'
2019 info = N_('Revert the unstaged changes?')
2020 ok_text = N_('Revert Unstaged Changes')
2021 return Interaction.confirm(
2022 title, text, info, ok_text, default=True, icon=self.icon
2026 class RevertUncommittedEdits(RevertEditsCommand):
2027 @staticmethod
2028 def name():
2029 return N_('Revert Uncommitted Edits...')
2031 def checkout_from_head(self):
2032 return True
2034 def confirm(self):
2035 """Prompt for reverting changes"""
2036 title = N_('Revert Uncommitted Changes?')
2037 text = N_(
2038 'This operation removes uncommitted edits from selected files.\n'
2039 'These changes cannot be recovered.'
2041 info = N_('Revert the uncommitted changes?')
2042 ok_text = N_('Revert Uncommitted Changes')
2043 return Interaction.confirm(
2044 title, text, info, ok_text, default=True, icon=self.icon
2048 class RunConfigAction(ContextCommand):
2049 """Run a user-configured action, typically from the "Tools" menu"""
2051 def __init__(self, context, action_name):
2052 super(RunConfigAction, self).__init__(context)
2053 self.action_name = action_name
2055 def do(self):
2056 """Run the user-configured action"""
2057 for env in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
2058 try:
2059 compat.unsetenv(env)
2060 except KeyError:
2061 pass
2062 rev = None
2063 args = None
2064 context = self.context
2065 cfg = self.cfg
2066 opts = cfg.get_guitool_opts(self.action_name)
2067 cmd = opts.get('cmd')
2068 if 'title' not in opts:
2069 opts['title'] = cmd
2071 if 'prompt' not in opts or opts.get('prompt') is True:
2072 prompt = N_('Run "%s"?') % cmd
2073 opts['prompt'] = prompt
2075 if opts.get('needsfile'):
2076 filename = self.selection.filename()
2077 if not filename:
2078 Interaction.information(
2079 N_('Please select a file'),
2080 N_('"%s" requires a selected file.') % cmd,
2082 return False
2083 dirname = utils.dirname(filename, current_dir='.')
2084 compat.setenv('FILENAME', filename)
2085 compat.setenv('DIRNAME', dirname)
2087 if opts.get('revprompt') or opts.get('argprompt'):
2088 while True:
2089 ok = Interaction.confirm_config_action(context, cmd, opts)
2090 if not ok:
2091 return False
2092 rev = opts.get('revision')
2093 args = opts.get('args')
2094 if opts.get('revprompt') and not rev:
2095 title = N_('Invalid Revision')
2096 msg = N_('The revision expression cannot be empty.')
2097 Interaction.critical(title, msg)
2098 continue
2099 break
2101 elif opts.get('confirm'):
2102 title = os.path.expandvars(opts.get('title'))
2103 prompt = os.path.expandvars(opts.get('prompt'))
2104 if not Interaction.question(title, prompt):
2105 return False
2106 if rev:
2107 compat.setenv('REVISION', rev)
2108 if args:
2109 compat.setenv('ARGS', args)
2110 title = os.path.expandvars(cmd)
2111 Interaction.log(N_('Running command: %s') % title)
2112 cmd = ['sh', '-c', cmd]
2114 if opts.get('background'):
2115 core.fork(cmd)
2116 status, out, err = (0, '', '')
2117 elif opts.get('noconsole'):
2118 status, out, err = core.run_command(cmd)
2119 else:
2120 status, out, err = Interaction.run_command(title, cmd)
2122 if not opts.get('background') and not opts.get('norescan'):
2123 self.model.update_status()
2125 title = N_('Error')
2126 Interaction.command(title, cmd, status, out, err)
2128 return status == 0
2131 class SetDefaultRepo(ContextCommand):
2132 """Set the default repository"""
2134 def __init__(self, context, repo):
2135 super(SetDefaultRepo, self).__init__(context)
2136 self.repo = repo
2138 def do(self):
2139 self.cfg.set_user('cola.defaultrepo', self.repo)
2142 class SetDiffText(EditModel):
2143 """Set the diff text"""
2145 UNDOABLE = True
2147 def __init__(self, context, text):
2148 super(SetDiffText, self).__init__(context)
2149 self.new_diff_text = text
2150 self.new_diff_type = main.Types.TEXT
2151 self.new_file_type = main.Types.TEXT
2154 class SetUpstreamBranch(ContextCommand):
2155 """Set the upstream branch"""
2157 def __init__(self, context, branch, remote, remote_branch):
2158 super(SetUpstreamBranch, self).__init__(context)
2159 self.branch = branch
2160 self.remote = remote
2161 self.remote_branch = remote_branch
2163 def do(self):
2164 cfg = self.cfg
2165 remote = self.remote
2166 branch = self.branch
2167 remote_branch = self.remote_branch
2168 cfg.set_repo('branch.%s.remote' % branch, remote)
2169 cfg.set_repo('branch.%s.merge' % branch, 'refs/heads/' + remote_branch)
2172 def format_hex(data):
2173 """Translate binary data into a hex dump"""
2174 hexdigits = '0123456789ABCDEF'
2175 result = ''
2176 offset = 0
2177 byte_offset_to_int = compat.byte_offset_to_int_converter()
2178 while offset < len(data):
2179 result += '%04u |' % offset
2180 textpart = ''
2181 for i in range(0, 16):
2182 if i > 0 and i % 4 == 0:
2183 result += ' '
2184 if offset < len(data):
2185 v = byte_offset_to_int(data[offset])
2186 result += ' ' + hexdigits[v >> 4] + hexdigits[v & 0xF]
2187 textpart += chr(v) if 32 <= v < 127 else '.'
2188 offset += 1
2189 else:
2190 result += ' '
2191 textpart += ' '
2192 result += ' | ' + textpart + ' |\n'
2194 return result
2197 class ShowUntracked(EditModel):
2198 """Show an untracked file."""
2200 def __init__(self, context, filename):
2201 super(ShowUntracked, self).__init__(context)
2202 self.new_filename = filename
2203 self.new_mode = self.model.mode_untracked
2204 self.new_diff_text = self.read(filename)
2205 self.new_diff_type = main.Types.TEXT
2206 self.new_file_type = main.Types.TEXT
2208 def read(self, filename):
2209 """Read file contents"""
2210 cfg = self.cfg
2211 size = cfg.get('cola.readsize', 2048)
2212 try:
2213 result = core.read(filename, size=size, encoding='bytes')
2214 except (IOError, OSError):
2215 result = ''
2217 truncated = len(result) == size
2219 encoding = cfg.file_encoding(filename) or core.ENCODING
2220 try:
2221 text_result = core.decode_maybe(result, encoding)
2222 except UnicodeError:
2223 text_result = format_hex(result)
2225 if truncated:
2226 text_result += '...'
2227 return text_result
2230 class SignOff(ContextCommand):
2231 """Append a signoff to the commit message"""
2233 UNDOABLE = True
2235 @staticmethod
2236 def name():
2237 return N_('Sign Off')
2239 def __init__(self, context):
2240 super(SignOff, self).__init__(context)
2241 self.old_commitmsg = self.model.commitmsg
2243 def do(self):
2244 """Add a signoff to the commit message"""
2245 signoff = self.signoff()
2246 if signoff in self.model.commitmsg:
2247 return
2248 msg = self.model.commitmsg.rstrip()
2249 self.model.set_commitmsg(msg + '\n' + signoff)
2251 def undo(self):
2252 """Restore the commit message"""
2253 self.model.set_commitmsg(self.old_commitmsg)
2255 def signoff(self):
2256 """Generate the signoff string"""
2257 try:
2258 import pwd # pylint: disable=all
2260 user = pwd.getpwuid(os.getuid()).pw_name
2261 except ImportError:
2262 user = os.getenv('USER', N_('unknown'))
2264 cfg = self.cfg
2265 name = cfg.get('user.name', user)
2266 email = cfg.get('user.email', '%s@%s' % (user, core.node()))
2267 return '\nSigned-off-by: %s <%s>' % (name, email)
2270 def check_conflicts(context, unmerged):
2271 """Check paths for conflicts
2273 Conflicting files can be filtered out one-by-one.
2276 if prefs.check_conflicts(context):
2277 unmerged = [path for path in unmerged if is_conflict_free(path)]
2278 return unmerged
2281 def is_conflict_free(path):
2282 """Return True if `path` contains no conflict markers
2284 rgx = re.compile(r'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2285 try:
2286 with core.xopen(path, 'r') as f:
2287 for line in f:
2288 line = core.decode(line, errors='ignore')
2289 if rgx.match(line):
2290 return should_stage_conflicts(path)
2291 except IOError:
2292 # We can't read this file ~ we may be staging a removal
2293 pass
2294 return True
2297 def should_stage_conflicts(path):
2298 """Inform the user that a file contains merge conflicts
2300 Return `True` if we should stage the path nonetheless.
2303 title = msg = N_('Stage conflicts?')
2304 info = (
2306 '%s appears to contain merge conflicts.\n\n'
2307 'You should probably skip this file.\n'
2308 'Stage it anyways?'
2310 % path
2312 ok_text = N_('Stage conflicts')
2313 cancel_text = N_('Skip')
2314 return Interaction.confirm(
2315 title, msg, info, ok_text, default=False, cancel_text=cancel_text
2319 class Stage(ContextCommand):
2320 """Stage a set of paths."""
2322 @staticmethod
2323 def name():
2324 return N_('Stage')
2326 def __init__(self, context, paths):
2327 super(Stage, self).__init__(context)
2328 self.paths = paths
2330 def do(self):
2331 msg = N_('Staging: %s') % (', '.join(self.paths))
2332 Interaction.log(msg)
2333 return self.stage_paths()
2335 def stage_paths(self):
2336 """Stages add/removals to git."""
2337 context = self.context
2338 paths = self.paths
2339 if not paths:
2340 if self.model.cfg.get('cola.safemode', False):
2341 return (0, '', '')
2342 return self.stage_all()
2344 add = []
2345 remove = []
2347 for path in set(paths):
2348 if core.exists(path) or core.islink(path):
2349 if path.endswith('/'):
2350 path = path.rstrip('/')
2351 add.append(path)
2352 else:
2353 remove.append(path)
2355 self.model.emit_about_to_update()
2357 # `git add -u` doesn't work on untracked files
2358 if add:
2359 status, out, err = gitcmds.add(context, add)
2360 Interaction.command(N_('Error'), 'git add', status, out, err)
2362 # If a path doesn't exist then that means it should be removed
2363 # from the index. We use `git add -u` for that.
2364 if remove:
2365 status, out, err = gitcmds.add(context, remove, u=True)
2366 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2368 self.model.update_files(emit=True)
2369 return status, out, err
2371 def stage_all(self):
2372 """Stage all files"""
2373 status, out, err = self.git.add(v=True, u=True)
2374 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2375 self.model.update_file_status()
2376 return (status, out, err)
2379 class StageCarefully(Stage):
2380 """Only stage when the path list is non-empty
2382 We use "git add -u -- <pathspec>" to stage, and it stages everything by
2383 default when no pathspec is specified, so this class ensures that paths
2384 are specified before calling git.
2386 When no paths are specified, the command does nothing.
2390 def __init__(self, context):
2391 super(StageCarefully, self).__init__(context, None)
2392 self.init_paths()
2394 # pylint: disable=no-self-use
2395 def init_paths(self):
2396 """Initialize path data"""
2397 return
2399 def ok_to_run(self):
2400 """Prevent catch-all "git add -u" from adding unmerged files"""
2401 return self.paths or not self.model.unmerged
2403 def do(self):
2404 """Stage files when ok_to_run() return True"""
2405 if self.ok_to_run():
2406 return super(StageCarefully, self).do()
2407 return (0, '', '')
2410 class StageModified(StageCarefully):
2411 """Stage all modified files."""
2413 @staticmethod
2414 def name():
2415 return N_('Stage Modified')
2417 def init_paths(self):
2418 self.paths = self.model.modified
2421 class StageUnmerged(StageCarefully):
2422 """Stage unmerged files."""
2424 @staticmethod
2425 def name():
2426 return N_('Stage Unmerged')
2428 def init_paths(self):
2429 self.paths = check_conflicts(self.context, self.model.unmerged)
2432 class StageUntracked(StageCarefully):
2433 """Stage all untracked files."""
2435 @staticmethod
2436 def name():
2437 return N_('Stage Untracked')
2439 def init_paths(self):
2440 self.paths = self.model.untracked
2443 class StageOrUnstage(ContextCommand):
2444 """If the selection is staged, unstage it, otherwise stage"""
2446 @staticmethod
2447 def name():
2448 return N_('Stage / Unstage')
2450 def do(self):
2451 s = self.selection.selection()
2452 if s.staged:
2453 do(Unstage, self.context, s.staged)
2455 unstaged = []
2456 unmerged = check_conflicts(self.context, s.unmerged)
2457 if unmerged:
2458 unstaged.extend(unmerged)
2459 if s.modified:
2460 unstaged.extend(s.modified)
2461 if s.untracked:
2462 unstaged.extend(s.untracked)
2463 if unstaged:
2464 do(Stage, self.context, unstaged)
2467 class Tag(ContextCommand):
2468 """Create a tag object."""
2470 def __init__(self, context, name, revision, sign=False, message=''):
2471 super(Tag, self).__init__(context)
2472 self._name = name
2473 self._message = message
2474 self._revision = revision
2475 self._sign = sign
2477 def do(self):
2478 result = False
2479 git = self.git
2480 revision = self._revision
2481 tag_name = self._name
2482 tag_message = self._message
2484 if not revision:
2485 Interaction.critical(
2486 N_('Missing Revision'), N_('Please specify a revision to tag.')
2488 return result
2490 if not tag_name:
2491 Interaction.critical(
2492 N_('Missing Name'), N_('Please specify a name for the new tag.')
2494 return result
2496 title = N_('Missing Tag Message')
2497 message = N_('Tag-signing was requested but the tag message is empty.')
2498 info = N_(
2499 'An unsigned, lightweight tag will be created instead.\n'
2500 'Create an unsigned tag?'
2502 ok_text = N_('Create Unsigned Tag')
2503 sign = self._sign
2504 if sign and not tag_message:
2505 # We require a message in order to sign the tag, so if they
2506 # choose to create an unsigned tag we have to clear the sign flag.
2507 if not Interaction.confirm(
2508 title, message, info, ok_text, default=False, icon=icons.save()
2510 return result
2511 sign = False
2513 opts = {}
2514 tmp_file = None
2515 try:
2516 if tag_message:
2517 tmp_file = utils.tmp_filename('tag-message')
2518 opts['file'] = tmp_file
2519 core.write(tmp_file, tag_message)
2521 if sign:
2522 opts['sign'] = True
2523 if tag_message:
2524 opts['annotate'] = True
2525 status, out, err = git.tag(tag_name, revision, **opts)
2526 finally:
2527 if tmp_file:
2528 core.unlink(tmp_file)
2530 title = N_('Error: could not create tag "%s"') % tag_name
2531 Interaction.command(title, 'git tag', status, out, err)
2533 if status == 0:
2534 result = True
2535 self.model.update_status()
2536 Interaction.information(
2537 N_('Tag Created'),
2538 N_('Created a new tag named "%s"') % tag_name,
2539 details=tag_message or None,
2542 return result
2545 class Unstage(ContextCommand):
2546 """Unstage a set of paths."""
2548 @staticmethod
2549 def name():
2550 return N_('Unstage')
2552 def __init__(self, context, paths):
2553 super(Unstage, self).__init__(context)
2554 self.paths = paths
2556 def do(self):
2557 """Unstage paths"""
2558 context = self.context
2559 head = self.model.head
2560 paths = self.paths
2562 msg = N_('Unstaging: %s') % (', '.join(paths))
2563 Interaction.log(msg)
2564 if not paths:
2565 return unstage_all(context)
2566 status, out, err = gitcmds.unstage_paths(context, paths, head=head)
2567 Interaction.command(N_('Error'), 'git reset', status, out, err)
2568 self.model.update_file_status()
2569 return (status, out, err)
2572 class UnstageAll(ContextCommand):
2573 """Unstage all files; resets the index."""
2575 def do(self):
2576 return unstage_all(self.context)
2579 def unstage_all(context):
2580 """Unstage all files, even while amending"""
2581 model = context.model
2582 git = context.git
2583 head = model.head
2584 status, out, err = git.reset(head, '--', '.')
2585 Interaction.command(N_('Error'), 'git reset', status, out, err)
2586 model.update_file_status()
2587 return (status, out, err)
2590 class StageSelected(ContextCommand):
2591 """Stage selected files, or all files if no selection exists."""
2593 def do(self):
2594 context = self.context
2595 paths = self.selection.unstaged
2596 if paths:
2597 do(Stage, context, paths)
2598 elif self.cfg.get('cola.safemode', False):
2599 do(StageModified, context)
2602 class UnstageSelected(Unstage):
2603 """Unstage selected files."""
2605 def __init__(self, context):
2606 staged = self.selection.staged
2607 super(UnstageSelected, self).__init__(context, staged)
2610 class Untrack(ContextCommand):
2611 """Unstage a set of paths."""
2613 def __init__(self, context, paths):
2614 super(Untrack, self).__init__(context)
2615 self.paths = paths
2617 def do(self):
2618 msg = N_('Untracking: %s') % (', '.join(self.paths))
2619 Interaction.log(msg)
2620 status, out, err = self.model.untrack_paths(self.paths)
2621 Interaction.log_status(status, out, err)
2624 class UntrackedSummary(EditModel):
2625 """List possible .gitignore rules as the diff text."""
2627 def __init__(self, context):
2628 super(UntrackedSummary, self).__init__(context)
2629 untracked = self.model.untracked
2630 suffix = 's' if untracked else ''
2631 io = StringIO()
2632 io.write('# %s untracked file%s\n' % (len(untracked), suffix))
2633 if untracked:
2634 io.write('# possible .gitignore rule%s:\n' % suffix)
2635 for u in untracked:
2636 io.write('/' + u + '\n')
2637 self.new_diff_text = io.getvalue()
2638 self.new_diff_type = main.Types.TEXT
2639 self.new_file_type = main.Types.TEXT
2640 self.new_mode = self.model.mode_untracked
2643 class VisualizeAll(ContextCommand):
2644 """Visualize all branches."""
2646 def do(self):
2647 context = self.context
2648 browser = utils.shell_split(prefs.history_browser(context))
2649 launch_history_browser(browser + ['--all'])
2652 class VisualizeCurrent(ContextCommand):
2653 """Visualize all branches."""
2655 def do(self):
2656 context = self.context
2657 browser = utils.shell_split(prefs.history_browser(context))
2658 launch_history_browser(browser + [self.model.currentbranch] + ['--'])
2661 class VisualizePaths(ContextCommand):
2662 """Path-limited visualization."""
2664 def __init__(self, context, paths):
2665 super(VisualizePaths, self).__init__(context)
2666 context = self.context
2667 browser = utils.shell_split(prefs.history_browser(context))
2668 if paths:
2669 self.argv = browser + ['--'] + list(paths)
2670 else:
2671 self.argv = browser
2673 def do(self):
2674 launch_history_browser(self.argv)
2677 class VisualizeRevision(ContextCommand):
2678 """Visualize a specific revision."""
2680 def __init__(self, context, revision, paths=None):
2681 super(VisualizeRevision, self).__init__(context)
2682 self.revision = revision
2683 self.paths = paths
2685 def do(self):
2686 context = self.context
2687 argv = utils.shell_split(prefs.history_browser(context))
2688 if self.revision:
2689 argv.append(self.revision)
2690 if self.paths:
2691 argv.append('--')
2692 argv.extend(self.paths)
2693 launch_history_browser(argv)
2696 class SubmoduleAdd(ConfirmAction):
2697 """Add specified submodules"""
2699 def __init__(self, context, url, path, branch, depth, reference):
2700 super(SubmoduleAdd, self).__init__(context)
2701 self.url = url
2702 self.path = path
2703 self.branch = branch
2704 self.depth = depth
2705 self.reference = reference
2707 def confirm(self):
2708 title = N_('Add Submodule...')
2709 question = N_('Add this submodule?')
2710 info = N_('The submodule will be added using\n' '"%s"' % self.command())
2711 ok_txt = N_('Add Submodule')
2712 return Interaction.confirm(title, question, info, ok_txt, icon=icons.ok())
2714 def action(self):
2715 context = self.context
2716 args = self.get_args()
2717 return context.git.submodule('add', *args)
2719 def success(self):
2720 self.model.update_file_status()
2721 self.model.update_submodules_list()
2723 def error_message(self):
2724 return N_('Error updating submodule %s' % self.path)
2726 def command(self):
2727 cmd = ['git', 'submodule', 'add']
2728 cmd.extend(self.get_args())
2729 return core.list2cmdline(cmd)
2731 def get_args(self):
2732 args = []
2733 if self.branch:
2734 args.extend(['--branch', self.branch])
2735 if self.reference:
2736 args.extend(['--reference', self.reference])
2737 if self.depth:
2738 args.extend(['--depth', '%d' % self.depth])
2739 args.extend(['--', self.url])
2740 if self.path:
2741 args.append(self.path)
2742 return args
2745 class SubmoduleUpdate(ConfirmAction):
2746 """Update specified submodule"""
2748 def __init__(self, context, path):
2749 super(SubmoduleUpdate, self).__init__(context)
2750 self.path = path
2752 def confirm(self):
2753 title = N_('Update Submodule...')
2754 question = N_('Update this submodule?')
2755 info = N_('The submodule will be updated using\n' '"%s"' % self.command())
2756 ok_txt = N_('Update Submodule')
2757 return Interaction.confirm(
2758 title, question, info, ok_txt, default=False, icon=icons.pull()
2761 def action(self):
2762 context = self.context
2763 args = self.get_args()
2764 return context.git.submodule(*args)
2766 def success(self):
2767 self.model.update_file_status()
2769 def error_message(self):
2770 return N_('Error updating submodule %s' % self.path)
2772 def command(self):
2773 cmd = ['git', 'submodule']
2774 cmd.extend(self.get_args())
2775 return core.list2cmdline(cmd)
2777 def get_args(self):
2778 cmd = ['update']
2779 if version.check_git(self.context, 'submodule-update-recursive'):
2780 cmd.append('--recursive')
2781 cmd.extend(['--', self.path])
2782 return cmd
2785 class SubmodulesUpdate(ConfirmAction):
2786 """Update all submodules"""
2788 def confirm(self):
2789 title = N_('Update submodules...')
2790 question = N_('Update all submodules?')
2791 info = N_('All submodules will be updated using\n' '"%s"' % self.command())
2792 ok_txt = N_('Update Submodules')
2793 return Interaction.confirm(
2794 title, question, info, ok_txt, default=False, icon=icons.pull()
2797 def action(self):
2798 context = self.context
2799 args = self.get_args()
2800 return context.git.submodule(*args)
2802 def success(self):
2803 self.model.update_file_status()
2805 def error_message(self):
2806 return N_('Error updating submodules')
2808 def command(self):
2809 cmd = ['git', 'submodule']
2810 cmd.extend(self.get_args())
2811 return core.list2cmdline(cmd)
2813 def get_args(self):
2814 cmd = ['update']
2815 if version.check_git(self.context, 'submodule-update-recursive'):
2816 cmd.append('--recursive')
2817 return cmd
2820 def launch_history_browser(argv):
2821 """Launch the configured history browser"""
2822 try:
2823 core.fork(argv)
2824 except OSError as e:
2825 _, details = utils.format_exception(e)
2826 title = N_('Error Launching History Browser')
2827 msg = N_('Cannot exec "%s": please configure a history browser') % ' '.join(
2828 argv
2830 Interaction.critical(title, message=msg, details=details)
2833 def run(cls, *args, **opts):
2835 Returns a callback that runs a command
2837 If the caller of run() provides args or opts then those are
2838 used instead of the ones provided by the invoker of the callback.
2842 def runner(*local_args, **local_opts):
2843 """Closure return by run() which runs the command"""
2844 if args or opts:
2845 do(cls, *args, **opts)
2846 else:
2847 do(cls, *local_args, **local_opts)
2849 return runner
2852 def do(cls, *args, **opts):
2853 """Run a command in-place"""
2854 try:
2855 cmd = cls(*args, **opts)
2856 return cmd.do()
2857 except Exception as e: # pylint: disable=broad-except
2858 msg, details = utils.format_exception(e)
2859 if hasattr(cls, '__name__'):
2860 msg = '%s exception:\n%s' % (cls.__name__, msg)
2861 Interaction.critical(N_('Error'), message=msg, details=details)
2862 return None
2865 def difftool_run(context):
2866 """Start a default difftool session"""
2867 selection = context.selection
2868 files = selection.group()
2869 if not files:
2870 return
2871 s = selection.selection()
2872 head = context.model.head
2873 difftool_launch_with_head(context, files, bool(s.staged), head)
2876 def difftool_launch_with_head(context, filenames, staged, head):
2877 """Launch difftool against the provided head"""
2878 if head == 'HEAD':
2879 left = None
2880 else:
2881 left = head
2882 difftool_launch(context, left=left, staged=staged, paths=filenames)
2885 def difftool_launch(
2886 context,
2887 left=None,
2888 right=None,
2889 paths=None,
2890 staged=False,
2891 dir_diff=False,
2892 left_take_magic=False,
2893 left_take_parent=False,
2895 """Launches 'git difftool' with given parameters
2897 :param left: first argument to difftool
2898 :param right: second argument to difftool_args
2899 :param paths: paths to diff
2900 :param staged: activate `git difftool --staged`
2901 :param dir_diff: activate `git difftool --dir-diff`
2902 :param left_take_magic: whether to append the magic ^! diff expression
2903 :param left_take_parent: whether to append the first-parent ~ for diffing
2907 difftool_args = ['git', 'difftool', '--no-prompt']
2908 if staged:
2909 difftool_args.append('--cached')
2910 if dir_diff:
2911 difftool_args.append('--dir-diff')
2913 if left:
2914 if left_take_parent or left_take_magic:
2915 suffix = '^!' if left_take_magic else '~'
2916 # Check root commit (no parents and thus cannot execute '~')
2917 git = context.git
2918 status, out, err = git.rev_list(left, parents=True, n=1)
2919 Interaction.log_status(status, out, err)
2920 if status:
2921 raise OSError('git rev-list command failed')
2923 if len(out.split()) >= 2:
2924 # Commit has a parent, so we can take its child as requested
2925 left += suffix
2926 else:
2927 # No parent, assume it's the root commit, so we have to diff
2928 # against the empty tree.
2929 left = EMPTY_TREE_OID
2930 if not right and left_take_magic:
2931 right = left
2932 difftool_args.append(left)
2934 if right:
2935 difftool_args.append(right)
2937 if paths:
2938 difftool_args.append('--')
2939 difftool_args.extend(paths)
2941 runtask = context.runtask
2942 if runtask:
2943 Interaction.async_command(N_('Difftool'), difftool_args, runtask)
2944 else:
2945 core.fork(difftool_args)