widgets: move PlainTextLabel and RichTextLabel to the text module
[git-cola.git] / cola / cmds.py
blobd0b4e5c15fef724ef0ba22d9050dacb7028ad8a3
1 """Editor commands"""
2 # pylint: disable=too-many-lines
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 MISSING_BLOB_OID
25 from .i18n import N_
26 from .interaction import Interaction
27 from .models import main
28 from .models import prefs
31 class UsageError(Exception):
32 """Exception class for usage errors."""
34 def __init__(self, title, message):
35 Exception.__init__(self, message)
36 self.title = title
37 self.msg = message
40 class EditModel(ContextCommand):
41 """Commands that mutate the main model diff data"""
43 UNDOABLE = True
45 def __init__(self, context):
46 """Common edit operations on the main model"""
47 super().__init__(context)
49 self.old_diff_text = self.model.diff_text
50 self.old_filename = self.model.filename
51 self.old_mode = self.model.mode
52 self.old_diff_type = self.model.diff_type
53 self.old_file_type = self.model.file_type
55 self.new_diff_text = self.old_diff_text
56 self.new_filename = self.old_filename
57 self.new_mode = self.old_mode
58 self.new_diff_type = self.old_diff_type
59 self.new_file_type = self.old_file_type
61 def do(self):
62 """Perform the operation."""
63 self.model.filename = self.new_filename
64 self.model.set_mode(self.new_mode)
65 self.model.set_diff_text(self.new_diff_text)
66 self.model.set_diff_type(self.new_diff_type)
67 self.model.set_file_type(self.new_file_type)
69 def undo(self):
70 """Undo the operation."""
71 self.model.filename = self.old_filename
72 self.model.set_mode(self.old_mode)
73 self.model.set_diff_text(self.old_diff_text)
74 self.model.set_diff_type(self.old_diff_type)
75 self.model.set_file_type(self.old_file_type)
78 class ConfirmAction(ContextCommand):
79 """Confirm an action before running it"""
81 def ok_to_run(self):
82 """Return True when the command is ok to run"""
83 return True
85 def confirm(self):
86 """Prompt for confirmation"""
87 return True
89 def action(self):
90 """Run the command and return (status, out, err)"""
91 return (-1, '', '')
93 def success(self):
94 """Callback run on success"""
95 return
97 def command(self):
98 """Command name, for error messages"""
99 return 'git'
101 def error_message(self):
102 """Command error message"""
103 return ''
105 def do(self):
106 """Prompt for confirmation before running a command"""
107 status = -1
108 out = err = ''
109 ok = self.ok_to_run() and self.confirm()
110 if ok:
111 status, out, err = self.action()
112 if status == 0:
113 self.success()
114 title = self.error_message()
115 cmd = self.command()
116 Interaction.command(title, cmd, status, out, err)
118 return ok, status, out, err
121 class AbortApplyPatch(ConfirmAction):
122 """Reset an in-progress "git am" patch application"""
124 def confirm(self):
125 title = N_('Abort Applying Patch...')
126 question = N_('Aborting applying the current patch?')
127 info = N_(
128 'Aborting a patch can cause uncommitted changes to be lost.\n'
129 'Recovering uncommitted changes is not possible.'
131 ok_txt = N_('Abort Applying Patch')
132 return Interaction.confirm(
133 title, question, info, ok_txt, default=False, icon=icons.undo()
136 def action(self):
137 status, out, err = gitcmds.abort_apply_patch(self.context)
138 self.model.update_file_merge_status()
139 return status, out, err
141 def success(self):
142 self.model.set_commitmsg('')
144 def error_message(self):
145 return N_('Error')
147 def command(self):
148 return 'git am --abort'
151 class AbortCherryPick(ConfirmAction):
152 """Reset an in-progress cherry-pick"""
154 def confirm(self):
155 title = N_('Abort Cherry-Pick...')
156 question = N_('Aborting the current cherry-pick?')
157 info = N_(
158 'Aborting a cherry-pick can cause uncommitted changes to be lost.\n'
159 'Recovering uncommitted changes is not possible.'
161 ok_txt = N_('Abort Cherry-Pick')
162 return Interaction.confirm(
163 title, question, info, ok_txt, default=False, icon=icons.undo()
166 def action(self):
167 status, out, err = gitcmds.abort_cherry_pick(self.context)
168 self.model.update_file_merge_status()
169 return status, out, err
171 def success(self):
172 self.model.set_commitmsg('')
174 def error_message(self):
175 return N_('Error')
177 def command(self):
178 return 'git cherry-pick --abort'
181 class AbortMerge(ConfirmAction):
182 """Reset an in-progress merge back to HEAD"""
184 def confirm(self):
185 title = N_('Abort Merge...')
186 question = N_('Aborting the current merge?')
187 info = N_(
188 'Aborting the current merge will cause '
189 '*ALL* uncommitted changes to be lost.\n'
190 'Recovering uncommitted changes is not possible.'
192 ok_txt = N_('Abort Merge')
193 return Interaction.confirm(
194 title, question, info, ok_txt, default=False, icon=icons.undo()
197 def action(self):
198 status, out, err = gitcmds.abort_merge(self.context)
199 self.model.update_file_merge_status()
200 return status, out, err
202 def success(self):
203 self.model.set_commitmsg('')
205 def error_message(self):
206 return N_('Error')
208 def command(self):
209 return 'git merge'
212 class AmendMode(EditModel):
213 """Try to amend a commit."""
215 UNDOABLE = True
216 LAST_MESSAGE = None
218 @staticmethod
219 def name():
220 return N_('Amend')
222 def __init__(self, context, amend=True):
223 super().__init__(context)
224 self.skip = False
225 self.amending = amend
226 self.old_commitmsg = self.model.commitmsg
227 self.old_mode = self.model.mode
229 if self.amending:
230 self.new_mode = self.model.mode_amend
231 self.new_commitmsg = gitcmds.prev_commitmsg(context)
232 AmendMode.LAST_MESSAGE = self.model.commitmsg
233 return
234 # else, amend unchecked, regular commit
235 self.new_mode = self.model.mode_none
236 self.new_diff_text = ''
237 self.new_commitmsg = self.model.commitmsg
238 # If we're going back into new-commit-mode then search the
239 # undo stack for a previous amend-commit-mode and grab the
240 # commit message at that point in time.
241 if AmendMode.LAST_MESSAGE is not None:
242 self.new_commitmsg = AmendMode.LAST_MESSAGE
243 AmendMode.LAST_MESSAGE = None
245 def do(self):
246 """Leave/enter amend mode."""
247 # Attempt to enter amend mode. Do not allow this when merging.
248 if self.amending:
249 if self.model.is_merging:
250 self.skip = True
251 self.model.set_mode(self.old_mode)
252 Interaction.information(
253 N_('Cannot Amend'),
255 'You are in the middle of a merge.\n'
256 'Cannot amend while merging.'
259 return
260 self.skip = False
261 super().do()
262 self.model.set_commitmsg(self.new_commitmsg)
263 self.model.update_file_status()
264 self.context.selection.reset(emit=True)
266 def undo(self):
267 if self.skip:
268 return
269 self.model.set_commitmsg(self.old_commitmsg)
270 super().undo()
271 self.model.update_file_status()
272 self.context.selection.reset(emit=True)
275 class AnnexAdd(ContextCommand):
276 """Add to Git Annex"""
278 def __init__(self, context):
279 super().__init__(context)
280 self.filename = self.selection.filename()
282 def do(self):
283 status, out, err = self.git.annex('add', self.filename)
284 Interaction.command(N_('Error'), 'git annex add', status, out, err)
285 self.model.update_status()
288 class AnnexInit(ContextCommand):
289 """Initialize Git Annex"""
291 def do(self):
292 status, out, err = self.git.annex('init')
293 Interaction.command(N_('Error'), 'git annex init', status, out, err)
294 self.model.cfg.reset()
295 self.model.emit_updated()
298 class LFSTrack(ContextCommand):
299 """Add a file to git lfs"""
301 def __init__(self, context):
302 super().__init__(context)
303 self.filename = self.selection.filename()
304 self.stage_cmd = Stage(context, [self.filename])
306 def do(self):
307 status, out, err = self.git.lfs('track', self.filename)
308 Interaction.command(N_('Error'), 'git lfs track', status, out, err)
309 if status == 0:
310 self.stage_cmd.do()
313 class LFSInstall(ContextCommand):
314 """Initialize git lfs"""
316 def do(self):
317 status, out, err = self.git.lfs('install')
318 Interaction.command(N_('Error'), 'git lfs install', status, out, err)
319 self.model.update_config(reset=True, emit=True)
322 class ApplyPatch(ContextCommand):
323 """Apply the specfied patch to the worktree or index"""
325 def __init__(
326 self,
327 context,
328 patch,
329 encoding,
330 apply_to_worktree,
332 super().__init__(context)
333 self.patch = patch
334 self.encoding = encoding
335 self.apply_to_worktree = apply_to_worktree
337 def do(self):
338 context = self.context
340 tmp_file = utils.tmp_filename('apply', suffix='.patch')
341 try:
342 core.write(tmp_file, self.patch.as_text(), encoding=self.encoding)
343 if self.apply_to_worktree:
344 status, out, err = gitcmds.apply_diff_to_worktree(context, tmp_file)
345 else:
346 status, out, err = gitcmds.apply_diff(context, tmp_file)
347 finally:
348 core.unlink(tmp_file)
350 Interaction.log_status(status, out, err)
351 self.model.update_file_status(update_index=True)
354 class ApplyPatches(ContextCommand):
355 """Apply patches using the "git am" command"""
357 def __init__(self, context, patches):
358 super().__init__(context)
359 self.patches = patches
361 def do(self):
362 status, output, err = self.git.am('-3', *self.patches)
363 out = f'# git am -3 {core.list2cmdline(self.patches)}\n\n{output}'
364 Interaction.command(N_('Patch failed to apply'), 'git am -3', status, out, err)
365 # Display a diffstat
366 self.model.update_file_status()
368 patch_basenames = [os.path.basename(p) for p in self.patches]
369 if len(patch_basenames) > 25:
370 patch_basenames = patch_basenames[:25]
371 patch_basenames.append('...')
373 basenames = '\n'.join(patch_basenames)
374 if status == 0:
375 Interaction.information(
376 N_('Patch(es) Applied'),
377 (N_('%d patch(es) applied.') + '\n\n%s')
378 % (len(self.patches), basenames),
382 class ApplyPatchesContinue(ContextCommand):
383 """Run "git am --continue" to continue on the next patch in a "git am" session"""
385 def do(self):
386 status, out, err = self.git.am('--continue')
387 Interaction.command(
388 N_('Failed to commit and continue applying patches'),
389 'git am --continue',
390 status,
391 out,
392 err,
394 self.model.update_status()
395 return status, out, err
398 class ApplyPatchesSkip(ContextCommand):
399 """Run "git am --skip" to continue on the next patch in a "git am" session"""
401 def do(self):
402 status, out, err = self.git.am(skip=True)
403 Interaction.command(
404 N_('Failed to continue applying patches after skipping the current patch'),
405 'git am --skip',
406 status,
407 out,
408 err,
410 self.model.update_status()
411 return status, out, err
414 class Archive(ContextCommand):
415 """ "Export archives using the "git archive" command"""
417 def __init__(self, context, ref, fmt, prefix, filename):
418 super().__init__(context)
419 self.ref = ref
420 self.fmt = fmt
421 self.prefix = prefix
422 self.filename = filename
424 def do(self):
425 fp = core.xopen(self.filename, 'wb')
426 cmd = ['git', 'archive', '--format=' + self.fmt]
427 if self.fmt in ('tgz', 'tar.gz'):
428 cmd.append('-9')
429 if self.prefix:
430 cmd.append('--prefix=' + self.prefix)
431 cmd.append(self.ref)
432 proc = core.start_command(cmd, stdout=fp)
433 out, err = proc.communicate()
434 fp.close()
435 status = proc.returncode
436 Interaction.log_status(status, out or '', err or '')
439 class Checkout(EditModel):
440 """A command object for git-checkout.
442 'argv' is handed off directly to git.
446 def __init__(self, context, argv, checkout_branch=False):
447 super().__init__(context)
448 self.argv = argv
449 self.checkout_branch = checkout_branch
450 self.new_diff_text = ''
451 self.new_diff_type = main.Types.TEXT
452 self.new_file_type = main.Types.TEXT
454 def do(self):
455 super().do()
456 status, out, err = self.git.checkout(*self.argv)
457 if self.checkout_branch:
458 self.model.update_status()
459 else:
460 self.model.update_file_status()
461 Interaction.command(N_('Error'), 'git checkout', status, out, err)
462 return status, out, err
465 class CheckoutTheirs(ConfirmAction):
466 """Checkout "their" version of a file when performing a merge"""
468 @staticmethod
469 def name():
470 return N_('Checkout files from their branch (MERGE_HEAD)')
472 def confirm(self):
473 title = self.name()
474 question = N_('Checkout files from their branch?')
475 info = N_(
476 'This operation will replace the selected unmerged files with content '
477 'from the branch being merged using "git checkout --theirs".\n'
478 '*ALL* uncommitted changes will be lost.\n'
479 'Recovering uncommitted changes is not possible.'
481 ok_txt = N_('Checkout Files')
482 return Interaction.confirm(
483 title, question, info, ok_txt, default=True, icon=icons.merge()
486 def action(self):
487 selection = self.selection.selection()
488 paths = selection.unmerged
489 if not paths:
490 return 0, '', ''
492 argv = ['--theirs', '--'] + paths
493 cmd = Checkout(self.context, argv)
494 return cmd.do()
496 def error_message(self):
497 return N_('Error')
499 def command(self):
500 return 'git checkout --theirs'
503 class CheckoutOurs(ConfirmAction):
504 """Checkout "our" version of a file when performing a merge"""
506 @staticmethod
507 def name():
508 return N_('Checkout files from our branch (HEAD)')
510 def confirm(self):
511 title = self.name()
512 question = N_('Checkout files from our branch?')
513 info = N_(
514 'This operation will replace the selected unmerged files with content '
515 'from your current branch using "git checkout --ours".\n'
516 '*ALL* uncommitted changes will be lost.\n'
517 'Recovering uncommitted changes is not possible.'
519 ok_txt = N_('Checkout Files')
520 return Interaction.confirm(
521 title, question, info, ok_txt, default=True, icon=icons.merge()
524 def action(self):
525 selection = self.selection.selection()
526 paths = selection.unmerged
527 if not paths:
528 return 0, '', ''
530 argv = ['--ours', '--'] + paths
531 cmd = Checkout(self.context, argv)
532 return cmd.do()
534 def error_message(self):
535 return N_('Error')
537 def command(self):
538 return 'git checkout --ours'
541 class BlamePaths(ContextCommand):
542 """Blame view for paths."""
544 @staticmethod
545 def name():
546 return N_('Blame...')
548 def __init__(self, context, paths=None):
549 super().__init__(context)
550 if not paths:
551 paths = context.selection.union()
552 viewer = utils.shell_split(prefs.blame_viewer(context))
553 self.argv = viewer + list(paths)
555 def do(self):
556 try:
557 core.fork(self.argv)
558 except OSError as e:
559 _, details = utils.format_exception(e)
560 title = N_('Error Launching Blame Viewer')
561 msg = N_('Cannot exec "%s": please configure a blame viewer') % ' '.join(
562 self.argv
564 Interaction.critical(title, message=msg, details=details)
567 class CheckoutBranch(Checkout):
568 """Checkout a branch."""
570 def __init__(self, context, branch):
571 args = [branch]
572 super().__init__(context, args, checkout_branch=True)
575 class CherryPick(ContextCommand):
576 """Cherry pick commits into the current branch."""
578 def __init__(self, context, commits):
579 super().__init__(context)
580 self.commits = commits
582 def do(self):
583 status, out, err = gitcmds.cherry_pick(self.context, self.commits)
584 self.model.update_file_merge_status()
585 title = N_('Cherry-pick failed')
586 Interaction.command(title, 'git cherry-pick', status, out, err)
589 class Revert(ContextCommand):
590 """Revert a commit"""
592 def __init__(self, context, oid):
593 super().__init__(context)
594 self.oid = oid
596 def do(self):
597 status, out, err = self.git.revert(self.oid, no_edit=True)
598 self.model.update_file_status()
599 title = N_('Revert failed')
600 out = '# git revert %s\n\n' % self.oid
601 Interaction.command(title, 'git revert', status, out, err)
604 class ResetMode(EditModel):
605 """Reset the mode and clear the model's diff text."""
607 def __init__(self, context):
608 super().__init__(context)
609 self.new_mode = self.model.mode_none
610 self.new_diff_text = ''
611 self.new_diff_type = main.Types.TEXT
612 self.new_file_type = main.Types.TEXT
613 self.new_filename = ''
615 def do(self):
616 super().do()
617 self.model.update_file_status()
618 self.context.selection.reset(emit=True)
621 class ResetCommand(ConfirmAction):
622 """Reset state using the "git reset" command"""
624 def __init__(self, context, ref):
625 super().__init__(context)
626 self.ref = ref
628 def action(self):
629 return self.reset()
631 def command(self):
632 return 'git reset'
634 def error_message(self):
635 return N_('Error')
637 def success(self):
638 self.model.update_file_status()
640 def confirm(self):
641 raise NotImplementedError('confirm() must be overridden')
643 def reset(self):
644 raise NotImplementedError('reset() must be overridden')
647 class ResetMixed(ResetCommand):
648 @staticmethod
649 def tooltip(ref):
650 tooltip = N_('The branch will be reset using "git reset --mixed %s"')
651 return tooltip % ref
653 def confirm(self):
654 title = N_('Reset Branch and Stage (Mixed)')
655 question = N_('Point the current branch head to a new commit?')
656 info = self.tooltip(self.ref)
657 ok_text = N_('Reset Branch')
658 return Interaction.confirm(title, question, info, ok_text)
660 def reset(self):
661 return self.git.reset(self.ref, '--', mixed=True)
664 class ResetKeep(ResetCommand):
665 @staticmethod
666 def tooltip(ref):
667 tooltip = N_('The repository will be reset using "git reset --keep %s"')
668 return tooltip % ref
670 def confirm(self):
671 title = N_('Restore Worktree and Reset All (Keep Unstaged Changes)')
672 question = N_('Restore worktree, reset, and preserve unstaged edits?')
673 info = self.tooltip(self.ref)
674 ok_text = N_('Reset and Restore')
675 return Interaction.confirm(title, question, info, ok_text)
677 def reset(self):
678 return self.git.reset(self.ref, '--', keep=True)
681 class ResetMerge(ResetCommand):
682 @staticmethod
683 def tooltip(ref):
684 tooltip = N_('The repository will be reset using "git reset --merge %s"')
685 return tooltip % ref
687 def confirm(self):
688 title = N_('Restore Worktree and Reset All (Merge)')
689 question = N_('Reset Worktree and Reset All?')
690 info = self.tooltip(self.ref)
691 ok_text = N_('Reset and Restore')
692 return Interaction.confirm(title, question, info, ok_text)
694 def reset(self):
695 return self.git.reset(self.ref, '--', merge=True)
698 class ResetSoft(ResetCommand):
699 @staticmethod
700 def tooltip(ref):
701 tooltip = N_('The branch will be reset using "git reset --soft %s"')
702 return tooltip % ref
704 def confirm(self):
705 title = N_('Reset Branch (Soft)')
706 question = N_('Reset branch?')
707 info = self.tooltip(self.ref)
708 ok_text = N_('Reset Branch')
709 return Interaction.confirm(title, question, info, ok_text)
711 def reset(self):
712 return self.git.reset(self.ref, '--', soft=True)
715 class ResetHard(ResetCommand):
716 @staticmethod
717 def tooltip(ref):
718 tooltip = N_('The repository will be reset using "git reset --hard %s"')
719 return tooltip % ref
721 def confirm(self):
722 title = N_('Restore Worktree and Reset All (Hard)')
723 question = N_('Restore Worktree and Reset All?')
724 info = self.tooltip(self.ref)
725 ok_text = N_('Reset and Restore')
726 return Interaction.confirm(title, question, info, ok_text)
728 def reset(self):
729 return self.git.reset(self.ref, '--', hard=True)
732 class RestoreWorktree(ConfirmAction):
733 """Reset the worktree using the "git read-tree" command"""
735 @staticmethod
736 def tooltip(ref):
737 tooltip = N_(
738 'The worktree will be restored using "git read-tree --reset -u %s"'
740 return tooltip % ref
742 def __init__(self, context, ref):
743 super().__init__(context)
744 self.ref = ref
746 def action(self):
747 return self.git.read_tree(self.ref, reset=True, u=True)
749 def command(self):
750 return 'git read-tree --reset -u %s' % self.ref
752 def error_message(self):
753 return N_('Error')
755 def success(self):
756 self.model.update_file_status()
758 def confirm(self):
759 title = N_('Restore Worktree')
760 question = N_('Restore Worktree to %s?') % self.ref
761 info = self.tooltip(self.ref)
762 ok_text = N_('Restore Worktree')
763 return Interaction.confirm(title, question, info, ok_text)
766 class UndoLastCommit(ResetCommand):
767 """Undo the last commit"""
769 # NOTE: this is the similar to ResetSoft() with an additional check for
770 # published commits and different messages.
771 def __init__(self, context):
772 super().__init__(context, 'HEAD^')
774 def confirm(self):
775 check_published = prefs.check_published_commits(self.context)
776 if check_published and self.model.is_commit_published():
777 return Interaction.confirm(
778 N_('Rewrite Published Commit?'),
780 'This commit has already been published.\n'
781 'This operation will rewrite published history.\n'
782 "You probably don't want to do this."
784 N_('Undo the published commit?'),
785 N_('Undo Last Commit'),
786 default=False,
787 icon=icons.save(),
790 title = N_('Undo Last Commit')
791 question = N_('Undo last commit?')
792 info = N_('The branch will be reset using "git reset --soft %s"')
793 ok_text = N_('Undo Last Commit')
794 info_text = info % self.ref
795 return Interaction.confirm(title, question, info_text, ok_text)
797 def reset(self):
798 return self.git.reset('HEAD^', '--', soft=True)
801 class Commit(ResetMode):
802 """Attempt to create a new commit."""
804 def __init__(self, context, amend, msg, sign, no_verify=False):
805 super().__init__(context)
806 self.amend = amend
807 self.msg = msg
808 self.sign = sign
809 self.no_verify = no_verify
810 self.old_commitmsg = self.model.commitmsg
811 self.new_commitmsg = ''
813 def do(self):
814 # Create the commit message file
815 context = self.context
816 msg = self.msg
817 tmp_file = utils.tmp_filename('commit-message')
818 try:
819 core.write(tmp_file, msg)
820 # Run 'git commit'
821 status, out, err = self.git.commit(
822 F=tmp_file,
823 v=True,
824 gpg_sign=self.sign,
825 amend=self.amend,
826 no_verify=self.no_verify,
828 finally:
829 core.unlink(tmp_file)
830 if status == 0:
831 super().do()
832 if context.cfg.get(prefs.AUTOTEMPLATE):
833 template_loader = LoadCommitMessageFromTemplate(context)
834 template_loader.do()
835 else:
836 self.model.set_commitmsg(self.new_commitmsg)
838 return status, out, err
840 @staticmethod
841 def strip_comments(msg, comment_char='#'):
842 # Strip off comments
843 message_lines = [
844 line for line in msg.split('\n') if not line.startswith(comment_char)
846 msg = '\n'.join(message_lines)
847 if not msg.endswith('\n'):
848 msg += '\n'
850 return msg
853 class CycleReferenceSort(ContextCommand):
854 """Choose the next reference sort type"""
856 def do(self):
857 self.model.cycle_ref_sort()
860 class Ignore(ContextCommand):
861 """Add files to an exclusion file"""
863 def __init__(self, context, filenames, local=False):
864 super().__init__(context)
865 self.filenames = list(filenames)
866 self.local = local
868 def do(self):
869 if not self.filenames:
870 return
871 new_additions = '\n'.join(self.filenames) + '\n'
872 for_status = new_additions
873 if self.local:
874 filename = os.path.join('.git', 'info', 'exclude')
875 else:
876 filename = '.gitignore'
877 if core.exists(filename):
878 current_list = core.read(filename)
879 new_additions = current_list.rstrip() + '\n' + new_additions
880 core.write(filename, new_additions)
881 Interaction.log_status(0, f'Added to {filename}:\n{for_status}', '')
882 self.model.update_file_status()
885 def file_summary(files):
886 txt = core.list2cmdline(files)
887 if len(txt) > 768:
888 txt = txt[:768].rstrip() + '...'
889 wrap = textwrap.TextWrapper()
890 return '\n'.join(wrap.wrap(txt))
893 class RemoteCommand(ConfirmAction):
894 def __init__(self, context, remote):
895 super().__init__(context)
896 self.remote = remote
898 def success(self):
899 self.cfg.reset()
900 self.model.update_remotes()
903 class RemoteAdd(RemoteCommand):
904 def __init__(self, context, remote, url):
905 super().__init__(context, remote)
906 self.url = url
908 def action(self):
909 return self.git.remote('add', self.remote, self.url)
911 def error_message(self):
912 return N_('Error creating remote "%s"') % self.remote
914 def command(self):
915 return f'git remote add "{self.remote}" "{self.url}"'
918 class RemoteRemove(RemoteCommand):
919 def confirm(self):
920 title = N_('Delete Remote')
921 question = N_('Delete remote?')
922 info = N_('Delete remote "%s"') % self.remote
923 ok_text = N_('Delete')
924 return Interaction.confirm(title, question, info, ok_text)
926 def action(self):
927 return self.git.remote('rm', self.remote)
929 def error_message(self):
930 return N_('Error deleting remote "%s"') % self.remote
932 def command(self):
933 return 'git remote rm "%s"' % self.remote
936 class RemoteRename(RemoteCommand):
937 def __init__(self, context, remote, new_name):
938 super().__init__(context, remote)
939 self.new_name = new_name
941 def confirm(self):
942 title = N_('Rename Remote')
943 text = N_('Rename remote "%(current)s" to "%(new)s"?') % {
944 'current': self.remote,
945 'new': self.new_name,
947 info_text = ''
948 ok_text = title
949 return Interaction.confirm(title, text, info_text, ok_text)
951 def action(self):
952 return self.git.remote('rename', self.remote, self.new_name)
954 def error_message(self):
955 return N_('Error renaming "%(name)s" to "%(new_name)s"') % {
956 'name': self.remote,
957 'new_name': self.new_name,
960 def command(self):
961 return f'git remote rename "{self.remote}" "{self.new_name}"'
964 class RemoteSetURL(RemoteCommand):
965 def __init__(self, context, remote, url):
966 super().__init__(context, remote)
967 self.url = url
969 def action(self):
970 return self.git.remote('set-url', self.remote, self.url)
972 def error_message(self):
973 return N_('Unable to set URL for "%(name)s" to "%(url)s"') % {
974 'name': self.remote,
975 'url': self.url,
978 def command(self):
979 return f'git remote set-url "{self.remote}" "{self.url}"'
982 class RemoteEdit(ContextCommand):
983 """Combine RemoteRename and RemoteSetURL"""
985 def __init__(self, context, old_name, remote, url):
986 super().__init__(context)
987 self.rename = RemoteRename(context, old_name, remote)
988 self.set_url = RemoteSetURL(context, remote, url)
990 def do(self):
991 result = self.rename.do()
992 name_ok = result[0]
993 url_ok = False
994 if name_ok:
995 result = self.set_url.do()
996 url_ok = result[0]
997 return name_ok, url_ok
1000 class RemoveFromSettings(ConfirmAction):
1001 def __init__(self, context, repo, entry, icon=None):
1002 super().__init__(context)
1003 self.context = context
1004 self.repo = repo
1005 self.entry = entry
1006 self.icon = icon
1008 def success(self):
1009 self.context.settings.save()
1012 class RemoveBookmark(RemoveFromSettings):
1013 def confirm(self):
1014 entry = self.entry
1015 title = msg = N_('Delete Bookmark?')
1016 info = N_('%s will be removed from your bookmarks.') % entry
1017 ok_text = N_('Delete Bookmark')
1018 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
1020 def action(self):
1021 self.context.settings.remove_bookmark(self.repo, self.entry)
1022 return (0, '', '')
1025 class RemoveRecent(RemoveFromSettings):
1026 def confirm(self):
1027 repo = self.repo
1028 title = msg = N_('Remove %s from the recent list?') % repo
1029 info = N_('%s will be removed from your recent repositories.') % repo
1030 ok_text = N_('Remove')
1031 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
1033 def action(self):
1034 self.context.settings.remove_recent(self.repo)
1035 return (0, '', '')
1038 class RemoveFiles(ContextCommand):
1039 """Removes files"""
1041 def __init__(self, context, remover, filenames):
1042 super().__init__(context)
1043 if remover is None:
1044 remover = os.remove
1045 self.remover = remover
1046 self.filenames = filenames
1047 # We could git-hash-object stuff and provide undo-ability
1048 # as an option. Heh.
1050 def do(self):
1051 files = self.filenames
1052 if not files:
1053 return
1055 rescan = False
1056 bad_filenames = []
1057 remove = self.remover
1058 for filename in files:
1059 if filename:
1060 try:
1061 remove(filename)
1062 rescan = True
1063 except OSError:
1064 bad_filenames.append(filename)
1066 if bad_filenames:
1067 Interaction.information(
1068 N_('Error'), N_('Deleting "%s" failed') % file_summary(bad_filenames)
1071 if rescan:
1072 self.model.update_file_status()
1075 class Delete(RemoveFiles):
1076 """Delete files."""
1078 def __init__(self, context, filenames):
1079 super().__init__(context, os.remove, filenames)
1081 def do(self):
1082 files = self.filenames
1083 if not files:
1084 return
1086 title = N_('Delete Files?')
1087 msg = N_('The following files will be deleted:') + '\n\n'
1088 msg += file_summary(files)
1089 info_txt = N_('Delete %d file(s)?') % len(files)
1090 ok_txt = N_('Delete Files')
1092 if Interaction.confirm(
1093 title, msg, info_txt, ok_txt, default=True, icon=icons.remove()
1095 super().do()
1098 class MoveToTrash(RemoveFiles):
1099 """Move files to the trash using send2trash"""
1101 AVAILABLE = send2trash is not None
1103 def __init__(self, context, filenames):
1104 super().__init__(context, send2trash, filenames)
1107 class DeleteBranch(ConfirmAction):
1108 """Delete a git branch."""
1110 def __init__(self, context, branch):
1111 super().__init__(context)
1112 self.branch = branch
1114 def confirm(self):
1115 title = N_('Delete Branch')
1116 question = N_('Delete branch "%s"?') % self.branch
1117 info = N_('The branch will be no longer available.')
1118 ok_txt = N_('Delete Branch')
1119 return Interaction.confirm(
1120 title, question, info, ok_txt, default=True, icon=icons.discard()
1123 def action(self):
1124 return self.model.delete_branch(self.branch)
1126 def error_message(self):
1127 return N_('Error deleting branch "%s"' % self.branch)
1129 def command(self):
1130 command = 'git branch -D %s'
1131 return command % self.branch
1134 class Rename(ContextCommand):
1135 """Rename a set of paths."""
1137 def __init__(self, context, paths):
1138 super().__init__(context)
1139 self.paths = paths
1141 def do(self):
1142 msg = N_('Untracking: %s') % (', '.join(self.paths))
1143 Interaction.log(msg)
1145 for path in self.paths:
1146 ok = self.rename(path)
1147 if not ok:
1148 return
1150 self.model.update_status()
1152 def rename(self, path):
1153 git = self.git
1154 title = N_('Rename "%s"') % path
1156 if os.path.isdir(path):
1157 base_path = os.path.dirname(path)
1158 else:
1159 base_path = path
1160 new_path = Interaction.save_as(base_path, title)
1161 if not new_path:
1162 return False
1164 status, out, err = git.mv(path, new_path, force=True, verbose=True)
1165 Interaction.command(N_('Error'), 'git mv', status, out, err)
1166 return status == 0
1169 class RenameBranch(ContextCommand):
1170 """Rename a git branch."""
1172 def __init__(self, context, branch, new_branch):
1173 super().__init__(context)
1174 self.branch = branch
1175 self.new_branch = new_branch
1177 def do(self):
1178 branch = self.branch
1179 new_branch = self.new_branch
1180 status, out, err = self.model.rename_branch(branch, new_branch)
1181 Interaction.log_status(status, out, err)
1184 class DeleteRemoteBranch(DeleteBranch):
1185 """Delete a remote git branch."""
1187 def __init__(self, context, remote, branch):
1188 super().__init__(context, branch)
1189 self.remote = remote
1191 def action(self):
1192 return self.git.push(self.remote, self.branch, delete=True)
1194 def success(self):
1195 self.model.update_status()
1196 Interaction.information(
1197 N_('Remote Branch Deleted'),
1198 N_('"%(branch)s" has been deleted from "%(remote)s".')
1200 'branch': self.branch,
1201 'remote': self.remote,
1205 def error_message(self):
1206 return N_('Error Deleting Remote Branch')
1208 def command(self):
1209 command = 'git push --delete %s %s'
1210 return command % (self.remote, self.branch)
1213 def get_mode(context, filename, staged, modified, unmerged, untracked):
1214 model = context.model
1215 if staged:
1216 mode = model.mode_index
1217 elif modified or unmerged:
1218 mode = model.mode_worktree
1219 elif untracked:
1220 if gitcmds.is_binary(context, filename):
1221 mode = model.mode_untracked
1222 else:
1223 mode = model.mode_untracked_diff
1224 else:
1225 mode = model.mode
1226 return mode
1229 class DiffAgainstCommitMode(ContextCommand):
1230 """Diff against arbitrary commits"""
1232 def __init__(self, context, oid):
1233 super().__init__(context)
1234 self.oid = oid
1236 def do(self):
1237 self.model.set_mode(self.model.mode_diff, head=self.oid)
1238 self.model.update_file_status()
1241 class DiffText(EditModel):
1242 """Set the diff type to text"""
1244 def __init__(self, context):
1245 super().__init__(context)
1246 self.new_file_type = main.Types.TEXT
1247 self.new_diff_type = main.Types.TEXT
1250 class ToggleDiffType(ContextCommand):
1251 """Toggle the diff type between image and text"""
1253 def __init__(self, context):
1254 super().__init__(context)
1255 if self.model.diff_type == main.Types.IMAGE:
1256 self.new_diff_type = main.Types.TEXT
1257 self.new_value = False
1258 else:
1259 self.new_diff_type = main.Types.IMAGE
1260 self.new_value = True
1262 def do(self):
1263 diff_type = self.new_diff_type
1264 value = self.new_value
1266 self.model.set_diff_type(diff_type)
1268 filename = self.model.filename
1269 _, ext = os.path.splitext(filename)
1270 if ext.startswith('.'):
1271 cfg = 'cola.imagediff' + ext
1272 self.cfg.set_repo(cfg, value)
1275 class DiffImage(EditModel):
1276 def __init__(
1277 self, context, filename, deleted, staged, modified, unmerged, untracked
1279 super().__init__(context)
1281 self.new_filename = filename
1282 self.new_diff_type = self.get_diff_type(filename)
1283 self.new_file_type = main.Types.IMAGE
1284 self.new_mode = get_mode(
1285 context, filename, staged, modified, unmerged, untracked
1287 self.staged = staged
1288 self.modified = modified
1289 self.unmerged = unmerged
1290 self.untracked = untracked
1291 self.deleted = deleted
1292 self.annex = self.cfg.is_annex()
1294 def get_diff_type(self, filename):
1295 """Query the diff type to use based on cola.imagediff.<extension>"""
1296 _, ext = os.path.splitext(filename)
1297 if ext.startswith('.'):
1298 # Check eg. "cola.imagediff.svg" to see if we should imagediff.
1299 cfg = 'cola.imagediff' + ext
1300 if self.cfg.get(cfg, True):
1301 result = main.Types.IMAGE
1302 else:
1303 result = main.Types.TEXT
1304 else:
1305 result = main.Types.IMAGE
1306 return result
1308 def do(self):
1309 filename = self.new_filename
1311 if self.staged:
1312 images = self.staged_images()
1313 elif self.modified:
1314 images = self.modified_images()
1315 elif self.unmerged:
1316 images = self.unmerged_images()
1317 elif self.untracked:
1318 images = [(filename, False)]
1319 else:
1320 images = []
1322 self.model.set_images(images)
1323 super().do()
1325 def staged_images(self):
1326 context = self.context
1327 git = self.git
1328 head = self.model.head
1329 filename = self.new_filename
1330 annex = self.annex
1332 images = []
1333 index = git.diff_index(head, '--', filename, cached=True)[STDOUT]
1334 if index:
1335 # Example:
1336 # :100644 100644 fabadb8... 4866510... M describe.c
1337 parts = index.split(' ')
1338 if len(parts) > 3:
1339 old_oid = parts[2]
1340 new_oid = parts[3]
1342 if old_oid != MISSING_BLOB_OID:
1343 # First, check if we can get a pre-image from git-annex
1344 annex_image = None
1345 if annex:
1346 annex_image = gitcmds.annex_path(context, head, filename)
1347 if annex_image:
1348 images.append((annex_image, False)) # git annex HEAD
1349 else:
1350 image = gitcmds.write_blob_path(context, head, old_oid, filename)
1351 if image:
1352 images.append((image, True))
1354 if new_oid != MISSING_BLOB_OID:
1355 found_in_annex = False
1356 if annex and core.islink(filename):
1357 status, out, _ = git.annex('status', '--', filename)
1358 if status == 0:
1359 details = out.split(' ')
1360 if details and details[0] == 'A': # newly added file
1361 images.append((filename, False))
1362 found_in_annex = True
1364 if not found_in_annex:
1365 image = gitcmds.write_blob(context, new_oid, filename)
1366 if image:
1367 images.append((image, True))
1369 return images
1371 def unmerged_images(self):
1372 context = self.context
1373 git = self.git
1374 head = self.model.head
1375 filename = self.new_filename
1376 annex = self.annex
1378 candidate_merge_heads = ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1379 merge_heads = [
1380 merge_head
1381 for merge_head in candidate_merge_heads
1382 if core.exists(git.git_path(merge_head))
1385 if annex: # Attempt to find files in git-annex
1386 annex_images = []
1387 for merge_head in merge_heads:
1388 image = gitcmds.annex_path(context, merge_head, filename)
1389 if image:
1390 annex_images.append((image, False))
1391 if annex_images:
1392 annex_images.append((filename, False))
1393 return annex_images
1395 # DIFF FORMAT FOR MERGES
1396 # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1397 # can take -c or --cc option to generate diff output also
1398 # for merge commits. The output differs from the format
1399 # described above in the following way:
1401 # 1. there is a colon for each parent
1402 # 2. there are more "src" modes and "src" sha1
1403 # 3. status is concatenated status characters for each parent
1404 # 4. no optional "score" number
1405 # 5. single path, only for "dst"
1406 # Example:
1407 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1408 # MM describe.c
1409 images = []
1410 index = git.diff_index(head, '--', filename, cached=True, cc=True)[STDOUT]
1411 if index:
1412 parts = index.split(' ')
1413 if len(parts) > 3:
1414 first_mode = parts[0]
1415 num_parents = first_mode.count(':')
1416 # colon for each parent, but for the index, the "parents"
1417 # are really entries in stages 1,2,3 (head, base, remote)
1418 # remote, base, head
1419 for i in range(num_parents):
1420 offset = num_parents + i + 1
1421 oid = parts[offset]
1422 try:
1423 merge_head = merge_heads[i]
1424 except IndexError:
1425 merge_head = 'HEAD'
1426 if oid != MISSING_BLOB_OID:
1427 image = gitcmds.write_blob_path(
1428 context, merge_head, oid, filename
1430 if image:
1431 images.append((image, True))
1433 images.append((filename, False))
1434 return images
1436 def modified_images(self):
1437 context = self.context
1438 git = self.git
1439 head = self.model.head
1440 filename = self.new_filename
1441 annex = self.annex
1443 images = []
1444 annex_image = None
1445 if annex: # Check for a pre-image from git-annex
1446 annex_image = gitcmds.annex_path(context, head, filename)
1447 if annex_image:
1448 images.append((annex_image, False)) # git annex HEAD
1449 else:
1450 worktree = git.diff_files('--', filename)[STDOUT]
1451 parts = worktree.split(' ')
1452 if len(parts) > 3:
1453 oid = parts[2]
1454 if oid != MISSING_BLOB_OID:
1455 image = gitcmds.write_blob_path(context, head, oid, filename)
1456 if image:
1457 images.append((image, True)) # HEAD
1459 images.append((filename, False)) # worktree
1460 return images
1463 class Diff(EditModel):
1464 """Perform a diff and set the model's current text."""
1466 def __init__(self, context, filename, cached=False, deleted=False):
1467 super().__init__(context)
1468 opts = {}
1469 if cached and gitcmds.is_valid_ref(context, self.model.head):
1470 opts['ref'] = self.model.head
1471 self.new_filename = filename
1472 self.new_mode = self.model.mode_worktree
1473 self.new_diff_text = gitcmds.diff_helper(
1474 self.context, filename=filename, cached=cached, deleted=deleted, **opts
1478 class Diffstat(EditModel):
1479 """Perform a diffstat and set the model's diff text."""
1481 def __init__(self, context):
1482 super().__init__(context)
1483 cfg = self.cfg
1484 diff_context = cfg.get('diff.context', 3)
1485 diff = self.git.diff(
1486 self.model.head,
1487 unified=diff_context,
1488 no_ext_diff=True,
1489 no_color=True,
1490 M=True,
1491 stat=True,
1492 )[STDOUT]
1493 self.new_diff_text = diff
1494 self.new_diff_type = main.Types.TEXT
1495 self.new_file_type = main.Types.TEXT
1496 self.new_mode = self.model.mode_diffstat
1499 class DiffStaged(Diff):
1500 """Perform a staged diff on a file."""
1502 def __init__(self, context, filename, deleted=None):
1503 super().__init__(context, filename, cached=True, deleted=deleted)
1504 self.new_mode = self.model.mode_index
1507 class DiffStagedSummary(EditModel):
1508 def __init__(self, context):
1509 super().__init__(context)
1510 diff = self.git.diff(
1511 self.model.head,
1512 cached=True,
1513 no_color=True,
1514 no_ext_diff=True,
1515 patch_with_stat=True,
1516 M=True,
1517 )[STDOUT]
1518 self.new_diff_text = diff
1519 self.new_diff_type = main.Types.TEXT
1520 self.new_file_type = main.Types.TEXT
1521 self.new_mode = self.model.mode_index
1524 class Edit(ContextCommand):
1525 """Edit a file using the configured gui.editor."""
1527 @staticmethod
1528 def name():
1529 return N_('Launch Editor')
1531 def __init__(self, context, filenames, line_number=None, background_editor=False):
1532 super().__init__(context)
1533 self.filenames = filenames
1534 self.line_number = line_number
1535 self.background_editor = background_editor
1537 def do(self):
1538 context = self.context
1539 if not self.filenames:
1540 return
1541 filename = self.filenames[0]
1542 if not core.exists(filename):
1543 return
1544 if self.background_editor:
1545 editor = prefs.background_editor(context)
1546 else:
1547 editor = prefs.editor(context)
1548 opts = []
1550 if self.line_number is None:
1551 opts = self.filenames
1552 else:
1553 # Single-file w/ line-numbers (likely from grep)
1554 editor_opts = {
1555 '*vim*': [filename, '+%s' % self.line_number],
1556 '*emacs*': ['+%s' % self.line_number, filename],
1557 '*textpad*': [f'{filename}({self.line_number},0)'],
1558 '*notepad++*': ['-n%s' % self.line_number, filename],
1559 '*subl*': [f'{filename}:{self.line_number}'],
1562 opts = self.filenames
1563 for pattern, opt in editor_opts.items():
1564 if fnmatch(editor, pattern):
1565 opts = opt
1566 break
1568 try:
1569 core.fork(utils.shell_split(editor) + opts)
1570 except (OSError, ValueError) as e:
1571 message = N_('Cannot exec "%s": please configure your editor') % editor
1572 _, details = utils.format_exception(e)
1573 Interaction.critical(N_('Error Editing File'), message, details)
1576 class FormatPatch(ContextCommand):
1577 """Output a patch series given all revisions and a selected subset."""
1579 def __init__(self, context, to_export, revs, output='patches'):
1580 super().__init__(context)
1581 self.to_export = list(to_export)
1582 self.revs = list(revs)
1583 self.output = output
1585 def do(self):
1586 context = self.context
1587 status, out, err = gitcmds.format_patchsets(
1588 context, self.to_export, self.revs, self.output
1590 Interaction.log_status(status, out, err)
1593 class LaunchTerminal(ContextCommand):
1594 @staticmethod
1595 def name():
1596 return N_('Launch Terminal')
1598 @staticmethod
1599 def is_available(context):
1600 return context.cfg.terminal() is not None
1602 def __init__(self, context, path):
1603 super().__init__(context)
1604 self.path = path
1606 def do(self):
1607 cmd = self.context.cfg.terminal()
1608 if cmd is None:
1609 return
1610 if utils.is_win32():
1611 argv = ['start', '', cmd, '--login']
1612 shell = True
1613 else:
1614 argv = utils.shell_split(cmd)
1615 command = '/bin/sh'
1616 shells = ('zsh', 'fish', 'bash', 'sh')
1617 for basename in shells:
1618 executable = core.find_executable(basename)
1619 if executable:
1620 command = executable
1621 break
1622 argv.append(os.getenv('SHELL', command))
1623 shell = False
1625 core.fork(argv, cwd=self.path, shell=shell)
1628 class LaunchEditor(Edit):
1629 @staticmethod
1630 def name():
1631 return N_('Launch Editor')
1633 def __init__(self, context):
1634 s = context.selection.selection()
1635 filenames = s.staged + s.unmerged + s.modified + s.untracked
1636 super().__init__(context, filenames, background_editor=True)
1639 class LaunchEditorAtLine(LaunchEditor):
1640 """Launch an editor at the specified line"""
1642 def __init__(self, context):
1643 super().__init__(context)
1644 self.line_number = context.selection.line_number
1647 class LoadCommitMessageFromFile(ContextCommand):
1648 """Loads a commit message from a path."""
1650 UNDOABLE = True
1652 def __init__(self, context, path):
1653 super().__init__(context)
1654 self.path = path
1655 self.old_commitmsg = self.model.commitmsg
1656 self.old_directory = self.model.directory
1658 def do(self):
1659 path = os.path.expanduser(self.path)
1660 if not path or not core.isfile(path):
1661 raise UsageError(
1662 N_('Error: Cannot find commit template'),
1663 N_('%s: No such file or directory.') % path,
1665 self.model.set_directory(os.path.dirname(path))
1666 self.model.set_commitmsg(core.read(path))
1668 def undo(self):
1669 self.model.set_commitmsg(self.old_commitmsg)
1670 self.model.set_directory(self.old_directory)
1673 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile):
1674 """Loads the commit message template specified by commit.template."""
1676 def __init__(self, context):
1677 cfg = context.cfg
1678 template = cfg.get('commit.template')
1679 super().__init__(context, template)
1681 def do(self):
1682 if self.path is None:
1683 raise UsageError(
1684 N_('Error: Unconfigured commit template'),
1686 'A commit template has not been configured.\n'
1687 'Use "git config" to define "commit.template"\n'
1688 'so that it points to a commit template.'
1691 return LoadCommitMessageFromFile.do(self)
1694 class LoadCommitMessageFromOID(ContextCommand):
1695 """Load a previous commit message"""
1697 UNDOABLE = True
1699 def __init__(self, context, oid, prefix=''):
1700 super().__init__(context)
1701 self.oid = oid
1702 self.old_commitmsg = self.model.commitmsg
1703 self.new_commitmsg = prefix + gitcmds.prev_commitmsg(context, oid)
1705 def do(self):
1706 self.model.set_commitmsg(self.new_commitmsg)
1708 def undo(self):
1709 self.model.set_commitmsg(self.old_commitmsg)
1712 class PrepareCommitMessageHook(ContextCommand):
1713 """Use the cola-prepare-commit-msg hook to prepare the commit message"""
1715 UNDOABLE = True
1717 def __init__(self, context):
1718 super().__init__(context)
1719 self.old_commitmsg = self.model.commitmsg
1721 def get_message(self):
1722 title = N_('Error running prepare-commitmsg hook')
1723 hook = gitcmds.prepare_commit_message_hook(self.context)
1725 if os.path.exists(hook):
1726 filename = self.model.save_commitmsg()
1727 status, out, err = core.run_command([hook, filename])
1729 if status == 0:
1730 result = core.read(filename)
1731 else:
1732 result = self.old_commitmsg
1733 Interaction.command_error(title, hook, status, out, err)
1734 else:
1735 message = N_('A hook must be provided at "%s"') % hook
1736 Interaction.critical(title, message=message)
1737 result = self.old_commitmsg
1739 return result
1741 def do(self):
1742 msg = self.get_message()
1743 self.model.set_commitmsg(msg)
1745 def undo(self):
1746 self.model.set_commitmsg(self.old_commitmsg)
1749 class LoadFixupMessage(LoadCommitMessageFromOID):
1750 """Load a fixup message"""
1752 def __init__(self, context, oid):
1753 super().__init__(context, oid, prefix='fixup! ')
1754 if self.new_commitmsg:
1755 self.new_commitmsg = self.new_commitmsg.splitlines()[0]
1758 class Merge(ContextCommand):
1759 """Merge commits"""
1761 def __init__(self, context, revision, no_commit, squash, no_ff, sign):
1762 super().__init__(context)
1763 self.revision = revision
1764 self.no_ff = no_ff
1765 self.no_commit = no_commit
1766 self.squash = squash
1767 self.sign = sign
1769 def do(self):
1770 squash = self.squash
1771 revision = self.revision
1772 no_ff = self.no_ff
1773 no_commit = self.no_commit
1774 sign = self.sign
1776 status, out, err = self.git.merge(
1777 revision, gpg_sign=sign, no_ff=no_ff, no_commit=no_commit, squash=squash
1779 self.model.update_status()
1780 title = N_('Merge failed. Conflict resolution is required.')
1781 Interaction.command(title, 'git merge', status, out, err)
1783 return status, out, err
1786 class OpenDefaultApp(ContextCommand):
1787 """Open a file using the OS default."""
1789 @staticmethod
1790 def name():
1791 return N_('Open Using Default Application')
1793 def __init__(self, context, filenames):
1794 super().__init__(context)
1795 self.filenames = filenames
1797 def do(self):
1798 if not self.filenames:
1799 return
1800 utils.launch_default_app(self.filenames)
1803 class OpenDir(OpenDefaultApp):
1804 """Open directories using the OS default."""
1806 @staticmethod
1807 def name():
1808 return N_('Open Directory')
1810 @property
1811 def _dirnames(self):
1812 return self.filenames
1814 def do(self):
1815 dirnames = self._dirnames
1816 if not dirnames:
1817 return
1818 # An empty dirname defaults to CWD.
1819 dirs = [(dirname or core.getcwd()) for dirname in dirnames]
1820 utils.launch_default_app(dirs)
1823 class OpenParentDir(OpenDir):
1824 """Open parent directories using the OS default."""
1826 @staticmethod
1827 def name():
1828 return N_('Open Parent Directory')
1830 @property
1831 def _dirnames(self):
1832 dirnames = list({os.path.dirname(x) for x in self.filenames})
1833 return dirnames
1836 class OpenWorktree(OpenDir):
1837 """Open worktree directory using the OS default."""
1839 @staticmethod
1840 def name():
1841 return N_('Open Worktree')
1843 # The _unused parameter is needed by worktree_dir_action() -> common.cmd_action().
1844 def __init__(self, context, _unused=None):
1845 dirnames = [context.git.worktree()]
1846 super().__init__(context, dirnames)
1849 class OpenNewRepo(ContextCommand):
1850 """Launches git-cola on a repo."""
1852 def __init__(self, context, repo_path):
1853 super().__init__(context)
1854 self.repo_path = repo_path
1856 def do(self):
1857 self.model.set_directory(self.repo_path)
1858 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
1861 class OpenRepo(EditModel):
1862 def __init__(self, context, repo_path):
1863 super().__init__(context)
1864 self.repo_path = repo_path
1865 self.new_mode = self.model.mode_none
1866 self.new_diff_text = ''
1867 self.new_diff_type = main.Types.TEXT
1868 self.new_file_type = main.Types.TEXT
1869 self.new_commitmsg = ''
1870 self.new_filename = ''
1872 def do(self):
1873 old_repo = self.git.getcwd()
1874 if self.model.set_worktree(self.repo_path):
1875 self.fsmonitor.stop()
1876 self.fsmonitor.start()
1877 self.model.update_status(reset=True)
1878 # Check if template should be loaded
1879 if self.context.cfg.get(prefs.AUTOTEMPLATE):
1880 template_loader = LoadCommitMessageFromTemplate(self.context)
1881 template_loader.do()
1882 else:
1883 self.model.set_commitmsg(self.new_commitmsg)
1884 settings = self.context.settings
1885 settings.load()
1886 settings.add_recent(self.repo_path, prefs.maxrecent(self.context))
1887 settings.save()
1888 super().do()
1889 else:
1890 self.model.set_worktree(old_repo)
1893 class OpenParentRepo(OpenRepo):
1894 def __init__(self, context):
1895 path = ''
1896 if version.check_git(context, 'show-superproject-working-tree'):
1897 status, out, _ = context.git.rev_parse(show_superproject_working_tree=True)
1898 if status == 0:
1899 path = out
1900 if not path:
1901 path = os.path.dirname(core.getcwd())
1902 super().__init__(context, path)
1905 class Clone(ContextCommand):
1906 """Clones a repository and optionally spawns a new cola session."""
1908 def __init__(
1909 self, context, url, new_directory, submodules=False, shallow=False, spawn=True
1911 super().__init__(context)
1912 self.url = url
1913 self.new_directory = new_directory
1914 self.submodules = submodules
1915 self.shallow = shallow
1916 self.spawn = spawn
1917 self.status = -1
1918 self.out = ''
1919 self.err = ''
1921 def do(self):
1922 kwargs = {}
1923 if self.shallow:
1924 kwargs['depth'] = 1
1925 recurse_submodules = self.submodules
1926 shallow_submodules = self.submodules and self.shallow
1928 status, out, err = self.git.clone(
1929 self.url,
1930 self.new_directory,
1931 recurse_submodules=recurse_submodules,
1932 shallow_submodules=shallow_submodules,
1933 **kwargs,
1936 self.status = status
1937 self.out = out
1938 self.err = err
1939 if status == 0 and self.spawn:
1940 executable = sys.executable
1941 core.fork([executable, sys.argv[0], '--repo', self.new_directory])
1942 return self
1945 class NewBareRepo(ContextCommand):
1946 """Create a new shared bare repository"""
1948 def __init__(self, context, path):
1949 super().__init__(context)
1950 self.path = path
1952 def do(self):
1953 path = self.path
1954 status, out, err = self.git.init(path, bare=True, shared=True)
1955 Interaction.command(
1956 N_('Error'), 'git init --bare --shared "%s"' % path, status, out, err
1958 return status == 0
1961 def unix_path(path, is_win32=utils.is_win32):
1962 """Git for Windows requires unix paths, so force them here"""
1963 if is_win32():
1964 path = path.replace('\\', '/')
1965 first = path[0]
1966 second = path[1]
1967 if second == ':': # sanity check, this better be a Windows-style path
1968 path = '/' + first + path[2:]
1970 return path
1973 def sequence_editor():
1974 """Set GIT_SEQUENCE_EDITOR for running git-cola-sequence-editor"""
1975 xbase = unix_path(resources.command('git-cola-sequence-editor'))
1976 if utils.is_win32():
1977 editor = core.list2cmdline([unix_path(sys.executable), xbase])
1978 else:
1979 editor = core.list2cmdline([xbase])
1980 return editor
1983 class SequenceEditorEnvironment:
1984 """Set environment variables to enable git-cola-sequence-editor"""
1986 def __init__(self, context, **kwargs):
1987 self.env = {
1988 'GIT_EDITOR': prefs.editor(context),
1989 'GIT_SEQUENCE_EDITOR': sequence_editor(),
1990 'GIT_COLA_SEQ_EDITOR_CANCEL_ACTION': 'save',
1992 self.env.update(kwargs)
1994 def __enter__(self):
1995 for var, value in self.env.items():
1996 compat.setenv(var, value)
1997 return self
1999 def __exit__(self, exc_type, exc_val, exc_tb):
2000 for var in self.env:
2001 compat.unsetenv(var)
2004 class Rebase(ContextCommand):
2005 def __init__(self, context, upstream=None, branch=None, **kwargs):
2006 """Start an interactive rebase session
2008 :param upstream: upstream branch
2009 :param branch: optional branch to checkout
2010 :param kwargs: forwarded directly to `git.rebase()`
2013 super().__init__(context)
2015 self.upstream = upstream
2016 self.branch = branch
2017 self.kwargs = kwargs
2019 def prepare_arguments(self, upstream):
2020 args = []
2021 kwargs = {}
2023 # Rebase actions must be the only option specified
2024 for action in ('continue', 'abort', 'skip', 'edit_todo'):
2025 if self.kwargs.get(action, False):
2026 kwargs[action] = self.kwargs[action]
2027 return args, kwargs
2029 kwargs['interactive'] = True
2030 kwargs['autosquash'] = self.kwargs.get('autosquash', True)
2031 kwargs.update(self.kwargs)
2033 # Prompt to determine whether or not to use "git rebase --update-refs".
2034 has_update_refs = version.check_git(self.context, 'rebase-update-refs')
2035 if has_update_refs and not kwargs.get('update_refs', False):
2036 title = N_('Update stacked branches when rebasing?')
2037 text = N_(
2038 '"git rebase --update-refs" automatically force-updates any\n'
2039 'branches that point to commits that are being rebased.\n\n'
2040 'Any branches that are checked out in a worktree are not updated.\n\n'
2041 'Using this feature is helpful for "stacked" branch workflows.'
2043 info = N_('Update stacked branches when rebasing?')
2044 ok_text = N_('Update stacked branches')
2045 cancel_text = N_('Do not update stacked branches')
2046 update_refs = Interaction.confirm(
2047 title,
2048 text,
2049 info,
2050 ok_text,
2051 default=True,
2052 cancel_text=cancel_text,
2054 if update_refs:
2055 kwargs['update_refs'] = True
2057 if upstream:
2058 args.append(upstream)
2059 if self.branch:
2060 args.append(self.branch)
2062 return args, kwargs
2064 def do(self):
2065 (status, out, err) = (1, '', '')
2066 context = self.context
2067 cfg = self.cfg
2068 model = self.model
2070 if not cfg.get('rebase.autostash', False):
2071 if model.staged or model.unmerged or model.modified:
2072 Interaction.information(
2073 N_('Unable to rebase'),
2074 N_('You cannot rebase with uncommitted changes.'),
2076 return status, out, err
2078 upstream = self.upstream or Interaction.choose_ref(
2079 context,
2080 N_('Select New Upstream'),
2081 N_('Interactive Rebase'),
2082 default='@{upstream}',
2084 if not upstream:
2085 return status, out, err
2087 self.model.is_rebasing = True
2088 self.model.emit_updated()
2090 args, kwargs = self.prepare_arguments(upstream)
2091 upstream_title = upstream or '@{upstream}'
2092 with SequenceEditorEnvironment(
2093 self.context,
2094 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase onto %s') % upstream_title,
2095 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
2097 # This blocks the user interface window for the duration
2098 # of git-cola-sequence-editor. We would need to run the command
2099 # in a QRunnable task to avoid blocking the main thread.
2100 # Alternatively, we can hide the main window while rebasing,
2101 # which doesn't require as much effort.
2102 status, out, err = self.git.rebase(
2103 *args, _no_win32_startupinfo=True, **kwargs
2105 self.model.update_status()
2106 if err.strip() != 'Nothing to do':
2107 title = N_('Rebase stopped')
2108 Interaction.command(title, 'git rebase', status, out, err)
2109 return status, out, err
2112 class RebaseEditTodo(ContextCommand):
2113 def do(self):
2114 (status, out, err) = (1, '', '')
2115 with SequenceEditorEnvironment(
2116 self.context,
2117 GIT_COLA_SEQ_EDITOR_TITLE=N_('Edit Rebase'),
2118 GIT_COLA_SEQ_EDITOR_ACTION=N_('Save'),
2120 status, out, err = self.git.rebase(edit_todo=True)
2121 Interaction.log_status(status, out, err)
2122 self.model.update_status()
2123 return status, out, err
2126 class RebaseContinue(ContextCommand):
2127 def do(self):
2128 (status, out, err) = (1, '', '')
2129 with SequenceEditorEnvironment(
2130 self.context,
2131 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase'),
2132 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
2134 status, out, err = self.git.rebase('--continue')
2135 Interaction.log_status(status, out, err)
2136 self.model.update_status()
2137 return status, out, err
2140 class RebaseSkip(ContextCommand):
2141 def do(self):
2142 (status, out, err) = (1, '', '')
2143 with SequenceEditorEnvironment(
2144 self.context,
2145 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase'),
2146 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
2148 status, out, err = self.git.rebase(skip=True)
2149 Interaction.log_status(status, out, err)
2150 self.model.update_status()
2151 return status, out, err
2154 class RebaseAbort(ContextCommand):
2155 def do(self):
2156 status, out, err = self.git.rebase(abort=True)
2157 Interaction.log_status(status, out, err)
2158 self.model.update_status()
2161 class Rescan(ContextCommand):
2162 """Rescan for changes"""
2164 def do(self):
2165 self.model.update_status()
2168 class Refresh(ContextCommand):
2169 """Update refs, refresh the index, and update config"""
2171 @staticmethod
2172 def name():
2173 return N_('Refresh')
2175 def do(self):
2176 self.model.update_status(update_index=True)
2177 self.cfg.update()
2178 self.fsmonitor.refresh()
2181 class RefreshConfig(ContextCommand):
2182 """Refresh the git config cache"""
2184 def do(self):
2185 self.cfg.update()
2188 class RevertEditsCommand(ConfirmAction):
2189 def __init__(self, context):
2190 super().__init__(context)
2191 self.icon = icons.undo()
2193 def ok_to_run(self):
2194 return self.model.is_undoable()
2196 def checkout_from_head(self):
2197 return False
2199 def checkout_args(self):
2200 args = []
2201 s = self.selection.selection()
2202 if self.checkout_from_head():
2203 args.append(self.model.head)
2204 args.append('--')
2206 if s.staged:
2207 items = s.staged
2208 else:
2209 items = s.modified
2210 args.extend(items)
2212 return args
2214 def action(self):
2215 checkout_args = self.checkout_args()
2216 return self.git.checkout(*checkout_args)
2218 def success(self):
2219 self.model.set_diff_type(main.Types.TEXT)
2220 self.model.update_file_status()
2223 class RevertUnstagedEdits(RevertEditsCommand):
2224 @staticmethod
2225 def name():
2226 return N_('Revert Unstaged Edits...')
2228 def checkout_from_head(self):
2229 # Being in amend mode should not affect the behavior of this command.
2230 # The only sensible thing to do is to checkout from the index.
2231 return False
2233 def confirm(self):
2234 title = N_('Revert Unstaged Changes?')
2235 text = N_(
2236 'This operation removes unstaged edits from selected files.\n'
2237 'These changes cannot be recovered.'
2239 info = N_('Revert the unstaged changes?')
2240 ok_text = N_('Revert Unstaged Changes')
2241 return Interaction.confirm(
2242 title, text, info, ok_text, default=True, icon=self.icon
2246 class RevertUncommittedEdits(RevertEditsCommand):
2247 @staticmethod
2248 def name():
2249 return N_('Revert Uncommitted Edits...')
2251 def checkout_from_head(self):
2252 return True
2254 def confirm(self):
2255 """Prompt for reverting changes"""
2256 title = N_('Revert Uncommitted Changes?')
2257 text = N_(
2258 'This operation removes uncommitted edits from selected files.\n'
2259 'These changes cannot be recovered.'
2261 info = N_('Revert the uncommitted changes?')
2262 ok_text = N_('Revert Uncommitted Changes')
2263 return Interaction.confirm(
2264 title, text, info, ok_text, default=True, icon=self.icon
2268 class RunConfigAction(ContextCommand):
2269 """Run a user-configured action, typically from the "Tools" menu"""
2271 def __init__(self, context, action_name):
2272 super().__init__(context)
2273 self.action_name = action_name
2275 def do(self):
2276 """Run the user-configured action"""
2277 for env in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
2278 try:
2279 compat.unsetenv(env)
2280 except KeyError:
2281 pass
2282 rev = None
2283 args = None
2284 context = self.context
2285 cfg = self.cfg
2286 opts = cfg.get_guitool_opts(self.action_name)
2287 cmd = opts.get('cmd')
2288 if 'title' not in opts:
2289 opts['title'] = cmd
2291 if 'prompt' not in opts or opts.get('prompt') is True:
2292 prompt = N_('Run "%s"?') % cmd
2293 opts['prompt'] = prompt
2295 if opts.get('needsfile'):
2296 filename = self.selection.filename()
2297 if not filename:
2298 Interaction.information(
2299 N_('Please select a file'),
2300 N_('"%s" requires a selected file.') % cmd,
2302 return False
2303 dirname = utils.dirname(filename, current_dir='.')
2304 compat.setenv('FILENAME', filename)
2305 compat.setenv('DIRNAME', dirname)
2307 if opts.get('revprompt') or opts.get('argprompt'):
2308 while True:
2309 ok = Interaction.confirm_config_action(context, cmd, opts)
2310 if not ok:
2311 return False
2312 rev = opts.get('revision')
2313 args = opts.get('args')
2314 if opts.get('revprompt') and not rev:
2315 title = N_('Invalid Revision')
2316 msg = N_('The revision expression cannot be empty.')
2317 Interaction.critical(title, msg)
2318 continue
2319 break
2321 elif opts.get('confirm'):
2322 title = os.path.expandvars(opts.get('title'))
2323 prompt = os.path.expandvars(opts.get('prompt'))
2324 if not Interaction.question(title, prompt):
2325 return False
2326 if rev:
2327 compat.setenv('REVISION', rev)
2328 if args:
2329 compat.setenv('ARGS', args)
2330 title = os.path.expandvars(cmd)
2331 Interaction.log(N_('Running command: %s') % title)
2332 cmd = ['sh', '-c', cmd]
2334 if opts.get('background'):
2335 core.fork(cmd)
2336 status, out, err = (0, '', '')
2337 elif opts.get('noconsole'):
2338 status, out, err = core.run_command(cmd)
2339 else:
2340 status, out, err = Interaction.run_command(title, cmd)
2342 if not opts.get('background') and not opts.get('norescan'):
2343 self.model.update_status()
2345 title = N_('Error')
2346 Interaction.command(title, cmd, status, out, err)
2348 return status == 0
2351 class SetDefaultRepo(ContextCommand):
2352 """Set the default repository"""
2354 def __init__(self, context, repo):
2355 super().__init__(context)
2356 self.repo = repo
2358 def do(self):
2359 self.cfg.set_user('cola.defaultrepo', self.repo)
2362 class SetDiffText(EditModel):
2363 """Set the diff text"""
2365 UNDOABLE = True
2367 def __init__(self, context, text):
2368 super().__init__(context)
2369 self.new_diff_text = text
2370 self.new_diff_type = main.Types.TEXT
2371 self.new_file_type = main.Types.TEXT
2374 class SetUpstreamBranch(ContextCommand):
2375 """Set the upstream branch"""
2377 def __init__(self, context, branch, remote, remote_branch):
2378 super().__init__(context)
2379 self.branch = branch
2380 self.remote = remote
2381 self.remote_branch = remote_branch
2383 def do(self):
2384 cfg = self.cfg
2385 remote = self.remote
2386 branch = self.branch
2387 remote_branch = self.remote_branch
2388 cfg.set_repo('branch.%s.remote' % branch, remote)
2389 cfg.set_repo('branch.%s.merge' % branch, 'refs/heads/' + remote_branch)
2392 def format_hex(data):
2393 """Translate binary data into a hex dump"""
2394 hexdigits = '0123456789ABCDEF'
2395 result = ''
2396 offset = 0
2397 byte_offset_to_int = compat.byte_offset_to_int_converter()
2398 while offset < len(data):
2399 result += '%04u |' % offset
2400 textpart = ''
2401 for i in range(0, 16):
2402 if i > 0 and i % 4 == 0:
2403 result += ' '
2404 if offset < len(data):
2405 v = byte_offset_to_int(data[offset])
2406 result += ' ' + hexdigits[v >> 4] + hexdigits[v & 0xF]
2407 textpart += chr(v) if 32 <= v < 127 else '.'
2408 offset += 1
2409 else:
2410 result += ' '
2411 textpart += ' '
2412 result += ' | ' + textpart + ' |\n'
2414 return result
2417 class ShowUntracked(EditModel):
2418 """Show an untracked file."""
2420 def __init__(self, context, filename):
2421 super().__init__(context)
2422 self.new_filename = filename
2423 if gitcmds.is_binary(context, filename):
2424 self.new_mode = self.model.mode_untracked
2425 self.new_diff_text = self.read(filename)
2426 else:
2427 self.new_mode = self.model.mode_untracked_diff
2428 self.new_diff_text = gitcmds.diff_helper(
2429 self.context, filename=filename, cached=False, untracked=True
2431 self.new_diff_type = main.Types.TEXT
2432 self.new_file_type = main.Types.TEXT
2434 def read(self, filename):
2435 """Read file contents"""
2436 cfg = self.cfg
2437 size = cfg.get('cola.readsize', 2048)
2438 try:
2439 result = core.read(filename, size=size, encoding='bytes')
2440 except OSError:
2441 result = ''
2443 truncated = len(result) == size
2445 encoding = cfg.file_encoding(filename) or core.ENCODING
2446 try:
2447 text_result = core.decode_maybe(result, encoding)
2448 except UnicodeError:
2449 text_result = format_hex(result)
2451 if truncated:
2452 text_result += '...'
2453 return text_result
2456 class SignOff(ContextCommand):
2457 """Append a signoff to the commit message"""
2459 UNDOABLE = True
2461 @staticmethod
2462 def name():
2463 return N_('Sign Off')
2465 def __init__(self, context):
2466 super().__init__(context)
2467 self.old_commitmsg = self.model.commitmsg
2469 def do(self):
2470 """Add a signoff to the commit message"""
2471 signoff = self.signoff()
2472 if signoff in self.model.commitmsg:
2473 return
2474 msg = self.model.commitmsg.rstrip()
2475 self.model.set_commitmsg(msg + '\n' + signoff)
2477 def undo(self):
2478 """Restore the commit message"""
2479 self.model.set_commitmsg(self.old_commitmsg)
2481 def signoff(self):
2482 """Generate the signoff string"""
2483 name, email = self.cfg.get_author()
2484 return f'\nSigned-off-by: {name} <{email}>'
2487 def check_conflicts(context, unmerged):
2488 """Check paths for conflicts
2490 Conflicting files can be filtered out one-by-one.
2493 if prefs.check_conflicts(context):
2494 unmerged = [path for path in unmerged if is_conflict_free(path)]
2495 return unmerged
2498 def is_conflict_free(path):
2499 """Return True if `path` contains no conflict markers"""
2500 rgx = re.compile(r'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2501 try:
2502 with core.xopen(path, 'rb') as f:
2503 for line in f:
2504 line = core.decode(line, errors='ignore')
2505 if rgx.match(line):
2506 return should_stage_conflicts(path)
2507 except OSError:
2508 # We can't read this file ~ we may be staging a removal
2509 pass
2510 return True
2513 def should_stage_conflicts(path):
2514 """Inform the user that a file contains merge conflicts
2516 Return `True` if we should stage the path nonetheless.
2519 title = msg = N_('Stage conflicts?')
2520 info = (
2522 '%s appears to contain merge conflicts.\n\n'
2523 'You should probably skip this file.\n'
2524 'Stage it anyways?'
2526 % path
2528 ok_text = N_('Stage conflicts')
2529 cancel_text = N_('Skip')
2530 return Interaction.confirm(
2531 title, msg, info, ok_text, default=False, cancel_text=cancel_text
2535 class Stage(ContextCommand):
2536 """Stage a set of paths."""
2538 @staticmethod
2539 def name():
2540 return N_('Stage')
2542 def __init__(self, context, paths):
2543 super().__init__(context)
2544 self.paths = paths
2546 def do(self):
2547 msg = N_('Staging: %s') % (', '.join(self.paths))
2548 Interaction.log(msg)
2549 return self.stage_paths()
2551 def stage_paths(self):
2552 """Stages add/removals to git."""
2553 context = self.context
2554 paths = self.paths
2555 if not paths:
2556 if self.model.cfg.get('cola.safemode', False):
2557 return (0, '', '')
2558 return self.stage_all()
2560 add = []
2561 remove = []
2562 status = 0
2563 out = ''
2564 err = ''
2566 for path in set(paths):
2567 if core.exists(path) or core.islink(path):
2568 if path.endswith('/'):
2569 path = path.rstrip('/')
2570 add.append(path)
2571 else:
2572 remove.append(path)
2574 self.model.emit_about_to_update()
2576 # `git add -u` doesn't work on untracked files
2577 if add:
2578 status, out, err = gitcmds.add(context, add)
2579 Interaction.command(N_('Error'), 'git add', status, out, err)
2581 # If a path doesn't exist then that means it should be removed
2582 # from the index. We use `git add -u` for that.
2583 if remove:
2584 status, out, err = gitcmds.add(context, remove, u=True)
2585 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2587 self.model.update_files(emit=True)
2588 return status, out, err
2590 def stage_all(self):
2591 """Stage all files"""
2592 status, out, err = self.git.add(v=True, u=True)
2593 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2594 self.model.update_file_status()
2595 return (status, out, err)
2598 class StageCarefully(Stage):
2599 """Only stage when the path list is non-empty
2601 We use "git add -u -- <pathspec>" to stage, and it stages everything by
2602 default when no pathspec is specified, so this class ensures that paths
2603 are specified before calling git.
2605 When no paths are specified, the command does nothing.
2609 def __init__(self, context):
2610 super().__init__(context, None)
2611 self.init_paths()
2613 def init_paths(self):
2614 """Initialize path data"""
2615 return
2617 def ok_to_run(self):
2618 """Prevent catch-all "git add -u" from adding unmerged files"""
2619 return self.paths or not self.model.unmerged
2621 def do(self):
2622 """Stage files when ok_to_run() return True"""
2623 if self.ok_to_run():
2624 return super().do()
2625 return (0, '', '')
2628 class StageModified(StageCarefully):
2629 """Stage all modified files."""
2631 @staticmethod
2632 def name():
2633 return N_('Stage Modified')
2635 def init_paths(self):
2636 self.paths = self.model.modified
2639 class StageUnmerged(StageCarefully):
2640 """Stage unmerged files."""
2642 @staticmethod
2643 def name():
2644 return N_('Stage Unmerged')
2646 def init_paths(self):
2647 self.paths = check_conflicts(self.context, self.model.unmerged)
2650 class StageUntracked(StageCarefully):
2651 """Stage all untracked files."""
2653 @staticmethod
2654 def name():
2655 return N_('Stage Untracked')
2657 def init_paths(self):
2658 self.paths = self.model.untracked
2661 class StageModifiedAndUntracked(StageCarefully):
2662 """Stage all untracked files."""
2664 @staticmethod
2665 def name():
2666 return N_('Stage Modified and Untracked')
2668 def init_paths(self):
2669 self.paths = self.model.modified + self.model.untracked
2672 class StageOrUnstageAll(ContextCommand):
2673 """If the selection is staged, unstage it, otherwise stage"""
2675 @staticmethod
2676 def name():
2677 return N_('Stage / Unstage All')
2679 def do(self):
2680 if self.model.staged:
2681 do(Unstage, self.context, self.model.staged)
2682 else:
2683 if self.cfg.get('cola.safemode', False):
2684 unstaged = self.model.modified
2685 else:
2686 unstaged = self.model.modified + self.model.untracked
2687 do(Stage, self.context, unstaged)
2690 class StageOrUnstage(ContextCommand):
2691 """If the selection is staged, unstage it, otherwise stage"""
2693 @staticmethod
2694 def name():
2695 return N_('Stage / Unstage')
2697 def do(self):
2698 s = self.selection.selection()
2699 if s.staged:
2700 do(Unstage, self.context, s.staged)
2702 unstaged = []
2703 unmerged = check_conflicts(self.context, s.unmerged)
2704 if unmerged:
2705 unstaged.extend(unmerged)
2706 if s.modified:
2707 unstaged.extend(s.modified)
2708 if s.untracked:
2709 unstaged.extend(s.untracked)
2710 if unstaged:
2711 do(Stage, self.context, unstaged)
2714 class Tag(ContextCommand):
2715 """Create a tag object."""
2717 def __init__(self, context, name, revision, sign=False, message=''):
2718 super().__init__(context)
2719 self._name = name
2720 self._message = message
2721 self._revision = revision
2722 self._sign = sign
2724 def do(self):
2725 result = False
2726 git = self.git
2727 revision = self._revision
2728 tag_name = self._name
2729 tag_message = self._message
2731 if not revision:
2732 Interaction.critical(
2733 N_('Missing Revision'), N_('Please specify a revision to tag.')
2735 return result
2737 if not tag_name:
2738 Interaction.critical(
2739 N_('Missing Name'), N_('Please specify a name for the new tag.')
2741 return result
2743 title = N_('Missing Tag Message')
2744 message = N_('Tag-signing was requested but the tag message is empty.')
2745 info = N_(
2746 'An unsigned, lightweight tag will be created instead.\n'
2747 'Create an unsigned tag?'
2749 ok_text = N_('Create Unsigned Tag')
2750 sign = self._sign
2751 if sign and not tag_message:
2752 # We require a message in order to sign the tag, so if they
2753 # choose to create an unsigned tag we have to clear the sign flag.
2754 if not Interaction.confirm(
2755 title, message, info, ok_text, default=False, icon=icons.save()
2757 return result
2758 sign = False
2760 opts = {}
2761 tmp_file = None
2762 try:
2763 if tag_message:
2764 tmp_file = utils.tmp_filename('tag-message')
2765 opts['file'] = tmp_file
2766 core.write(tmp_file, tag_message)
2768 if sign:
2769 opts['sign'] = True
2770 if tag_message:
2771 opts['annotate'] = True
2772 status, out, err = git.tag(tag_name, revision, **opts)
2773 finally:
2774 if tmp_file:
2775 core.unlink(tmp_file)
2777 title = N_('Error: could not create tag "%s"') % tag_name
2778 Interaction.command(title, 'git tag', status, out, err)
2780 if status == 0:
2781 result = True
2782 self.model.update_status()
2783 Interaction.information(
2784 N_('Tag Created'),
2785 N_('Created a new tag named "%s"') % tag_name,
2786 details=tag_message or None,
2789 return result
2792 class Unstage(ContextCommand):
2793 """Unstage a set of paths."""
2795 @staticmethod
2796 def name():
2797 return N_('Unstage')
2799 def __init__(self, context, paths):
2800 super().__init__(context)
2801 self.paths = paths
2803 def do(self):
2804 """Unstage paths"""
2805 context = self.context
2806 head = self.model.head
2807 paths = self.paths
2809 msg = N_('Unstaging: %s') % (', '.join(paths))
2810 Interaction.log(msg)
2811 if not paths:
2812 return unstage_all(context)
2813 status, out, err = gitcmds.unstage_paths(context, paths, head=head)
2814 Interaction.command(N_('Error'), 'git reset', status, out, err)
2815 self.model.update_file_status()
2816 return (status, out, err)
2819 class UnstageAll(ContextCommand):
2820 """Unstage all files; resets the index."""
2822 def do(self):
2823 return unstage_all(self.context)
2826 def unstage_all(context):
2827 """Unstage all files, even while amending"""
2828 model = context.model
2829 git = context.git
2830 head = model.head
2831 status, out, err = git.reset(head, '--', '.')
2832 Interaction.command(N_('Error'), 'git reset', status, out, err)
2833 model.update_file_status()
2834 return (status, out, err)
2837 class StageSelected(ContextCommand):
2838 """Stage selected files, or all files if no selection exists."""
2840 def do(self):
2841 context = self.context
2842 paths = self.selection.unstaged
2843 if paths:
2844 do(Stage, context, paths)
2845 elif self.cfg.get('cola.safemode', False):
2846 do(StageModified, context)
2849 class UnstageSelected(Unstage):
2850 """Unstage selected files."""
2852 def __init__(self, context):
2853 staged = context.selection.staged
2854 super().__init__(context, staged)
2857 class Untrack(ContextCommand):
2858 """Unstage a set of paths."""
2860 def __init__(self, context, paths):
2861 super().__init__(context)
2862 self.paths = paths
2864 def do(self):
2865 msg = N_('Untracking: %s') % (', '.join(self.paths))
2866 Interaction.log(msg)
2867 status, out, err = self.model.untrack_paths(self.paths)
2868 Interaction.log_status(status, out, err)
2871 class UnmergedSummary(EditModel):
2872 """List unmerged files in the diff text."""
2874 def __init__(self, context):
2875 super().__init__(context)
2876 unmerged = self.model.unmerged
2877 io = StringIO()
2878 io.write('# %s unmerged file(s)\n' % len(unmerged))
2879 if unmerged:
2880 io.write('\n'.join(unmerged) + '\n')
2881 self.new_diff_text = io.getvalue()
2882 self.new_diff_type = main.Types.TEXT
2883 self.new_file_type = main.Types.TEXT
2884 self.new_mode = self.model.mode_display
2887 class UntrackedSummary(EditModel):
2888 """List possible .gitignore rules as the diff text."""
2890 def __init__(self, context):
2891 super().__init__(context)
2892 untracked = self.model.untracked
2893 io = StringIO()
2894 io.write('# %s untracked file(s)\n' % len(untracked))
2895 if untracked:
2896 io.write('# Add these lines to ".gitignore" to ignore these files:\n')
2897 io.write('\n'.join('/' + filename for filename in untracked) + '\n')
2898 self.new_diff_text = io.getvalue()
2899 self.new_diff_type = main.Types.TEXT
2900 self.new_file_type = main.Types.TEXT
2901 self.new_mode = self.model.mode_display
2904 class VisualizeAll(ContextCommand):
2905 """Visualize all branches."""
2907 def do(self):
2908 context = self.context
2909 browser = utils.shell_split(prefs.history_browser(context))
2910 launch_history_browser(browser + ['--all'])
2913 class VisualizeCurrent(ContextCommand):
2914 """Visualize all branches."""
2916 def do(self):
2917 context = self.context
2918 browser = utils.shell_split(prefs.history_browser(context))
2919 launch_history_browser(browser + [self.model.currentbranch] + ['--'])
2922 class VisualizePaths(ContextCommand):
2923 """Path-limited visualization."""
2925 def __init__(self, context, paths):
2926 super().__init__(context)
2927 context = self.context
2928 browser = utils.shell_split(prefs.history_browser(context))
2929 if paths:
2930 self.argv = browser + ['--'] + list(paths)
2931 else:
2932 self.argv = browser
2934 def do(self):
2935 launch_history_browser(self.argv)
2938 class VisualizeRevision(ContextCommand):
2939 """Visualize a specific revision."""
2941 def __init__(self, context, revision, paths=None):
2942 super().__init__(context)
2943 self.revision = revision
2944 self.paths = paths
2946 def do(self):
2947 context = self.context
2948 argv = utils.shell_split(prefs.history_browser(context))
2949 if self.revision:
2950 argv.append(self.revision)
2951 if self.paths:
2952 argv.append('--')
2953 argv.extend(self.paths)
2954 launch_history_browser(argv)
2957 class SubmoduleAdd(ConfirmAction):
2958 """Add specified submodules"""
2960 def __init__(self, context, url, path, branch, depth, reference):
2961 super().__init__(context)
2962 self.url = url
2963 self.path = path
2964 self.branch = branch
2965 self.depth = depth
2966 self.reference = reference
2968 def confirm(self):
2969 title = N_('Add Submodule...')
2970 question = N_('Add this submodule?')
2971 info = N_('The submodule will be added using\n' '"%s"' % self.command())
2972 ok_txt = N_('Add Submodule')
2973 return Interaction.confirm(title, question, info, ok_txt, icon=icons.ok())
2975 def action(self):
2976 context = self.context
2977 args = self.get_args()
2978 return context.git.submodule('add', *args)
2980 def success(self):
2981 self.model.update_file_status()
2982 self.model.update_submodules_list()
2984 def error_message(self):
2985 return N_('Error updating submodule %s' % self.path)
2987 def command(self):
2988 cmd = ['git', 'submodule', 'add']
2989 cmd.extend(self.get_args())
2990 return core.list2cmdline(cmd)
2992 def get_args(self):
2993 args = []
2994 if self.branch:
2995 args.extend(['--branch', self.branch])
2996 if self.reference:
2997 args.extend(['--reference', self.reference])
2998 if self.depth:
2999 args.extend(['--depth', '%d' % self.depth])
3000 args.extend(['--', self.url])
3001 if self.path:
3002 args.append(self.path)
3003 return args
3006 class SubmoduleUpdate(ConfirmAction):
3007 """Update specified submodule"""
3009 def __init__(self, context, path):
3010 super().__init__(context)
3011 self.path = path
3013 def confirm(self):
3014 title = N_('Update Submodule...')
3015 question = N_('Update this submodule?')
3016 info = N_('The submodule will be updated using\n' '"%s"' % self.command())
3017 ok_txt = N_('Update Submodule')
3018 return Interaction.confirm(
3019 title, question, info, ok_txt, default=False, icon=icons.pull()
3022 def action(self):
3023 context = self.context
3024 args = self.get_args()
3025 return context.git.submodule(*args)
3027 def success(self):
3028 self.model.update_file_status()
3030 def error_message(self):
3031 return N_('Error updating submodule %s' % self.path)
3033 def command(self):
3034 cmd = ['git', 'submodule']
3035 cmd.extend(self.get_args())
3036 return core.list2cmdline(cmd)
3038 def get_args(self):
3039 cmd = ['update']
3040 if version.check_git(self.context, 'submodule-update-recursive'):
3041 cmd.append('--recursive')
3042 cmd.extend(['--', self.path])
3043 return cmd
3046 class SubmodulesUpdate(ConfirmAction):
3047 """Update all submodules"""
3049 def confirm(self):
3050 title = N_('Update submodules...')
3051 question = N_('Update all submodules?')
3052 info = N_('All submodules will be updated using\n' '"%s"' % self.command())
3053 ok_txt = N_('Update Submodules')
3054 return Interaction.confirm(
3055 title, question, info, ok_txt, default=False, icon=icons.pull()
3058 def action(self):
3059 context = self.context
3060 args = self.get_args()
3061 return context.git.submodule(*args)
3063 def success(self):
3064 self.model.update_file_status()
3066 def error_message(self):
3067 return N_('Error updating submodules')
3069 def command(self):
3070 cmd = ['git', 'submodule']
3071 cmd.extend(self.get_args())
3072 return core.list2cmdline(cmd)
3074 def get_args(self):
3075 cmd = ['update']
3076 if version.check_git(self.context, 'submodule-update-recursive'):
3077 cmd.append('--recursive')
3078 return cmd
3081 def launch_history_browser(argv):
3082 """Launch the configured history browser"""
3083 try:
3084 core.fork(argv)
3085 except OSError as e:
3086 _, details = utils.format_exception(e)
3087 title = N_('Error Launching History Browser')
3088 msg = N_('Cannot exec "%s": please configure a history browser') % ' '.join(
3089 argv
3091 Interaction.critical(title, message=msg, details=details)
3094 def run(cls, *args, **opts):
3096 Returns a callback that runs a command
3098 If the caller of run() provides args or opts then those are
3099 used instead of the ones provided by the invoker of the callback.
3103 def runner(*local_args, **local_opts):
3104 """Closure return by run() which runs the command"""
3105 if args or opts:
3106 return do(cls, *args, **opts)
3107 return do(cls, *local_args, **local_opts)
3109 return runner
3112 def do(cls, *args, **opts):
3113 """Run a command in-place"""
3114 try:
3115 cmd = cls(*args, **opts)
3116 return cmd.do()
3117 except Exception as e: # pylint: disable=broad-except
3118 msg, details = utils.format_exception(e)
3119 if hasattr(cls, '__name__'):
3120 msg = f'{cls.__name__} exception:\n{msg}'
3121 Interaction.critical(N_('Error'), message=msg, details=details)
3122 return None