diff: make the context menu more consistent when unstaging
[git-cola.git] / cola / cmds.py
blobac33a036bb57073cc65e7f57c48c03da0c45f453
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()
265 def undo(self):
266 if self.skip:
267 return
268 self.model.set_commitmsg(self.old_commitmsg)
269 super().undo()
270 self.model.update_file_status()
273 class AnnexAdd(ContextCommand):
274 """Add to Git Annex"""
276 def __init__(self, context):
277 super().__init__(context)
278 self.filename = self.selection.filename()
280 def do(self):
281 status, out, err = self.git.annex('add', self.filename)
282 Interaction.command(N_('Error'), 'git annex add', status, out, err)
283 self.model.update_status()
286 class AnnexInit(ContextCommand):
287 """Initialize Git Annex"""
289 def do(self):
290 status, out, err = self.git.annex('init')
291 Interaction.command(N_('Error'), 'git annex init', status, out, err)
292 self.model.cfg.reset()
293 self.model.emit_updated()
296 class LFSTrack(ContextCommand):
297 """Add a file to git lfs"""
299 def __init__(self, context):
300 super().__init__(context)
301 self.filename = self.selection.filename()
302 self.stage_cmd = Stage(context, [self.filename])
304 def do(self):
305 status, out, err = self.git.lfs('track', self.filename)
306 Interaction.command(N_('Error'), 'git lfs track', status, out, err)
307 if status == 0:
308 self.stage_cmd.do()
311 class LFSInstall(ContextCommand):
312 """Initialize git lfs"""
314 def do(self):
315 status, out, err = self.git.lfs('install')
316 Interaction.command(N_('Error'), 'git lfs install', status, out, err)
317 self.model.update_config(reset=True, emit=True)
320 class ApplyPatch(ContextCommand):
321 """Apply the specfied patch to the worktree or index"""
323 def __init__(
324 self,
325 context,
326 patch,
327 encoding,
328 apply_to_worktree,
330 super().__init__(context)
331 self.patch = patch
332 self.encoding = encoding
333 self.apply_to_worktree = apply_to_worktree
335 def do(self):
336 context = self.context
338 tmp_file = utils.tmp_filename('apply', suffix='.patch')
339 try:
340 core.write(tmp_file, self.patch.as_text(), encoding=self.encoding)
341 if self.apply_to_worktree:
342 status, out, err = gitcmds.apply_diff_to_worktree(context, tmp_file)
343 else:
344 status, out, err = gitcmds.apply_diff(context, tmp_file)
345 finally:
346 core.unlink(tmp_file)
348 Interaction.log_status(status, out, err)
349 self.model.update_file_status(update_index=True)
352 class ApplyPatches(ContextCommand):
353 """Apply patches using the "git am" command"""
355 def __init__(self, context, patches):
356 super().__init__(context)
357 self.patches = patches
359 def do(self):
360 status, output, err = self.git.am('-3', *self.patches)
361 out = f'# git am -3 {core.list2cmdline(self.patches)}\n\n{output}'
362 Interaction.command(N_('Patch failed to apply'), 'git am -3', status, out, err)
363 # Display a diffstat
364 self.model.update_file_status()
366 patch_basenames = [os.path.basename(p) for p in self.patches]
367 if len(patch_basenames) > 25:
368 patch_basenames = patch_basenames[:25]
369 patch_basenames.append('...')
371 basenames = '\n'.join(patch_basenames)
372 if status == 0:
373 Interaction.information(
374 N_('Patch(es) Applied'),
375 (N_('%d patch(es) applied.') + '\n\n%s')
376 % (len(self.patches), basenames),
380 class ApplyPatchesContinue(ContextCommand):
381 """Run "git am --continue" to continue on the next patch in a "git am" session"""
383 def do(self):
384 status, out, err = self.git.am('--continue')
385 Interaction.command(
386 N_('Failed to commit and continue applying patches'),
387 'git am --continue',
388 status,
389 out,
390 err,
392 self.model.update_status()
393 return status, out, err
396 class ApplyPatchesSkip(ContextCommand):
397 """Run "git am --skip" to continue on the next patch in a "git am" session"""
399 def do(self):
400 status, out, err = self.git.am(skip=True)
401 Interaction.command(
402 N_('Failed to continue applying patches after skipping the current patch'),
403 'git am --skip',
404 status,
405 out,
406 err,
408 self.model.update_status()
409 return status, out, err
412 class Archive(ContextCommand):
413 """ "Export archives using the "git archive" command"""
415 def __init__(self, context, ref, fmt, prefix, filename):
416 super().__init__(context)
417 self.ref = ref
418 self.fmt = fmt
419 self.prefix = prefix
420 self.filename = filename
422 def do(self):
423 fp = core.xopen(self.filename, 'wb')
424 cmd = ['git', 'archive', '--format=' + self.fmt]
425 if self.fmt in ('tgz', 'tar.gz'):
426 cmd.append('-9')
427 if self.prefix:
428 cmd.append('--prefix=' + self.prefix)
429 cmd.append(self.ref)
430 proc = core.start_command(cmd, stdout=fp)
431 out, err = proc.communicate()
432 fp.close()
433 status = proc.returncode
434 Interaction.log_status(status, out or '', err or '')
437 class Checkout(EditModel):
438 """A command object for git-checkout.
440 'argv' is handed off directly to git.
444 def __init__(self, context, argv, checkout_branch=False):
445 super().__init__(context)
446 self.argv = argv
447 self.checkout_branch = checkout_branch
448 self.new_diff_text = ''
449 self.new_diff_type = main.Types.TEXT
450 self.new_file_type = main.Types.TEXT
452 def do(self):
453 super().do()
454 status, out, err = self.git.checkout(*self.argv)
455 if self.checkout_branch:
456 self.model.update_status()
457 else:
458 self.model.update_file_status()
459 Interaction.command(N_('Error'), 'git checkout', status, out, err)
460 return status, out, err
463 class CheckoutTheirs(ConfirmAction):
464 """Checkout "their" version of a file when performing a merge"""
466 @staticmethod
467 def name():
468 return N_('Checkout files from their branch (MERGE_HEAD)')
470 def confirm(self):
471 title = self.name()
472 question = N_('Checkout files from their branch?')
473 info = N_(
474 'This operation will replace the selected unmerged files with content '
475 'from the branch being merged using "git checkout --theirs".\n'
476 '*ALL* uncommitted changes will be lost.\n'
477 'Recovering uncommitted changes is not possible.'
479 ok_txt = N_('Checkout Files')
480 return Interaction.confirm(
481 title, question, info, ok_txt, default=True, icon=icons.merge()
484 def action(self):
485 selection = self.selection.selection()
486 paths = selection.unmerged
487 if not paths:
488 return 0, '', ''
490 argv = ['--theirs', '--'] + paths
491 cmd = Checkout(self.context, argv)
492 return cmd.do()
494 def error_message(self):
495 return N_('Error')
497 def command(self):
498 return 'git checkout --theirs'
501 class CheckoutOurs(ConfirmAction):
502 """Checkout "our" version of a file when performing a merge"""
504 @staticmethod
505 def name():
506 return N_('Checkout files from our branch (HEAD)')
508 def confirm(self):
509 title = self.name()
510 question = N_('Checkout files from our branch?')
511 info = N_(
512 'This operation will replace the selected unmerged files with content '
513 'from your current branch using "git checkout --ours".\n'
514 '*ALL* uncommitted changes will be lost.\n'
515 'Recovering uncommitted changes is not possible.'
517 ok_txt = N_('Checkout Files')
518 return Interaction.confirm(
519 title, question, info, ok_txt, default=True, icon=icons.merge()
522 def action(self):
523 selection = self.selection.selection()
524 paths = selection.unmerged
525 if not paths:
526 return 0, '', ''
528 argv = ['--ours', '--'] + paths
529 cmd = Checkout(self.context, argv)
530 return cmd.do()
532 def error_message(self):
533 return N_('Error')
535 def command(self):
536 return 'git checkout --ours'
539 class BlamePaths(ContextCommand):
540 """Blame view for paths."""
542 @staticmethod
543 def name():
544 return N_('Blame...')
546 def __init__(self, context, paths=None):
547 super().__init__(context)
548 if not paths:
549 paths = context.selection.union()
550 viewer = utils.shell_split(prefs.blame_viewer(context))
551 self.argv = viewer + list(paths)
553 def do(self):
554 try:
555 core.fork(self.argv)
556 except OSError as e:
557 _, details = utils.format_exception(e)
558 title = N_('Error Launching Blame Viewer')
559 msg = N_('Cannot exec "%s": please configure a blame viewer') % ' '.join(
560 self.argv
562 Interaction.critical(title, message=msg, details=details)
565 class CheckoutBranch(Checkout):
566 """Checkout a branch."""
568 def __init__(self, context, branch):
569 args = [branch]
570 super().__init__(context, args, checkout_branch=True)
573 class CherryPick(ContextCommand):
574 """Cherry pick commits into the current branch."""
576 def __init__(self, context, commits):
577 super().__init__(context)
578 self.commits = commits
580 def do(self):
581 status, out, err = gitcmds.cherry_pick(self.context, self.commits)
582 self.model.update_file_merge_status()
583 title = N_('Cherry-pick failed')
584 Interaction.command(title, 'git cherry-pick', status, out, err)
587 class Revert(ContextCommand):
588 """Revert a commit"""
590 def __init__(self, context, oid):
591 super().__init__(context)
592 self.oid = oid
594 def do(self):
595 status, out, err = self.git.revert(self.oid, no_edit=True)
596 self.model.update_file_status()
597 title = N_('Revert failed')
598 out = '# git revert %s\n\n' % self.oid
599 Interaction.command(title, 'git revert', status, out, err)
602 class ResetMode(EditModel):
603 """Reset the mode and clear the model's diff text."""
605 def __init__(self, context):
606 super().__init__(context)
607 self.new_mode = self.model.mode_none
608 self.new_diff_text = ''
609 self.new_diff_type = main.Types.TEXT
610 self.new_file_type = main.Types.TEXT
611 self.new_filename = ''
613 def do(self):
614 super().do()
615 self.model.update_file_status()
618 class ResetCommand(ConfirmAction):
619 """Reset state using the "git reset" command"""
621 def __init__(self, context, ref):
622 super().__init__(context)
623 self.ref = ref
625 def action(self):
626 return self.reset()
628 def command(self):
629 return 'git reset'
631 def error_message(self):
632 return N_('Error')
634 def success(self):
635 self.model.update_file_status()
637 def confirm(self):
638 raise NotImplementedError('confirm() must be overridden')
640 def reset(self):
641 raise NotImplementedError('reset() must be overridden')
644 class ResetMixed(ResetCommand):
645 @staticmethod
646 def tooltip(ref):
647 tooltip = N_('The branch will be reset using "git reset --mixed %s"')
648 return tooltip % ref
650 def confirm(self):
651 title = N_('Reset Branch and Stage (Mixed)')
652 question = N_('Point the current branch head to a new commit?')
653 info = self.tooltip(self.ref)
654 ok_text = N_('Reset Branch')
655 return Interaction.confirm(title, question, info, ok_text)
657 def reset(self):
658 return self.git.reset(self.ref, '--', mixed=True)
661 class ResetKeep(ResetCommand):
662 @staticmethod
663 def tooltip(ref):
664 tooltip = N_('The repository will be reset using "git reset --keep %s"')
665 return tooltip % ref
667 def confirm(self):
668 title = N_('Restore Worktree and Reset All (Keep Unstaged Changes)')
669 question = N_('Restore worktree, reset, and preserve unstaged edits?')
670 info = self.tooltip(self.ref)
671 ok_text = N_('Reset and Restore')
672 return Interaction.confirm(title, question, info, ok_text)
674 def reset(self):
675 return self.git.reset(self.ref, '--', keep=True)
678 class ResetMerge(ResetCommand):
679 @staticmethod
680 def tooltip(ref):
681 tooltip = N_('The repository will be reset using "git reset --merge %s"')
682 return tooltip % ref
684 def confirm(self):
685 title = N_('Restore Worktree and Reset All (Merge)')
686 question = N_('Reset Worktree and Reset All?')
687 info = self.tooltip(self.ref)
688 ok_text = N_('Reset and Restore')
689 return Interaction.confirm(title, question, info, ok_text)
691 def reset(self):
692 return self.git.reset(self.ref, '--', merge=True)
695 class ResetSoft(ResetCommand):
696 @staticmethod
697 def tooltip(ref):
698 tooltip = N_('The branch will be reset using "git reset --soft %s"')
699 return tooltip % ref
701 def confirm(self):
702 title = N_('Reset Branch (Soft)')
703 question = N_('Reset branch?')
704 info = self.tooltip(self.ref)
705 ok_text = N_('Reset Branch')
706 return Interaction.confirm(title, question, info, ok_text)
708 def reset(self):
709 return self.git.reset(self.ref, '--', soft=True)
712 class ResetHard(ResetCommand):
713 @staticmethod
714 def tooltip(ref):
715 tooltip = N_('The repository will be reset using "git reset --hard %s"')
716 return tooltip % ref
718 def confirm(self):
719 title = N_('Restore Worktree and Reset All (Hard)')
720 question = N_('Restore Worktree and Reset All?')
721 info = self.tooltip(self.ref)
722 ok_text = N_('Reset and Restore')
723 return Interaction.confirm(title, question, info, ok_text)
725 def reset(self):
726 return self.git.reset(self.ref, '--', hard=True)
729 class RestoreWorktree(ConfirmAction):
730 """Reset the worktree using the "git read-tree" command"""
732 @staticmethod
733 def tooltip(ref):
734 tooltip = N_(
735 'The worktree will be restored using "git read-tree --reset -u %s"'
737 return tooltip % ref
739 def __init__(self, context, ref):
740 super().__init__(context)
741 self.ref = ref
743 def action(self):
744 return self.git.read_tree(self.ref, reset=True, u=True)
746 def command(self):
747 return 'git read-tree --reset -u %s' % self.ref
749 def error_message(self):
750 return N_('Error')
752 def success(self):
753 self.model.update_file_status()
755 def confirm(self):
756 title = N_('Restore Worktree')
757 question = N_('Restore Worktree to %s?') % self.ref
758 info = self.tooltip(self.ref)
759 ok_text = N_('Restore Worktree')
760 return Interaction.confirm(title, question, info, ok_text)
763 class UndoLastCommit(ResetCommand):
764 """Undo the last commit"""
766 # NOTE: this is the similar to ResetSoft() with an additional check for
767 # published commits and different messages.
768 def __init__(self, context):
769 super().__init__(context, 'HEAD^')
771 def confirm(self):
772 check_published = prefs.check_published_commits(self.context)
773 if check_published and self.model.is_commit_published():
774 return Interaction.confirm(
775 N_('Rewrite Published Commit?'),
777 'This commit has already been published.\n'
778 'This operation will rewrite published history.\n'
779 "You probably don't want to do this."
781 N_('Undo the published commit?'),
782 N_('Undo Last Commit'),
783 default=False,
784 icon=icons.save(),
787 title = N_('Undo Last Commit')
788 question = N_('Undo last commit?')
789 info = N_('The branch will be reset using "git reset --soft %s"')
790 ok_text = N_('Undo Last Commit')
791 info_text = info % self.ref
792 return Interaction.confirm(title, question, info_text, ok_text)
794 def reset(self):
795 return self.git.reset('HEAD^', '--', soft=True)
798 class Commit(ResetMode):
799 """Attempt to create a new commit."""
801 def __init__(self, context, amend, msg, sign, no_verify=False):
802 super().__init__(context)
803 self.amend = amend
804 self.msg = msg
805 self.sign = sign
806 self.no_verify = no_verify
807 self.old_commitmsg = self.model.commitmsg
808 self.new_commitmsg = ''
810 def do(self):
811 # Create the commit message file
812 context = self.context
813 msg = self.msg
814 tmp_file = utils.tmp_filename('commit-message')
815 try:
816 core.write(tmp_file, msg)
817 # Run 'git commit'
818 status, out, err = self.git.commit(
819 F=tmp_file,
820 v=True,
821 gpg_sign=self.sign,
822 amend=self.amend,
823 no_verify=self.no_verify,
825 finally:
826 core.unlink(tmp_file)
827 if status == 0:
828 super().do()
829 if context.cfg.get(prefs.AUTOTEMPLATE):
830 template_loader = LoadCommitMessageFromTemplate(context)
831 template_loader.do()
832 else:
833 self.model.set_commitmsg(self.new_commitmsg)
835 return status, out, err
837 @staticmethod
838 def strip_comments(msg, comment_char='#'):
839 # Strip off comments
840 message_lines = [
841 line for line in msg.split('\n') if not line.startswith(comment_char)
843 msg = '\n'.join(message_lines)
844 if not msg.endswith('\n'):
845 msg += '\n'
847 return msg
850 class CycleReferenceSort(ContextCommand):
851 """Choose the next reference sort type"""
853 def do(self):
854 self.model.cycle_ref_sort()
857 class Ignore(ContextCommand):
858 """Add files to an exclusion file"""
860 def __init__(self, context, filenames, local=False):
861 super().__init__(context)
862 self.filenames = list(filenames)
863 self.local = local
865 def do(self):
866 if not self.filenames:
867 return
868 new_additions = '\n'.join(self.filenames) + '\n'
869 for_status = new_additions
870 if self.local:
871 filename = os.path.join('.git', 'info', 'exclude')
872 else:
873 filename = '.gitignore'
874 if core.exists(filename):
875 current_list = core.read(filename)
876 new_additions = current_list.rstrip() + '\n' + new_additions
877 core.write(filename, new_additions)
878 Interaction.log_status(0, f'Added to {filename}:\n{for_status}', '')
879 self.model.update_file_status()
882 def file_summary(files):
883 txt = core.list2cmdline(files)
884 if len(txt) > 768:
885 txt = txt[:768].rstrip() + '...'
886 wrap = textwrap.TextWrapper()
887 return '\n'.join(wrap.wrap(txt))
890 class RemoteCommand(ConfirmAction):
891 def __init__(self, context, remote):
892 super().__init__(context)
893 self.remote = remote
895 def success(self):
896 self.cfg.reset()
897 self.model.update_remotes()
900 class RemoteAdd(RemoteCommand):
901 def __init__(self, context, remote, url):
902 super().__init__(context, remote)
903 self.url = url
905 def action(self):
906 return self.git.remote('add', self.remote, self.url)
908 def error_message(self):
909 return N_('Error creating remote "%s"') % self.remote
911 def command(self):
912 return f'git remote add "{self.remote}" "{self.url}"'
915 class RemoteRemove(RemoteCommand):
916 def confirm(self):
917 title = N_('Delete Remote')
918 question = N_('Delete remote?')
919 info = N_('Delete remote "%s"') % self.remote
920 ok_text = N_('Delete')
921 return Interaction.confirm(title, question, info, ok_text)
923 def action(self):
924 return self.git.remote('rm', self.remote)
926 def error_message(self):
927 return N_('Error deleting remote "%s"') % self.remote
929 def command(self):
930 return 'git remote rm "%s"' % self.remote
933 class RemoteRename(RemoteCommand):
934 def __init__(self, context, remote, new_name):
935 super().__init__(context, remote)
936 self.new_name = new_name
938 def confirm(self):
939 title = N_('Rename Remote')
940 text = N_('Rename remote "%(current)s" to "%(new)s"?') % {
941 'current': self.remote,
942 'new': self.new_name,
944 info_text = ''
945 ok_text = title
946 return Interaction.confirm(title, text, info_text, ok_text)
948 def action(self):
949 return self.git.remote('rename', self.remote, self.new_name)
951 def error_message(self):
952 return N_('Error renaming "%(name)s" to "%(new_name)s"') % {
953 'name': self.remote,
954 'new_name': self.new_name,
957 def command(self):
958 return f'git remote rename "{self.remote}" "{self.new_name}"'
961 class RemoteSetURL(RemoteCommand):
962 def __init__(self, context, remote, url):
963 super().__init__(context, remote)
964 self.url = url
966 def action(self):
967 return self.git.remote('set-url', self.remote, self.url)
969 def error_message(self):
970 return N_('Unable to set URL for "%(name)s" to "%(url)s"') % {
971 'name': self.remote,
972 'url': self.url,
975 def command(self):
976 return f'git remote set-url "{self.remote}" "{self.url}"'
979 class RemoteEdit(ContextCommand):
980 """Combine RemoteRename and RemoteSetURL"""
982 def __init__(self, context, old_name, remote, url):
983 super().__init__(context)
984 self.rename = RemoteRename(context, old_name, remote)
985 self.set_url = RemoteSetURL(context, remote, url)
987 def do(self):
988 result = self.rename.do()
989 name_ok = result[0]
990 url_ok = False
991 if name_ok:
992 result = self.set_url.do()
993 url_ok = result[0]
994 return name_ok, url_ok
997 class RemoveFromSettings(ConfirmAction):
998 def __init__(self, context, repo, entry, icon=None):
999 super().__init__(context)
1000 self.context = context
1001 self.repo = repo
1002 self.entry = entry
1003 self.icon = icon
1005 def success(self):
1006 self.context.settings.save()
1009 class RemoveBookmark(RemoveFromSettings):
1010 def confirm(self):
1011 entry = self.entry
1012 title = msg = N_('Delete Bookmark?')
1013 info = N_('%s will be removed from your bookmarks.') % entry
1014 ok_text = N_('Delete Bookmark')
1015 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
1017 def action(self):
1018 self.context.settings.remove_bookmark(self.repo, self.entry)
1019 return (0, '', '')
1022 class RemoveRecent(RemoveFromSettings):
1023 def confirm(self):
1024 repo = self.repo
1025 title = msg = N_('Remove %s from the recent list?') % repo
1026 info = N_('%s will be removed from your recent repositories.') % repo
1027 ok_text = N_('Remove')
1028 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
1030 def action(self):
1031 self.context.settings.remove_recent(self.repo)
1032 return (0, '', '')
1035 class RemoveFiles(ContextCommand):
1036 """Removes files"""
1038 def __init__(self, context, remover, filenames):
1039 super().__init__(context)
1040 if remover is None:
1041 remover = os.remove
1042 self.remover = remover
1043 self.filenames = filenames
1044 # We could git-hash-object stuff and provide undo-ability
1045 # as an option. Heh.
1047 def do(self):
1048 files = self.filenames
1049 if not files:
1050 return
1052 rescan = False
1053 bad_filenames = []
1054 remove = self.remover
1055 for filename in files:
1056 if filename:
1057 try:
1058 remove(filename)
1059 rescan = True
1060 except OSError:
1061 bad_filenames.append(filename)
1063 if bad_filenames:
1064 Interaction.information(
1065 N_('Error'), N_('Deleting "%s" failed') % file_summary(bad_filenames)
1068 if rescan:
1069 self.model.update_file_status()
1072 class Delete(RemoveFiles):
1073 """Delete files."""
1075 def __init__(self, context, filenames):
1076 super().__init__(context, os.remove, filenames)
1078 def do(self):
1079 files = self.filenames
1080 if not files:
1081 return
1083 title = N_('Delete Files?')
1084 msg = N_('The following files will be deleted:') + '\n\n'
1085 msg += file_summary(files)
1086 info_txt = N_('Delete %d file(s)?') % len(files)
1087 ok_txt = N_('Delete Files')
1089 if Interaction.confirm(
1090 title, msg, info_txt, ok_txt, default=True, icon=icons.remove()
1092 super().do()
1095 class MoveToTrash(RemoveFiles):
1096 """Move files to the trash using send2trash"""
1098 AVAILABLE = send2trash is not None
1100 def __init__(self, context, filenames):
1101 super().__init__(context, send2trash, filenames)
1104 class DeleteBranch(ConfirmAction):
1105 """Delete a git branch."""
1107 def __init__(self, context, branch):
1108 super().__init__(context)
1109 self.branch = branch
1111 def confirm(self):
1112 title = N_('Delete Branch')
1113 question = N_('Delete branch "%s"?') % self.branch
1114 info = N_('The branch will be no longer available.')
1115 ok_txt = N_('Delete Branch')
1116 return Interaction.confirm(
1117 title, question, info, ok_txt, default=True, icon=icons.discard()
1120 def action(self):
1121 return self.model.delete_branch(self.branch)
1123 def error_message(self):
1124 return N_('Error deleting branch "%s"' % self.branch)
1126 def command(self):
1127 command = 'git branch -D %s'
1128 return command % self.branch
1131 class Rename(ContextCommand):
1132 """Rename a set of paths."""
1134 def __init__(self, context, paths):
1135 super().__init__(context)
1136 self.paths = paths
1138 def do(self):
1139 msg = N_('Untracking: %s') % (', '.join(self.paths))
1140 Interaction.log(msg)
1142 for path in self.paths:
1143 ok = self.rename(path)
1144 if not ok:
1145 return
1147 self.model.update_status()
1149 def rename(self, path):
1150 git = self.git
1151 title = N_('Rename "%s"') % path
1153 if os.path.isdir(path):
1154 base_path = os.path.dirname(path)
1155 else:
1156 base_path = path
1157 new_path = Interaction.save_as(base_path, title)
1158 if not new_path:
1159 return False
1161 status, out, err = git.mv(path, new_path, force=True, verbose=True)
1162 Interaction.command(N_('Error'), 'git mv', status, out, err)
1163 return status == 0
1166 class RenameBranch(ContextCommand):
1167 """Rename a git branch."""
1169 def __init__(self, context, branch, new_branch):
1170 super().__init__(context)
1171 self.branch = branch
1172 self.new_branch = new_branch
1174 def do(self):
1175 branch = self.branch
1176 new_branch = self.new_branch
1177 status, out, err = self.model.rename_branch(branch, new_branch)
1178 Interaction.log_status(status, out, err)
1181 class DeleteRemoteBranch(DeleteBranch):
1182 """Delete a remote git branch."""
1184 def __init__(self, context, remote, branch):
1185 super().__init__(context, branch)
1186 self.remote = remote
1188 def action(self):
1189 return self.git.push(self.remote, self.branch, delete=True)
1191 def success(self):
1192 self.model.update_status()
1193 Interaction.information(
1194 N_('Remote Branch Deleted'),
1195 N_('"%(branch)s" has been deleted from "%(remote)s".')
1197 'branch': self.branch,
1198 'remote': self.remote,
1202 def error_message(self):
1203 return N_('Error Deleting Remote Branch')
1205 def command(self):
1206 command = 'git push --delete %s %s'
1207 return command % (self.remote, self.branch)
1210 def get_mode(context, filename, staged, modified, unmerged, untracked):
1211 model = context.model
1212 if staged:
1213 mode = model.mode_index
1214 elif modified or unmerged:
1215 mode = model.mode_worktree
1216 elif untracked:
1217 if gitcmds.is_binary(context, filename):
1218 mode = model.mode_untracked
1219 else:
1220 mode = model.mode_untracked_diff
1221 else:
1222 mode = model.mode
1223 return mode
1226 class DiffAgainstCommitMode(ContextCommand):
1227 """Diff against arbitrary commits"""
1229 def __init__(self, context, oid):
1230 super().__init__(context)
1231 self.oid = oid
1233 def do(self):
1234 self.model.set_mode(self.model.mode_diff, head=self.oid)
1235 self.model.update_file_status()
1238 class DiffText(EditModel):
1239 """Set the diff type to text"""
1241 def __init__(self, context):
1242 super().__init__(context)
1243 self.new_file_type = main.Types.TEXT
1244 self.new_diff_type = main.Types.TEXT
1247 class ToggleDiffType(ContextCommand):
1248 """Toggle the diff type between image and text"""
1250 def __init__(self, context):
1251 super().__init__(context)
1252 if self.model.diff_type == main.Types.IMAGE:
1253 self.new_diff_type = main.Types.TEXT
1254 self.new_value = False
1255 else:
1256 self.new_diff_type = main.Types.IMAGE
1257 self.new_value = True
1259 def do(self):
1260 diff_type = self.new_diff_type
1261 value = self.new_value
1263 self.model.set_diff_type(diff_type)
1265 filename = self.model.filename
1266 _, ext = os.path.splitext(filename)
1267 if ext.startswith('.'):
1268 cfg = 'cola.imagediff' + ext
1269 self.cfg.set_repo(cfg, value)
1272 class DiffImage(EditModel):
1273 def __init__(
1274 self, context, filename, deleted, staged, modified, unmerged, untracked
1276 super().__init__(context)
1278 self.new_filename = filename
1279 self.new_diff_type = self.get_diff_type(filename)
1280 self.new_file_type = main.Types.IMAGE
1281 self.new_mode = get_mode(
1282 context, filename, staged, modified, unmerged, untracked
1284 self.staged = staged
1285 self.modified = modified
1286 self.unmerged = unmerged
1287 self.untracked = untracked
1288 self.deleted = deleted
1289 self.annex = self.cfg.is_annex()
1291 def get_diff_type(self, filename):
1292 """Query the diff type to use based on cola.imagediff.<extension>"""
1293 _, ext = os.path.splitext(filename)
1294 if ext.startswith('.'):
1295 # Check eg. "cola.imagediff.svg" to see if we should imagediff.
1296 cfg = 'cola.imagediff' + ext
1297 if self.cfg.get(cfg, True):
1298 result = main.Types.IMAGE
1299 else:
1300 result = main.Types.TEXT
1301 else:
1302 result = main.Types.IMAGE
1303 return result
1305 def do(self):
1306 filename = self.new_filename
1308 if self.staged:
1309 images = self.staged_images()
1310 elif self.modified:
1311 images = self.modified_images()
1312 elif self.unmerged:
1313 images = self.unmerged_images()
1314 elif self.untracked:
1315 images = [(filename, False)]
1316 else:
1317 images = []
1319 self.model.set_images(images)
1320 super().do()
1322 def staged_images(self):
1323 context = self.context
1324 git = self.git
1325 head = self.model.head
1326 filename = self.new_filename
1327 annex = self.annex
1329 images = []
1330 index = git.diff_index(head, '--', filename, cached=True)[STDOUT]
1331 if index:
1332 # Example:
1333 # :100644 100644 fabadb8... 4866510... M describe.c
1334 parts = index.split(' ')
1335 if len(parts) > 3:
1336 old_oid = parts[2]
1337 new_oid = parts[3]
1339 if old_oid != MISSING_BLOB_OID:
1340 # First, check if we can get a pre-image from git-annex
1341 annex_image = None
1342 if annex:
1343 annex_image = gitcmds.annex_path(context, head, filename)
1344 if annex_image:
1345 images.append((annex_image, False)) # git annex HEAD
1346 else:
1347 image = gitcmds.write_blob_path(context, head, old_oid, filename)
1348 if image:
1349 images.append((image, True))
1351 if new_oid != MISSING_BLOB_OID:
1352 found_in_annex = False
1353 if annex and core.islink(filename):
1354 status, out, _ = git.annex('status', '--', filename)
1355 if status == 0:
1356 details = out.split(' ')
1357 if details and details[0] == 'A': # newly added file
1358 images.append((filename, False))
1359 found_in_annex = True
1361 if not found_in_annex:
1362 image = gitcmds.write_blob(context, new_oid, filename)
1363 if image:
1364 images.append((image, True))
1366 return images
1368 def unmerged_images(self):
1369 context = self.context
1370 git = self.git
1371 head = self.model.head
1372 filename = self.new_filename
1373 annex = self.annex
1375 candidate_merge_heads = ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1376 merge_heads = [
1377 merge_head
1378 for merge_head in candidate_merge_heads
1379 if core.exists(git.git_path(merge_head))
1382 if annex: # Attempt to find files in git-annex
1383 annex_images = []
1384 for merge_head in merge_heads:
1385 image = gitcmds.annex_path(context, merge_head, filename)
1386 if image:
1387 annex_images.append((image, False))
1388 if annex_images:
1389 annex_images.append((filename, False))
1390 return annex_images
1392 # DIFF FORMAT FOR MERGES
1393 # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1394 # can take -c or --cc option to generate diff output also
1395 # for merge commits. The output differs from the format
1396 # described above in the following way:
1398 # 1. there is a colon for each parent
1399 # 2. there are more "src" modes and "src" sha1
1400 # 3. status is concatenated status characters for each parent
1401 # 4. no optional "score" number
1402 # 5. single path, only for "dst"
1403 # Example:
1404 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1405 # MM describe.c
1406 images = []
1407 index = git.diff_index(head, '--', filename, cached=True, cc=True)[STDOUT]
1408 if index:
1409 parts = index.split(' ')
1410 if len(parts) > 3:
1411 first_mode = parts[0]
1412 num_parents = first_mode.count(':')
1413 # colon for each parent, but for the index, the "parents"
1414 # are really entries in stages 1,2,3 (head, base, remote)
1415 # remote, base, head
1416 for i in range(num_parents):
1417 offset = num_parents + i + 1
1418 oid = parts[offset]
1419 try:
1420 merge_head = merge_heads[i]
1421 except IndexError:
1422 merge_head = 'HEAD'
1423 if oid != MISSING_BLOB_OID:
1424 image = gitcmds.write_blob_path(
1425 context, merge_head, oid, filename
1427 if image:
1428 images.append((image, True))
1430 images.append((filename, False))
1431 return images
1433 def modified_images(self):
1434 context = self.context
1435 git = self.git
1436 head = self.model.head
1437 filename = self.new_filename
1438 annex = self.annex
1440 images = []
1441 annex_image = None
1442 if annex: # Check for a pre-image from git-annex
1443 annex_image = gitcmds.annex_path(context, head, filename)
1444 if annex_image:
1445 images.append((annex_image, False)) # git annex HEAD
1446 else:
1447 worktree = git.diff_files('--', filename)[STDOUT]
1448 parts = worktree.split(' ')
1449 if len(parts) > 3:
1450 oid = parts[2]
1451 if oid != MISSING_BLOB_OID:
1452 image = gitcmds.write_blob_path(context, head, oid, filename)
1453 if image:
1454 images.append((image, True)) # HEAD
1456 images.append((filename, False)) # worktree
1457 return images
1460 class Diff(EditModel):
1461 """Perform a diff and set the model's current text."""
1463 def __init__(self, context, filename, cached=False, deleted=False):
1464 super().__init__(context)
1465 opts = {}
1466 if cached and gitcmds.is_valid_ref(context, self.model.head):
1467 opts['ref'] = self.model.head
1468 self.new_filename = filename
1469 self.new_mode = self.model.mode_worktree
1470 self.new_diff_text = gitcmds.diff_helper(
1471 self.context, filename=filename, cached=cached, deleted=deleted, **opts
1475 class Diffstat(EditModel):
1476 """Perform a diffstat and set the model's diff text."""
1478 def __init__(self, context):
1479 super().__init__(context)
1480 cfg = self.cfg
1481 diff_context = cfg.get('diff.context', 3)
1482 diff = self.git.diff(
1483 self.model.head,
1484 unified=diff_context,
1485 no_ext_diff=True,
1486 no_color=True,
1487 M=True,
1488 stat=True,
1489 )[STDOUT]
1490 self.new_diff_text = diff
1491 self.new_diff_type = main.Types.TEXT
1492 self.new_file_type = main.Types.TEXT
1493 self.new_mode = self.model.mode_diffstat
1496 class DiffStaged(Diff):
1497 """Perform a staged diff on a file."""
1499 def __init__(self, context, filename, deleted=None):
1500 super().__init__(context, filename, cached=True, deleted=deleted)
1501 self.new_mode = self.model.mode_index
1504 class DiffStagedSummary(EditModel):
1505 def __init__(self, context):
1506 super().__init__(context)
1507 diff = self.git.diff(
1508 self.model.head,
1509 cached=True,
1510 no_color=True,
1511 no_ext_diff=True,
1512 patch_with_stat=True,
1513 M=True,
1514 )[STDOUT]
1515 self.new_diff_text = diff
1516 self.new_diff_type = main.Types.TEXT
1517 self.new_file_type = main.Types.TEXT
1518 self.new_mode = self.model.mode_index
1521 class Edit(ContextCommand):
1522 """Edit a file using the configured gui.editor."""
1524 @staticmethod
1525 def name():
1526 return N_('Launch Editor')
1528 def __init__(self, context, filenames, line_number=None, background_editor=False):
1529 super().__init__(context)
1530 self.filenames = filenames
1531 self.line_number = line_number
1532 self.background_editor = background_editor
1534 def do(self):
1535 context = self.context
1536 if not self.filenames:
1537 return
1538 filename = self.filenames[0]
1539 if not core.exists(filename):
1540 return
1541 if self.background_editor:
1542 editor = prefs.background_editor(context)
1543 else:
1544 editor = prefs.editor(context)
1545 opts = []
1547 if self.line_number is None:
1548 opts = self.filenames
1549 else:
1550 # Single-file w/ line-numbers (likely from grep)
1551 editor_opts = {
1552 '*vim*': [filename, '+%s' % self.line_number],
1553 '*emacs*': ['+%s' % self.line_number, filename],
1554 '*textpad*': [f'{filename}({self.line_number},0)'],
1555 '*notepad++*': ['-n%s' % self.line_number, filename],
1556 '*subl*': [f'{filename}:{self.line_number}'],
1559 opts = self.filenames
1560 for pattern, opt in editor_opts.items():
1561 if fnmatch(editor, pattern):
1562 opts = opt
1563 break
1565 try:
1566 core.fork(utils.shell_split(editor) + opts)
1567 except (OSError, ValueError) as e:
1568 message = N_('Cannot exec "%s": please configure your editor') % editor
1569 _, details = utils.format_exception(e)
1570 Interaction.critical(N_('Error Editing File'), message, details)
1573 class FormatPatch(ContextCommand):
1574 """Output a patch series given all revisions and a selected subset."""
1576 def __init__(self, context, to_export, revs, output='patches'):
1577 super().__init__(context)
1578 self.to_export = list(to_export)
1579 self.revs = list(revs)
1580 self.output = output
1582 def do(self):
1583 context = self.context
1584 status, out, err = gitcmds.format_patchsets(
1585 context, self.to_export, self.revs, self.output
1587 Interaction.log_status(status, out, err)
1590 class LaunchTerminal(ContextCommand):
1591 @staticmethod
1592 def name():
1593 return N_('Launch Terminal')
1595 @staticmethod
1596 def is_available(context):
1597 return context.cfg.terminal() is not None
1599 def __init__(self, context, path):
1600 super().__init__(context)
1601 self.path = path
1603 def do(self):
1604 cmd = self.context.cfg.terminal()
1605 if cmd is None:
1606 return
1607 if utils.is_win32():
1608 argv = ['start', '', cmd, '--login']
1609 shell = True
1610 else:
1611 argv = utils.shell_split(cmd)
1612 command = '/bin/sh'
1613 shells = ('zsh', 'fish', 'bash', 'sh')
1614 for basename in shells:
1615 executable = core.find_executable(basename)
1616 if executable:
1617 command = executable
1618 break
1619 argv.append(os.getenv('SHELL', command))
1620 shell = False
1622 core.fork(argv, cwd=self.path, shell=shell)
1625 class LaunchEditor(Edit):
1626 @staticmethod
1627 def name():
1628 return N_('Launch Editor')
1630 def __init__(self, context):
1631 s = context.selection.selection()
1632 filenames = s.staged + s.unmerged + s.modified + s.untracked
1633 super().__init__(context, filenames, background_editor=True)
1636 class LaunchEditorAtLine(LaunchEditor):
1637 """Launch an editor at the specified line"""
1639 def __init__(self, context):
1640 super().__init__(context)
1641 self.line_number = context.selection.line_number
1644 class LoadCommitMessageFromFile(ContextCommand):
1645 """Loads a commit message from a path."""
1647 UNDOABLE = True
1649 def __init__(self, context, path):
1650 super().__init__(context)
1651 self.path = path
1652 self.old_commitmsg = self.model.commitmsg
1653 self.old_directory = self.model.directory
1655 def do(self):
1656 path = os.path.expanduser(self.path)
1657 if not path or not core.isfile(path):
1658 raise UsageError(
1659 N_('Error: Cannot find commit template'),
1660 N_('%s: No such file or directory.') % path,
1662 self.model.set_directory(os.path.dirname(path))
1663 self.model.set_commitmsg(core.read(path))
1665 def undo(self):
1666 self.model.set_commitmsg(self.old_commitmsg)
1667 self.model.set_directory(self.old_directory)
1670 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile):
1671 """Loads the commit message template specified by commit.template."""
1673 def __init__(self, context):
1674 cfg = context.cfg
1675 template = cfg.get('commit.template')
1676 super().__init__(context, template)
1678 def do(self):
1679 if self.path is None:
1680 raise UsageError(
1681 N_('Error: Unconfigured commit template'),
1683 'A commit template has not been configured.\n'
1684 'Use "git config" to define "commit.template"\n'
1685 'so that it points to a commit template.'
1688 return LoadCommitMessageFromFile.do(self)
1691 class LoadCommitMessageFromOID(ContextCommand):
1692 """Load a previous commit message"""
1694 UNDOABLE = True
1696 def __init__(self, context, oid, prefix=''):
1697 super().__init__(context)
1698 self.oid = oid
1699 self.old_commitmsg = self.model.commitmsg
1700 self.new_commitmsg = prefix + gitcmds.prev_commitmsg(context, oid)
1702 def do(self):
1703 self.model.set_commitmsg(self.new_commitmsg)
1705 def undo(self):
1706 self.model.set_commitmsg(self.old_commitmsg)
1709 class PrepareCommitMessageHook(ContextCommand):
1710 """Use the cola-prepare-commit-msg hook to prepare the commit message"""
1712 UNDOABLE = True
1714 def __init__(self, context):
1715 super().__init__(context)
1716 self.old_commitmsg = self.model.commitmsg
1718 def get_message(self):
1719 title = N_('Error running prepare-commitmsg hook')
1720 hook = gitcmds.prepare_commit_message_hook(self.context)
1722 if os.path.exists(hook):
1723 filename = self.model.save_commitmsg()
1724 status, out, err = core.run_command([hook, filename])
1726 if status == 0:
1727 result = core.read(filename)
1728 else:
1729 result = self.old_commitmsg
1730 Interaction.command_error(title, hook, status, out, err)
1731 else:
1732 message = N_('A hook must be provided at "%s"') % hook
1733 Interaction.critical(title, message=message)
1734 result = self.old_commitmsg
1736 return result
1738 def do(self):
1739 msg = self.get_message()
1740 self.model.set_commitmsg(msg)
1742 def undo(self):
1743 self.model.set_commitmsg(self.old_commitmsg)
1746 class LoadFixupMessage(LoadCommitMessageFromOID):
1747 """Load a fixup message"""
1749 def __init__(self, context, oid):
1750 super().__init__(context, oid, prefix='fixup! ')
1751 if self.new_commitmsg:
1752 self.new_commitmsg = self.new_commitmsg.splitlines()[0]
1755 class Merge(ContextCommand):
1756 """Merge commits"""
1758 def __init__(self, context, revision, no_commit, squash, no_ff, sign):
1759 super().__init__(context)
1760 self.revision = revision
1761 self.no_ff = no_ff
1762 self.no_commit = no_commit
1763 self.squash = squash
1764 self.sign = sign
1766 def do(self):
1767 squash = self.squash
1768 revision = self.revision
1769 no_ff = self.no_ff
1770 no_commit = self.no_commit
1771 sign = self.sign
1773 status, out, err = self.git.merge(
1774 revision, gpg_sign=sign, no_ff=no_ff, no_commit=no_commit, squash=squash
1776 self.model.update_status()
1777 title = N_('Merge failed. Conflict resolution is required.')
1778 Interaction.command(title, 'git merge', status, out, err)
1780 return status, out, err
1783 class OpenDefaultApp(ContextCommand):
1784 """Open a file using the OS default."""
1786 @staticmethod
1787 def name():
1788 return N_('Open Using Default Application')
1790 def __init__(self, context, filenames):
1791 super().__init__(context)
1792 self.filenames = filenames
1794 def do(self):
1795 if not self.filenames:
1796 return
1797 utils.launch_default_app(self.filenames)
1800 class OpenDir(OpenDefaultApp):
1801 """Open directories using the OS default."""
1803 @staticmethod
1804 def name():
1805 return N_('Open Directory')
1807 @property
1808 def _dirnames(self):
1809 return self.filenames
1811 def do(self):
1812 dirnames = self._dirnames
1813 if not dirnames:
1814 return
1815 # An empty dirname defaults to CWD.
1816 dirs = [(dirname or core.getcwd()) for dirname in dirnames]
1817 utils.launch_default_app(dirs)
1820 class OpenParentDir(OpenDir):
1821 """Open parent directories using the OS default."""
1823 @staticmethod
1824 def name():
1825 return N_('Open Parent Directory')
1827 @property
1828 def _dirnames(self):
1829 dirnames = list({os.path.dirname(x) for x in self.filenames})
1830 return dirnames
1833 class OpenWorktree(OpenDir):
1834 """Open worktree directory using the OS default."""
1836 @staticmethod
1837 def name():
1838 return N_('Open Worktree')
1840 # The _unused parameter is needed by worktree_dir_action() -> common.cmd_action().
1841 def __init__(self, context, _unused=None):
1842 dirnames = [context.git.worktree()]
1843 super().__init__(context, dirnames)
1846 class OpenNewRepo(ContextCommand):
1847 """Launches git-cola on a repo."""
1849 def __init__(self, context, repo_path):
1850 super().__init__(context)
1851 self.repo_path = repo_path
1853 def do(self):
1854 self.model.set_directory(self.repo_path)
1855 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
1858 class OpenRepo(EditModel):
1859 def __init__(self, context, repo_path):
1860 super().__init__(context)
1861 self.repo_path = repo_path
1862 self.new_mode = self.model.mode_none
1863 self.new_diff_text = ''
1864 self.new_diff_type = main.Types.TEXT
1865 self.new_file_type = main.Types.TEXT
1866 self.new_commitmsg = ''
1867 self.new_filename = ''
1869 def do(self):
1870 old_repo = self.git.getcwd()
1871 if self.model.set_worktree(self.repo_path):
1872 self.fsmonitor.stop()
1873 self.fsmonitor.start()
1874 self.model.update_status(reset=True)
1875 # Check if template should be loaded
1876 if self.context.cfg.get(prefs.AUTOTEMPLATE):
1877 template_loader = LoadCommitMessageFromTemplate(self.context)
1878 template_loader.do()
1879 else:
1880 self.model.set_commitmsg(self.new_commitmsg)
1881 settings = self.context.settings
1882 settings.load()
1883 settings.add_recent(self.repo_path, prefs.maxrecent(self.context))
1884 settings.save()
1885 super().do()
1886 else:
1887 self.model.set_worktree(old_repo)
1890 class OpenParentRepo(OpenRepo):
1891 def __init__(self, context):
1892 path = ''
1893 if version.check_git(context, 'show-superproject-working-tree'):
1894 status, out, _ = context.git.rev_parse(show_superproject_working_tree=True)
1895 if status == 0:
1896 path = out
1897 if not path:
1898 path = os.path.dirname(core.getcwd())
1899 super().__init__(context, path)
1902 class Clone(ContextCommand):
1903 """Clones a repository and optionally spawns a new cola session."""
1905 def __init__(
1906 self, context, url, new_directory, submodules=False, shallow=False, spawn=True
1908 super().__init__(context)
1909 self.url = url
1910 self.new_directory = new_directory
1911 self.submodules = submodules
1912 self.shallow = shallow
1913 self.spawn = spawn
1914 self.status = -1
1915 self.out = ''
1916 self.err = ''
1918 def do(self):
1919 kwargs = {}
1920 if self.shallow:
1921 kwargs['depth'] = 1
1922 recurse_submodules = self.submodules
1923 shallow_submodules = self.submodules and self.shallow
1925 status, out, err = self.git.clone(
1926 self.url,
1927 self.new_directory,
1928 recurse_submodules=recurse_submodules,
1929 shallow_submodules=shallow_submodules,
1930 **kwargs,
1933 self.status = status
1934 self.out = out
1935 self.err = err
1936 if status == 0 and self.spawn:
1937 executable = sys.executable
1938 core.fork([executable, sys.argv[0], '--repo', self.new_directory])
1939 return self
1942 class NewBareRepo(ContextCommand):
1943 """Create a new shared bare repository"""
1945 def __init__(self, context, path):
1946 super().__init__(context)
1947 self.path = path
1949 def do(self):
1950 path = self.path
1951 status, out, err = self.git.init(path, bare=True, shared=True)
1952 Interaction.command(
1953 N_('Error'), 'git init --bare --shared "%s"' % path, status, out, err
1955 return status == 0
1958 def unix_path(path, is_win32=utils.is_win32):
1959 """Git for Windows requires unix paths, so force them here"""
1960 if is_win32():
1961 path = path.replace('\\', '/')
1962 first = path[0]
1963 second = path[1]
1964 if second == ':': # sanity check, this better be a Windows-style path
1965 path = '/' + first + path[2:]
1967 return path
1970 def sequence_editor():
1971 """Set GIT_SEQUENCE_EDITOR for running git-cola-sequence-editor"""
1972 xbase = unix_path(resources.command('git-cola-sequence-editor'))
1973 if utils.is_win32():
1974 editor = core.list2cmdline([unix_path(sys.executable), xbase])
1975 else:
1976 editor = core.list2cmdline([xbase])
1977 return editor
1980 class SequenceEditorEnvironment:
1981 """Set environment variables to enable git-cola-sequence-editor"""
1983 def __init__(self, context, **kwargs):
1984 self.env = {
1985 'GIT_EDITOR': prefs.editor(context),
1986 'GIT_SEQUENCE_EDITOR': sequence_editor(),
1987 'GIT_COLA_SEQ_EDITOR_CANCEL_ACTION': 'save',
1989 self.env.update(kwargs)
1991 def __enter__(self):
1992 for var, value in self.env.items():
1993 compat.setenv(var, value)
1994 return self
1996 def __exit__(self, exc_type, exc_val, exc_tb):
1997 for var in self.env:
1998 compat.unsetenv(var)
2001 class Rebase(ContextCommand):
2002 def __init__(self, context, upstream=None, branch=None, **kwargs):
2003 """Start an interactive rebase session
2005 :param upstream: upstream branch
2006 :param branch: optional branch to checkout
2007 :param kwargs: forwarded directly to `git.rebase()`
2010 super().__init__(context)
2012 self.upstream = upstream
2013 self.branch = branch
2014 self.kwargs = kwargs
2016 def prepare_arguments(self, upstream):
2017 args = []
2018 kwargs = {}
2020 # Rebase actions must be the only option specified
2021 for action in ('continue', 'abort', 'skip', 'edit_todo'):
2022 if self.kwargs.get(action, False):
2023 kwargs[action] = self.kwargs[action]
2024 return args, kwargs
2026 kwargs['interactive'] = True
2027 kwargs['autosquash'] = self.kwargs.get('autosquash', True)
2028 kwargs.update(self.kwargs)
2030 # Prompt to determine whether or not to use "git rebase --update-refs".
2031 has_update_refs = version.check_git(self.context, 'rebase-update-refs')
2032 if has_update_refs and not kwargs.get('update_refs', False):
2033 title = N_('Update stacked branches when rebasing?')
2034 text = N_(
2035 '"git rebase --update-refs" automatically force-updates any\n'
2036 'branches that point to commits that are being rebased.\n\n'
2037 'Any branches that are checked out in a worktree are not updated.\n\n'
2038 'Using this feature is helpful for "stacked" branch workflows.'
2040 info = N_('Update stacked branches when rebasing?')
2041 ok_text = N_('Update stacked branches')
2042 cancel_text = N_('Do not update stacked branches')
2043 update_refs = Interaction.confirm(
2044 title,
2045 text,
2046 info,
2047 ok_text,
2048 default=True,
2049 cancel_text=cancel_text,
2051 if update_refs:
2052 kwargs['update_refs'] = True
2054 if upstream:
2055 args.append(upstream)
2056 if self.branch:
2057 args.append(self.branch)
2059 return args, kwargs
2061 def do(self):
2062 (status, out, err) = (1, '', '')
2063 context = self.context
2064 cfg = self.cfg
2065 model = self.model
2067 if not cfg.get('rebase.autostash', False):
2068 if model.staged or model.unmerged or model.modified:
2069 Interaction.information(
2070 N_('Unable to rebase'),
2071 N_('You cannot rebase with uncommitted changes.'),
2073 return status, out, err
2075 upstream = self.upstream or Interaction.choose_ref(
2076 context,
2077 N_('Select New Upstream'),
2078 N_('Interactive Rebase'),
2079 default='@{upstream}',
2081 if not upstream:
2082 return status, out, err
2084 self.model.is_rebasing = True
2085 self.model.emit_updated()
2087 args, kwargs = self.prepare_arguments(upstream)
2088 upstream_title = upstream or '@{upstream}'
2089 with SequenceEditorEnvironment(
2090 self.context,
2091 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase onto %s') % upstream_title,
2092 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
2094 # This blocks the user interface window for the duration
2095 # of git-cola-sequence-editor. We would need to run the command
2096 # in a QRunnable task to avoid blocking the main thread.
2097 # Alternatively, we can hide the main window while rebasing,
2098 # which doesn't require as much effort.
2099 status, out, err = self.git.rebase(
2100 *args, _no_win32_startupinfo=True, **kwargs
2102 self.model.update_status()
2103 if err.strip() != 'Nothing to do':
2104 title = N_('Rebase stopped')
2105 Interaction.command(title, 'git rebase', status, out, err)
2106 return status, out, err
2109 class RebaseEditTodo(ContextCommand):
2110 def do(self):
2111 (status, out, err) = (1, '', '')
2112 with SequenceEditorEnvironment(
2113 self.context,
2114 GIT_COLA_SEQ_EDITOR_TITLE=N_('Edit Rebase'),
2115 GIT_COLA_SEQ_EDITOR_ACTION=N_('Save'),
2117 status, out, err = self.git.rebase(edit_todo=True)
2118 Interaction.log_status(status, out, err)
2119 self.model.update_status()
2120 return status, out, err
2123 class RebaseContinue(ContextCommand):
2124 def do(self):
2125 (status, out, err) = (1, '', '')
2126 with SequenceEditorEnvironment(
2127 self.context,
2128 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase'),
2129 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
2131 status, out, err = self.git.rebase('--continue')
2132 Interaction.log_status(status, out, err)
2133 self.model.update_status()
2134 return status, out, err
2137 class RebaseSkip(ContextCommand):
2138 def do(self):
2139 (status, out, err) = (1, '', '')
2140 with SequenceEditorEnvironment(
2141 self.context,
2142 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase'),
2143 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'),
2145 status, out, err = self.git.rebase(skip=True)
2146 Interaction.log_status(status, out, err)
2147 self.model.update_status()
2148 return status, out, err
2151 class RebaseAbort(ContextCommand):
2152 def do(self):
2153 status, out, err = self.git.rebase(abort=True)
2154 Interaction.log_status(status, out, err)
2155 self.model.update_status()
2158 class Rescan(ContextCommand):
2159 """Rescan for changes"""
2161 def do(self):
2162 self.model.update_status()
2165 class Refresh(ContextCommand):
2166 """Update refs, refresh the index, and update config"""
2168 @staticmethod
2169 def name():
2170 return N_('Refresh')
2172 def do(self):
2173 self.model.update_status(update_index=True)
2174 self.cfg.update()
2175 self.fsmonitor.refresh()
2178 class RefreshConfig(ContextCommand):
2179 """Refresh the git config cache"""
2181 def do(self):
2182 self.cfg.update()
2185 class RevertEditsCommand(ConfirmAction):
2186 def __init__(self, context):
2187 super().__init__(context)
2188 self.icon = icons.undo()
2190 def ok_to_run(self):
2191 return self.model.is_undoable()
2193 def checkout_from_head(self):
2194 return False
2196 def checkout_args(self):
2197 args = []
2198 s = self.selection.selection()
2199 if self.checkout_from_head():
2200 args.append(self.model.head)
2201 args.append('--')
2203 if s.staged:
2204 items = s.staged
2205 else:
2206 items = s.modified
2207 args.extend(items)
2209 return args
2211 def action(self):
2212 checkout_args = self.checkout_args()
2213 return self.git.checkout(*checkout_args)
2215 def success(self):
2216 self.model.set_diff_type(main.Types.TEXT)
2217 self.model.update_file_status()
2220 class RevertUnstagedEdits(RevertEditsCommand):
2221 @staticmethod
2222 def name():
2223 return N_('Revert Unstaged Edits...')
2225 def checkout_from_head(self):
2226 # Being in amend mode should not affect the behavior of this command.
2227 # The only sensible thing to do is to checkout from the index.
2228 return False
2230 def confirm(self):
2231 title = N_('Revert Unstaged Changes?')
2232 text = N_(
2233 'This operation removes unstaged edits from selected files.\n'
2234 'These changes cannot be recovered.'
2236 info = N_('Revert the unstaged changes?')
2237 ok_text = N_('Revert Unstaged Changes')
2238 return Interaction.confirm(
2239 title, text, info, ok_text, default=True, icon=self.icon
2243 class RevertUncommittedEdits(RevertEditsCommand):
2244 @staticmethod
2245 def name():
2246 return N_('Revert Uncommitted Edits...')
2248 def checkout_from_head(self):
2249 return True
2251 def confirm(self):
2252 """Prompt for reverting changes"""
2253 title = N_('Revert Uncommitted Changes?')
2254 text = N_(
2255 'This operation removes uncommitted edits from selected files.\n'
2256 'These changes cannot be recovered.'
2258 info = N_('Revert the uncommitted changes?')
2259 ok_text = N_('Revert Uncommitted Changes')
2260 return Interaction.confirm(
2261 title, text, info, ok_text, default=True, icon=self.icon
2265 class RunConfigAction(ContextCommand):
2266 """Run a user-configured action, typically from the "Tools" menu"""
2268 def __init__(self, context, action_name):
2269 super().__init__(context)
2270 self.action_name = action_name
2272 def do(self):
2273 """Run the user-configured action"""
2274 for env in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
2275 try:
2276 compat.unsetenv(env)
2277 except KeyError:
2278 pass
2279 rev = None
2280 args = None
2281 context = self.context
2282 cfg = self.cfg
2283 opts = cfg.get_guitool_opts(self.action_name)
2284 cmd = opts.get('cmd')
2285 if 'title' not in opts:
2286 opts['title'] = cmd
2288 if 'prompt' not in opts or opts.get('prompt') is True:
2289 prompt = N_('Run "%s"?') % cmd
2290 opts['prompt'] = prompt
2292 if opts.get('needsfile'):
2293 filename = self.selection.filename()
2294 if not filename:
2295 Interaction.information(
2296 N_('Please select a file'),
2297 N_('"%s" requires a selected file.') % cmd,
2299 return False
2300 dirname = utils.dirname(filename, current_dir='.')
2301 compat.setenv('FILENAME', filename)
2302 compat.setenv('DIRNAME', dirname)
2304 if opts.get('revprompt') or opts.get('argprompt'):
2305 while True:
2306 ok = Interaction.confirm_config_action(context, cmd, opts)
2307 if not ok:
2308 return False
2309 rev = opts.get('revision')
2310 args = opts.get('args')
2311 if opts.get('revprompt') and not rev:
2312 title = N_('Invalid Revision')
2313 msg = N_('The revision expression cannot be empty.')
2314 Interaction.critical(title, msg)
2315 continue
2316 break
2318 elif opts.get('confirm'):
2319 title = os.path.expandvars(opts.get('title'))
2320 prompt = os.path.expandvars(opts.get('prompt'))
2321 if not Interaction.question(title, prompt):
2322 return False
2323 if rev:
2324 compat.setenv('REVISION', rev)
2325 if args:
2326 compat.setenv('ARGS', args)
2327 title = os.path.expandvars(cmd)
2328 Interaction.log(N_('Running command: %s') % title)
2329 cmd = ['sh', '-c', cmd]
2331 if opts.get('background'):
2332 core.fork(cmd)
2333 status, out, err = (0, '', '')
2334 elif opts.get('noconsole'):
2335 status, out, err = core.run_command(cmd)
2336 else:
2337 status, out, err = Interaction.run_command(title, cmd)
2339 if not opts.get('background') and not opts.get('norescan'):
2340 self.model.update_status()
2342 title = N_('Error')
2343 Interaction.command(title, cmd, status, out, err)
2345 return status == 0
2348 class SetDefaultRepo(ContextCommand):
2349 """Set the default repository"""
2351 def __init__(self, context, repo):
2352 super().__init__(context)
2353 self.repo = repo
2355 def do(self):
2356 self.cfg.set_user('cola.defaultrepo', self.repo)
2359 class SetDiffText(EditModel):
2360 """Set the diff text"""
2362 UNDOABLE = True
2364 def __init__(self, context, text):
2365 super().__init__(context)
2366 self.new_diff_text = text
2367 self.new_diff_type = main.Types.TEXT
2368 self.new_file_type = main.Types.TEXT
2371 class SetUpstreamBranch(ContextCommand):
2372 """Set the upstream branch"""
2374 def __init__(self, context, branch, remote, remote_branch):
2375 super().__init__(context)
2376 self.branch = branch
2377 self.remote = remote
2378 self.remote_branch = remote_branch
2380 def do(self):
2381 cfg = self.cfg
2382 remote = self.remote
2383 branch = self.branch
2384 remote_branch = self.remote_branch
2385 cfg.set_repo('branch.%s.remote' % branch, remote)
2386 cfg.set_repo('branch.%s.merge' % branch, 'refs/heads/' + remote_branch)
2389 def format_hex(data):
2390 """Translate binary data into a hex dump"""
2391 hexdigits = '0123456789ABCDEF'
2392 result = ''
2393 offset = 0
2394 byte_offset_to_int = compat.byte_offset_to_int_converter()
2395 while offset < len(data):
2396 result += '%04u |' % offset
2397 textpart = ''
2398 for i in range(0, 16):
2399 if i > 0 and i % 4 == 0:
2400 result += ' '
2401 if offset < len(data):
2402 v = byte_offset_to_int(data[offset])
2403 result += ' ' + hexdigits[v >> 4] + hexdigits[v & 0xF]
2404 textpart += chr(v) if 32 <= v < 127 else '.'
2405 offset += 1
2406 else:
2407 result += ' '
2408 textpart += ' '
2409 result += ' | ' + textpart + ' |\n'
2411 return result
2414 class ShowUntracked(EditModel):
2415 """Show an untracked file."""
2417 def __init__(self, context, filename):
2418 super().__init__(context)
2419 self.new_filename = filename
2420 if gitcmds.is_binary(context, filename):
2421 self.new_mode = self.model.mode_untracked
2422 self.new_diff_text = self.read(filename)
2423 else:
2424 self.new_mode = self.model.mode_untracked_diff
2425 self.new_diff_text = gitcmds.diff_helper(
2426 self.context, filename=filename, cached=False, untracked=True
2428 self.new_diff_type = main.Types.TEXT
2429 self.new_file_type = main.Types.TEXT
2431 def read(self, filename):
2432 """Read file contents"""
2433 cfg = self.cfg
2434 size = cfg.get('cola.readsize', 2048)
2435 try:
2436 result = core.read(filename, size=size, encoding='bytes')
2437 except OSError:
2438 result = ''
2440 truncated = len(result) == size
2442 encoding = cfg.file_encoding(filename) or core.ENCODING
2443 try:
2444 text_result = core.decode_maybe(result, encoding)
2445 except UnicodeError:
2446 text_result = format_hex(result)
2448 if truncated:
2449 text_result += '...'
2450 return text_result
2453 class SignOff(ContextCommand):
2454 """Append a signoff to the commit message"""
2456 UNDOABLE = True
2458 @staticmethod
2459 def name():
2460 return N_('Sign Off')
2462 def __init__(self, context):
2463 super().__init__(context)
2464 self.old_commitmsg = self.model.commitmsg
2466 def do(self):
2467 """Add a signoff to the commit message"""
2468 signoff = self.signoff()
2469 if signoff in self.model.commitmsg:
2470 return
2471 msg = self.model.commitmsg.rstrip()
2472 self.model.set_commitmsg(msg + '\n' + signoff)
2474 def undo(self):
2475 """Restore the commit message"""
2476 self.model.set_commitmsg(self.old_commitmsg)
2478 def signoff(self):
2479 """Generate the signoff string"""
2480 name, email = self.cfg.get_author()
2481 return f'\nSigned-off-by: {name} <{email}>'
2484 def check_conflicts(context, unmerged):
2485 """Check paths for conflicts
2487 Conflicting files can be filtered out one-by-one.
2490 if prefs.check_conflicts(context):
2491 unmerged = [path for path in unmerged if is_conflict_free(path)]
2492 return unmerged
2495 def is_conflict_free(path):
2496 """Return True if `path` contains no conflict markers"""
2497 rgx = re.compile(r'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2498 try:
2499 with core.xopen(path, 'rb') as f:
2500 for line in f:
2501 line = core.decode(line, errors='ignore')
2502 if rgx.match(line):
2503 return should_stage_conflicts(path)
2504 except OSError:
2505 # We can't read this file ~ we may be staging a removal
2506 pass
2507 return True
2510 def should_stage_conflicts(path):
2511 """Inform the user that a file contains merge conflicts
2513 Return `True` if we should stage the path nonetheless.
2516 title = msg = N_('Stage conflicts?')
2517 info = (
2519 '%s appears to contain merge conflicts.\n\n'
2520 'You should probably skip this file.\n'
2521 'Stage it anyways?'
2523 % path
2525 ok_text = N_('Stage conflicts')
2526 cancel_text = N_('Skip')
2527 return Interaction.confirm(
2528 title, msg, info, ok_text, default=False, cancel_text=cancel_text
2532 class Stage(ContextCommand):
2533 """Stage a set of paths."""
2535 @staticmethod
2536 def name():
2537 return N_('Stage')
2539 def __init__(self, context, paths):
2540 super().__init__(context)
2541 self.paths = paths
2543 def do(self):
2544 msg = N_('Staging: %s') % (', '.join(self.paths))
2545 Interaction.log(msg)
2546 return self.stage_paths()
2548 def stage_paths(self):
2549 """Stages add/removals to git."""
2550 context = self.context
2551 paths = self.paths
2552 if not paths:
2553 if self.model.cfg.get('cola.safemode', False):
2554 return (0, '', '')
2555 return self.stage_all()
2557 add = []
2558 remove = []
2559 status = 0
2560 out = ''
2561 err = ''
2563 for path in set(paths):
2564 if core.exists(path) or core.islink(path):
2565 if path.endswith('/'):
2566 path = path.rstrip('/')
2567 add.append(path)
2568 else:
2569 remove.append(path)
2571 self.model.emit_about_to_update()
2573 # `git add -u` doesn't work on untracked files
2574 if add:
2575 status, out, err = gitcmds.add(context, add)
2576 Interaction.command(N_('Error'), 'git add', status, out, err)
2578 # If a path doesn't exist then that means it should be removed
2579 # from the index. We use `git add -u` for that.
2580 if remove:
2581 status, out, err = gitcmds.add(context, remove, u=True)
2582 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2584 self.model.update_files(emit=True)
2585 return status, out, err
2587 def stage_all(self):
2588 """Stage all files"""
2589 status, out, err = self.git.add(v=True, u=True)
2590 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2591 self.model.update_file_status()
2592 return (status, out, err)
2595 class StageCarefully(Stage):
2596 """Only stage when the path list is non-empty
2598 We use "git add -u -- <pathspec>" to stage, and it stages everything by
2599 default when no pathspec is specified, so this class ensures that paths
2600 are specified before calling git.
2602 When no paths are specified, the command does nothing.
2606 def __init__(self, context):
2607 super().__init__(context, None)
2608 self.init_paths()
2610 def init_paths(self):
2611 """Initialize path data"""
2612 return
2614 def ok_to_run(self):
2615 """Prevent catch-all "git add -u" from adding unmerged files"""
2616 return self.paths or not self.model.unmerged
2618 def do(self):
2619 """Stage files when ok_to_run() return True"""
2620 if self.ok_to_run():
2621 return super().do()
2622 return (0, '', '')
2625 class StageModified(StageCarefully):
2626 """Stage all modified files."""
2628 @staticmethod
2629 def name():
2630 return N_('Stage Modified')
2632 def init_paths(self):
2633 self.paths = self.model.modified
2636 class StageUnmerged(StageCarefully):
2637 """Stage unmerged files."""
2639 @staticmethod
2640 def name():
2641 return N_('Stage Unmerged')
2643 def init_paths(self):
2644 self.paths = check_conflicts(self.context, self.model.unmerged)
2647 class StageUntracked(StageCarefully):
2648 """Stage all untracked files."""
2650 @staticmethod
2651 def name():
2652 return N_('Stage Untracked')
2654 def init_paths(self):
2655 self.paths = self.model.untracked
2658 class StageModifiedAndUntracked(StageCarefully):
2659 """Stage all untracked files."""
2661 @staticmethod
2662 def name():
2663 return N_('Stage Modified and Untracked')
2665 def init_paths(self):
2666 self.paths = self.model.modified + self.model.untracked
2669 class StageOrUnstageAll(ContextCommand):
2670 """If the selection is staged, unstage it, otherwise stage"""
2672 @staticmethod
2673 def name():
2674 return N_('Stage / Unstage All')
2676 def do(self):
2677 if self.model.staged:
2678 do(Unstage, self.context, self.model.staged)
2679 else:
2680 if self.cfg.get('cola.safemode', False):
2681 unstaged = self.model.modified
2682 else:
2683 unstaged = self.model.modified + self.model.untracked
2684 do(Stage, self.context, unstaged)
2687 class StageOrUnstage(ContextCommand):
2688 """If the selection is staged, unstage it, otherwise stage"""
2690 @staticmethod
2691 def name():
2692 return N_('Stage / Unstage')
2694 def do(self):
2695 s = self.selection.selection()
2696 if s.staged:
2697 do(Unstage, self.context, s.staged)
2699 unstaged = []
2700 unmerged = check_conflicts(self.context, s.unmerged)
2701 if unmerged:
2702 unstaged.extend(unmerged)
2703 if s.modified:
2704 unstaged.extend(s.modified)
2705 if s.untracked:
2706 unstaged.extend(s.untracked)
2707 if unstaged:
2708 do(Stage, self.context, unstaged)
2711 class Tag(ContextCommand):
2712 """Create a tag object."""
2714 def __init__(self, context, name, revision, sign=False, message=''):
2715 super().__init__(context)
2716 self._name = name
2717 self._message = message
2718 self._revision = revision
2719 self._sign = sign
2721 def do(self):
2722 result = False
2723 git = self.git
2724 revision = self._revision
2725 tag_name = self._name
2726 tag_message = self._message
2728 if not revision:
2729 Interaction.critical(
2730 N_('Missing Revision'), N_('Please specify a revision to tag.')
2732 return result
2734 if not tag_name:
2735 Interaction.critical(
2736 N_('Missing Name'), N_('Please specify a name for the new tag.')
2738 return result
2740 title = N_('Missing Tag Message')
2741 message = N_('Tag-signing was requested but the tag message is empty.')
2742 info = N_(
2743 'An unsigned, lightweight tag will be created instead.\n'
2744 'Create an unsigned tag?'
2746 ok_text = N_('Create Unsigned Tag')
2747 sign = self._sign
2748 if sign and not tag_message:
2749 # We require a message in order to sign the tag, so if they
2750 # choose to create an unsigned tag we have to clear the sign flag.
2751 if not Interaction.confirm(
2752 title, message, info, ok_text, default=False, icon=icons.save()
2754 return result
2755 sign = False
2757 opts = {}
2758 tmp_file = None
2759 try:
2760 if tag_message:
2761 tmp_file = utils.tmp_filename('tag-message')
2762 opts['file'] = tmp_file
2763 core.write(tmp_file, tag_message)
2765 if sign:
2766 opts['sign'] = True
2767 if tag_message:
2768 opts['annotate'] = True
2769 status, out, err = git.tag(tag_name, revision, **opts)
2770 finally:
2771 if tmp_file:
2772 core.unlink(tmp_file)
2774 title = N_('Error: could not create tag "%s"') % tag_name
2775 Interaction.command(title, 'git tag', status, out, err)
2777 if status == 0:
2778 result = True
2779 self.model.update_status()
2780 Interaction.information(
2781 N_('Tag Created'),
2782 N_('Created a new tag named "%s"') % tag_name,
2783 details=tag_message or None,
2786 return result
2789 class Unstage(ContextCommand):
2790 """Unstage a set of paths."""
2792 @staticmethod
2793 def name():
2794 return N_('Unstage')
2796 def __init__(self, context, paths):
2797 super().__init__(context)
2798 self.paths = paths
2800 def do(self):
2801 """Unstage paths"""
2802 context = self.context
2803 head = self.model.head
2804 paths = self.paths
2806 msg = N_('Unstaging: %s') % (', '.join(paths))
2807 Interaction.log(msg)
2808 if not paths:
2809 return unstage_all(context)
2810 status, out, err = gitcmds.unstage_paths(context, paths, head=head)
2811 Interaction.command(N_('Error'), 'git reset', status, out, err)
2812 self.model.update_file_status()
2813 return (status, out, err)
2816 class UnstageAll(ContextCommand):
2817 """Unstage all files; resets the index."""
2819 def do(self):
2820 return unstage_all(self.context)
2823 def unstage_all(context):
2824 """Unstage all files, even while amending"""
2825 model = context.model
2826 git = context.git
2827 head = model.head
2828 status, out, err = git.reset(head, '--', '.')
2829 Interaction.command(N_('Error'), 'git reset', status, out, err)
2830 model.update_file_status()
2831 return (status, out, err)
2834 class StageSelected(ContextCommand):
2835 """Stage selected files, or all files if no selection exists."""
2837 def do(self):
2838 context = self.context
2839 paths = self.selection.unstaged
2840 if paths:
2841 do(Stage, context, paths)
2842 elif self.cfg.get('cola.safemode', False):
2843 do(StageModified, context)
2846 class UnstageSelected(Unstage):
2847 """Unstage selected files."""
2849 def __init__(self, context):
2850 staged = context.selection.staged
2851 super().__init__(context, staged)
2854 class Untrack(ContextCommand):
2855 """Unstage a set of paths."""
2857 def __init__(self, context, paths):
2858 super().__init__(context)
2859 self.paths = paths
2861 def do(self):
2862 msg = N_('Untracking: %s') % (', '.join(self.paths))
2863 Interaction.log(msg)
2864 status, out, err = self.model.untrack_paths(self.paths)
2865 Interaction.log_status(status, out, err)
2868 class UnmergedSummary(EditModel):
2869 """List unmerged files in the diff text."""
2871 def __init__(self, context):
2872 super().__init__(context)
2873 unmerged = self.model.unmerged
2874 io = StringIO()
2875 io.write('# %s unmerged file(s)\n' % len(unmerged))
2876 if unmerged:
2877 io.write('\n'.join(unmerged) + '\n')
2878 self.new_diff_text = io.getvalue()
2879 self.new_diff_type = main.Types.TEXT
2880 self.new_file_type = main.Types.TEXT
2881 self.new_mode = self.model.mode_display
2884 class UntrackedSummary(EditModel):
2885 """List possible .gitignore rules as the diff text."""
2887 def __init__(self, context):
2888 super().__init__(context)
2889 untracked = self.model.untracked
2890 io = StringIO()
2891 io.write('# %s untracked file(s)\n' % len(untracked))
2892 if untracked:
2893 io.write('# Add these lines to ".gitignore" to ignore these files:\n')
2894 io.write('\n'.join('/' + filename for filename in untracked) + '\n')
2895 self.new_diff_text = io.getvalue()
2896 self.new_diff_type = main.Types.TEXT
2897 self.new_file_type = main.Types.TEXT
2898 self.new_mode = self.model.mode_display
2901 class VisualizeAll(ContextCommand):
2902 """Visualize all branches."""
2904 def do(self):
2905 context = self.context
2906 browser = utils.shell_split(prefs.history_browser(context))
2907 launch_history_browser(browser + ['--all'])
2910 class VisualizeCurrent(ContextCommand):
2911 """Visualize all branches."""
2913 def do(self):
2914 context = self.context
2915 browser = utils.shell_split(prefs.history_browser(context))
2916 launch_history_browser(browser + [self.model.currentbranch] + ['--'])
2919 class VisualizePaths(ContextCommand):
2920 """Path-limited visualization."""
2922 def __init__(self, context, paths):
2923 super().__init__(context)
2924 context = self.context
2925 browser = utils.shell_split(prefs.history_browser(context))
2926 if paths:
2927 self.argv = browser + ['--'] + list(paths)
2928 else:
2929 self.argv = browser
2931 def do(self):
2932 launch_history_browser(self.argv)
2935 class VisualizeRevision(ContextCommand):
2936 """Visualize a specific revision."""
2938 def __init__(self, context, revision, paths=None):
2939 super().__init__(context)
2940 self.revision = revision
2941 self.paths = paths
2943 def do(self):
2944 context = self.context
2945 argv = utils.shell_split(prefs.history_browser(context))
2946 if self.revision:
2947 argv.append(self.revision)
2948 if self.paths:
2949 argv.append('--')
2950 argv.extend(self.paths)
2951 launch_history_browser(argv)
2954 class SubmoduleAdd(ConfirmAction):
2955 """Add specified submodules"""
2957 def __init__(self, context, url, path, branch, depth, reference):
2958 super().__init__(context)
2959 self.url = url
2960 self.path = path
2961 self.branch = branch
2962 self.depth = depth
2963 self.reference = reference
2965 def confirm(self):
2966 title = N_('Add Submodule...')
2967 question = N_('Add this submodule?')
2968 info = N_('The submodule will be added using\n' '"%s"' % self.command())
2969 ok_txt = N_('Add Submodule')
2970 return Interaction.confirm(title, question, info, ok_txt, icon=icons.ok())
2972 def action(self):
2973 context = self.context
2974 args = self.get_args()
2975 return context.git.submodule('add', *args)
2977 def success(self):
2978 self.model.update_file_status()
2979 self.model.update_submodules_list()
2981 def error_message(self):
2982 return N_('Error updating submodule %s' % self.path)
2984 def command(self):
2985 cmd = ['git', 'submodule', 'add']
2986 cmd.extend(self.get_args())
2987 return core.list2cmdline(cmd)
2989 def get_args(self):
2990 args = []
2991 if self.branch:
2992 args.extend(['--branch', self.branch])
2993 if self.reference:
2994 args.extend(['--reference', self.reference])
2995 if self.depth:
2996 args.extend(['--depth', '%d' % self.depth])
2997 args.extend(['--', self.url])
2998 if self.path:
2999 args.append(self.path)
3000 return args
3003 class SubmoduleUpdate(ConfirmAction):
3004 """Update specified submodule"""
3006 def __init__(self, context, path):
3007 super().__init__(context)
3008 self.path = path
3010 def confirm(self):
3011 title = N_('Update Submodule...')
3012 question = N_('Update this submodule?')
3013 info = N_('The submodule will be updated using\n' '"%s"' % self.command())
3014 ok_txt = N_('Update Submodule')
3015 return Interaction.confirm(
3016 title, question, info, ok_txt, default=False, icon=icons.pull()
3019 def action(self):
3020 context = self.context
3021 args = self.get_args()
3022 return context.git.submodule(*args)
3024 def success(self):
3025 self.model.update_file_status()
3027 def error_message(self):
3028 return N_('Error updating submodule %s' % self.path)
3030 def command(self):
3031 cmd = ['git', 'submodule']
3032 cmd.extend(self.get_args())
3033 return core.list2cmdline(cmd)
3035 def get_args(self):
3036 cmd = ['update']
3037 if version.check_git(self.context, 'submodule-update-recursive'):
3038 cmd.append('--recursive')
3039 cmd.extend(['--', self.path])
3040 return cmd
3043 class SubmodulesUpdate(ConfirmAction):
3044 """Update all submodules"""
3046 def confirm(self):
3047 title = N_('Update submodules...')
3048 question = N_('Update all submodules?')
3049 info = N_('All submodules will be updated using\n' '"%s"' % self.command())
3050 ok_txt = N_('Update Submodules')
3051 return Interaction.confirm(
3052 title, question, info, ok_txt, default=False, icon=icons.pull()
3055 def action(self):
3056 context = self.context
3057 args = self.get_args()
3058 return context.git.submodule(*args)
3060 def success(self):
3061 self.model.update_file_status()
3063 def error_message(self):
3064 return N_('Error updating submodules')
3066 def command(self):
3067 cmd = ['git', 'submodule']
3068 cmd.extend(self.get_args())
3069 return core.list2cmdline(cmd)
3071 def get_args(self):
3072 cmd = ['update']
3073 if version.check_git(self.context, 'submodule-update-recursive'):
3074 cmd.append('--recursive')
3075 return cmd
3078 def launch_history_browser(argv):
3079 """Launch the configured history browser"""
3080 try:
3081 core.fork(argv)
3082 except OSError as e:
3083 _, details = utils.format_exception(e)
3084 title = N_('Error Launching History Browser')
3085 msg = N_('Cannot exec "%s": please configure a history browser') % ' '.join(
3086 argv
3088 Interaction.critical(title, message=msg, details=details)
3091 def run(cls, *args, **opts):
3093 Returns a callback that runs a command
3095 If the caller of run() provides args or opts then those are
3096 used instead of the ones provided by the invoker of the callback.
3100 def runner(*local_args, **local_opts):
3101 """Closure return by run() which runs the command"""
3102 if args or opts:
3103 return do(cls, *args, **opts)
3104 return do(cls, *local_args, **local_opts)
3106 return runner
3109 def do(cls, *args, **opts):
3110 """Run a command in-place"""
3111 try:
3112 cmd = cls(*args, **opts)
3113 return cmd.do()
3114 except Exception as e: # pylint: disable=broad-except
3115 msg, details = utils.format_exception(e)
3116 if hasattr(cls, '__name__'):
3117 msg = f'{cls.__name__} exception:\n{msg}'
3118 Interaction.critical(N_('Error'), message=msg, details=details)
3119 return None