status: add support for "git checkout --theirs/--ours" on unmerge files
[git-cola.git] / cola / cmds.py
blob9cd8a2fb7d6230a9150230b4a45ea3971db46813
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 .git import STDOUT
24 from .git import EMPTY_TREE_OID
25 from .git import MISSING_BLOB_OID
26 from .i18n import N_
27 from .interaction import Interaction
28 from .models import main
29 from .models import prefs
32 class UsageError(Exception):
33 """Exception class for usage errors."""
35 def __init__(self, title, message):
36 Exception.__init__(self, message)
37 self.title = title
38 self.msg = message
41 class EditModel(ContextCommand):
42 """Commands that mutate the main model diff data"""
44 UNDOABLE = True
46 def __init__(self, context):
47 """Common edit operations on the main model"""
48 super(EditModel, self).__init__(context)
50 self.old_diff_text = self.model.diff_text
51 self.old_filename = self.model.filename
52 self.old_mode = self.model.mode
53 self.old_diff_type = self.model.diff_type
54 self.old_file_type = self.model.file_type
56 self.new_diff_text = self.old_diff_text
57 self.new_filename = self.old_filename
58 self.new_mode = self.old_mode
59 self.new_diff_type = self.old_diff_type
60 self.new_file_type = self.old_file_type
62 def do(self):
63 """Perform the operation."""
64 self.model.filename = self.new_filename
65 self.model.set_mode(self.new_mode)
66 self.model.set_diff_text(self.new_diff_text)
67 self.model.set_diff_type(self.new_diff_type)
68 self.model.set_file_type(self.new_file_type)
70 def undo(self):
71 """Undo the operation."""
72 self.model.filename = self.old_filename
73 self.model.set_mode(self.old_mode)
74 self.model.set_diff_text(self.old_diff_text)
75 self.model.set_diff_type(self.old_diff_type)
76 self.model.set_file_type(self.old_file_type)
79 class ConfirmAction(ContextCommand):
80 """Confirm an action before running it"""
82 # pylint: disable=no-self-use
83 def ok_to_run(self):
84 """Return True when the command is ok to run"""
85 return True
87 # pylint: disable=no-self-use
88 def confirm(self):
89 """Prompt for confirmation"""
90 return True
92 # pylint: disable=no-self-use
93 def action(self):
94 """Run the command and return (status, out, err)"""
95 return (-1, '', '')
97 # pylint: disable=no-self-use
98 def success(self):
99 """Callback run on success"""
100 return
102 # pylint: disable=no-self-use
103 def command(self):
104 """Command name, for error messages"""
105 return 'git'
107 # pylint: disable=no-self-use
108 def error_message(self):
109 """Command error message"""
110 return ''
112 def do(self):
113 """Prompt for confirmation before running a command"""
114 status = -1
115 out = err = ''
116 ok = self.ok_to_run() and self.confirm()
117 if ok:
118 status, out, err = self.action()
119 if status == 0:
120 self.success()
121 title = self.error_message()
122 cmd = self.command()
123 Interaction.command(title, cmd, status, out, err)
125 return ok, status, out, err
128 class AbortApplyPatch(ConfirmAction):
129 """Reset an in-progress "git am" patch application"""
131 def confirm(self):
132 title = N_('Abort Applying Patch...')
133 question = N_('Aborting applying the current patch?')
134 info = N_(
135 'Aborting a patch can cause uncommitted changes to be lost.\n'
136 'Recovering uncommitted changes is not possible.'
138 ok_txt = N_('Abort Applying Patch')
139 return Interaction.confirm(
140 title, question, info, ok_txt, default=False, icon=icons.undo()
143 def action(self):
144 status, out, err = gitcmds.abort_apply_patch(self.context)
145 self.model.update_file_merge_status()
146 return status, out, err
148 def success(self):
149 self.model.set_commitmsg('')
151 def error_message(self):
152 return N_('Error')
154 def command(self):
155 return 'git am --abort'
158 class AbortCherryPick(ConfirmAction):
159 """Reset an in-progress cherry-pick"""
161 def confirm(self):
162 title = N_('Abort Cherry-Pick...')
163 question = N_('Aborting the current cherry-pick?')
164 info = N_(
165 'Aborting a cherry-pick can cause uncommitted changes to be lost.\n'
166 'Recovering uncommitted changes is not possible.'
168 ok_txt = N_('Abort Cherry-Pick')
169 return Interaction.confirm(
170 title, question, info, ok_txt, default=False, icon=icons.undo()
173 def action(self):
174 status, out, err = gitcmds.abort_cherry_pick(self.context)
175 self.model.update_file_merge_status()
176 return status, out, err
178 def success(self):
179 self.model.set_commitmsg('')
181 def error_message(self):
182 return N_('Error')
184 def command(self):
185 return 'git cherry-pick --abort'
188 class AbortMerge(ConfirmAction):
189 """Reset an in-progress merge back to HEAD"""
191 def confirm(self):
192 title = N_('Abort Merge...')
193 question = N_('Aborting the current merge?')
194 info = N_(
195 'Aborting the current merge will cause '
196 '*ALL* uncommitted changes to be lost.\n'
197 'Recovering uncommitted changes is not possible.'
199 ok_txt = N_('Abort Merge')
200 return Interaction.confirm(
201 title, question, info, ok_txt, default=False, icon=icons.undo()
204 def action(self):
205 status, out, err = gitcmds.abort_merge(self.context)
206 self.model.update_file_merge_status()
207 return status, out, err
209 def success(self):
210 self.model.set_commitmsg('')
212 def error_message(self):
213 return N_('Error')
215 def command(self):
216 return 'git merge'
219 class AmendMode(EditModel):
220 """Try to amend a commit."""
222 UNDOABLE = True
223 LAST_MESSAGE = None
225 @staticmethod
226 def name():
227 return N_('Amend')
229 def __init__(self, context, amend=True):
230 super(AmendMode, self).__init__(context)
231 self.skip = False
232 self.amending = amend
233 self.old_commitmsg = self.model.commitmsg
234 self.old_mode = self.model.mode
236 if self.amending:
237 self.new_mode = self.model.mode_amend
238 self.new_commitmsg = gitcmds.prev_commitmsg(context)
239 AmendMode.LAST_MESSAGE = self.model.commitmsg
240 return
241 # else, amend unchecked, regular commit
242 self.new_mode = self.model.mode_none
243 self.new_diff_text = ''
244 self.new_commitmsg = self.model.commitmsg
245 # If we're going back into new-commit-mode then search the
246 # undo stack for a previous amend-commit-mode and grab the
247 # commit message at that point in time.
248 if AmendMode.LAST_MESSAGE is not None:
249 self.new_commitmsg = AmendMode.LAST_MESSAGE
250 AmendMode.LAST_MESSAGE = None
252 def do(self):
253 """Leave/enter amend mode."""
254 # Attempt to enter amend mode. Do not allow this when merging.
255 if self.amending:
256 if self.model.is_merging:
257 self.skip = True
258 self.model.set_mode(self.old_mode)
259 Interaction.information(
260 N_('Cannot Amend'),
262 'You are in the middle of a merge.\n'
263 'Cannot amend while merging.'
266 return
267 self.skip = False
268 super(AmendMode, self).do()
269 self.model.set_commitmsg(self.new_commitmsg)
270 self.model.update_file_status()
272 def undo(self):
273 if self.skip:
274 return
275 self.model.set_commitmsg(self.old_commitmsg)
276 super(AmendMode, self).undo()
277 self.model.update_file_status()
280 class AnnexAdd(ContextCommand):
281 """Add to Git Annex"""
283 def __init__(self, context):
284 super(AnnexAdd, self).__init__(context)
285 self.filename = self.selection.filename()
287 def do(self):
288 status, out, err = self.git.annex('add', self.filename)
289 Interaction.command(N_('Error'), 'git annex add', status, out, err)
290 self.model.update_status()
293 class AnnexInit(ContextCommand):
294 """Initialize Git Annex"""
296 def do(self):
297 status, out, err = self.git.annex('init')
298 Interaction.command(N_('Error'), 'git annex init', status, out, err)
299 self.model.cfg.reset()
300 self.model.emit_updated()
303 class LFSTrack(ContextCommand):
304 """Add a file to git lfs"""
306 def __init__(self, context):
307 super(LFSTrack, self).__init__(context)
308 self.filename = self.selection.filename()
309 self.stage_cmd = Stage(context, [self.filename])
311 def do(self):
312 status, out, err = self.git.lfs('track', self.filename)
313 Interaction.command(N_('Error'), 'git lfs track', status, out, err)
314 if status == 0:
315 self.stage_cmd.do()
318 class LFSInstall(ContextCommand):
319 """Initialize git lfs"""
321 def do(self):
322 status, out, err = self.git.lfs('install')
323 Interaction.command(N_('Error'), 'git lfs install', status, out, err)
324 self.model.update_config(reset=True, emit=True)
327 class ApplyPatch(ContextCommand):
328 """Apply the specfied patch to the worktree or index"""
330 def __init__(
331 self,
332 context,
333 patch,
334 encoding,
335 apply_to_worktree,
337 super(ApplyPatch, self).__init__(context)
338 self.patch = patch
339 self.encoding = encoding
340 self.apply_to_worktree = apply_to_worktree
342 def do(self):
343 context = self.context
345 tmp_file = utils.tmp_filename('apply', suffix='.patch')
346 try:
347 core.write(tmp_file, self.patch.as_text(), encoding=self.encoding)
348 if self.apply_to_worktree:
349 status, out, err = gitcmds.apply_diff_to_worktree(context, tmp_file)
350 else:
351 status, out, err = gitcmds.apply_diff(context, tmp_file)
352 finally:
353 core.unlink(tmp_file)
355 Interaction.log_status(status, out, err)
356 self.model.update_file_status(update_index=True)
359 class ApplyPatches(ContextCommand):
360 """Apply patches using the "git am" command"""
362 def __init__(self, context, patches):
363 super(ApplyPatches, self).__init__(context)
364 self.patches = patches
366 def do(self):
367 status, output, err = self.git.am('-3', *self.patches)
368 out = '# git am -3 %s\n\n%s' % (core.list2cmdline(self.patches), output)
369 Interaction.command(N_('Patch failed to apply'), 'git am -3', status, out, err)
370 # Display a diffstat
371 self.model.update_file_status()
373 patch_basenames = [os.path.basename(p) for p in self.patches]
374 if len(patch_basenames) > 25:
375 patch_basenames = patch_basenames[:25]
376 patch_basenames.append('...')
378 basenames = '\n'.join(patch_basenames)
379 if status == 0:
380 Interaction.information(
381 N_('Patch(es) Applied'),
382 (N_('%d patch(es) applied.') + '\n\n%s')
383 % (len(self.patches), basenames),
387 class ApplyPatchesContinue(ContextCommand):
388 """Run "git am --continue" to continue on the next patch in a "git am" session"""
390 def do(self):
391 status, out, err = self.git.am('--continue')
392 Interaction.command(
393 N_('Failed to commit and continue applying patches'),
394 'git am --continue',
395 status,
396 out,
397 err,
399 self.model.update_status()
400 return status, out, err
403 class ApplyPatchesSkip(ContextCommand):
404 """Run "git am --skip" to continue on the next patch in a "git am" session"""
406 def do(self):
407 status, out, err = self.git.am(skip=True)
408 Interaction.command(
409 N_('Failed to continue applying patches after skipping the current patch'),
410 'git am --skip',
411 status,
412 out,
413 err,
415 self.model.update_status()
416 return status, out, err
419 class Archive(ContextCommand):
420 """ "Export archives using the "git archive" command"""
422 def __init__(self, context, ref, fmt, prefix, filename):
423 super(Archive, self).__init__(context)
424 self.ref = ref
425 self.fmt = fmt
426 self.prefix = prefix
427 self.filename = filename
429 def do(self):
430 fp = core.xopen(self.filename, 'wb')
431 cmd = ['git', 'archive', '--format=' + self.fmt]
432 if self.fmt in ('tgz', 'tar.gz'):
433 cmd.append('-9')
434 if self.prefix:
435 cmd.append('--prefix=' + self.prefix)
436 cmd.append(self.ref)
437 proc = core.start_command(cmd, stdout=fp)
438 out, err = proc.communicate()
439 fp.close()
440 status = proc.returncode
441 Interaction.log_status(status, out or '', err or '')
444 class Checkout(EditModel):
445 """A command object for git-checkout.
447 'argv' is handed off directly to git.
451 def __init__(self, context, argv, checkout_branch=False):
452 super(Checkout, self).__init__(context)
453 self.argv = argv
454 self.checkout_branch = checkout_branch
455 self.new_diff_text = ''
456 self.new_diff_type = main.Types.TEXT
457 self.new_file_type = main.Types.TEXT
459 def do(self):
460 super(Checkout, self).do()
461 status, out, err = self.git.checkout(*self.argv)
462 if self.checkout_branch:
463 self.model.update_status()
464 else:
465 self.model.update_file_status()
466 Interaction.command(N_('Error'), 'git checkout', status, out, err)
467 return status, out, err
470 class CheckoutTheirs(ConfirmAction):
471 """Checkout "their" version of a file when performing a merge"""
473 @staticmethod
474 def name():
475 return N_('Checkout files from "their" branch (MERGE_HEAD)')
477 def confirm(self):
478 title = self.name()
479 question = N_('Checkout files from their MERGE_HEAD using "git checkout --theirs"?')
480 info = N_(
481 'This operation will replace the selected unmerged files with content '
482 'from the branch being merged.\n'
483 '*ALL* uncommitted changes will be lost.\n'
484 'Recovering uncommitted changes is not possible.'
486 ok_txt = N_('Checkout Files')
487 return Interaction.confirm(
488 title, question, info, ok_txt, default=True, icon=icons.merge()
491 def action(self):
492 selection = self.selection.selection()
493 paths = selection.unmerged
494 if not paths:
495 return 0, '', ''
497 argv = ['--theirs', '--'] + paths
498 cmd = Checkout(self.context, argv)
499 return cmd.do()
501 def error_message(self):
502 return N_('Error')
504 def command(self):
505 return 'git checkout --theirs'
508 class CheckoutOurs(ConfirmAction):
509 """Checkout "our" version of a file when performing a merge"""
511 @staticmethod
512 def name():
513 return N_('Checkout files from "our" branch (HEAD)')
515 def confirm(self):
516 title = self.name()
517 question = N_('Checkout files from MERGE_HEAD?')
518 info = N_(
519 'This will replace the selected unmerged files with content '
520 'from the branch being merged into.\n'
521 '*ALL* uncommitted changes will be lost.\n'
522 'Recovering uncommitted changes is not possible.'
524 ok_txt = N_('Checkout Files')
525 return Interaction.confirm(
526 title, question, info, ok_txt, default=True, icon=icons.merge()
529 def action(self):
530 selection = self.selection.selection()
531 paths = selection.unmerged
532 if not paths:
533 return 0, '', ''
535 argv = ['--ours', '--'] + paths
536 cmd = Checkout(self.context, argv)
537 return cmd.do()
539 def error_message(self):
540 return N_('Error')
542 def command(self):
543 return 'git checkout --ours'
546 class BlamePaths(ContextCommand):
547 """Blame view for paths."""
549 @staticmethod
550 def name():
551 return N_('Blame...')
553 def __init__(self, context, paths=None):
554 super(BlamePaths, self).__init__(context)
555 if not paths:
556 paths = context.selection.union()
557 viewer = utils.shell_split(prefs.blame_viewer(context))
558 self.argv = viewer + list(paths)
560 def do(self):
561 try:
562 core.fork(self.argv)
563 except OSError as e:
564 _, details = utils.format_exception(e)
565 title = N_('Error Launching Blame Viewer')
566 msg = N_('Cannot exec "%s": please configure a blame viewer') % ' '.join(
567 self.argv
569 Interaction.critical(title, message=msg, details=details)
572 class CheckoutBranch(Checkout):
573 """Checkout a branch."""
575 def __init__(self, context, branch):
576 args = [branch]
577 super(CheckoutBranch, self).__init__(context, args, checkout_branch=True)
580 class CherryPick(ContextCommand):
581 """Cherry pick commits into the current branch."""
583 def __init__(self, context, commits):
584 super(CherryPick, self).__init__(context)
585 self.commits = commits
587 def do(self):
588 status, out, err = gitcmds.cherry_pick(self.context, self.commits)
589 self.model.update_file_merge_status()
590 title = N_('Cherry-pick failed')
591 Interaction.command(title, 'git cherry-pick', status, out, err)
594 class Revert(ContextCommand):
595 """Cherry pick commits into the current branch."""
597 def __init__(self, context, oid):
598 super(Revert, self).__init__(context)
599 self.oid = oid
601 def do(self):
602 status, output, err = self.git.revert(self.oid, no_edit=True)
603 self.model.update_file_status()
604 title = N_('Revert failed')
605 out = '# git revert %s\n\n' % self.oid
606 Interaction.command(title, 'git revert', status, out, err)
609 class ResetMode(EditModel):
610 """Reset the mode and clear the model's diff text."""
612 def __init__(self, context):
613 super(ResetMode, self).__init__(context)
614 self.new_mode = self.model.mode_none
615 self.new_diff_text = ''
616 self.new_diff_type = main.Types.TEXT
617 self.new_file_type = main.Types.TEXT
618 self.new_filename = ''
620 def do(self):
621 super(ResetMode, self).do()
622 self.model.update_file_status()
625 class ResetCommand(ConfirmAction):
626 """Reset state using the "git reset" command"""
628 def __init__(self, context, ref):
629 super(ResetCommand, self).__init__(context)
630 self.ref = ref
632 def action(self):
633 return self.reset()
635 def command(self):
636 return 'git reset'
638 def error_message(self):
639 return N_('Error')
641 def success(self):
642 self.model.update_file_status()
644 def confirm(self):
645 raise NotImplementedError('confirm() must be overridden')
647 def reset(self):
648 raise NotImplementedError('reset() must be overridden')
651 class ResetMixed(ResetCommand):
652 @staticmethod
653 def tooltip(ref):
654 tooltip = N_('The branch will be reset using "git reset --mixed %s"')
655 return tooltip % ref
657 def confirm(self):
658 title = N_('Reset Branch and Stage (Mixed)')
659 question = N_('Point the current branch head to a new commit?')
660 info = self.tooltip(self.ref)
661 ok_text = N_('Reset Branch')
662 return Interaction.confirm(title, question, info, ok_text)
664 def reset(self):
665 return self.git.reset(self.ref, '--', mixed=True)
668 class ResetKeep(ResetCommand):
669 @staticmethod
670 def tooltip(ref):
671 tooltip = N_('The repository will be reset using "git reset --keep %s"')
672 return tooltip % ref
674 def confirm(self):
675 title = N_('Restore Worktree and Reset All (Keep Unstaged Changes)')
676 question = N_('Restore worktree, reset, and preserve unstaged edits?')
677 info = self.tooltip(self.ref)
678 ok_text = N_('Reset and Restore')
679 return Interaction.confirm(title, question, info, ok_text)
681 def reset(self):
682 return self.git.reset(self.ref, '--', keep=True)
685 class ResetMerge(ResetCommand):
686 @staticmethod
687 def tooltip(ref):
688 tooltip = N_('The repository will be reset using "git reset --merge %s"')
689 return tooltip % ref
691 def confirm(self):
692 title = N_('Restore Worktree and Reset All (Merge)')
693 question = N_('Reset Worktree and Reset All?')
694 info = self.tooltip(self.ref)
695 ok_text = N_('Reset and Restore')
696 return Interaction.confirm(title, question, info, ok_text)
698 def reset(self):
699 return self.git.reset(self.ref, '--', merge=True)
702 class ResetSoft(ResetCommand):
703 @staticmethod
704 def tooltip(ref):
705 tooltip = N_('The branch will be reset using "git reset --soft %s"')
706 return tooltip % ref
708 def confirm(self):
709 title = N_('Reset Branch (Soft)')
710 question = N_('Reset branch?')
711 info = self.tooltip(self.ref)
712 ok_text = N_('Reset Branch')
713 return Interaction.confirm(title, question, info, ok_text)
715 def reset(self):
716 return self.git.reset(self.ref, '--', soft=True)
719 class ResetHard(ResetCommand):
720 @staticmethod
721 def tooltip(ref):
722 tooltip = N_('The repository will be reset using "git reset --hard %s"')
723 return tooltip % ref
725 def confirm(self):
726 title = N_('Restore Worktree and Reset All (Hard)')
727 question = N_('Restore Worktree and Reset All?')
728 info = self.tooltip(self.ref)
729 ok_text = N_('Reset and Restore')
730 return Interaction.confirm(title, question, info, ok_text)
732 def reset(self):
733 return self.git.reset(self.ref, '--', hard=True)
736 class RestoreWorktree(ConfirmAction):
737 """Reset the worktree using the "git read-tree" command"""
739 @staticmethod
740 def tooltip(ref):
741 tooltip = N_(
742 'The worktree will be restored using "git read-tree --reset -u %s"'
744 return tooltip % ref
746 def __init__(self, context, ref):
747 super(RestoreWorktree, self).__init__(context)
748 self.ref = ref
750 def action(self):
751 return self.git.read_tree(self.ref, reset=True, u=True)
753 def command(self):
754 return 'git read-tree --reset -u %s' % self.ref
756 def error_message(self):
757 return N_('Error')
759 def success(self):
760 self.model.update_file_status()
762 def confirm(self):
763 title = N_('Restore Worktree')
764 question = N_('Restore Worktree to %s?') % self.ref
765 info = self.tooltip(self.ref)
766 ok_text = N_('Restore Worktree')
767 return Interaction.confirm(title, question, info, ok_text)
770 class UndoLastCommit(ResetCommand):
771 """Undo the last commit"""
773 # NOTE: this is the similar to ResetSoft() with an additional check for
774 # published commits and different messages.
775 def __init__(self, context):
776 super(UndoLastCommit, self).__init__(context, 'HEAD^')
778 def confirm(self):
779 check_published = prefs.check_published_commits(self.context)
780 if check_published and self.model.is_commit_published():
781 return Interaction.confirm(
782 N_('Rewrite Published Commit?'),
784 'This commit has already been published.\n'
785 'This operation will rewrite published history.\n'
786 'You probably don\'t want to do this.'
788 N_('Undo the published commit?'),
789 N_('Undo Last Commit'),
790 default=False,
791 icon=icons.save(),
794 title = N_('Undo Last Commit')
795 question = N_('Undo last commit?')
796 info = N_('The branch will be reset using "git reset --soft %s"')
797 ok_text = N_('Undo Last Commit')
798 info_text = info % self.ref
799 return Interaction.confirm(title, question, info_text, ok_text)
801 def reset(self):
802 return self.git.reset('HEAD^', '--', soft=True)
805 class Commit(ResetMode):
806 """Attempt to create a new commit."""
808 def __init__(self, context, amend, msg, sign, no_verify=False):
809 super(Commit, self).__init__(context)
810 self.amend = amend
811 self.msg = msg
812 self.sign = sign
813 self.no_verify = no_verify
814 self.old_commitmsg = self.model.commitmsg
815 self.new_commitmsg = ''
817 def do(self):
818 # Create the commit message file
819 context = self.context
820 comment_char = prefs.comment_char(context)
821 msg = self.strip_comments(self.msg, comment_char=comment_char)
822 tmp_file = utils.tmp_filename('commit-message')
823 try:
824 core.write(tmp_file, msg)
825 # Run 'git commit'
826 status, out, err = self.git.commit(
827 F=tmp_file,
828 v=True,
829 gpg_sign=self.sign,
830 amend=self.amend,
831 no_verify=self.no_verify,
833 finally:
834 core.unlink(tmp_file)
835 if status == 0:
836 super(Commit, self).do()
837 if context.cfg.get(prefs.AUTOTEMPLATE):
838 template_loader = LoadCommitMessageFromTemplate(context)
839 template_loader.do()
840 else:
841 self.model.set_commitmsg(self.new_commitmsg)
843 title = N_('Commit failed')
844 Interaction.command(title, 'git commit', status, out, err)
846 return status, out, err
848 @staticmethod
849 def strip_comments(msg, comment_char='#'):
850 # Strip off comments
851 message_lines = [
852 line for line in msg.split('\n') if not line.startswith(comment_char)
854 msg = '\n'.join(message_lines)
855 if not msg.endswith('\n'):
856 msg += '\n'
858 return msg
861 class CycleReferenceSort(ContextCommand):
862 """Choose the next reference sort type"""
864 def do(self):
865 self.model.cycle_ref_sort()
868 class Ignore(ContextCommand):
869 """Add files to an exclusion file"""
871 def __init__(self, context, filenames, local=False):
872 super(Ignore, self).__init__(context)
873 self.filenames = list(filenames)
874 self.local = local
876 def do(self):
877 if not self.filenames:
878 return
879 new_additions = '\n'.join(self.filenames) + '\n'
880 for_status = new_additions
881 if self.local:
882 filename = os.path.join('.git', 'info', 'exclude')
883 else:
884 filename = '.gitignore'
885 if core.exists(filename):
886 current_list = core.read(filename)
887 new_additions = current_list.rstrip() + '\n' + new_additions
888 core.write(filename, new_additions)
889 Interaction.log_status(0, 'Added to %s:\n%s' % (filename, for_status), '')
890 self.model.update_file_status()
893 def file_summary(files):
894 txt = core.list2cmdline(files)
895 if len(txt) > 768:
896 txt = txt[:768].rstrip() + '...'
897 wrap = textwrap.TextWrapper()
898 return '\n'.join(wrap.wrap(txt))
901 class RemoteCommand(ConfirmAction):
902 def __init__(self, context, remote):
903 super(RemoteCommand, self).__init__(context)
904 self.remote = remote
906 def success(self):
907 self.cfg.reset()
908 self.model.update_remotes()
911 class RemoteAdd(RemoteCommand):
912 def __init__(self, context, remote, url):
913 super(RemoteAdd, self).__init__(context, remote)
914 self.url = url
916 def action(self):
917 return self.git.remote('add', self.remote, self.url)
919 def error_message(self):
920 return N_('Error creating remote "%s"') % self.remote
922 def command(self):
923 return 'git remote add "%s" "%s"' % (self.remote, self.url)
926 class RemoteRemove(RemoteCommand):
927 def confirm(self):
928 title = N_('Delete Remote')
929 question = N_('Delete remote?')
930 info = N_('Delete remote "%s"') % self.remote
931 ok_text = N_('Delete')
932 return Interaction.confirm(title, question, info, ok_text)
934 def action(self):
935 return self.git.remote('rm', self.remote)
937 def error_message(self):
938 return N_('Error deleting remote "%s"') % self.remote
940 def command(self):
941 return 'git remote rm "%s"' % self.remote
944 class RemoteRename(RemoteCommand):
945 def __init__(self, context, remote, new_name):
946 super(RemoteRename, self).__init__(context, remote)
947 self.new_name = new_name
949 def confirm(self):
950 title = N_('Rename Remote')
951 text = N_('Rename remote "%(current)s" to "%(new)s"?') % dict(
952 current=self.remote, new=self.new_name
954 info_text = ''
955 ok_text = title
956 return Interaction.confirm(title, text, info_text, ok_text)
958 def action(self):
959 return self.git.remote('rename', self.remote, self.new_name)
961 def error_message(self):
962 return N_('Error renaming "%(name)s" to "%(new_name)s"') % dict(
963 name=self.remote, new_name=self.new_name
966 def command(self):
967 return 'git remote rename "%s" "%s"' % (self.remote, self.new_name)
970 class RemoteSetURL(RemoteCommand):
971 def __init__(self, context, remote, url):
972 super(RemoteSetURL, self).__init__(context, remote)
973 self.url = url
975 def action(self):
976 return self.git.remote('set-url', self.remote, self.url)
978 def error_message(self):
979 return N_('Unable to set URL for "%(name)s" to "%(url)s"') % dict(
980 name=self.remote, url=self.url
983 def command(self):
984 return 'git remote set-url "%s" "%s"' % (self.remote, self.url)
987 class RemoteEdit(ContextCommand):
988 """Combine RemoteRename and RemoteSetURL"""
990 def __init__(self, context, old_name, remote, url):
991 super(RemoteEdit, self).__init__(context)
992 self.rename = RemoteRename(context, old_name, remote)
993 self.set_url = RemoteSetURL(context, remote, url)
995 def do(self):
996 result = self.rename.do()
997 name_ok = result[0]
998 url_ok = False
999 if name_ok:
1000 result = self.set_url.do()
1001 url_ok = result[0]
1002 return name_ok, url_ok
1005 class RemoveFromSettings(ConfirmAction):
1006 def __init__(self, context, repo, entry, icon=None):
1007 super(RemoveFromSettings, self).__init__(context)
1008 self.context = context
1009 self.repo = repo
1010 self.entry = entry
1011 self.icon = icon
1013 def success(self):
1014 self.context.settings.save()
1017 class RemoveBookmark(RemoveFromSettings):
1018 def confirm(self):
1019 entry = self.entry
1020 title = msg = N_('Delete Bookmark?')
1021 info = N_('%s will be removed from your bookmarks.') % entry
1022 ok_text = N_('Delete Bookmark')
1023 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
1025 def action(self):
1026 self.context.settings.remove_bookmark(self.repo, self.entry)
1027 return (0, '', '')
1030 class RemoveRecent(RemoveFromSettings):
1031 def confirm(self):
1032 repo = self.repo
1033 title = msg = N_('Remove %s from the recent list?') % repo
1034 info = N_('%s will be removed from your recent repositories.') % repo
1035 ok_text = N_('Remove')
1036 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
1038 def action(self):
1039 self.context.settings.remove_recent(self.repo)
1040 return (0, '', '')
1043 class RemoveFiles(ContextCommand):
1044 """Removes files"""
1046 def __init__(self, context, remover, filenames):
1047 super(RemoveFiles, self).__init__(context)
1048 if remover is None:
1049 remover = os.remove
1050 self.remover = remover
1051 self.filenames = filenames
1052 # We could git-hash-object stuff and provide undo-ability
1053 # as an option. Heh.
1055 def do(self):
1056 files = self.filenames
1057 if not files:
1058 return
1060 rescan = False
1061 bad_filenames = []
1062 remove = self.remover
1063 for filename in files:
1064 if filename:
1065 try:
1066 remove(filename)
1067 rescan = True
1068 except OSError:
1069 bad_filenames.append(filename)
1071 if bad_filenames:
1072 Interaction.information(
1073 N_('Error'), N_('Deleting "%s" failed') % file_summary(bad_filenames)
1076 if rescan:
1077 self.model.update_file_status()
1080 class Delete(RemoveFiles):
1081 """Delete files."""
1083 def __init__(self, context, filenames):
1084 super(Delete, self).__init__(context, os.remove, filenames)
1086 def do(self):
1087 files = self.filenames
1088 if not files:
1089 return
1091 title = N_('Delete Files?')
1092 msg = N_('The following files will be deleted:') + '\n\n'
1093 msg += file_summary(files)
1094 info_txt = N_('Delete %d file(s)?') % len(files)
1095 ok_txt = N_('Delete Files')
1097 if Interaction.confirm(
1098 title, msg, info_txt, ok_txt, default=True, icon=icons.remove()
1100 super(Delete, self).do()
1103 class MoveToTrash(RemoveFiles):
1104 """Move files to the trash using send2trash"""
1106 AVAILABLE = send2trash is not None
1108 def __init__(self, context, filenames):
1109 super(MoveToTrash, self).__init__(context, send2trash, filenames)
1112 class DeleteBranch(ConfirmAction):
1113 """Delete a git branch."""
1115 def __init__(self, context, branch):
1116 super(DeleteBranch, self).__init__(context)
1117 self.branch = branch
1119 def confirm(self):
1120 title = N_('Delete Branch')
1121 question = N_('Delete branch "%s"?') % self.branch
1122 info = N_('The branch will be no longer available.')
1123 ok_txt = N_('Delete Branch')
1124 return Interaction.confirm(
1125 title, question, info, ok_txt, default=True, icon=icons.discard()
1128 def action(self):
1129 return self.model.delete_branch(self.branch)
1131 def error_message(self):
1132 return N_('Error deleting branch "%s"' % self.branch)
1134 def command(self):
1135 command = 'git branch -D %s'
1136 return command % self.branch
1139 class Rename(ContextCommand):
1140 """Rename a set of paths."""
1142 def __init__(self, context, paths):
1143 super(Rename, self).__init__(context)
1144 self.paths = paths
1146 def do(self):
1147 msg = N_('Untracking: %s') % (', '.join(self.paths))
1148 Interaction.log(msg)
1150 for path in self.paths:
1151 ok = self.rename(path)
1152 if not ok:
1153 return
1155 self.model.update_status()
1157 def rename(self, path):
1158 git = self.git
1159 title = N_('Rename "%s"') % path
1161 if os.path.isdir(path):
1162 base_path = os.path.dirname(path)
1163 else:
1164 base_path = path
1165 new_path = Interaction.save_as(base_path, title)
1166 if not new_path:
1167 return False
1169 status, out, err = git.mv(path, new_path, force=True, verbose=True)
1170 Interaction.command(N_('Error'), 'git mv', status, out, err)
1171 return status == 0
1174 class RenameBranch(ContextCommand):
1175 """Rename a git branch."""
1177 def __init__(self, context, branch, new_branch):
1178 super(RenameBranch, self).__init__(context)
1179 self.branch = branch
1180 self.new_branch = new_branch
1182 def do(self):
1183 branch = self.branch
1184 new_branch = self.new_branch
1185 status, out, err = self.model.rename_branch(branch, new_branch)
1186 Interaction.log_status(status, out, err)
1189 class DeleteRemoteBranch(DeleteBranch):
1190 """Delete a remote git branch."""
1192 def __init__(self, context, remote, branch):
1193 super(DeleteRemoteBranch, self).__init__(context, branch)
1194 self.remote = remote
1196 def action(self):
1197 return self.git.push(self.remote, self.branch, delete=True)
1199 def success(self):
1200 self.model.update_status()
1201 Interaction.information(
1202 N_('Remote Branch Deleted'),
1203 N_('"%(branch)s" has been deleted from "%(remote)s".')
1204 % dict(branch=self.branch, remote=self.remote),
1207 def error_message(self):
1208 return N_('Error Deleting Remote Branch')
1210 def command(self):
1211 command = 'git push --delete %s %s'
1212 return command % (self.remote, self.branch)
1215 def get_mode(context, filename, staged, modified, unmerged, untracked):
1216 model = context.model
1217 if staged:
1218 mode = model.mode_index
1219 elif modified or unmerged:
1220 mode = model.mode_worktree
1221 elif untracked:
1222 if gitcmds.is_binary(context, filename):
1223 mode = model.mode_untracked
1224 else:
1225 mode = model.mode_untracked_diff
1226 else:
1227 mode = model.mode
1228 return mode
1231 class DiffAgainstCommitMode(ContextCommand):
1232 """Diff against arbitrary commits"""
1234 def __init__(self, context, oid):
1235 super(DiffAgainstCommitMode, self).__init__(context)
1236 self.oid = oid
1238 def do(self):
1239 self.model.set_mode(self.model.mode_diff, head=self.oid)
1240 self.model.update_file_status()
1243 class DiffText(EditModel):
1244 """Set the diff type to text"""
1246 def __init__(self, context):
1247 super(DiffText, self).__init__(context)
1248 self.new_file_type = main.Types.TEXT
1249 self.new_diff_type = main.Types.TEXT
1252 class ToggleDiffType(ContextCommand):
1253 """Toggle the diff type between image and text"""
1255 def __init__(self, context):
1256 super(ToggleDiffType, self).__init__(context)
1257 if self.model.diff_type == main.Types.IMAGE:
1258 self.new_diff_type = main.Types.TEXT
1259 self.new_value = False
1260 else:
1261 self.new_diff_type = main.Types.IMAGE
1262 self.new_value = True
1264 def do(self):
1265 diff_type = self.new_diff_type
1266 value = self.new_value
1268 self.model.set_diff_type(diff_type)
1270 filename = self.model.filename
1271 _, ext = os.path.splitext(filename)
1272 if ext.startswith('.'):
1273 cfg = 'cola.imagediff' + ext
1274 self.cfg.set_repo(cfg, value)
1277 class DiffImage(EditModel):
1278 def __init__(
1279 self, context, filename, deleted, staged, modified, unmerged, untracked
1281 super(DiffImage, self).__init__(context)
1283 self.new_filename = filename
1284 self.new_diff_type = self.get_diff_type(filename)
1285 self.new_file_type = main.Types.IMAGE
1286 self.new_mode = get_mode(
1287 context, filename, staged, modified, unmerged, untracked
1289 self.staged = staged
1290 self.modified = modified
1291 self.unmerged = unmerged
1292 self.untracked = untracked
1293 self.deleted = deleted
1294 self.annex = self.cfg.is_annex()
1296 def get_diff_type(self, filename):
1297 """Query the diff type to use based on cola.imagediff.<extension>"""
1298 _, ext = os.path.splitext(filename)
1299 if ext.startswith('.'):
1300 # Check eg. "cola.imagediff.svg" to see if we should imagediff.
1301 cfg = 'cola.imagediff' + ext
1302 if self.cfg.get(cfg, True):
1303 result = main.Types.IMAGE
1304 else:
1305 result = main.Types.TEXT
1306 else:
1307 result = main.Types.IMAGE
1308 return result
1310 def do(self):
1311 filename = self.new_filename
1313 if self.staged:
1314 images = self.staged_images()
1315 elif self.modified:
1316 images = self.modified_images()
1317 elif self.unmerged:
1318 images = self.unmerged_images()
1319 elif self.untracked:
1320 images = [(filename, False)]
1321 else:
1322 images = []
1324 self.model.set_images(images)
1325 super(DiffImage, self).do()
1327 def staged_images(self):
1328 context = self.context
1329 git = self.git
1330 head = self.model.head
1331 filename = self.new_filename
1332 annex = self.annex
1334 images = []
1335 index = git.diff_index(head, '--', filename, cached=True)[STDOUT]
1336 if index:
1337 # Example:
1338 # :100644 100644 fabadb8... 4866510... M describe.c
1339 parts = index.split(' ')
1340 if len(parts) > 3:
1341 old_oid = parts[2]
1342 new_oid = parts[3]
1344 if old_oid != MISSING_BLOB_OID:
1345 # First, check if we can get a pre-image from git-annex
1346 annex_image = None
1347 if annex:
1348 annex_image = gitcmds.annex_path(context, head, filename)
1349 if annex_image:
1350 images.append((annex_image, False)) # git annex HEAD
1351 else:
1352 image = gitcmds.write_blob_path(context, head, old_oid, filename)
1353 if image:
1354 images.append((image, True))
1356 if new_oid != MISSING_BLOB_OID:
1357 found_in_annex = False
1358 if annex and core.islink(filename):
1359 status, out, _ = git.annex('status', '--', filename)
1360 if status == 0:
1361 details = out.split(' ')
1362 if details and details[0] == 'A': # newly added file
1363 images.append((filename, False))
1364 found_in_annex = True
1366 if not found_in_annex:
1367 image = gitcmds.write_blob(context, new_oid, filename)
1368 if image:
1369 images.append((image, True))
1371 return images
1373 def unmerged_images(self):
1374 context = self.context
1375 git = self.git
1376 head = self.model.head
1377 filename = self.new_filename
1378 annex = self.annex
1380 candidate_merge_heads = ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1381 merge_heads = [
1382 merge_head
1383 for merge_head in candidate_merge_heads
1384 if core.exists(git.git_path(merge_head))
1387 if annex: # Attempt to find files in git-annex
1388 annex_images = []
1389 for merge_head in merge_heads:
1390 image = gitcmds.annex_path(context, merge_head, filename)
1391 if image:
1392 annex_images.append((image, False))
1393 if annex_images:
1394 annex_images.append((filename, False))
1395 return annex_images
1397 # DIFF FORMAT FOR MERGES
1398 # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1399 # can take -c or --cc option to generate diff output also
1400 # for merge commits. The output differs from the format
1401 # described above in the following way:
1403 # 1. there is a colon for each parent
1404 # 2. there are more "src" modes and "src" sha1
1405 # 3. status is concatenated status characters for each parent
1406 # 4. no optional "score" number
1407 # 5. single path, only for "dst"
1408 # Example:
1409 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1410 # MM describe.c
1411 images = []
1412 index = git.diff_index(head, '--', filename, cached=True, cc=True)[STDOUT]
1413 if index:
1414 parts = index.split(' ')
1415 if len(parts) > 3:
1416 first_mode = parts[0]
1417 num_parents = first_mode.count(':')
1418 # colon for each parent, but for the index, the "parents"
1419 # are really entries in stages 1,2,3 (head, base, remote)
1420 # remote, base, head
1421 for i in range(num_parents):
1422 offset = num_parents + i + 1
1423 oid = parts[offset]
1424 try:
1425 merge_head = merge_heads[i]
1426 except IndexError:
1427 merge_head = 'HEAD'
1428 if oid != MISSING_BLOB_OID:
1429 image = gitcmds.write_blob_path(
1430 context, merge_head, oid, filename
1432 if image:
1433 images.append((image, True))
1435 images.append((filename, False))
1436 return images
1438 def modified_images(self):
1439 context = self.context
1440 git = self.git
1441 head = self.model.head
1442 filename = self.new_filename
1443 annex = self.annex
1445 images = []
1446 annex_image = None
1447 if annex: # Check for a pre-image from git-annex
1448 annex_image = gitcmds.annex_path(context, head, filename)
1449 if annex_image:
1450 images.append((annex_image, False)) # git annex HEAD
1451 else:
1452 worktree = git.diff_files('--', filename)[STDOUT]
1453 parts = worktree.split(' ')
1454 if len(parts) > 3:
1455 oid = parts[2]
1456 if oid != MISSING_BLOB_OID:
1457 image = gitcmds.write_blob_path(context, head, oid, filename)
1458 if image:
1459 images.append((image, True)) # HEAD
1461 images.append((filename, False)) # worktree
1462 return images
1465 class Diff(EditModel):
1466 """Perform a diff and set the model's current text."""
1468 def __init__(self, context, filename, cached=False, deleted=False):
1469 super(Diff, self).__init__(context)
1470 opts = {}
1471 if cached and gitcmds.is_valid_ref(context, self.model.head):
1472 opts['ref'] = self.model.head
1473 self.new_filename = filename
1474 self.new_mode = self.model.mode_worktree
1475 self.new_diff_text = gitcmds.diff_helper(
1476 self.context, filename=filename, cached=cached, deleted=deleted, **opts
1480 class Diffstat(EditModel):
1481 """Perform a diffstat and set the model's diff text."""
1483 def __init__(self, context):
1484 super(Diffstat, self).__init__(context)
1485 cfg = self.cfg
1486 diff_context = cfg.get('diff.context', 3)
1487 diff = self.git.diff(
1488 self.model.head,
1489 unified=diff_context,
1490 no_ext_diff=True,
1491 no_color=True,
1492 M=True,
1493 stat=True,
1494 )[STDOUT]
1495 self.new_diff_text = diff
1496 self.new_diff_type = main.Types.TEXT
1497 self.new_file_type = main.Types.TEXT
1498 self.new_mode = self.model.mode_diffstat
1501 class DiffStaged(Diff):
1502 """Perform a staged diff on a file."""
1504 def __init__(self, context, filename, deleted=None):
1505 super(DiffStaged, self).__init__(
1506 context, filename, cached=True, deleted=deleted
1508 self.new_mode = self.model.mode_index
1511 class DiffStagedSummary(EditModel):
1512 def __init__(self, context):
1513 super(DiffStagedSummary, self).__init__(context)
1514 diff = self.git.diff(
1515 self.model.head,
1516 cached=True,
1517 no_color=True,
1518 no_ext_diff=True,
1519 patch_with_stat=True,
1520 M=True,
1521 )[STDOUT]
1522 self.new_diff_text = diff
1523 self.new_diff_type = main.Types.TEXT
1524 self.new_file_type = main.Types.TEXT
1525 self.new_mode = self.model.mode_index
1528 class Difftool(ContextCommand):
1529 """Run git-difftool limited by path."""
1531 def __init__(self, context, staged, filenames):
1532 super(Difftool, self).__init__(context)
1533 self.staged = staged
1534 self.filenames = filenames
1536 def do(self):
1537 difftool_launch_with_head(
1538 self.context, self.filenames, self.staged, self.model.head
1542 class Edit(ContextCommand):
1543 """Edit a file using the configured gui.editor."""
1545 @staticmethod
1546 def name():
1547 return N_('Launch Editor')
1549 def __init__(self, context, filenames, line_number=None, background_editor=False):
1550 super(Edit, self).__init__(context)
1551 self.filenames = filenames
1552 self.line_number = line_number
1553 self.background_editor = background_editor
1555 def do(self):
1556 context = self.context
1557 if not self.filenames:
1558 return
1559 filename = self.filenames[0]
1560 if not core.exists(filename):
1561 return
1562 if self.background_editor:
1563 editor = prefs.background_editor(context)
1564 else:
1565 editor = prefs.editor(context)
1566 opts = []
1568 if self.line_number is None:
1569 opts = self.filenames
1570 else:
1571 # Single-file w/ line-numbers (likely from grep)
1572 editor_opts = {
1573 '*vim*': [filename, '+%s' % self.line_number],
1574 '*emacs*': ['+%s' % self.line_number, filename],
1575 '*textpad*': ['%s(%s,0)' % (filename, self.line_number)],
1576 '*notepad++*': ['-n%s' % self.line_number, filename],
1577 '*subl*': ['%s:%s' % (filename, self.line_number)],
1580 opts = self.filenames
1581 for pattern, opt in editor_opts.items():
1582 if fnmatch(editor, pattern):
1583 opts = opt
1584 break
1586 try:
1587 core.fork(utils.shell_split(editor) + opts)
1588 except (OSError, ValueError) as e:
1589 message = N_('Cannot exec "%s": please configure your editor') % editor
1590 _, details = utils.format_exception(e)
1591 Interaction.critical(N_('Error Editing File'), message, details)
1594 class FormatPatch(ContextCommand):
1595 """Output a patch series given all revisions and a selected subset."""
1597 def __init__(self, context, to_export, revs, output='patches'):
1598 super(FormatPatch, self).__init__(context)
1599 self.to_export = list(to_export)
1600 self.revs = list(revs)
1601 self.output = output
1603 def do(self):
1604 context = self.context
1605 status, out, err = gitcmds.format_patchsets(
1606 context, self.to_export, self.revs, self.output
1608 Interaction.log_status(status, out, err)
1611 class LaunchDifftool(ContextCommand):
1612 @staticmethod
1613 def name():
1614 return N_('Launch Diff Tool')
1616 def do(self):
1617 s = self.selection.selection()
1618 if s.unmerged:
1619 paths = s.unmerged
1620 if utils.is_win32():
1621 core.fork(['git', 'mergetool', '--no-prompt', '--'] + paths)
1622 else:
1623 cfg = self.cfg
1624 cmd = cfg.terminal()
1625 argv = utils.shell_split(cmd)
1627 terminal = os.path.basename(argv[0])
1628 shellquote_terms = set(['xfce4-terminal'])
1629 shellquote_default = terminal in shellquote_terms
1631 mergetool = ['git', 'mergetool', '--no-prompt', '--']
1632 mergetool.extend(paths)
1633 needs_shellquote = cfg.get(
1634 'cola.terminalshellquote', shellquote_default
1637 if needs_shellquote:
1638 argv.append(core.list2cmdline(mergetool))
1639 else:
1640 argv.extend(mergetool)
1642 core.fork(argv)
1643 else:
1644 difftool_run(self.context)
1647 class LaunchTerminal(ContextCommand):
1648 @staticmethod
1649 def name():
1650 return N_('Launch Terminal')
1652 @staticmethod
1653 def is_available(context):
1654 return context.cfg.terminal() is not None
1656 def __init__(self, context, path):
1657 super(LaunchTerminal, self).__init__(context)
1658 self.path = path
1660 def do(self):
1661 cmd = self.context.cfg.terminal()
1662 if cmd is None:
1663 return
1664 if utils.is_win32():
1665 argv = ['start', '', cmd, '--login']
1666 shell = True
1667 else:
1668 argv = utils.shell_split(cmd)
1669 command = '/bin/sh'
1670 shells = ('zsh', 'fish', 'bash', 'sh')
1671 for basename in shells:
1672 executable = core.find_executable(basename)
1673 if executable:
1674 command = executable
1675 break
1676 argv.append(os.getenv('SHELL', command))
1677 shell = False
1679 core.fork(argv, cwd=self.path, shell=shell)
1682 class LaunchEditor(Edit):
1683 @staticmethod
1684 def name():
1685 return N_('Launch Editor')
1687 def __init__(self, context):
1688 s = context.selection.selection()
1689 filenames = s.staged + s.unmerged + s.modified + s.untracked
1690 super(LaunchEditor, self).__init__(context, filenames, background_editor=True)
1693 class LaunchEditorAtLine(LaunchEditor):
1694 """Launch an editor at the specified line"""
1696 def __init__(self, context):
1697 super(LaunchEditorAtLine, self).__init__(context)
1698 self.line_number = context.selection.line_number
1701 class LoadCommitMessageFromFile(ContextCommand):
1702 """Loads a commit message from a path."""
1704 UNDOABLE = True
1706 def __init__(self, context, path):
1707 super(LoadCommitMessageFromFile, self).__init__(context)
1708 self.path = path
1709 self.old_commitmsg = self.model.commitmsg
1710 self.old_directory = self.model.directory
1712 def do(self):
1713 path = os.path.expanduser(self.path)
1714 if not path or not core.isfile(path):
1715 raise UsageError(
1716 N_('Error: Cannot find commit template'),
1717 N_('%s: No such file or directory.') % path,
1719 self.model.set_directory(os.path.dirname(path))
1720 self.model.set_commitmsg(core.read(path))
1722 def undo(self):
1723 self.model.set_commitmsg(self.old_commitmsg)
1724 self.model.set_directory(self.old_directory)
1727 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile):
1728 """Loads the commit message template specified by commit.template."""
1730 def __init__(self, context):
1731 cfg = context.cfg
1732 template = cfg.get('commit.template')
1733 super(LoadCommitMessageFromTemplate, self).__init__(context, template)
1735 def do(self):
1736 if self.path is None:
1737 raise UsageError(
1738 N_('Error: Unconfigured commit template'),
1740 'A commit template has not been configured.\n'
1741 'Use "git config" to define "commit.template"\n'
1742 'so that it points to a commit template.'
1745 return LoadCommitMessageFromFile.do(self)
1748 class LoadCommitMessageFromOID(ContextCommand):
1749 """Load a previous commit message"""
1751 UNDOABLE = True
1753 def __init__(self, context, oid, prefix=''):
1754 super(LoadCommitMessageFromOID, self).__init__(context)
1755 self.oid = oid
1756 self.old_commitmsg = self.model.commitmsg
1757 self.new_commitmsg = prefix + gitcmds.prev_commitmsg(context, oid)
1759 def do(self):
1760 self.model.set_commitmsg(self.new_commitmsg)
1762 def undo(self):
1763 self.model.set_commitmsg(self.old_commitmsg)
1766 class PrepareCommitMessageHook(ContextCommand):
1767 """Use the cola-prepare-commit-msg hook to prepare the commit message"""
1769 UNDOABLE = True
1771 def __init__(self, context):
1772 super(PrepareCommitMessageHook, self).__init__(context)
1773 self.old_commitmsg = self.model.commitmsg
1775 def get_message(self):
1777 title = N_('Error running prepare-commitmsg hook')
1778 hook = gitcmds.prepare_commit_message_hook(self.context)
1780 if os.path.exists(hook):
1781 filename = self.model.save_commitmsg()
1782 status, out, err = core.run_command([hook, filename])
1784 if status == 0:
1785 result = core.read(filename)
1786 else:
1787 result = self.old_commitmsg
1788 Interaction.command_error(title, hook, status, out, err)
1789 else:
1790 message = N_('A hook must be provided at "%s"') % hook
1791 Interaction.critical(title, message=message)
1792 result = self.old_commitmsg
1794 return result
1796 def do(self):
1797 msg = self.get_message()
1798 self.model.set_commitmsg(msg)
1800 def undo(self):
1801 self.model.set_commitmsg(self.old_commitmsg)
1804 class LoadFixupMessage(LoadCommitMessageFromOID):
1805 """Load a fixup message"""
1807 def __init__(self, context, oid):
1808 super(LoadFixupMessage, self).__init__(context, oid, prefix='fixup! ')
1809 if self.new_commitmsg:
1810 self.new_commitmsg = self.new_commitmsg.splitlines()[0]
1813 class Merge(ContextCommand):
1814 """Merge commits"""
1816 def __init__(self, context, revision, no_commit, squash, no_ff, sign):
1817 super(Merge, self).__init__(context)
1818 self.revision = revision
1819 self.no_ff = no_ff
1820 self.no_commit = no_commit
1821 self.squash = squash
1822 self.sign = sign
1824 def do(self):
1825 squash = self.squash
1826 revision = self.revision
1827 no_ff = self.no_ff
1828 no_commit = self.no_commit
1829 sign = self.sign
1831 status, out, err = self.git.merge(
1832 revision, gpg_sign=sign, no_ff=no_ff, no_commit=no_commit, squash=squash
1834 self.model.update_status()
1835 title = N_('Merge failed. Conflict resolution is required.')
1836 Interaction.command(title, 'git merge', status, out, err)
1838 return status, out, err
1841 class OpenDefaultApp(ContextCommand):
1842 """Open a file using the OS default."""
1844 @staticmethod
1845 def name():
1846 return N_('Open Using Default Application')
1848 def __init__(self, context, filenames):
1849 super(OpenDefaultApp, self).__init__(context)
1850 self.filenames = filenames
1852 def do(self):
1853 if not self.filenames:
1854 return
1855 utils.launch_default_app(self.filenames)
1858 class OpenDir(OpenDefaultApp):
1859 """Open directories using the OS default."""
1861 @staticmethod
1862 def name():
1863 return N_('Open Directory')
1865 @property
1866 def _dirnames(self):
1867 return self.filenames
1869 def do(self):
1870 dirnames = self._dirnames
1871 if not dirnames:
1872 return
1873 # An empty dirname defaults to CWD.
1874 dirs = [(dirname or core.getcwd()) for dirname in dirnames]
1875 utils.launch_default_app(dirs)
1878 class OpenParentDir(OpenDir):
1879 """Open parent directories using the OS default."""
1881 @staticmethod
1882 def name():
1883 return N_('Open Parent Directory')
1885 @property
1886 def _dirnames(self):
1887 dirnames = list(set([os.path.dirname(x) for x in self.filenames]))
1888 return dirnames
1891 class OpenWorktree(OpenDir):
1892 """Open worktree directory using the OS default."""
1894 @staticmethod
1895 def name():
1896 return N_('Open Worktree')
1898 # The _unused parameter is needed by worktree_dir_action() -> common.cmd_action().
1899 def __init__(self, context, _unused=None):
1900 dirnames = [context.git.worktree()]
1901 super(OpenWorktree, self).__init__(context, dirnames)
1904 class OpenNewRepo(ContextCommand):
1905 """Launches git-cola on a repo."""
1907 def __init__(self, context, repo_path):
1908 super(OpenNewRepo, self).__init__(context)
1909 self.repo_path = repo_path
1911 def do(self):
1912 self.model.set_directory(self.repo_path)
1913 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
1916 class OpenRepo(EditModel):
1917 def __init__(self, context, repo_path):
1918 super(OpenRepo, self).__init__(context)
1919 self.repo_path = repo_path
1920 self.new_mode = self.model.mode_none
1921 self.new_diff_text = ''
1922 self.new_diff_type = main.Types.TEXT
1923 self.new_file_type = main.Types.TEXT
1924 self.new_commitmsg = ''
1925 self.new_filename = ''
1927 def do(self):
1928 old_repo = self.git.getcwd()
1929 if self.model.set_worktree(self.repo_path):
1930 self.fsmonitor.stop()
1931 self.fsmonitor.start()
1932 self.model.update_status(reset=True)
1933 # Check if template should be loaded
1934 if self.context.cfg.get(prefs.AUTOTEMPLATE):
1935 template_loader = LoadCommitMessageFromTemplate(self.context)
1936 template_loader.do()
1937 else:
1938 self.model.set_commitmsg(self.new_commitmsg)
1939 settings = self.context.settings
1940 settings.load()
1941 settings.add_recent(self.repo_path, prefs.maxrecent(self.context))
1942 settings.save()
1943 super(OpenRepo, self).do()
1944 else:
1945 self.model.set_worktree(old_repo)
1948 class OpenParentRepo(OpenRepo):
1949 def __init__(self, context):
1950 path = ''
1951 if version.check_git(context, 'show-superproject-working-tree'):
1952 status, out, _ = context.git.rev_parse(show_superproject_working_tree=True)
1953 if status == 0:
1954 path = out
1955 if not path:
1956 path = os.path.dirname(core.getcwd())
1957 super(OpenParentRepo, self).__init__(context, path)
1960 class Clone(ContextCommand):
1961 """Clones a repository and optionally spawns a new cola session."""
1963 def __init__(
1964 self, context, url, new_directory, submodules=False, shallow=False, spawn=True
1966 super(Clone, self).__init__(context)
1967 self.url = url
1968 self.new_directory = new_directory
1969 self.submodules = submodules
1970 self.shallow = shallow
1971 self.spawn = spawn
1972 self.status = -1
1973 self.out = ''
1974 self.err = ''
1976 def do(self):
1977 kwargs = {}
1978 if self.shallow:
1979 kwargs['depth'] = 1
1980 recurse_submodules = self.submodules
1981 shallow_submodules = self.submodules and self.shallow
1983 status, out, err = self.git.clone(
1984 self.url,
1985 self.new_directory,
1986 recurse_submodules=recurse_submodules,
1987 shallow_submodules=shallow_submodules,
1988 **kwargs
1991 self.status = status
1992 self.out = out
1993 self.err = err
1994 if status == 0 and self.spawn:
1995 executable = sys.executable
1996 core.fork([executable, sys.argv[0], '--repo', self.new_directory])
1997 return self
2000 class NewBareRepo(ContextCommand):
2001 """Create a new shared bare repository"""
2003 def __init__(self, context, path):
2004 super(NewBareRepo, self).__init__(context)
2005 self.path = path
2007 def do(self):
2008 path = self.path
2009 status, out, err = self.git.init(path, bare=True, shared=True)
2010 Interaction.command(
2011 N_('Error'), 'git init --bare --shared "%s"' % path, status, out, err
2013 return status == 0
2016 def unix_path(path, is_win32=utils.is_win32):
2017 """Git for Windows requires unix paths, so force them here"""
2018 if is_win32():
2019 path = path.replace('\\', '/')
2020 first = path[0]
2021 second = path[1]
2022 if second == ':': # sanity check, this better be a Windows-style path
2023 path = '/' + first + path[2:]
2025 return path
2028 def sequence_editor():
2029 """Set GIT_SEQUENCE_EDITOR for running git-cola-sequence-editor"""
2030 xbase = unix_path(resources.command('git-cola-sequence-editor'))
2031 if utils.is_win32():
2032 editor = core.list2cmdline([unix_path(sys.executable), xbase])
2033 else:
2034 editor = core.list2cmdline([xbase])
2035 return editor
2038 class SequenceEditorEnvironment(object):
2039 """Set environment variables to enable git-cola-sequence-editor"""
2041 def __init__(self, context, **kwargs):
2042 self.env = {
2043 'GIT_EDITOR': prefs.editor(context),
2044 'GIT_SEQUENCE_EDITOR': sequence_editor(),
2045 'GIT_COLA_SEQ_EDITOR_CANCEL_ACTION': 'save',
2047 self.env.update(kwargs)
2049 def __enter__(self):
2050 for var, value in self.env.items():
2051 compat.setenv(var, value)
2052 return self
2054 def __exit__(self, exc_type, exc_val, exc_tb):
2055 for var in self.env:
2056 compat.unsetenv(var)
2059 class Rebase(ContextCommand):
2060 def __init__(self, context, upstream=None, branch=None, **kwargs):
2061 """Start an interactive rebase session
2063 :param upstream: upstream branch
2064 :param branch: optional branch to checkout
2065 :param kwargs: forwarded directly to `git.rebase()`
2068 super(Rebase, self).__init__(context)
2070 self.upstream = upstream
2071 self.branch = branch
2072 self.kwargs = kwargs
2074 def prepare_arguments(self, upstream):
2075 args = []
2076 kwargs = {}
2078 # Rebase actions must be the only option specified
2079 for action in ('continue', 'abort', 'skip', 'edit_todo'):
2080 if self.kwargs.get(action, False):
2081 kwargs[action] = self.kwargs[action]
2082 return args, kwargs
2084 kwargs['interactive'] = True
2085 kwargs['autosquash'] = self.kwargs.get('autosquash', True)
2086 kwargs.update(self.kwargs)
2088 # Prompt to determine whether or not to use "git rebase --update-refs".
2089 has_update_refs = version.check_git(self.context, 'rebase-update-refs')
2090 if has_update_refs and not kwargs.get('update_refs', False):
2091 title = N_('Update stacked branches when rebasing?')
2092 text = N_(
2093 '"git rebase --update-refs" automatically force-updates any\n'
2094 'branches that point to commits that are being rebased.\n\n'
2095 'Any branches that are checked out in a worktree are not updated.\n\n'
2096 'Using this feature is helpful for "stacked" branch workflows.'
2098 info = N_('Update stacked branches when rebasing?')
2099 ok_text = N_('Update stacked branches')
2100 cancel_text = N_('Do not update stacked branches')
2101 update_refs = Interaction.confirm(
2102 title,
2103 text,
2104 info,
2105 ok_text,
2106 default=True,
2107 cancel_text=cancel_text,
2109 if update_refs:
2110 kwargs['update_refs'] = True
2112 if upstream:
2113 args.append(upstream)
2114 if self.branch:
2115 args.append(self.branch)
2117 return args, kwargs
2119 def do(self):
2120 (status, out, err) = (1, '', '')
2121 context = self.context
2122 cfg = self.cfg
2123 model = self.model
2125 if not cfg.get('rebase.autostash', False):
2126 if model.staged or model.unmerged or model.modified:
2127 Interaction.information(
2128 N_('Unable to rebase'),
2129 N_('You cannot rebase with uncommitted changes.'),
2131 return status, out, err
2133 upstream = self.upstream or Interaction.choose_ref(
2134 context,
2135 N_('Select New Upstream'),
2136 N_('Interactive Rebase'),
2137 default='@{upstream}',
2139 if not upstream:
2140 return status, out, err
2142 self.model.is_rebasing = True
2143 self.model.emit_updated()
2145 args, kwargs = self.prepare_arguments(upstream)
2146 upstream_title = upstream or '@{upstream}'
2147 with SequenceEditorEnvironment(
2148 self.context,
2149 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase onto %s') % upstream_title,
2150 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
2152 # TODO this blocks the user interface window for the duration
2153 # of git-cola-sequence-editor. We would need to implement
2154 # signals for QProcess and continue running the main thread.
2155 # Alternatively, we can hide the main window while rebasing.
2156 # That doesn't require as much effort.
2157 status, out, err = self.git.rebase(
2158 *args, _no_win32_startupinfo=True, **kwargs
2160 self.model.update_status()
2161 if err.strip() != 'Nothing to do':
2162 title = N_('Rebase stopped')
2163 Interaction.command(title, 'git rebase', status, out, err)
2164 return status, out, err
2167 class RebaseEditTodo(ContextCommand):
2168 def do(self):
2169 (status, out, err) = (1, '', '')
2170 with SequenceEditorEnvironment(
2171 self.context,
2172 GIT_COLA_SEQ_EDITOR_TITLE=N_('Edit Rebase'),
2173 GIT_COLA_SEQ_EDITOR_ACTION=N_('Save'),
2175 status, out, err = self.git.rebase(edit_todo=True)
2176 Interaction.log_status(status, out, err)
2177 self.model.update_status()
2178 return status, out, err
2181 class RebaseContinue(ContextCommand):
2182 def do(self):
2183 (status, out, err) = (1, '', '')
2184 with SequenceEditorEnvironment(
2185 self.context,
2186 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase'),
2187 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
2189 status, out, err = self.git.rebase('--continue')
2190 Interaction.log_status(status, out, err)
2191 self.model.update_status()
2192 return status, out, err
2195 class RebaseSkip(ContextCommand):
2196 def do(self):
2197 (status, out, err) = (1, '', '')
2198 with SequenceEditorEnvironment(
2199 self.context,
2200 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase'),
2201 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
2203 status, out, err = self.git.rebase(skip=True)
2204 Interaction.log_status(status, out, err)
2205 self.model.update_status()
2206 return status, out, err
2209 class RebaseAbort(ContextCommand):
2210 def do(self):
2211 status, out, err = self.git.rebase(abort=True)
2212 Interaction.log_status(status, out, err)
2213 self.model.update_status()
2216 class Rescan(ContextCommand):
2217 """Rescan for changes"""
2219 def do(self):
2220 self.model.update_status()
2223 class Refresh(ContextCommand):
2224 """Update refs, refresh the index, and update config"""
2226 @staticmethod
2227 def name():
2228 return N_('Refresh')
2230 def do(self):
2231 self.model.update_status(update_index=True)
2232 self.cfg.update()
2233 self.fsmonitor.refresh()
2236 class RefreshConfig(ContextCommand):
2237 """Refresh the git config cache"""
2239 def do(self):
2240 self.cfg.update()
2243 class RevertEditsCommand(ConfirmAction):
2244 def __init__(self, context):
2245 super(RevertEditsCommand, self).__init__(context)
2246 self.icon = icons.undo()
2248 def ok_to_run(self):
2249 return self.model.is_undoable()
2251 # pylint: disable=no-self-use
2252 def checkout_from_head(self):
2253 return False
2255 def checkout_args(self):
2256 args = []
2257 s = self.selection.selection()
2258 if self.checkout_from_head():
2259 args.append(self.model.head)
2260 args.append('--')
2262 if s.staged:
2263 items = s.staged
2264 else:
2265 items = s.modified
2266 args.extend(items)
2268 return args
2270 def action(self):
2271 checkout_args = self.checkout_args()
2272 return self.git.checkout(*checkout_args)
2274 def success(self):
2275 self.model.set_diff_type(main.Types.TEXT)
2276 self.model.update_file_status()
2279 class RevertUnstagedEdits(RevertEditsCommand):
2280 @staticmethod
2281 def name():
2282 return N_('Revert Unstaged Edits...')
2284 def checkout_from_head(self):
2285 # Being in amend mode should not affect the behavior of this command.
2286 # The only sensible thing to do is to checkout from the index.
2287 return False
2289 def confirm(self):
2290 title = N_('Revert Unstaged Changes?')
2291 text = N_(
2292 'This operation removes unstaged edits from selected files.\n'
2293 'These changes cannot be recovered.'
2295 info = N_('Revert the unstaged changes?')
2296 ok_text = N_('Revert Unstaged Changes')
2297 return Interaction.confirm(
2298 title, text, info, ok_text, default=True, icon=self.icon
2302 class RevertUncommittedEdits(RevertEditsCommand):
2303 @staticmethod
2304 def name():
2305 return N_('Revert Uncommitted Edits...')
2307 def checkout_from_head(self):
2308 return True
2310 def confirm(self):
2311 """Prompt for reverting changes"""
2312 title = N_('Revert Uncommitted Changes?')
2313 text = N_(
2314 'This operation removes uncommitted edits from selected files.\n'
2315 'These changes cannot be recovered.'
2317 info = N_('Revert the uncommitted changes?')
2318 ok_text = N_('Revert Uncommitted Changes')
2319 return Interaction.confirm(
2320 title, text, info, ok_text, default=True, icon=self.icon
2324 class RunConfigAction(ContextCommand):
2325 """Run a user-configured action, typically from the "Tools" menu"""
2327 def __init__(self, context, action_name):
2328 super(RunConfigAction, self).__init__(context)
2329 self.action_name = action_name
2331 def do(self):
2332 """Run the user-configured action"""
2333 for env in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
2334 try:
2335 compat.unsetenv(env)
2336 except KeyError:
2337 pass
2338 rev = None
2339 args = None
2340 context = self.context
2341 cfg = self.cfg
2342 opts = cfg.get_guitool_opts(self.action_name)
2343 cmd = opts.get('cmd')
2344 if 'title' not in opts:
2345 opts['title'] = cmd
2347 if 'prompt' not in opts or opts.get('prompt') is True:
2348 prompt = N_('Run "%s"?') % cmd
2349 opts['prompt'] = prompt
2351 if opts.get('needsfile'):
2352 filename = self.selection.filename()
2353 if not filename:
2354 Interaction.information(
2355 N_('Please select a file'),
2356 N_('"%s" requires a selected file.') % cmd,
2358 return False
2359 dirname = utils.dirname(filename, current_dir='.')
2360 compat.setenv('FILENAME', filename)
2361 compat.setenv('DIRNAME', dirname)
2363 if opts.get('revprompt') or opts.get('argprompt'):
2364 while True:
2365 ok = Interaction.confirm_config_action(context, cmd, opts)
2366 if not ok:
2367 return False
2368 rev = opts.get('revision')
2369 args = opts.get('args')
2370 if opts.get('revprompt') and not rev:
2371 title = N_('Invalid Revision')
2372 msg = N_('The revision expression cannot be empty.')
2373 Interaction.critical(title, msg)
2374 continue
2375 break
2377 elif opts.get('confirm'):
2378 title = os.path.expandvars(opts.get('title'))
2379 prompt = os.path.expandvars(opts.get('prompt'))
2380 if not Interaction.question(title, prompt):
2381 return False
2382 if rev:
2383 compat.setenv('REVISION', rev)
2384 if args:
2385 compat.setenv('ARGS', args)
2386 title = os.path.expandvars(cmd)
2387 Interaction.log(N_('Running command: %s') % title)
2388 cmd = ['sh', '-c', cmd]
2390 if opts.get('background'):
2391 core.fork(cmd)
2392 status, out, err = (0, '', '')
2393 elif opts.get('noconsole'):
2394 status, out, err = core.run_command(cmd)
2395 else:
2396 status, out, err = Interaction.run_command(title, cmd)
2398 if not opts.get('background') and not opts.get('norescan'):
2399 self.model.update_status()
2401 title = N_('Error')
2402 Interaction.command(title, cmd, status, out, err)
2404 return status == 0
2407 class SetDefaultRepo(ContextCommand):
2408 """Set the default repository"""
2410 def __init__(self, context, repo):
2411 super(SetDefaultRepo, self).__init__(context)
2412 self.repo = repo
2414 def do(self):
2415 self.cfg.set_user('cola.defaultrepo', self.repo)
2418 class SetDiffText(EditModel):
2419 """Set the diff text"""
2421 UNDOABLE = True
2423 def __init__(self, context, text):
2424 super(SetDiffText, self).__init__(context)
2425 self.new_diff_text = text
2426 self.new_diff_type = main.Types.TEXT
2427 self.new_file_type = main.Types.TEXT
2430 class SetUpstreamBranch(ContextCommand):
2431 """Set the upstream branch"""
2433 def __init__(self, context, branch, remote, remote_branch):
2434 super(SetUpstreamBranch, self).__init__(context)
2435 self.branch = branch
2436 self.remote = remote
2437 self.remote_branch = remote_branch
2439 def do(self):
2440 cfg = self.cfg
2441 remote = self.remote
2442 branch = self.branch
2443 remote_branch = self.remote_branch
2444 cfg.set_repo('branch.%s.remote' % branch, remote)
2445 cfg.set_repo('branch.%s.merge' % branch, 'refs/heads/' + remote_branch)
2448 def format_hex(data):
2449 """Translate binary data into a hex dump"""
2450 hexdigits = '0123456789ABCDEF'
2451 result = ''
2452 offset = 0
2453 byte_offset_to_int = compat.byte_offset_to_int_converter()
2454 while offset < len(data):
2455 result += '%04u |' % offset
2456 textpart = ''
2457 for i in range(0, 16):
2458 if i > 0 and i % 4 == 0:
2459 result += ' '
2460 if offset < len(data):
2461 v = byte_offset_to_int(data[offset])
2462 result += ' ' + hexdigits[v >> 4] + hexdigits[v & 0xF]
2463 textpart += chr(v) if 32 <= v < 127 else '.'
2464 offset += 1
2465 else:
2466 result += ' '
2467 textpart += ' '
2468 result += ' | ' + textpart + ' |\n'
2470 return result
2473 class ShowUntracked(EditModel):
2474 """Show an untracked file."""
2476 def __init__(self, context, filename):
2477 super(ShowUntracked, self).__init__(context)
2478 self.new_filename = filename
2479 if gitcmds.is_binary(context, filename):
2480 self.new_mode = self.model.mode_untracked
2481 self.new_diff_text = self.read(filename)
2482 else:
2483 self.new_mode = self.model.mode_untracked_diff
2484 self.new_diff_text = gitcmds.diff_helper(
2485 self.context, filename=filename, cached=False, untracked=True
2487 self.new_diff_type = main.Types.TEXT
2488 self.new_file_type = main.Types.TEXT
2490 def read(self, filename):
2491 """Read file contents"""
2492 cfg = self.cfg
2493 size = cfg.get('cola.readsize', 2048)
2494 try:
2495 result = core.read(filename, size=size, encoding='bytes')
2496 except (IOError, OSError):
2497 result = ''
2499 truncated = len(result) == size
2501 encoding = cfg.file_encoding(filename) or core.ENCODING
2502 try:
2503 text_result = core.decode_maybe(result, encoding)
2504 except UnicodeError:
2505 text_result = format_hex(result)
2507 if truncated:
2508 text_result += '...'
2509 return text_result
2512 class SignOff(ContextCommand):
2513 """Append a signoff to the commit message"""
2515 UNDOABLE = True
2517 @staticmethod
2518 def name():
2519 return N_('Sign Off')
2521 def __init__(self, context):
2522 super(SignOff, self).__init__(context)
2523 self.old_commitmsg = self.model.commitmsg
2525 def do(self):
2526 """Add a signoff to the commit message"""
2527 signoff = self.signoff()
2528 if signoff in self.model.commitmsg:
2529 return
2530 msg = self.model.commitmsg.rstrip()
2531 self.model.set_commitmsg(msg + '\n' + signoff)
2533 def undo(self):
2534 """Restore the commit message"""
2535 self.model.set_commitmsg(self.old_commitmsg)
2537 def signoff(self):
2538 """Generate the signoff string"""
2539 try:
2540 import pwd # pylint: disable=all
2542 user = pwd.getpwuid(os.getuid()).pw_name
2543 except ImportError:
2544 user = os.getenv('USER', N_('unknown'))
2546 cfg = self.cfg
2547 name = cfg.get('user.name', user)
2548 email = cfg.get('user.email', '%s@%s' % (user, core.node()))
2549 return '\nSigned-off-by: %s <%s>' % (name, email)
2552 def check_conflicts(context, unmerged):
2553 """Check paths for conflicts
2555 Conflicting files can be filtered out one-by-one.
2558 if prefs.check_conflicts(context):
2559 unmerged = [path for path in unmerged if is_conflict_free(path)]
2560 return unmerged
2563 def is_conflict_free(path):
2564 """Return True if `path` contains no conflict markers"""
2565 rgx = re.compile(r'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2566 try:
2567 with core.xopen(path, 'rb') as f:
2568 for line in f:
2569 line = core.decode(line, errors='ignore')
2570 if rgx.match(line):
2571 return should_stage_conflicts(path)
2572 except IOError:
2573 # We can't read this file ~ we may be staging a removal
2574 pass
2575 return True
2578 def should_stage_conflicts(path):
2579 """Inform the user that a file contains merge conflicts
2581 Return `True` if we should stage the path nonetheless.
2584 title = msg = N_('Stage conflicts?')
2585 info = (
2587 '%s appears to contain merge conflicts.\n\n'
2588 'You should probably skip this file.\n'
2589 'Stage it anyways?'
2591 % path
2593 ok_text = N_('Stage conflicts')
2594 cancel_text = N_('Skip')
2595 return Interaction.confirm(
2596 title, msg, info, ok_text, default=False, cancel_text=cancel_text
2600 class Stage(ContextCommand):
2601 """Stage a set of paths."""
2603 @staticmethod
2604 def name():
2605 return N_('Stage')
2607 def __init__(self, context, paths):
2608 super(Stage, self).__init__(context)
2609 self.paths = paths
2611 def do(self):
2612 msg = N_('Staging: %s') % (', '.join(self.paths))
2613 Interaction.log(msg)
2614 return self.stage_paths()
2616 def stage_paths(self):
2617 """Stages add/removals to git."""
2618 context = self.context
2619 paths = self.paths
2620 if not paths:
2621 if self.model.cfg.get('cola.safemode', False):
2622 return (0, '', '')
2623 return self.stage_all()
2625 add = []
2626 remove = []
2627 status = 0
2628 out = ''
2629 err = ''
2631 for path in set(paths):
2632 if core.exists(path) or core.islink(path):
2633 if path.endswith('/'):
2634 path = path.rstrip('/')
2635 add.append(path)
2636 else:
2637 remove.append(path)
2639 self.model.emit_about_to_update()
2641 # `git add -u` doesn't work on untracked files
2642 if add:
2643 status, out, err = gitcmds.add(context, add)
2644 Interaction.command(N_('Error'), 'git add', status, out, err)
2646 # If a path doesn't exist then that means it should be removed
2647 # from the index. We use `git add -u` for that.
2648 if remove:
2649 status, out, err = gitcmds.add(context, remove, u=True)
2650 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2652 self.model.update_files(emit=True)
2653 return status, out, err
2655 def stage_all(self):
2656 """Stage all files"""
2657 status, out, err = self.git.add(v=True, u=True)
2658 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2659 self.model.update_file_status()
2660 return (status, out, err)
2663 class StageCarefully(Stage):
2664 """Only stage when the path list is non-empty
2666 We use "git add -u -- <pathspec>" to stage, and it stages everything by
2667 default when no pathspec is specified, so this class ensures that paths
2668 are specified before calling git.
2670 When no paths are specified, the command does nothing.
2674 def __init__(self, context):
2675 super(StageCarefully, self).__init__(context, None)
2676 self.init_paths()
2678 # pylint: disable=no-self-use
2679 def init_paths(self):
2680 """Initialize path data"""
2681 return
2683 def ok_to_run(self):
2684 """Prevent catch-all "git add -u" from adding unmerged files"""
2685 return self.paths or not self.model.unmerged
2687 def do(self):
2688 """Stage files when ok_to_run() return True"""
2689 if self.ok_to_run():
2690 return super(StageCarefully, self).do()
2691 return (0, '', '')
2694 class StageModified(StageCarefully):
2695 """Stage all modified files."""
2697 @staticmethod
2698 def name():
2699 return N_('Stage Modified')
2701 def init_paths(self):
2702 self.paths = self.model.modified
2705 class StageUnmerged(StageCarefully):
2706 """Stage unmerged files."""
2708 @staticmethod
2709 def name():
2710 return N_('Stage Unmerged')
2712 def init_paths(self):
2713 self.paths = check_conflicts(self.context, self.model.unmerged)
2716 class StageUntracked(StageCarefully):
2717 """Stage all untracked files."""
2719 @staticmethod
2720 def name():
2721 return N_('Stage Untracked')
2723 def init_paths(self):
2724 self.paths = self.model.untracked
2727 class StageModifiedAndUntracked(StageCarefully):
2728 """Stage all untracked files."""
2730 @staticmethod
2731 def name():
2732 return N_('Stage Modified and Untracked')
2734 def init_paths(self):
2735 self.paths = self.model.modified + self.model.untracked
2738 class StageOrUnstageAll(ContextCommand):
2739 """If the selection is staged, unstage it, otherwise stage"""
2741 @staticmethod
2742 def name():
2743 return N_('Stage / Unstage All')
2745 def do(self):
2746 if self.model.staged:
2747 do(Unstage, self.context, self.model.staged)
2748 else:
2749 if self.cfg.get('cola.safemode', False):
2750 unstaged = self.model.modified
2751 else:
2752 unstaged = self.model.modified + self.model.untracked
2753 do(Stage, self.context, unstaged)
2756 class StageOrUnstage(ContextCommand):
2757 """If the selection is staged, unstage it, otherwise stage"""
2759 @staticmethod
2760 def name():
2761 return N_('Stage / Unstage')
2763 def do(self):
2764 s = self.selection.selection()
2765 if s.staged:
2766 do(Unstage, self.context, s.staged)
2768 unstaged = []
2769 unmerged = check_conflicts(self.context, s.unmerged)
2770 if unmerged:
2771 unstaged.extend(unmerged)
2772 if s.modified:
2773 unstaged.extend(s.modified)
2774 if s.untracked:
2775 unstaged.extend(s.untracked)
2776 if unstaged:
2777 do(Stage, self.context, unstaged)
2780 class Tag(ContextCommand):
2781 """Create a tag object."""
2783 def __init__(self, context, name, revision, sign=False, message=''):
2784 super(Tag, self).__init__(context)
2785 self._name = name
2786 self._message = message
2787 self._revision = revision
2788 self._sign = sign
2790 def do(self):
2791 result = False
2792 git = self.git
2793 revision = self._revision
2794 tag_name = self._name
2795 tag_message = self._message
2797 if not revision:
2798 Interaction.critical(
2799 N_('Missing Revision'), N_('Please specify a revision to tag.')
2801 return result
2803 if not tag_name:
2804 Interaction.critical(
2805 N_('Missing Name'), N_('Please specify a name for the new tag.')
2807 return result
2809 title = N_('Missing Tag Message')
2810 message = N_('Tag-signing was requested but the tag message is empty.')
2811 info = N_(
2812 'An unsigned, lightweight tag will be created instead.\n'
2813 'Create an unsigned tag?'
2815 ok_text = N_('Create Unsigned Tag')
2816 sign = self._sign
2817 if sign and not tag_message:
2818 # We require a message in order to sign the tag, so if they
2819 # choose to create an unsigned tag we have to clear the sign flag.
2820 if not Interaction.confirm(
2821 title, message, info, ok_text, default=False, icon=icons.save()
2823 return result
2824 sign = False
2826 opts = {}
2827 tmp_file = None
2828 try:
2829 if tag_message:
2830 tmp_file = utils.tmp_filename('tag-message')
2831 opts['file'] = tmp_file
2832 core.write(tmp_file, tag_message)
2834 if sign:
2835 opts['sign'] = True
2836 if tag_message:
2837 opts['annotate'] = True
2838 status, out, err = git.tag(tag_name, revision, **opts)
2839 finally:
2840 if tmp_file:
2841 core.unlink(tmp_file)
2843 title = N_('Error: could not create tag "%s"') % tag_name
2844 Interaction.command(title, 'git tag', status, out, err)
2846 if status == 0:
2847 result = True
2848 self.model.update_status()
2849 Interaction.information(
2850 N_('Tag Created'),
2851 N_('Created a new tag named "%s"') % tag_name,
2852 details=tag_message or None,
2855 return result
2858 class Unstage(ContextCommand):
2859 """Unstage a set of paths."""
2861 @staticmethod
2862 def name():
2863 return N_('Unstage')
2865 def __init__(self, context, paths):
2866 super(Unstage, self).__init__(context)
2867 self.paths = paths
2869 def do(self):
2870 """Unstage paths"""
2871 context = self.context
2872 head = self.model.head
2873 paths = self.paths
2875 msg = N_('Unstaging: %s') % (', '.join(paths))
2876 Interaction.log(msg)
2877 if not paths:
2878 return unstage_all(context)
2879 status, out, err = gitcmds.unstage_paths(context, paths, head=head)
2880 Interaction.command(N_('Error'), 'git reset', status, out, err)
2881 self.model.update_file_status()
2882 return (status, out, err)
2885 class UnstageAll(ContextCommand):
2886 """Unstage all files; resets the index."""
2888 def do(self):
2889 return unstage_all(self.context)
2892 def unstage_all(context):
2893 """Unstage all files, even while amending"""
2894 model = context.model
2895 git = context.git
2896 head = model.head
2897 status, out, err = git.reset(head, '--', '.')
2898 Interaction.command(N_('Error'), 'git reset', status, out, err)
2899 model.update_file_status()
2900 return (status, out, err)
2903 class StageSelected(ContextCommand):
2904 """Stage selected files, or all files if no selection exists."""
2906 def do(self):
2907 context = self.context
2908 paths = self.selection.unstaged
2909 if paths:
2910 do(Stage, context, paths)
2911 elif self.cfg.get('cola.safemode', False):
2912 do(StageModified, context)
2915 class UnstageSelected(Unstage):
2916 """Unstage selected files."""
2918 def __init__(self, context):
2919 staged = context.selection.staged
2920 super(UnstageSelected, self).__init__(context, staged)
2923 class Untrack(ContextCommand):
2924 """Unstage a set of paths."""
2926 def __init__(self, context, paths):
2927 super(Untrack, self).__init__(context)
2928 self.paths = paths
2930 def do(self):
2931 msg = N_('Untracking: %s') % (', '.join(self.paths))
2932 Interaction.log(msg)
2933 status, out, err = self.model.untrack_paths(self.paths)
2934 Interaction.log_status(status, out, err)
2937 class UntrackedSummary(EditModel):
2938 """List possible .gitignore rules as the diff text."""
2940 def __init__(self, context):
2941 super(UntrackedSummary, self).__init__(context)
2942 untracked = self.model.untracked
2943 suffix = 's' if untracked else ''
2944 io = StringIO()
2945 io.write('# %s untracked file%s\n' % (len(untracked), suffix))
2946 if untracked:
2947 io.write('# possible .gitignore rule%s:\n' % suffix)
2948 for u in untracked:
2949 io.write('/' + u + '\n')
2950 self.new_diff_text = io.getvalue()
2951 self.new_diff_type = main.Types.TEXT
2952 self.new_file_type = main.Types.TEXT
2953 self.new_mode = self.model.mode_display
2956 class VisualizeAll(ContextCommand):
2957 """Visualize all branches."""
2959 def do(self):
2960 context = self.context
2961 browser = utils.shell_split(prefs.history_browser(context))
2962 launch_history_browser(browser + ['--all'])
2965 class VisualizeCurrent(ContextCommand):
2966 """Visualize all branches."""
2968 def do(self):
2969 context = self.context
2970 browser = utils.shell_split(prefs.history_browser(context))
2971 launch_history_browser(browser + [self.model.currentbranch] + ['--'])
2974 class VisualizePaths(ContextCommand):
2975 """Path-limited visualization."""
2977 def __init__(self, context, paths):
2978 super(VisualizePaths, self).__init__(context)
2979 context = self.context
2980 browser = utils.shell_split(prefs.history_browser(context))
2981 if paths:
2982 self.argv = browser + ['--'] + list(paths)
2983 else:
2984 self.argv = browser
2986 def do(self):
2987 launch_history_browser(self.argv)
2990 class VisualizeRevision(ContextCommand):
2991 """Visualize a specific revision."""
2993 def __init__(self, context, revision, paths=None):
2994 super(VisualizeRevision, self).__init__(context)
2995 self.revision = revision
2996 self.paths = paths
2998 def do(self):
2999 context = self.context
3000 argv = utils.shell_split(prefs.history_browser(context))
3001 if self.revision:
3002 argv.append(self.revision)
3003 if self.paths:
3004 argv.append('--')
3005 argv.extend(self.paths)
3006 launch_history_browser(argv)
3009 class SubmoduleAdd(ConfirmAction):
3010 """Add specified submodules"""
3012 def __init__(self, context, url, path, branch, depth, reference):
3013 super(SubmoduleAdd, self).__init__(context)
3014 self.url = url
3015 self.path = path
3016 self.branch = branch
3017 self.depth = depth
3018 self.reference = reference
3020 def confirm(self):
3021 title = N_('Add Submodule...')
3022 question = N_('Add this submodule?')
3023 info = N_('The submodule will be added using\n' '"%s"' % self.command())
3024 ok_txt = N_('Add Submodule')
3025 return Interaction.confirm(title, question, info, ok_txt, icon=icons.ok())
3027 def action(self):
3028 context = self.context
3029 args = self.get_args()
3030 return context.git.submodule('add', *args)
3032 def success(self):
3033 self.model.update_file_status()
3034 self.model.update_submodules_list()
3036 def error_message(self):
3037 return N_('Error updating submodule %s' % self.path)
3039 def command(self):
3040 cmd = ['git', 'submodule', 'add']
3041 cmd.extend(self.get_args())
3042 return core.list2cmdline(cmd)
3044 def get_args(self):
3045 args = []
3046 if self.branch:
3047 args.extend(['--branch', self.branch])
3048 if self.reference:
3049 args.extend(['--reference', self.reference])
3050 if self.depth:
3051 args.extend(['--depth', '%d' % self.depth])
3052 args.extend(['--', self.url])
3053 if self.path:
3054 args.append(self.path)
3055 return args
3058 class SubmoduleUpdate(ConfirmAction):
3059 """Update specified submodule"""
3061 def __init__(self, context, path):
3062 super(SubmoduleUpdate, self).__init__(context)
3063 self.path = path
3065 def confirm(self):
3066 title = N_('Update Submodule...')
3067 question = N_('Update this submodule?')
3068 info = N_('The submodule will be updated using\n' '"%s"' % self.command())
3069 ok_txt = N_('Update Submodule')
3070 return Interaction.confirm(
3071 title, question, info, ok_txt, default=False, icon=icons.pull()
3074 def action(self):
3075 context = self.context
3076 args = self.get_args()
3077 return context.git.submodule(*args)
3079 def success(self):
3080 self.model.update_file_status()
3082 def error_message(self):
3083 return N_('Error updating submodule %s' % self.path)
3085 def command(self):
3086 cmd = ['git', 'submodule']
3087 cmd.extend(self.get_args())
3088 return core.list2cmdline(cmd)
3090 def get_args(self):
3091 cmd = ['update']
3092 if version.check_git(self.context, 'submodule-update-recursive'):
3093 cmd.append('--recursive')
3094 cmd.extend(['--', self.path])
3095 return cmd
3098 class SubmodulesUpdate(ConfirmAction):
3099 """Update all submodules"""
3101 def confirm(self):
3102 title = N_('Update submodules...')
3103 question = N_('Update all submodules?')
3104 info = N_('All submodules will be updated using\n' '"%s"' % self.command())
3105 ok_txt = N_('Update Submodules')
3106 return Interaction.confirm(
3107 title, question, info, ok_txt, default=False, icon=icons.pull()
3110 def action(self):
3111 context = self.context
3112 args = self.get_args()
3113 return context.git.submodule(*args)
3115 def success(self):
3116 self.model.update_file_status()
3118 def error_message(self):
3119 return N_('Error updating submodules')
3121 def command(self):
3122 cmd = ['git', 'submodule']
3123 cmd.extend(self.get_args())
3124 return core.list2cmdline(cmd)
3126 def get_args(self):
3127 cmd = ['update']
3128 if version.check_git(self.context, 'submodule-update-recursive'):
3129 cmd.append('--recursive')
3130 return cmd
3133 def launch_history_browser(argv):
3134 """Launch the configured history browser"""
3135 try:
3136 core.fork(argv)
3137 except OSError as e:
3138 _, details = utils.format_exception(e)
3139 title = N_('Error Launching History Browser')
3140 msg = N_('Cannot exec "%s": please configure a history browser') % ' '.join(
3141 argv
3143 Interaction.critical(title, message=msg, details=details)
3146 def run(cls, *args, **opts):
3148 Returns a callback that runs a command
3150 If the caller of run() provides args or opts then those are
3151 used instead of the ones provided by the invoker of the callback.
3155 def runner(*local_args, **local_opts):
3156 """Closure return by run() which runs the command"""
3157 if args or opts:
3158 do(cls, *args, **opts)
3159 else:
3160 do(cls, *local_args, **local_opts)
3162 return runner
3165 def do(cls, *args, **opts):
3166 """Run a command in-place"""
3167 try:
3168 cmd = cls(*args, **opts)
3169 return cmd.do()
3170 except Exception as e: # pylint: disable=broad-except
3171 msg, details = utils.format_exception(e)
3172 if hasattr(cls, '__name__'):
3173 msg = '%s exception:\n%s' % (cls.__name__, msg)
3174 Interaction.critical(N_('Error'), message=msg, details=details)
3175 return None
3178 def difftool_run(context):
3179 """Start a default difftool session"""
3180 selection = context.selection
3181 files = selection.group()
3182 if not files:
3183 return
3184 s = selection.selection()
3185 head = context.model.head
3186 difftool_launch_with_head(context, files, bool(s.staged), head)
3189 def difftool_launch_with_head(context, filenames, staged, head):
3190 """Launch difftool against the provided head"""
3191 if head == 'HEAD':
3192 left = None
3193 else:
3194 left = head
3195 difftool_launch(context, left=left, staged=staged, paths=filenames)
3198 def difftool_launch(
3199 context,
3200 left=None,
3201 right=None,
3202 paths=None,
3203 staged=False,
3204 dir_diff=False,
3205 left_take_magic=False,
3206 left_take_parent=False,
3208 """Launches 'git difftool' with given parameters
3210 :param left: first argument to difftool
3211 :param right: second argument to difftool_args
3212 :param paths: paths to diff
3213 :param staged: activate `git difftool --staged`
3214 :param dir_diff: activate `git difftool --dir-diff`
3215 :param left_take_magic: whether to append the magic ^! diff expression
3216 :param left_take_parent: whether to append the first-parent ~ for diffing
3220 difftool_args = ['git', 'difftool', '--no-prompt']
3221 if staged:
3222 difftool_args.append('--cached')
3223 if dir_diff:
3224 difftool_args.append('--dir-diff')
3226 if left:
3227 if left_take_parent or left_take_magic:
3228 suffix = '^!' if left_take_magic else '~'
3229 # Check root commit (no parents and thus cannot execute '~')
3230 git = context.git
3231 status, out, err = git.rev_list(left, parents=True, n=1)
3232 Interaction.log_status(status, out, err)
3233 if status:
3234 raise OSError('git rev-list command failed')
3236 if len(out.split()) >= 2:
3237 # Commit has a parent, so we can take its child as requested
3238 left += suffix
3239 else:
3240 # No parent, assume it's the root commit, so we have to diff
3241 # against the empty tree.
3242 left = EMPTY_TREE_OID
3243 if not right and left_take_magic:
3244 right = left
3245 difftool_args.append(left)
3247 if right:
3248 difftool_args.append(right)
3250 if paths:
3251 difftool_args.append('--')
3252 difftool_args.extend(paths)
3254 runtask = context.runtask
3255 if runtask:
3256 Interaction.async_command(N_('Difftool'), difftool_args, runtask)
3257 else:
3258 core.fork(difftool_args)