gitcfg: optimize set_repo() to avoid re-running `git config`
[git-cola.git] / cola / cmds.py
blobfb82bd20a605d343497ae3bdd307c482067ddcfa
1 """Editor commands"""
2 import os
3 import re
4 import sys
5 from fnmatch import fnmatch
6 from io import StringIO
8 try:
9 from send2trash import send2trash
10 except ImportError:
11 send2trash = None
13 from . import compat
14 from . import core
15 from . import gitcmds
16 from . import icons
17 from . import resources
18 from . import textwrap
19 from . import utils
20 from . import version
21 from .cmd import ContextCommand
22 from .git import STDOUT
23 from .git import MISSING_BLOB_OID
24 from .i18n import N_
25 from .interaction import Interaction
26 from .models import main
27 from .models import prefs
30 class UsageError(Exception):
31 """Exception class for usage errors."""
33 def __init__(self, title, message):
34 Exception.__init__(self, message)
35 self.title = title
36 self.msg = message
39 class EditModel(ContextCommand):
40 """Commands that mutate the main model diff data"""
42 UNDOABLE = True
44 def __init__(self, context):
45 """Common edit operations on the main model"""
46 super().__init__(context)
48 self.old_diff_text = self.model.diff_text
49 self.old_filename = self.model.filename
50 self.old_mode = self.model.mode
51 self.old_diff_type = self.model.diff_type
52 self.old_file_type = self.model.file_type
54 self.new_diff_text = self.old_diff_text
55 self.new_filename = self.old_filename
56 self.new_mode = self.old_mode
57 self.new_diff_type = self.old_diff_type
58 self.new_file_type = self.old_file_type
60 def do(self):
61 """Perform the operation."""
62 self.model.filename = self.new_filename
63 self.model.set_mode(self.new_mode)
64 self.model.set_diff_text(self.new_diff_text)
65 self.model.set_diff_type(self.new_diff_type)
66 self.model.set_file_type(self.new_file_type)
68 def undo(self):
69 """Undo the operation."""
70 self.model.filename = self.old_filename
71 self.model.set_mode(self.old_mode)
72 self.model.set_diff_text(self.old_diff_text)
73 self.model.set_diff_type(self.old_diff_type)
74 self.model.set_file_type(self.old_file_type)
77 class ConfirmAction(ContextCommand):
78 """Confirm an action before running it"""
80 def ok_to_run(self):
81 """Return True when the command is okay to run"""
82 return True
84 def confirm(self):
85 """Prompt for confirmation"""
86 return True
88 def action(self):
89 """Run the command and return (status, out, err)"""
90 return (-1, '', '')
92 def success(self):
93 """Callback run on success"""
94 return
96 def command(self):
97 """Command name, for error messages"""
98 return 'git'
100 def error_message(self):
101 """Command error message"""
102 return ''
104 def do(self):
105 """Prompt for confirmation before running a command"""
106 status = -1
107 out = err = ''
108 ok = self.ok_to_run() and self.confirm()
109 if ok:
110 status, out, err = self.action()
111 if status == 0:
112 self.success()
113 title = self.error_message()
114 cmd = self.command()
115 Interaction.command(title, cmd, status, out, err)
117 return ok, status, out, err
120 class AbortApplyPatch(ConfirmAction):
121 """Reset an in-progress "git am" patch application"""
123 def confirm(self):
124 title = N_('Abort Applying Patch...')
125 question = N_('Aborting applying the current patch?')
126 info = N_(
127 'Aborting a patch can cause uncommitted changes to be lost.\n'
128 'Recovering uncommitted changes is not possible.'
130 ok_txt = N_('Abort Applying Patch')
131 return Interaction.confirm(
132 title, question, info, ok_txt, default=False, icon=icons.undo()
135 def action(self):
136 status, out, err = gitcmds.abort_apply_patch(self.context)
137 self.model.update_file_merge_status()
138 return status, out, err
140 def success(self):
141 self.model.set_commitmsg('')
143 def error_message(self):
144 return N_('Error')
146 def command(self):
147 return 'git am --abort'
150 class AbortCherryPick(ConfirmAction):
151 """Reset an in-progress cherry-pick"""
153 def confirm(self):
154 title = N_('Abort Cherry-Pick...')
155 question = N_('Aborting the current cherry-pick?')
156 info = N_(
157 'Aborting a cherry-pick can cause uncommitted changes to be lost.\n'
158 'Recovering uncommitted changes is not possible.'
160 ok_txt = N_('Abort Cherry-Pick')
161 return Interaction.confirm(
162 title, question, info, ok_txt, default=False, icon=icons.undo()
165 def action(self):
166 status, out, err = gitcmds.abort_cherry_pick(self.context)
167 self.model.update_file_merge_status()
168 return status, out, err
170 def success(self):
171 self.model.set_commitmsg('')
173 def error_message(self):
174 return N_('Error')
176 def command(self):
177 return 'git cherry-pick --abort'
180 class AbortMerge(ConfirmAction):
181 """Reset an in-progress merge back to HEAD"""
183 def confirm(self):
184 title = N_('Abort Merge...')
185 question = N_('Aborting the current merge?')
186 info = N_(
187 'Aborting the current merge will cause '
188 '*ALL* uncommitted changes to be lost.\n'
189 'Recovering uncommitted changes is not possible.'
191 ok_txt = N_('Abort Merge')
192 return Interaction.confirm(
193 title, question, info, ok_txt, default=False, icon=icons.undo()
196 def action(self):
197 status, out, err = gitcmds.abort_merge(self.context)
198 self.model.update_file_merge_status()
199 return status, out, err
201 def success(self):
202 self.model.set_commitmsg('')
204 def error_message(self):
205 return N_('Error')
207 def command(self):
208 return 'git merge'
211 class AmendMode(EditModel):
212 """Try to amend a commit."""
214 UNDOABLE = True
215 LAST_MESSAGE = None
217 @staticmethod
218 def name():
219 return N_('Amend')
221 def __init__(self, context, amend=True):
222 super().__init__(context)
223 self.skip = False
224 self.amending = amend
225 self.old_commitmsg = self.model.commitmsg
226 self.old_mode = self.model.mode
228 if self.amending:
229 self.new_mode = self.model.mode_amend
230 self.new_commitmsg = gitcmds.prev_commitmsg(context)
231 AmendMode.LAST_MESSAGE = self.model.commitmsg
232 return
233 # else, amend unchecked, regular commit
234 self.new_mode = self.model.mode_none
235 self.new_diff_text = ''
236 self.new_commitmsg = self.model.commitmsg
237 # If we're going back into new-commit-mode then search the
238 # undo stack for a previous amend-commit-mode and grab the
239 # commit message at that point in time.
240 if AmendMode.LAST_MESSAGE is not None:
241 self.new_commitmsg = AmendMode.LAST_MESSAGE
242 AmendMode.LAST_MESSAGE = None
244 def do(self):
245 """Leave/enter amend mode."""
246 # Attempt to enter amend mode. Do not allow this when merging.
247 if self.amending:
248 if self.model.is_merging:
249 self.skip = True
250 self.model.set_mode(self.old_mode)
251 Interaction.information(
252 N_('Cannot Amend'),
254 'You are in the middle of a merge.\n'
255 'Cannot amend while merging.'
258 return
259 self.skip = False
260 super().do()
261 self.model.set_commitmsg(self.new_commitmsg)
262 self.model.update_file_status()
263 self.context.selection.reset(emit=True)
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()
271 self.context.selection.reset(emit=True)
274 class AnnexAdd(ContextCommand):
275 """Add to Git Annex"""
277 def __init__(self, context):
278 super().__init__(context)
279 self.filename = self.selection.filename()
281 def do(self):
282 status, out, err = self.git.annex('add', self.filename)
283 Interaction.command(N_('Error'), 'git annex add', status, out, err)
284 self.model.update_status()
287 class AnnexInit(ContextCommand):
288 """Initialize Git Annex"""
290 def do(self):
291 status, out, err = self.git.annex('init')
292 Interaction.command(N_('Error'), 'git annex init', status, out, err)
293 self.model.cfg.reset()
294 self.model.emit_updated()
297 class LFSTrack(ContextCommand):
298 """Add a file to git lfs"""
300 def __init__(self, context):
301 super().__init__(context)
302 self.filename = self.selection.filename()
303 self.stage_cmd = Stage(context, [self.filename])
305 def do(self):
306 status, out, err = self.git.lfs('track', self.filename)
307 Interaction.command(N_('Error'), 'git lfs track', status, out, err)
308 if status == 0:
309 self.stage_cmd.do()
312 class LFSInstall(ContextCommand):
313 """Initialize git lfs"""
315 def do(self):
316 status, out, err = self.git.lfs('install')
317 Interaction.command(N_('Error'), 'git lfs install', status, out, err)
318 self.model.update_config(reset=True, emit=True)
321 class ApplyPatch(ContextCommand):
322 """Apply the specified patch to the worktree or index"""
324 def __init__(
325 self,
326 context,
327 patch,
328 encoding,
329 apply_to_worktree,
331 super().__init__(context)
332 self.patch = patch
333 self.encoding = encoding
334 self.apply_to_worktree = apply_to_worktree
336 def do(self):
337 context = self.context
339 tmp_file = utils.tmp_filename('apply', suffix='.patch')
340 try:
341 core.write(tmp_file, self.patch.as_text(), encoding=self.encoding)
342 if self.apply_to_worktree:
343 status, out, err = gitcmds.apply_diff_to_worktree(context, tmp_file)
344 else:
345 status, out, err = gitcmds.apply_diff(context, tmp_file)
346 finally:
347 core.unlink(tmp_file)
349 Interaction.log_status(status, out, err)
350 self.model.update_file_status(update_index=True)
353 class ApplyPatches(ContextCommand):
354 """Apply patches using the "git am" command"""
356 def __init__(self, context, patches):
357 super().__init__(context)
358 self.patches = patches
360 def do(self):
361 status, output, err = self.git.am('-3', *self.patches)
362 out = f'# git am -3 {core.list2cmdline(self.patches)}\n\n{output}'
363 Interaction.command(N_('Patch failed to apply'), 'git am -3', status, out, err)
364 # Display a diffstat
365 self.model.update_file_status()
367 patch_basenames = [os.path.basename(p) for p in self.patches]
368 if len(patch_basenames) > 25:
369 patch_basenames = patch_basenames[:25]
370 patch_basenames.append('...')
372 basenames = '\n'.join(patch_basenames)
373 if status == 0:
374 Interaction.information(
375 N_('Patch(es) Applied'),
376 (N_('%d patch(es) applied.') + '\n\n%s')
377 % (len(self.patches), basenames),
381 class ApplyPatchesContinue(ContextCommand):
382 """Run "git am --continue" to continue on the next patch in a "git am" session"""
384 def do(self):
385 status, out, err = self.git.am('--continue')
386 Interaction.command(
387 N_('Failed to commit and continue applying patches'),
388 'git am --continue',
389 status,
390 out,
391 err,
393 self.model.update_status()
394 return status, out, err
397 class ApplyPatchesSkip(ContextCommand):
398 """Run "git am --skip" to continue on the next patch in a "git am" session"""
400 def do(self):
401 status, out, err = self.git.am(skip=True)
402 Interaction.command(
403 N_('Failed to continue applying patches after skipping the current patch'),
404 'git am --skip',
405 status,
406 out,
407 err,
409 self.model.update_status()
410 return status, out, err
413 class Archive(ContextCommand):
414 """ "Export archives using the "git archive" command"""
416 def __init__(self, context, ref, fmt, prefix, filename):
417 super().__init__(context)
418 self.ref = ref
419 self.fmt = fmt
420 self.prefix = prefix
421 self.filename = filename
423 def do(self):
424 fp = core.xopen(self.filename, 'wb')
425 cmd = ['git', 'archive', '--format=' + self.fmt]
426 if self.fmt in ('tgz', 'tar.gz'):
427 cmd.append('-9')
428 if self.prefix:
429 cmd.append('--prefix=' + self.prefix)
430 cmd.append(self.ref)
431 proc = core.start_command(cmd, stdout=fp)
432 out, err = proc.communicate()
433 fp.close()
434 status = proc.returncode
435 Interaction.log_status(status, out or '', err or '')
438 class Checkout(EditModel):
439 """A command object for git-checkout.
441 The argv list is forwarded 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()
616 self.context.selection.reset(emit=True)
619 class ResetCommand(ConfirmAction):
620 """Reset state using the "git reset" command"""
622 def __init__(self, context, ref):
623 super().__init__(context)
624 self.ref = ref
626 def action(self):
627 return self.reset()
629 def command(self):
630 return 'git reset'
632 def error_message(self):
633 return N_('Error')
635 def success(self):
636 self.model.update_file_status()
638 def confirm(self):
639 raise NotImplementedError('confirm() must be overridden')
641 def reset(self):
642 raise NotImplementedError('reset() must be overridden')
645 class ResetMixed(ResetCommand):
646 @staticmethod
647 def tooltip(ref):
648 tooltip = N_('The branch will be reset using "git reset --mixed %s"')
649 return tooltip % ref
651 def confirm(self):
652 title = N_('Reset Branch and Stage (Mixed)')
653 question = N_('Point the current branch head to a new commit?')
654 info = self.tooltip(self.ref)
655 ok_text = N_('Reset Branch')
656 return Interaction.confirm(title, question, info, ok_text)
658 def reset(self):
659 return self.git.reset(self.ref, '--', mixed=True)
662 class ResetKeep(ResetCommand):
663 @staticmethod
664 def tooltip(ref):
665 tooltip = N_('The repository will be reset using "git reset --keep %s"')
666 return tooltip % ref
668 def confirm(self):
669 title = N_('Restore Worktree and Reset All (Keep Unstaged Changes)')
670 question = N_('Restore worktree, reset, and preserve unstaged edits?')
671 info = self.tooltip(self.ref)
672 ok_text = N_('Reset and Restore')
673 return Interaction.confirm(title, question, info, ok_text)
675 def reset(self):
676 return self.git.reset(self.ref, '--', keep=True)
679 class ResetMerge(ResetCommand):
680 @staticmethod
681 def tooltip(ref):
682 tooltip = N_('The repository will be reset using "git reset --merge %s"')
683 return tooltip % ref
685 def confirm(self):
686 title = N_('Restore Worktree and Reset All (Merge)')
687 question = N_('Reset Worktree and Reset All?')
688 info = self.tooltip(self.ref)
689 ok_text = N_('Reset and Restore')
690 return Interaction.confirm(title, question, info, ok_text)
692 def reset(self):
693 return self.git.reset(self.ref, '--', merge=True)
696 class ResetSoft(ResetCommand):
697 @staticmethod
698 def tooltip(ref):
699 tooltip = N_('The branch will be reset using "git reset --soft %s"')
700 return tooltip % ref
702 def confirm(self):
703 title = N_('Reset Branch (Soft)')
704 question = N_('Reset branch?')
705 info = self.tooltip(self.ref)
706 ok_text = N_('Reset Branch')
707 return Interaction.confirm(title, question, info, ok_text)
709 def reset(self):
710 return self.git.reset(self.ref, '--', soft=True)
713 class ResetHard(ResetCommand):
714 @staticmethod
715 def tooltip(ref):
716 tooltip = N_('The repository will be reset using "git reset --hard %s"')
717 return tooltip % ref
719 def confirm(self):
720 title = N_('Restore Worktree and Reset All (Hard)')
721 question = N_('Restore Worktree and Reset All?')
722 info = self.tooltip(self.ref)
723 ok_text = N_('Reset and Restore')
724 return Interaction.confirm(title, question, info, ok_text)
726 def reset(self):
727 return self.git.reset(self.ref, '--', hard=True)
730 class RestoreWorktree(ConfirmAction):
731 """Reset the worktree using the "git read-tree" command"""
733 @staticmethod
734 def tooltip(ref):
735 tooltip = N_(
736 'The worktree will be restored using "git read-tree --reset -u %s"'
738 return tooltip % ref
740 def __init__(self, context, ref):
741 super().__init__(context)
742 self.ref = ref
744 def action(self):
745 return self.git.read_tree(self.ref, reset=True, u=True)
747 def command(self):
748 return 'git read-tree --reset -u %s' % self.ref
750 def error_message(self):
751 return N_('Error')
753 def success(self):
754 self.model.update_file_status()
756 def confirm(self):
757 title = N_('Restore Worktree')
758 question = N_('Restore Worktree to %s?') % self.ref
759 info = self.tooltip(self.ref)
760 ok_text = N_('Restore Worktree')
761 return Interaction.confirm(title, question, info, ok_text)
764 class UndoLastCommit(ResetCommand):
765 """Undo the last commit"""
767 # NOTE: this is the similar to ResetSoft() with an additional check for
768 # published commits and different messages.
769 def __init__(self, context):
770 super().__init__(context, 'HEAD^')
772 def confirm(self):
773 check_published = prefs.check_published_commits(self.context)
774 if check_published and self.model.is_commit_published():
775 return Interaction.confirm(
776 N_('Rewrite Published Commit?'),
778 'This commit has already been published.\n'
779 'This operation will rewrite published history.\n'
780 "You probably don't want to do this."
782 N_('Undo the published commit?'),
783 N_('Undo Last Commit'),
784 default=False,
785 icon=icons.save(),
788 title = N_('Undo Last Commit')
789 question = N_('Undo last commit?')
790 info = N_('The branch will be reset using "git reset --soft %s"')
791 ok_text = N_('Undo Last Commit')
792 info_text = info % self.ref
793 return Interaction.confirm(title, question, info_text, ok_text)
795 def reset(self):
796 return self.git.reset('HEAD^', '--', soft=True)
799 class Commit(ResetMode):
800 """Attempt to create a new commit."""
802 def __init__(self, context, amend, msg, sign, no_verify=False):
803 super().__init__(context)
804 self.amend = amend
805 self.msg = msg
806 self.sign = sign
807 self.no_verify = no_verify
808 self.old_commitmsg = self.model.commitmsg
809 self.new_commitmsg = ''
811 def do(self):
812 # Create the commit message file
813 context = self.context
814 msg = self.msg
815 tmp_file = utils.tmp_filename('commit-message')
816 try:
817 core.write(tmp_file, msg)
818 # Run 'git commit'
819 status, out, err = self.git.commit(
820 F=tmp_file,
821 v=True,
822 gpg_sign=self.sign,
823 amend=self.amend,
824 no_verify=self.no_verify,
826 finally:
827 core.unlink(tmp_file)
828 if status == 0:
829 super().do()
830 if context.cfg.get(prefs.AUTOTEMPLATE):
831 template_loader = LoadCommitMessageFromTemplate(context)
832 template_loader.do()
833 else:
834 self.model.set_commitmsg(self.new_commitmsg)
836 return status, out, err
838 @staticmethod
839 def strip_comments(msg, comment_char='#'):
840 # Strip off comments
841 message_lines = [
842 line for line in msg.split('\n') if not line.startswith(comment_char)
844 msg = '\n'.join(message_lines)
845 if not msg.endswith('\n'):
846 msg += '\n'
848 return msg
851 class CycleReferenceSort(ContextCommand):
852 """Choose the next reference sort type"""
854 def do(self):
855 self.model.cycle_ref_sort()
858 class Ignore(ContextCommand):
859 """Add files to an exclusion file"""
861 def __init__(self, context, filenames, local=False):
862 super().__init__(context)
863 self.filenames = list(filenames)
864 self.local = local
866 def do(self):
867 if not self.filenames:
868 return
869 new_additions = '\n'.join(self.filenames) + '\n'
870 for_status = new_additions
871 if self.local:
872 filename = self.git.git_path('info', 'exclude')
873 else:
874 filename = '.gitignore'
875 if core.exists(filename):
876 current_list = core.read(filename)
877 new_additions = current_list.rstrip() + '\n' + new_additions
878 core.write(filename, new_additions)
879 Interaction.log_status(0, f'Added to {filename}:\n{for_status}', '')
880 self.model.update_file_status()
883 def file_summary(files):
884 txt = core.list2cmdline(files)
885 if len(txt) > 768:
886 txt = txt[:768].rstrip() + '...'
887 wrap = textwrap.TextWrapper()
888 return '\n'.join(wrap.wrap(txt))
891 class RemoteCommand(ConfirmAction):
892 def __init__(self, context, remote):
893 super().__init__(context)
894 self.remote = remote
896 def success(self):
897 self.cfg.reset()
898 self.model.update_remotes()
901 class RemoteAdd(RemoteCommand):
902 def __init__(self, context, remote, url):
903 super().__init__(context, remote)
904 self.url = url
906 def action(self):
907 return self.git.remote('add', self.remote, self.url)
909 def error_message(self):
910 return N_('Error creating remote "%s"') % self.remote
912 def command(self):
913 return f'git remote add "{self.remote}" "{self.url}"'
916 class RemoteRemove(RemoteCommand):
917 def confirm(self):
918 title = N_('Delete Remote')
919 question = N_('Delete remote?')
920 info = N_('Delete remote "%s"') % self.remote
921 ok_text = N_('Delete')
922 return Interaction.confirm(title, question, info, ok_text)
924 def action(self):
925 return self.git.remote('rm', self.remote)
927 def error_message(self):
928 return N_('Error deleting remote "%s"') % self.remote
930 def command(self):
931 return 'git remote rm "%s"' % self.remote
934 class RemoteRename(RemoteCommand):
935 def __init__(self, context, remote, new_name):
936 super().__init__(context, remote)
937 self.new_name = new_name
939 def confirm(self):
940 title = N_('Rename Remote')
941 text = N_('Rename remote "%(current)s" to "%(new)s"?') % {
942 'current': self.remote,
943 'new': self.new_name,
945 info_text = ''
946 ok_text = title
947 return Interaction.confirm(title, text, info_text, ok_text)
949 def action(self):
950 return self.git.remote('rename', self.remote, self.new_name)
952 def error_message(self):
953 return N_('Error renaming "%(name)s" to "%(new_name)s"') % {
954 'name': self.remote,
955 'new_name': self.new_name,
958 def command(self):
959 return f'git remote rename "{self.remote}" "{self.new_name}"'
962 class RemoteSetURL(RemoteCommand):
963 def __init__(self, context, remote, url):
964 super().__init__(context, remote)
965 self.url = url
967 def action(self):
968 return self.git.remote('set-url', self.remote, self.url)
970 def error_message(self):
971 return N_('Unable to set URL for "%(name)s" to "%(url)s"') % {
972 'name': self.remote,
973 'url': self.url,
976 def command(self):
977 return f'git remote set-url "{self.remote}" "{self.url}"'
980 class RemoteEdit(ContextCommand):
981 """Combine RemoteRename and RemoteSetURL"""
983 def __init__(self, context, old_name, remote, url):
984 super().__init__(context)
985 self.rename = RemoteRename(context, old_name, remote)
986 self.set_url = RemoteSetURL(context, remote, url)
988 def do(self):
989 result = self.rename.do()
990 name_ok = result[0]
991 url_ok = False
992 if name_ok:
993 result = self.set_url.do()
994 url_ok = result[0]
995 return name_ok, url_ok
998 class RemoveFromSettings(ConfirmAction):
999 def __init__(self, context, repo, entry, icon=None):
1000 super().__init__(context)
1001 self.context = context
1002 self.repo = repo
1003 self.entry = entry
1004 self.icon = icon
1006 def success(self):
1007 self.context.settings.save()
1010 class RemoveBookmark(RemoveFromSettings):
1011 def confirm(self):
1012 entry = self.entry
1013 title = msg = N_('Delete Bookmark?')
1014 info = N_('%s will be removed from your bookmarks.') % entry
1015 ok_text = N_('Delete Bookmark')
1016 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
1018 def action(self):
1019 self.context.settings.remove_bookmark(self.repo, self.entry)
1020 return (0, '', '')
1023 class RemoveRecent(RemoveFromSettings):
1024 def confirm(self):
1025 repo = self.repo
1026 title = msg = N_('Remove %s from the recent list?') % repo
1027 info = N_('%s will be removed from your recent repositories.') % repo
1028 ok_text = N_('Remove')
1029 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon)
1031 def action(self):
1032 self.context.settings.remove_recent(self.repo)
1033 return (0, '', '')
1036 class RemoveFiles(ContextCommand):
1037 """Removes files"""
1039 def __init__(self, context, remover, filenames):
1040 super().__init__(context)
1041 if remover is None:
1042 remover = os.remove
1043 self.remover = remover
1044 self.filenames = filenames
1045 # We could git-hash-object stuff and provide undo-ability
1046 # as an option. Heh.
1048 def do(self):
1049 files = self.filenames
1050 if not files:
1051 return
1053 rescan = False
1054 bad_filenames = []
1055 remove = self.remover
1056 for filename in files:
1057 if filename:
1058 try:
1059 remove(filename)
1060 rescan = True
1061 except OSError:
1062 bad_filenames.append(filename)
1064 if bad_filenames:
1065 Interaction.information(
1066 N_('Error'), N_('Deleting "%s" failed') % file_summary(bad_filenames)
1069 if rescan:
1070 self.model.update_file_status()
1073 class Delete(RemoveFiles):
1074 """Delete files."""
1076 def __init__(self, context, filenames):
1077 super().__init__(context, os.remove, filenames)
1079 def do(self):
1080 files = self.filenames
1081 if not files:
1082 return
1084 title = N_('Delete Files?')
1085 msg = N_('The following files will be deleted:') + '\n\n'
1086 msg += file_summary(files)
1087 info_txt = N_('Delete %d file(s)?') % len(files)
1088 ok_txt = N_('Delete Files')
1090 if Interaction.confirm(
1091 title, msg, info_txt, ok_txt, default=True, icon=icons.remove()
1093 super().do()
1096 class MoveToTrash(RemoveFiles):
1097 """Move files to the trash using send2trash"""
1099 AVAILABLE = send2trash is not None
1101 def __init__(self, context, filenames):
1102 super().__init__(context, send2trash, filenames)
1105 class DeleteBranch(ConfirmAction):
1106 """Delete a git branch."""
1108 def __init__(self, context, branch):
1109 super().__init__(context)
1110 self.branch = branch
1112 def confirm(self):
1113 title = N_('Delete Branch')
1114 question = N_('Delete branch "%s"?') % self.branch
1115 info = N_('The branch will be no longer available.')
1116 ok_txt = N_('Delete Branch')
1117 return Interaction.confirm(
1118 title, question, info, ok_txt, default=True, icon=icons.discard()
1121 def action(self):
1122 return self.model.delete_branch(self.branch)
1124 def error_message(self):
1125 return N_('Error deleting branch "%s"' % self.branch)
1127 def command(self):
1128 command = 'git branch -D %s'
1129 return command % self.branch
1132 class Rename(ContextCommand):
1133 """Rename a set of paths."""
1135 def __init__(self, context, paths):
1136 super().__init__(context)
1137 self.paths = paths
1139 def do(self):
1140 msg = N_('Untracking: %s') % (', '.join(self.paths))
1141 Interaction.log(msg)
1143 for path in self.paths:
1144 ok = self.rename(path)
1145 if not ok:
1146 return
1148 self.model.update_status()
1150 def rename(self, path):
1151 git = self.git
1152 title = N_('Rename "%s"') % path
1154 if os.path.isdir(path):
1155 base_path = os.path.dirname(path)
1156 else:
1157 base_path = path
1158 new_path = Interaction.save_as(base_path, title)
1159 if not new_path:
1160 return False
1162 status, out, err = git.mv(path, new_path, force=True, verbose=True)
1163 Interaction.command(N_('Error'), 'git mv', status, out, err)
1164 return status == 0
1167 class RenameBranch(ContextCommand):
1168 """Rename a git branch."""
1170 def __init__(self, context, branch, new_branch):
1171 super().__init__(context)
1172 self.branch = branch
1173 self.new_branch = new_branch
1175 def do(self):
1176 branch = self.branch
1177 new_branch = self.new_branch
1178 status, out, err = self.model.rename_branch(branch, new_branch)
1179 Interaction.log_status(status, out, err)
1182 class DeleteRemoteBranch(DeleteBranch):
1183 """Delete a remote git branch."""
1185 def __init__(self, context, remote, branch):
1186 super().__init__(context, branch)
1187 self.remote = remote
1189 def action(self):
1190 return self.git.push(self.remote, self.branch, delete=True)
1192 def success(self):
1193 self.model.update_status()
1194 Interaction.information(
1195 N_('Remote Branch Deleted'),
1196 N_('"%(branch)s" has been deleted from "%(remote)s".')
1198 'branch': self.branch,
1199 'remote': self.remote,
1203 def error_message(self):
1204 return N_('Error Deleting Remote Branch')
1206 def command(self):
1207 command = 'git push --delete %s %s'
1208 return command % (self.remote, self.branch)
1211 def get_mode(context, filename, staged, modified, unmerged, untracked):
1212 model = context.model
1213 if staged:
1214 mode = model.mode_index
1215 elif modified or unmerged:
1216 mode = model.mode_worktree
1217 elif untracked:
1218 if gitcmds.is_binary(context, filename):
1219 mode = model.mode_untracked
1220 else:
1221 mode = model.mode_untracked_diff
1222 else:
1223 mode = model.mode
1224 return mode
1227 class DiffAgainstCommitMode(ContextCommand):
1228 """Diff against arbitrary commits"""
1230 def __init__(self, context, oid):
1231 super().__init__(context)
1232 self.oid = oid
1234 def do(self):
1235 self.model.set_mode(self.model.mode_diff, head=self.oid)
1236 self.model.update_file_status()
1239 class DiffText(EditModel):
1240 """Set the diff type to text"""
1242 def __init__(self, context):
1243 super().__init__(context)
1244 self.new_file_type = main.Types.TEXT
1245 self.new_diff_type = main.Types.TEXT
1248 class ToggleDiffType(ContextCommand):
1249 """Toggle the diff type between image and text"""
1251 def __init__(self, context):
1252 super().__init__(context)
1253 if self.model.diff_type == main.Types.IMAGE:
1254 self.new_diff_type = main.Types.TEXT
1255 self.new_value = False
1256 else:
1257 self.new_diff_type = main.Types.IMAGE
1258 self.new_value = True
1260 def do(self):
1261 diff_type = self.new_diff_type
1262 value = self.new_value
1264 self.model.set_diff_type(diff_type)
1266 filename = self.model.filename
1267 _, ext = os.path.splitext(filename)
1268 if ext.startswith('.'):
1269 cfg = 'cola.imagediff' + ext
1270 self.cfg.set_repo(cfg, value)
1273 class DiffImage(EditModel):
1274 def __init__(
1275 self, context, filename, deleted, staged, modified, unmerged, untracked
1277 super().__init__(context)
1279 self.new_filename = filename
1280 self.new_diff_type = self.get_diff_type(filename)
1281 self.new_file_type = main.Types.IMAGE
1282 self.new_mode = get_mode(
1283 context, filename, staged, modified, unmerged, untracked
1285 self.staged = staged
1286 self.modified = modified
1287 self.unmerged = unmerged
1288 self.untracked = untracked
1289 self.deleted = deleted
1290 self.annex = self.cfg.is_annex()
1292 def get_diff_type(self, filename):
1293 """Query the diff type to use based on cola.imagediff.<extension>"""
1294 _, ext = os.path.splitext(filename)
1295 if ext.startswith('.'):
1296 # Check e.g. "cola.imagediff.svg" to see if we should imagediff.
1297 cfg = 'cola.imagediff' + ext
1298 if self.cfg.get(cfg, True):
1299 result = main.Types.IMAGE
1300 else:
1301 result = main.Types.TEXT
1302 else:
1303 result = main.Types.IMAGE
1304 return result
1306 def do(self):
1307 filename = self.new_filename
1309 if self.staged:
1310 images = self.staged_images()
1311 elif self.modified:
1312 images = self.modified_images()
1313 elif self.unmerged:
1314 images = self.unmerged_images()
1315 elif self.untracked:
1316 images = [(filename, False)]
1317 else:
1318 images = []
1320 self.model.set_images(images)
1321 super().do()
1323 def staged_images(self):
1324 context = self.context
1325 git = self.git
1326 head = self.model.head
1327 filename = self.new_filename
1328 annex = self.annex
1330 images = []
1331 index = git.diff_index(head, '--', filename, cached=True)[STDOUT]
1332 if index:
1333 # Example:
1334 # :100644 100644 fabadb8... 4866510... M describe.c
1335 parts = index.split(' ')
1336 if len(parts) > 3:
1337 old_oid = parts[2]
1338 new_oid = parts[3]
1340 if old_oid != MISSING_BLOB_OID:
1341 # First, check if we can get a pre-image from git-annex
1342 annex_image = None
1343 if annex:
1344 annex_image = gitcmds.annex_path(context, head, filename)
1345 if annex_image:
1346 images.append((annex_image, False)) # git annex HEAD
1347 else:
1348 image = gitcmds.write_blob_path(context, head, old_oid, filename)
1349 if image:
1350 images.append((image, True))
1352 if new_oid != MISSING_BLOB_OID:
1353 found_in_annex = False
1354 if annex and core.islink(filename):
1355 status, out, _ = git.annex('status', '--', filename)
1356 if status == 0:
1357 details = out.split(' ')
1358 if details and details[0] == 'A': # newly added file
1359 images.append((filename, False))
1360 found_in_annex = True
1362 if not found_in_annex:
1363 image = gitcmds.write_blob(context, new_oid, filename)
1364 if image:
1365 images.append((image, True))
1367 return images
1369 def unmerged_images(self):
1370 context = self.context
1371 git = self.git
1372 head = self.model.head
1373 filename = self.new_filename
1374 annex = self.annex
1376 candidate_merge_heads = ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1377 merge_heads = [
1378 merge_head
1379 for merge_head in candidate_merge_heads
1380 if core.exists(git.git_path(merge_head))
1383 if annex: # Attempt to find files in git-annex
1384 annex_images = []
1385 for merge_head in merge_heads:
1386 image = gitcmds.annex_path(context, merge_head, filename)
1387 if image:
1388 annex_images.append((image, False))
1389 if annex_images:
1390 annex_images.append((filename, False))
1391 return annex_images
1393 # DIFF FORMAT FOR MERGES
1394 # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1395 # can take -c or --cc option to generate diff output also
1396 # for merge commits. The output differs from the format
1397 # described above in the following way:
1399 # 1. there is a colon for each parent
1400 # 2. there are more "src" modes and "src" sha1
1401 # 3. status is concatenated status characters for each parent
1402 # 4. no optional "score" number
1403 # 5. single path, only for "dst"
1404 # Example:
1405 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1406 # MM describe.c
1407 images = []
1408 index = git.diff_index(head, '--', filename, cached=True, cc=True)[STDOUT]
1409 if index:
1410 parts = index.split(' ')
1411 if len(parts) > 3:
1412 first_mode = parts[0]
1413 num_parents = first_mode.count(':')
1414 # colon for each parent, but for the index, the "parents"
1415 # are really entries in stages 1,2,3 (head, base, remote)
1416 # remote, base, head
1417 for i in range(num_parents):
1418 offset = num_parents + i + 1
1419 oid = parts[offset]
1420 try:
1421 merge_head = merge_heads[i]
1422 except IndexError:
1423 merge_head = 'HEAD'
1424 if oid != MISSING_BLOB_OID:
1425 image = gitcmds.write_blob_path(
1426 context, merge_head, oid, filename
1428 if image:
1429 images.append((image, True))
1431 images.append((filename, False))
1432 return images
1434 def modified_images(self):
1435 context = self.context
1436 git = self.git
1437 head = self.model.head
1438 filename = self.new_filename
1439 annex = self.annex
1441 images = []
1442 annex_image = None
1443 if annex: # Check for a pre-image from git-annex
1444 annex_image = gitcmds.annex_path(context, head, filename)
1445 if annex_image:
1446 images.append((annex_image, False)) # git annex HEAD
1447 else:
1448 worktree = git.diff_files('--', filename)[STDOUT]
1449 parts = worktree.split(' ')
1450 if len(parts) > 3:
1451 oid = parts[2]
1452 if oid != MISSING_BLOB_OID:
1453 image = gitcmds.write_blob_path(context, head, oid, filename)
1454 if image:
1455 images.append((image, True)) # HEAD
1457 images.append((filename, False)) # worktree
1458 return images
1461 class Diff(EditModel):
1462 """Perform a diff and set the model's current text."""
1464 def __init__(self, context, filename, cached=False, deleted=False):
1465 super().__init__(context)
1466 opts = {}
1467 if cached and gitcmds.is_valid_ref(context, self.model.head):
1468 opts['ref'] = self.model.head
1469 self.new_filename = filename
1470 self.new_mode = self.model.mode_worktree
1471 self.new_diff_text = gitcmds.diff_helper(
1472 self.context, filename=filename, cached=cached, deleted=deleted, **opts
1476 class Diffstat(EditModel):
1477 """Perform a diffstat and set the model's diff text."""
1479 def __init__(self, context):
1480 super().__init__(context)
1481 cfg = self.cfg
1482 diff_context = cfg.get('diff.context', 3)
1483 diff = self.git.diff(
1484 self.model.head,
1485 unified=diff_context,
1486 no_ext_diff=True,
1487 no_color=True,
1488 M=True,
1489 stat=True,
1490 )[STDOUT]
1491 self.new_diff_text = diff
1492 self.new_diff_type = main.Types.TEXT
1493 self.new_file_type = main.Types.TEXT
1494 self.new_mode = self.model.mode_diffstat
1497 class DiffStaged(Diff):
1498 """Perform a staged diff on a file."""
1500 def __init__(self, context, filename, deleted=None):
1501 super().__init__(context, filename, cached=True, deleted=deleted)
1502 self.new_mode = self.model.mode_index
1505 class DiffStagedSummary(EditModel):
1506 def __init__(self, context):
1507 super().__init__(context)
1508 diff = self.git.diff(
1509 self.model.head,
1510 cached=True,
1511 no_color=True,
1512 no_ext_diff=True,
1513 patch_with_stat=True,
1514 M=True,
1515 )[STDOUT]
1516 self.new_diff_text = diff
1517 self.new_diff_type = main.Types.TEXT
1518 self.new_file_type = main.Types.TEXT
1519 self.new_mode = self.model.mode_index
1522 class Edit(ContextCommand):
1523 """Edit a file using the configured gui.editor."""
1525 @staticmethod
1526 def name():
1527 return N_('Launch Editor')
1529 def __init__(self, context, filenames, line_number=None, background_editor=False):
1530 super().__init__(context)
1531 self.filenames = filenames
1532 self.line_number = line_number
1533 self.background_editor = background_editor
1535 def do(self):
1536 context = self.context
1537 if not self.filenames:
1538 return
1539 filename = self.filenames[0]
1540 if not core.exists(filename):
1541 return
1542 if self.background_editor:
1543 editor = prefs.background_editor(context)
1544 else:
1545 editor = prefs.editor(context)
1546 opts = []
1548 if self.line_number is None:
1549 opts = self.filenames
1550 else:
1551 # Single-file w/ line-numbers (likely from grep)
1552 editor_opts = {
1553 '*vim*': [filename, '+%s' % self.line_number],
1554 '*emacs*': ['+%s' % self.line_number, filename],
1555 '*textpad*': [f'{filename}({self.line_number},0)'],
1556 '*notepad++*': ['-n%s' % self.line_number, filename],
1557 '*subl*': [f'{filename}:{self.line_number}'],
1560 opts = self.filenames
1561 for pattern, opt in editor_opts.items():
1562 if fnmatch(editor, pattern):
1563 opts = opt
1564 break
1566 try:
1567 core.fork(utils.shell_split(editor) + opts)
1568 except (OSError, ValueError) as e:
1569 message = N_('Cannot exec "%s": please configure your editor') % editor
1570 _, details = utils.format_exception(e)
1571 Interaction.critical(N_('Error Editing File'), message, details)
1574 class FormatPatch(ContextCommand):
1575 """Output a patch series given all revisions and a selected subset."""
1577 def __init__(self, context, to_export, revs, output='patches'):
1578 super().__init__(context)
1579 self.to_export = list(to_export)
1580 self.revs = list(revs)
1581 self.output = output
1583 def do(self):
1584 context = self.context
1585 status, out, err = gitcmds.format_patchsets(
1586 context, self.to_export, self.revs, self.output
1588 Interaction.log_status(status, out, err)
1591 class LaunchTerminal(ContextCommand):
1592 @staticmethod
1593 def name():
1594 return N_('Launch Terminal')
1596 @staticmethod
1597 def is_available(context):
1598 return context.cfg.terminal() is not None
1600 def __init__(self, context, path):
1601 super().__init__(context)
1602 self.path = path
1604 def do(self):
1605 cmd = self.context.cfg.terminal()
1606 if cmd is None:
1607 return
1608 if utils.is_win32():
1609 argv = ['start', '', cmd, '--login']
1610 shell = True
1611 else:
1612 argv = utils.shell_split(cmd)
1613 command = '/bin/sh'
1614 shells = ('zsh', 'fish', 'bash', 'sh')
1615 for basename in shells:
1616 executable = core.find_executable(basename)
1617 if executable:
1618 command = executable
1619 break
1620 argv.append(os.getenv('SHELL', command))
1621 shell = False
1623 core.fork(argv, cwd=self.path, shell=shell)
1626 class LaunchEditor(Edit):
1627 @staticmethod
1628 def name():
1629 return N_('Launch Editor')
1631 def __init__(self, context):
1632 s = context.selection.selection()
1633 filenames = s.staged + s.unmerged + s.modified + s.untracked
1634 super().__init__(context, filenames, background_editor=True)
1637 class LaunchEditorAtLine(LaunchEditor):
1638 """Launch an editor at the specified line"""
1640 def __init__(self, context):
1641 super().__init__(context)
1642 self.line_number = context.selection.line_number
1645 class LoadCommitMessageFromFile(ContextCommand):
1646 """Loads a commit message from a path."""
1648 UNDOABLE = True
1650 def __init__(self, context, path):
1651 super().__init__(context)
1652 self.path = path
1653 self.old_commitmsg = self.model.commitmsg
1654 self.old_directory = self.model.directory
1656 def do(self):
1657 path = os.path.expanduser(self.path)
1658 if not path or not core.isfile(path):
1659 Interaction.log(N_('Error: Cannot find commit template'))
1660 Interaction.log(N_('%s: No such file or directory.') % path)
1661 return
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 Interaction.log(N_('Error: Unconfigured commit template'))
1681 Interaction.log(
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
1689 return LoadCommitMessageFromFile.do(self)
1692 class LoadCommitMessageFromOID(ContextCommand):
1693 """Load a previous commit message"""
1695 UNDOABLE = True
1697 def __init__(self, context, oid, prefix=''):
1698 super().__init__(context)
1699 self.oid = oid
1700 self.old_commitmsg = self.model.commitmsg
1701 self.new_commitmsg = prefix + gitcmds.prev_commitmsg(context, oid)
1703 def do(self):
1704 self.model.set_commitmsg(self.new_commitmsg)
1706 def undo(self):
1707 self.model.set_commitmsg(self.old_commitmsg)
1710 class PrepareCommitMessageHook(ContextCommand):
1711 """Use the cola-prepare-commit-msg hook to prepare the commit message"""
1713 UNDOABLE = True
1715 def __init__(self, context):
1716 super().__init__(context)
1717 self.old_commitmsg = self.model.commitmsg
1719 def get_message(self):
1720 title = N_('Error running prepare-commitmsg hook')
1721 hook = gitcmds.prepare_commit_message_hook(self.context)
1723 if os.path.exists(hook):
1724 filename = self.model.save_commitmsg()
1725 status, out, err = core.run_command([hook, filename])
1727 if status == 0:
1728 result = core.read(filename)
1729 else:
1730 result = self.old_commitmsg
1731 Interaction.command_error(title, hook, status, out, err)
1732 else:
1733 message = N_('A hook must be provided at "%s"') % hook
1734 Interaction.critical(title, message=message)
1735 result = self.old_commitmsg
1737 return result
1739 def do(self):
1740 msg = self.get_message()
1741 self.model.set_commitmsg(msg)
1743 def undo(self):
1744 self.model.set_commitmsg(self.old_commitmsg)
1747 class LoadFixupMessage(LoadCommitMessageFromOID):
1748 """Load a fixup message"""
1750 def __init__(self, context, oid):
1751 super().__init__(context, oid, prefix='fixup! ')
1752 if self.new_commitmsg:
1753 self.new_commitmsg = self.new_commitmsg.splitlines()[0]
1756 class Merge(ContextCommand):
1757 """Merge commits"""
1759 def __init__(self, context, revision, no_commit, squash, no_ff, sign):
1760 super().__init__(context)
1761 self.revision = revision
1762 self.no_ff = no_ff
1763 self.no_commit = no_commit
1764 self.squash = squash
1765 self.sign = sign
1767 def do(self):
1768 squash = self.squash
1769 revision = self.revision
1770 no_ff = self.no_ff
1771 no_commit = self.no_commit
1772 sign = self.sign
1774 status, out, err = self.git.merge(
1775 revision, gpg_sign=sign, no_ff=no_ff, no_commit=no_commit, squash=squash
1777 self.model.update_status()
1778 title = N_('Merge failed. Conflict resolution is required.')
1779 Interaction.command(title, 'git merge', status, out, err)
1781 return status, out, err
1784 class OpenDefaultApp(ContextCommand):
1785 """Open a file using the OS default."""
1787 @staticmethod
1788 def name():
1789 return N_('Open Using Default Application')
1791 def __init__(self, context, filenames):
1792 super().__init__(context)
1793 self.filenames = filenames
1795 def do(self):
1796 if not self.filenames:
1797 return
1798 utils.launch_default_app(self.filenames)
1801 class OpenDir(OpenDefaultApp):
1802 """Open directories using the OS default."""
1804 @staticmethod
1805 def name():
1806 return N_('Open Directory')
1808 @property
1809 def _dirnames(self):
1810 return self.filenames
1812 def do(self):
1813 dirnames = self._dirnames
1814 if not dirnames:
1815 return
1816 # An empty dirname defaults to to the current directory.
1817 dirs = [(dirname or core.getcwd()) for dirname in dirnames]
1818 utils.launch_default_app(dirs)
1821 class OpenParentDir(OpenDir):
1822 """Open parent directories using the OS default."""
1824 @staticmethod
1825 def name():
1826 return N_('Open Parent Directory')
1828 @property
1829 def _dirnames(self):
1830 dirnames = list({os.path.dirname(x) for x in self.filenames})
1831 return dirnames
1834 class OpenWorktree(OpenDir):
1835 """Open worktree directory using the OS default."""
1837 @staticmethod
1838 def name():
1839 return N_('Open Worktree')
1841 # The _unused parameter is needed by worktree_dir_action() -> common.cmd_action().
1842 def __init__(self, context, _unused=None):
1843 dirnames = [context.git.worktree()]
1844 super().__init__(context, dirnames)
1847 class OpenNewRepo(ContextCommand):
1848 """Launches git-cola on a repo."""
1850 def __init__(self, context, repo_path):
1851 super().__init__(context)
1852 self.repo_path = repo_path
1854 def do(self):
1855 self.model.set_directory(self.repo_path)
1856 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path])
1859 class OpenRepo(EditModel):
1860 def __init__(self, context, repo_path):
1861 super().__init__(context)
1862 self.repo_path = repo_path
1863 self.new_mode = self.model.mode_none
1864 self.new_diff_text = ''
1865 self.new_diff_type = main.Types.TEXT
1866 self.new_file_type = main.Types.TEXT
1867 self.new_commitmsg = ''
1868 self.new_filename = ''
1870 def do(self):
1871 old_repo = self.git.getcwd()
1872 if self.model.set_worktree(self.repo_path):
1873 self.fsmonitor.stop()
1874 self.fsmonitor.start()
1875 self.model.update_status(reset=True)
1876 # Check if template should be loaded
1877 if self.context.cfg.get(prefs.AUTOTEMPLATE):
1878 template_loader = LoadCommitMessageFromTemplate(self.context)
1879 template_loader.do()
1880 else:
1881 self.model.set_commitmsg(self.new_commitmsg)
1882 settings = self.context.settings
1883 settings.load()
1884 settings.add_recent(self.repo_path, prefs.maxrecent(self.context))
1885 settings.save()
1886 super().do()
1887 else:
1888 self.model.set_worktree(old_repo)
1891 class OpenParentRepo(OpenRepo):
1892 def __init__(self, context):
1893 path = ''
1894 if version.check_git(context, 'show-superproject-working-tree'):
1895 status, out, _ = context.git.rev_parse(show_superproject_working_tree=True)
1896 if status == 0:
1897 path = out
1898 if not path:
1899 path = os.path.dirname(core.getcwd())
1900 super().__init__(context, path)
1903 class Clone(ContextCommand):
1904 """Clones a repository and optionally spawns a new cola session."""
1906 def __init__(
1907 self, context, url, new_directory, submodules=False, shallow=False, spawn=True
1909 super().__init__(context)
1910 self.url = url
1911 self.new_directory = new_directory
1912 self.submodules = submodules
1913 self.shallow = shallow
1914 self.spawn = spawn
1915 self.status = -1
1916 self.out = ''
1917 self.err = ''
1919 def do(self):
1920 kwargs = {}
1921 if self.shallow:
1922 kwargs['depth'] = 1
1923 recurse_submodules = self.submodules
1924 shallow_submodules = self.submodules and self.shallow
1926 status, out, err = self.git.clone(
1927 self.url,
1928 self.new_directory,
1929 recurse_submodules=recurse_submodules,
1930 shallow_submodules=shallow_submodules,
1931 **kwargs,
1934 self.status = status
1935 self.out = out
1936 self.err = err
1937 if status == 0 and self.spawn:
1938 executable = sys.executable
1939 core.fork([executable, sys.argv[0], '--repo', self.new_directory])
1940 return self
1943 class NewBareRepo(ContextCommand):
1944 """Create a new shared bare repository"""
1946 def __init__(self, context, path):
1947 super().__init__(context)
1948 self.path = path
1950 def do(self):
1951 path = self.path
1952 status, out, err = self.git.init(path, bare=True, shared=True)
1953 Interaction.command(
1954 N_('Error'), 'git init --bare --shared "%s"' % path, status, out, err
1956 return status == 0
1959 def unix_path(path, is_win32=utils.is_win32):
1960 """Git for Windows requires Unix paths, so force them here"""
1961 if is_win32():
1962 path = path.replace('\\', '/')
1963 first = path[0]
1964 second = path[1]
1965 if second == ':': # sanity check, this better be a Windows-style path
1966 path = '/' + first + path[2:]
1968 return path
1971 def sequence_editor():
1972 """Set GIT_SEQUENCE_EDITOR for running git-cola-sequence-editor"""
1973 xbase = unix_path(resources.command('git-cola-sequence-editor'))
1974 if utils.is_win32():
1975 editor = core.list2cmdline([unix_path(sys.executable), xbase])
1976 else:
1977 editor = core.list2cmdline([xbase])
1978 return editor
1981 class SequenceEditorEnvironment:
1982 """Set environment variables to enable git-cola-sequence-editor"""
1984 def __init__(self, context, **kwargs):
1985 self.env = {
1986 'GIT_EDITOR': prefs.editor(context),
1987 'GIT_SEQUENCE_EDITOR': sequence_editor(),
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()
2176 self.selection.selection_changed.emit()
2179 class RefreshConfig(ContextCommand):
2180 """Refresh the git config cache"""
2182 def do(self):
2183 self.cfg.update()
2186 class RevertEditsCommand(ConfirmAction):
2187 def __init__(self, context):
2188 super().__init__(context)
2189 self.icon = icons.undo()
2191 def ok_to_run(self):
2192 return self.model.is_undoable()
2194 def checkout_from_head(self):
2195 return False
2197 def checkout_args(self):
2198 args = []
2199 s = self.selection.selection()
2200 if self.checkout_from_head():
2201 args.append(self.model.head)
2202 args.append('--')
2204 if s.staged:
2205 items = s.staged
2206 else:
2207 items = s.modified
2208 args.extend(items)
2210 return args
2212 def action(self):
2213 checkout_args = self.checkout_args()
2214 return self.git.checkout(*checkout_args)
2216 def success(self):
2217 self.model.set_diff_type(main.Types.TEXT)
2218 self.model.update_file_status()
2221 class RevertUnstagedEdits(RevertEditsCommand):
2222 @staticmethod
2223 def name():
2224 return N_('Revert Unstaged Edits...')
2226 def checkout_from_head(self):
2227 # Being in amend mode should not affect the behavior of this command.
2228 # The only sensible thing to do is to checkout from the index.
2229 return False
2231 def confirm(self):
2232 title = N_('Revert Unstaged Changes?')
2233 text = N_(
2234 'This operation removes unstaged edits from selected files.\n'
2235 'These changes cannot be recovered.'
2237 info = N_('Revert the unstaged changes?')
2238 ok_text = N_('Revert Unstaged Changes')
2239 return Interaction.confirm(
2240 title, text, info, ok_text, default=True, icon=self.icon
2244 class RevertUncommittedEdits(RevertEditsCommand):
2245 @staticmethod
2246 def name():
2247 return N_('Revert Uncommitted Edits...')
2249 def checkout_from_head(self):
2250 return True
2252 def confirm(self):
2253 """Prompt for reverting changes"""
2254 title = N_('Revert Uncommitted Changes?')
2255 text = N_(
2256 'This operation removes uncommitted edits from selected files.\n'
2257 'These changes cannot be recovered.'
2259 info = N_('Revert the uncommitted changes?')
2260 ok_text = N_('Revert Uncommitted Changes')
2261 return Interaction.confirm(
2262 title, text, info, ok_text, default=True, icon=self.icon
2266 class RunConfigAction(ContextCommand):
2267 """Run a user-configured action, typically from the "Tools" menu"""
2269 def __init__(self, context, action_name):
2270 super().__init__(context)
2271 self.action_name = action_name
2273 def do(self):
2274 """Run the user-configured action"""
2275 for env in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
2276 try:
2277 compat.unsetenv(env)
2278 except KeyError:
2279 pass
2280 rev = None
2281 args = None
2282 context = self.context
2283 cfg = self.cfg
2284 opts = cfg.get_guitool_opts(self.action_name)
2285 cmd = opts.get('cmd')
2286 if 'title' not in opts:
2287 opts['title'] = cmd
2289 if 'prompt' not in opts or opts.get('prompt') is True:
2290 prompt = N_('Run "%s"?') % cmd
2291 opts['prompt'] = prompt
2293 if opts.get('needsfile'):
2294 filename = self.selection.filename()
2295 if not filename:
2296 Interaction.information(
2297 N_('Please select a file'),
2298 N_('"%s" requires a selected file.') % cmd,
2300 return False
2301 dirname = utils.dirname(filename, current_dir='.')
2302 compat.setenv('FILENAME', filename)
2303 compat.setenv('DIRNAME', dirname)
2305 if opts.get('revprompt') or opts.get('argprompt'):
2306 while True:
2307 ok = Interaction.confirm_config_action(context, cmd, opts)
2308 if not ok:
2309 return False
2310 rev = opts.get('revision')
2311 args = opts.get('args')
2312 if opts.get('revprompt') and not rev:
2313 title = N_('Invalid Revision')
2314 msg = N_('The revision expression cannot be empty.')
2315 Interaction.critical(title, msg)
2316 continue
2317 break
2319 elif opts.get('confirm'):
2320 title = os.path.expandvars(opts.get('title'))
2321 prompt = os.path.expandvars(opts.get('prompt'))
2322 if not Interaction.question(title, prompt):
2323 return False
2324 if rev:
2325 compat.setenv('REVISION', rev)
2326 if args:
2327 compat.setenv('ARGS', args)
2328 title = os.path.expandvars(cmd)
2329 Interaction.log(N_('Running command: %s') % title)
2330 cmd = ['sh', '-c', cmd]
2332 if opts.get('background'):
2333 core.fork(cmd)
2334 status, out, err = (0, '', '')
2335 elif opts.get('noconsole'):
2336 status, out, err = core.run_command(cmd)
2337 else:
2338 status, out, err = Interaction.run_command(title, cmd)
2340 if not opts.get('background') and not opts.get('norescan'):
2341 self.model.update_status()
2343 title = N_('Error')
2344 Interaction.command(title, cmd, status, out, err)
2346 return status == 0
2349 class SetDefaultRepo(ContextCommand):
2350 """Set the default repository"""
2352 def __init__(self, context, repo):
2353 super().__init__(context)
2354 self.repo = repo
2356 def do(self):
2357 self.cfg.set_user('cola.defaultrepo', self.repo)
2360 class SetDiffText(EditModel):
2361 """Set the diff text"""
2363 UNDOABLE = True
2365 def __init__(self, context, text):
2366 super().__init__(context)
2367 self.new_diff_text = text
2368 self.new_diff_type = main.Types.TEXT
2369 self.new_file_type = main.Types.TEXT
2372 class SetUpstreamBranch(ContextCommand):
2373 """Set the upstream branch"""
2375 def __init__(self, context, branch, remote, remote_branch):
2376 super().__init__(context)
2377 self.branch = branch
2378 self.remote = remote
2379 self.remote_branch = remote_branch
2381 def do(self):
2382 cfg = self.cfg
2383 remote = self.remote
2384 branch = self.branch
2385 remote_branch = self.remote_branch
2386 cfg.set_repo('branch.%s.remote' % branch, remote)
2387 cfg.set_repo('branch.%s.merge' % branch, 'refs/heads/' + remote_branch)
2390 def format_hex(data):
2391 """Translate binary data into a hex dump"""
2392 hexdigits = '0123456789ABCDEF'
2393 result = ''
2394 offset = 0
2395 byte_offset_to_int = compat.byte_offset_to_int_converter()
2396 while offset < len(data):
2397 result += '%04u |' % offset
2398 textpart = ''
2399 for i in range(0, 16):
2400 if i > 0 and i % 4 == 0:
2401 result += ' '
2402 if offset < len(data):
2403 v = byte_offset_to_int(data[offset])
2404 result += ' ' + hexdigits[v >> 4] + hexdigits[v & 0xF]
2405 textpart += chr(v) if 32 <= v < 127 else '.'
2406 offset += 1
2407 else:
2408 result += ' '
2409 textpart += ' '
2410 result += ' | ' + textpart + ' |\n'
2412 return result
2415 class ShowUntracked(EditModel):
2416 """Show an untracked file."""
2418 def __init__(self, context, filename):
2419 super().__init__(context)
2420 self.new_filename = filename
2421 if gitcmds.is_binary(context, filename):
2422 self.new_mode = self.model.mode_untracked
2423 self.new_diff_text = self.read(filename)
2424 else:
2425 self.new_mode = self.model.mode_untracked_diff
2426 self.new_diff_text = gitcmds.diff_helper(
2427 self.context, filename=filename, cached=False, untracked=True
2429 self.new_diff_type = main.Types.TEXT
2430 self.new_file_type = main.Types.TEXT
2432 def read(self, filename):
2433 """Read file contents"""
2434 cfg = self.cfg
2435 size = cfg.get('cola.readsize', 2048)
2436 try:
2437 result = core.read(filename, size=size, encoding='bytes')
2438 except OSError:
2439 result = ''
2441 truncated = len(result) == size
2443 encoding = cfg.file_encoding(filename) or core.ENCODING
2444 try:
2445 text_result = core.decode_maybe(result, encoding)
2446 except UnicodeError:
2447 text_result = format_hex(result)
2449 if truncated:
2450 text_result += '...'
2451 return text_result
2454 class SignOff(ContextCommand):
2455 """Append a sign-off to the commit message"""
2457 UNDOABLE = True
2459 @staticmethod
2460 def name():
2461 return N_('Sign Off')
2463 def __init__(self, context):
2464 super().__init__(context)
2465 self.old_commitmsg = self.model.commitmsg
2467 def do(self):
2468 """Add a sign-off to the commit message"""
2469 signoff = self.signoff()
2470 if signoff in self.model.commitmsg:
2471 return
2472 msg = self.model.commitmsg.rstrip()
2473 self.model.set_commitmsg(msg + '\n' + signoff)
2475 def undo(self):
2476 """Restore the commit message"""
2477 self.model.set_commitmsg(self.old_commitmsg)
2479 def signoff(self):
2480 """Generate the sign-off string"""
2481 name, email = self.cfg.get_author()
2482 return f'\nSigned-off-by: {name} <{email}>'
2485 def check_conflicts(context, unmerged):
2486 """Check paths for conflicts
2488 Conflicting files can be filtered out one-by-one.
2491 if prefs.check_conflicts(context):
2492 unmerged = [path for path in unmerged if is_conflict_free(path)]
2493 return unmerged
2496 def is_conflict_free(path):
2497 """Return True if `path` contains no conflict markers"""
2498 rgx = re.compile(r'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2499 try:
2500 with core.xopen(path, 'rb') as f:
2501 for line in f:
2502 line = core.decode(line, errors='ignore')
2503 if rgx.match(line):
2504 return should_stage_conflicts(path)
2505 except OSError:
2506 # We can't read this file ~ we may be staging a removal
2507 pass
2508 return True
2511 def should_stage_conflicts(path):
2512 """Inform the user that a file contains merge conflicts
2514 Return `True` if we should stage the path nonetheless.
2517 title = msg = N_('Stage conflicts?')
2518 info = (
2520 '%s appears to contain merge conflicts.\n\n'
2521 'You should probably skip this file.\n'
2522 'Stage it anyways?'
2524 % path
2526 ok_text = N_('Stage conflicts')
2527 cancel_text = N_('Skip')
2528 return Interaction.confirm(
2529 title, msg, info, ok_text, default=False, cancel_text=cancel_text
2533 class Stage(ContextCommand):
2534 """Stage a set of paths."""
2536 @staticmethod
2537 def name():
2538 return N_('Stage')
2540 def __init__(self, context, paths):
2541 super().__init__(context)
2542 self.paths = paths
2544 def do(self):
2545 msg = N_('Staging: %s') % (', '.join(self.paths))
2546 Interaction.log(msg)
2547 return self.stage_paths()
2549 def stage_paths(self):
2550 """Stages add/removals to git."""
2551 context = self.context
2552 paths = self.paths
2553 if not paths:
2554 if self.model.cfg.get('cola.safemode', False):
2555 return (0, '', '')
2556 return self.stage_all()
2558 add = []
2559 remove = []
2560 status = 0
2561 out = ''
2562 err = ''
2564 for path in set(paths):
2565 if core.exists(path) or core.islink(path):
2566 if path.endswith('/'):
2567 path = path.rstrip('/')
2568 add.append(path)
2569 else:
2570 remove.append(path)
2572 self.model.emit_about_to_update()
2574 # `git add -u` doesn't work on untracked files
2575 if add:
2576 status, out, err = gitcmds.add(context, add)
2577 Interaction.command(N_('Error'), 'git add', status, out, err)
2579 # If a path doesn't exist then that means it should be removed
2580 # from the index. We use `git add -u` for that.
2581 if remove:
2582 status, out, err = gitcmds.add(context, remove, u=True)
2583 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2585 self.model.update_files(emit=True)
2586 return status, out, err
2588 def stage_all(self):
2589 """Stage all files"""
2590 status, out, err = self.git.add(v=True, u=True)
2591 Interaction.command(N_('Error'), 'git add -u', status, out, err)
2592 self.model.update_file_status()
2593 return (status, out, err)
2596 class StageCarefully(Stage):
2597 """Only stage when the path list is non-empty
2599 We use "git add -u -- <pathspec>" to stage, and it stages everything by
2600 default when no pathspec is specified, so this class ensures that paths
2601 are specified before calling git.
2603 When no paths are specified, the command does nothing.
2607 def __init__(self, context):
2608 super().__init__(context, None)
2609 self.init_paths()
2611 def init_paths(self):
2612 """Initialize path data"""
2613 return
2615 def ok_to_run(self):
2616 """Prevent catch-all "git add -u" from adding unmerged files"""
2617 return self.paths or not self.model.unmerged
2619 def do(self):
2620 """Stage files when ok_to_run() return True"""
2621 if self.ok_to_run():
2622 return super().do()
2623 return (0, '', '')
2626 class StageModified(StageCarefully):
2627 """Stage all modified files."""
2629 @staticmethod
2630 def name():
2631 return N_('Stage Modified')
2633 def init_paths(self):
2634 self.paths = self.model.modified
2637 class StageUnmerged(StageCarefully):
2638 """Stage unmerged files."""
2640 @staticmethod
2641 def name():
2642 return N_('Stage Unmerged')
2644 def init_paths(self):
2645 self.paths = check_conflicts(self.context, self.model.unmerged)
2648 class StageUntracked(StageCarefully):
2649 """Stage all untracked files."""
2651 @staticmethod
2652 def name():
2653 return N_('Stage Untracked')
2655 def init_paths(self):
2656 self.paths = self.model.untracked
2658 def stage_all(self):
2659 """Disable the stage_all() behavior for untracked files"""
2660 return (0, '', '')
2663 class StageModifiedAndUntracked(StageCarefully):
2664 """Stage all untracked files."""
2666 @staticmethod
2667 def name():
2668 return N_('Stage Modified and Untracked')
2670 def init_paths(self):
2671 self.paths = self.model.modified + self.model.untracked
2674 class StageOrUnstageAll(ContextCommand):
2675 """If the selection is staged, unstage it, otherwise stage"""
2677 @staticmethod
2678 def name():
2679 return N_('Stage / Unstage All')
2681 def do(self):
2682 if self.model.staged:
2683 do(Unstage, self.context, self.model.staged)
2684 else:
2685 if self.cfg.get('cola.safemode', False):
2686 unstaged = self.model.modified
2687 else:
2688 unstaged = self.model.modified + self.model.untracked
2689 do(Stage, self.context, unstaged)
2692 class StageOrUnstage(ContextCommand):
2693 """If the selection is staged, unstage it, otherwise stage"""
2695 @staticmethod
2696 def name():
2697 return N_('Stage / Unstage')
2699 def do(self):
2700 s = self.selection.selection()
2701 if s.staged:
2702 do(Unstage, self.context, s.staged)
2704 unstaged = []
2705 unmerged = check_conflicts(self.context, s.unmerged)
2706 if unmerged:
2707 unstaged.extend(unmerged)
2708 if s.modified:
2709 unstaged.extend(s.modified)
2710 if s.untracked:
2711 unstaged.extend(s.untracked)
2712 if unstaged:
2713 do(Stage, self.context, unstaged)
2716 class Tag(ContextCommand):
2717 """Create a tag object."""
2719 def __init__(self, context, name, revision, sign=False, message=''):
2720 super().__init__(context)
2721 self._name = name
2722 self._message = message
2723 self._revision = revision
2724 self._sign = sign
2726 def do(self):
2727 result = False
2728 git = self.git
2729 revision = self._revision
2730 tag_name = self._name
2731 tag_message = self._message
2733 if not revision:
2734 Interaction.critical(
2735 N_('Missing Revision'), N_('Please specify a revision to tag.')
2737 return result
2739 if not tag_name:
2740 Interaction.critical(
2741 N_('Missing Name'), N_('Please specify a name for the new tag.')
2743 return result
2745 title = N_('Missing Tag Message')
2746 message = N_('Tag-signing was requested but the tag message is empty.')
2747 info = N_(
2748 'An unsigned, lightweight tag will be created instead.\n'
2749 'Create an unsigned tag?'
2751 ok_text = N_('Create Unsigned Tag')
2752 sign = self._sign
2753 if sign and not tag_message:
2754 # We require a message in order to sign the tag, so if they
2755 # choose to create an unsigned tag we have to clear the sign flag.
2756 if not Interaction.confirm(
2757 title, message, info, ok_text, default=False, icon=icons.save()
2759 return result
2760 sign = False
2762 opts = {}
2763 tmp_file = None
2764 try:
2765 if tag_message:
2766 tmp_file = utils.tmp_filename('tag-message')
2767 opts['file'] = tmp_file
2768 core.write(tmp_file, tag_message)
2770 if sign:
2771 opts['sign'] = True
2772 if tag_message:
2773 opts['annotate'] = True
2774 status, out, err = git.tag(tag_name, revision, **opts)
2775 finally:
2776 if tmp_file:
2777 core.unlink(tmp_file)
2779 title = N_('Error: could not create tag "%s"') % tag_name
2780 Interaction.command(title, 'git tag', status, out, err)
2782 if status == 0:
2783 result = True
2784 self.model.update_status()
2785 Interaction.information(
2786 N_('Tag Created'),
2787 N_('Created a new tag named "%s"') % tag_name,
2788 details=tag_message or None,
2791 return result
2794 class Unstage(ContextCommand):
2795 """Unstage a set of paths."""
2797 @staticmethod
2798 def name():
2799 return N_('Unstage')
2801 def __init__(self, context, paths):
2802 super().__init__(context)
2803 self.paths = paths
2805 def do(self):
2806 """Unstage paths"""
2807 context = self.context
2808 head = self.model.head
2809 paths = self.paths
2811 msg = N_('Unstaging: %s') % (', '.join(paths))
2812 Interaction.log(msg)
2813 if not paths:
2814 return unstage_all(context)
2815 status, out, err = gitcmds.unstage_paths(context, paths, head=head)
2816 Interaction.command(N_('Error'), 'git reset', status, out, err)
2817 self.model.update_file_status()
2818 return (status, out, err)
2821 class UnstageAll(ContextCommand):
2822 """Unstage all files; resets the index."""
2824 def do(self):
2825 return unstage_all(self.context)
2828 def unstage_all(context):
2829 """Unstage all files, even while amending"""
2830 model = context.model
2831 git = context.git
2832 head = model.head
2833 status, out, err = git.reset(head, '--', '.')
2834 Interaction.command(N_('Error'), 'git reset', status, out, err)
2835 model.update_file_status()
2836 return (status, out, err)
2839 class StageSelected(ContextCommand):
2840 """Stage selected files, or all files if no selection exists."""
2842 def do(self):
2843 context = self.context
2844 paths = self.selection.unstaged
2845 if paths:
2846 do(Stage, context, paths)
2847 elif self.cfg.get('cola.safemode', False):
2848 do(StageModified, context)
2851 class UnstageSelected(Unstage):
2852 """Unstage selected files."""
2854 def __init__(self, context):
2855 staged = context.selection.staged
2856 super().__init__(context, staged)
2859 class Untrack(ContextCommand):
2860 """Unstage a set of paths."""
2862 def __init__(self, context, paths):
2863 super().__init__(context)
2864 self.paths = paths
2866 def do(self):
2867 msg = N_('Untracking: %s') % (', '.join(self.paths))
2868 Interaction.log(msg)
2869 status, out, err = self.model.untrack_paths(self.paths)
2870 Interaction.log_status(status, out, err)
2873 class UnmergedSummary(EditModel):
2874 """List unmerged files in the diff text."""
2876 def __init__(self, context):
2877 super().__init__(context)
2878 unmerged = self.model.unmerged
2879 io = StringIO()
2880 io.write('# %s unmerged file(s)\n' % len(unmerged))
2881 if unmerged:
2882 io.write('\n'.join(unmerged) + '\n')
2883 self.new_diff_text = io.getvalue()
2884 self.new_diff_type = main.Types.TEXT
2885 self.new_file_type = main.Types.TEXT
2886 self.new_mode = self.model.mode_display
2889 class UntrackedSummary(EditModel):
2890 """List possible .gitignore rules as the diff text."""
2892 def __init__(self, context):
2893 super().__init__(context)
2894 untracked = self.model.untracked
2895 io = StringIO()
2896 io.write('# %s untracked file(s)\n' % len(untracked))
2897 if untracked:
2898 io.write('# Add these lines to ".gitignore" to ignore these files:\n')
2899 io.write('\n'.join('/' + filename for filename in untracked) + '\n')
2900 self.new_diff_text = io.getvalue()
2901 self.new_diff_type = main.Types.TEXT
2902 self.new_file_type = main.Types.TEXT
2903 self.new_mode = self.model.mode_display
2906 class VisualizeAll(ContextCommand):
2907 """Visualize all branches."""
2909 def do(self):
2910 context = self.context
2911 browser = utils.shell_split(prefs.history_browser(context))
2912 launch_history_browser(browser + ['--all'])
2915 class VisualizeCurrent(ContextCommand):
2916 """Visualize all branches."""
2918 def do(self):
2919 context = self.context
2920 browser = utils.shell_split(prefs.history_browser(context))
2921 launch_history_browser(browser + [self.model.currentbranch] + ['--'])
2924 class VisualizePaths(ContextCommand):
2925 """Path-limited visualization."""
2927 def __init__(self, context, paths):
2928 super().__init__(context)
2929 context = self.context
2930 browser = utils.shell_split(prefs.history_browser(context))
2931 if paths:
2932 self.argv = browser + ['--'] + list(paths)
2933 else:
2934 self.argv = browser
2936 def do(self):
2937 launch_history_browser(self.argv)
2940 class VisualizeRevision(ContextCommand):
2941 """Visualize a specific revision."""
2943 def __init__(self, context, revision, paths=None):
2944 super().__init__(context)
2945 self.revision = revision
2946 self.paths = paths
2948 def do(self):
2949 context = self.context
2950 argv = utils.shell_split(prefs.history_browser(context))
2951 if self.revision:
2952 argv.append(self.revision)
2953 if self.paths:
2954 argv.append('--')
2955 argv.extend(self.paths)
2956 launch_history_browser(argv)
2959 class SubmoduleAdd(ConfirmAction):
2960 """Add specified submodules"""
2962 def __init__(self, context, url, path, branch, depth, reference):
2963 super().__init__(context)
2964 self.url = url
2965 self.path = path
2966 self.branch = branch
2967 self.depth = depth
2968 self.reference = reference
2970 def confirm(self):
2971 title = N_('Add Submodule...')
2972 question = N_('Add this submodule?')
2973 info = N_('The submodule will be added using\n' '"%s"' % self.command())
2974 ok_txt = N_('Add Submodule')
2975 return Interaction.confirm(title, question, info, ok_txt, icon=icons.ok())
2977 def action(self):
2978 context = self.context
2979 args = self.get_args()
2980 return context.git.submodule('add', *args)
2982 def success(self):
2983 self.model.update_file_status()
2984 self.model.update_submodules_list()
2986 def error_message(self):
2987 return N_('Error updating submodule %s' % self.path)
2989 def command(self):
2990 cmd = ['git', 'submodule', 'add']
2991 cmd.extend(self.get_args())
2992 return core.list2cmdline(cmd)
2994 def get_args(self):
2995 args = []
2996 if self.branch:
2997 args.extend(['--branch', self.branch])
2998 if self.reference:
2999 args.extend(['--reference', self.reference])
3000 if self.depth:
3001 args.extend(['--depth', '%d' % self.depth])
3002 args.extend(['--', self.url])
3003 if self.path:
3004 args.append(self.path)
3005 return args
3008 class SubmoduleUpdate(ConfirmAction):
3009 """Update specified submodule"""
3011 def __init__(self, context, path):
3012 super().__init__(context)
3013 self.path = path
3015 def confirm(self):
3016 title = N_('Update Submodule...')
3017 question = N_('Update this submodule?')
3018 info = N_('The submodule will be updated using\n' '"%s"' % self.command())
3019 ok_txt = N_('Update Submodule')
3020 return Interaction.confirm(
3021 title, question, info, ok_txt, default=False, icon=icons.pull()
3024 def action(self):
3025 context = self.context
3026 args = self.get_args()
3027 return context.git.submodule(*args)
3029 def success(self):
3030 self.model.update_file_status()
3032 def error_message(self):
3033 return N_('Error updating submodule %s' % self.path)
3035 def command(self):
3036 cmd = ['git', 'submodule']
3037 cmd.extend(self.get_args())
3038 return core.list2cmdline(cmd)
3040 def get_args(self):
3041 cmd = ['update']
3042 if version.check_git(self.context, 'submodule-update-recursive'):
3043 cmd.append('--recursive')
3044 cmd.extend(['--', self.path])
3045 return cmd
3048 class SubmodulesUpdate(ConfirmAction):
3049 """Update all submodules"""
3051 def confirm(self):
3052 title = N_('Update submodules...')
3053 question = N_('Update all submodules?')
3054 info = N_('All submodules will be updated using\n' '"%s"' % self.command())
3055 ok_txt = N_('Update Submodules')
3056 return Interaction.confirm(
3057 title, question, info, ok_txt, default=False, icon=icons.pull()
3060 def action(self):
3061 context = self.context
3062 args = self.get_args()
3063 return context.git.submodule(*args)
3065 def success(self):
3066 self.model.update_file_status()
3068 def error_message(self):
3069 return N_('Error updating submodules')
3071 def command(self):
3072 cmd = ['git', 'submodule']
3073 cmd.extend(self.get_args())
3074 return core.list2cmdline(cmd)
3076 def get_args(self):
3077 cmd = ['update']
3078 if version.check_git(self.context, 'submodule-update-recursive'):
3079 cmd.append('--recursive')
3080 return cmd
3083 def launch_history_browser(argv):
3084 """Launch the configured history browser"""
3085 try:
3086 core.fork(argv)
3087 except OSError as e:
3088 _, details = utils.format_exception(e)
3089 title = N_('Error Launching History Browser')
3090 msg = N_('Cannot exec "%s": please configure a history browser') % ' '.join(
3091 argv
3093 Interaction.critical(title, message=msg, details=details)
3096 def run(cls, *args, **opts):
3098 Returns a callback that runs a command
3100 If the caller of run() provides args or opts then those are
3101 used instead of the ones provided by the invoker of the callback.
3105 def runner(*local_args, **local_opts):
3106 """Closure return by run() which runs the command"""
3107 if args or opts:
3108 return do(cls, *args, **opts)
3109 return do(cls, *local_args, **local_opts)
3111 return runner
3114 def do(cls, *args, **opts):
3115 """Run a command in-place"""
3116 try:
3117 cmd = cls(*args, **opts)
3118 return cmd.do()
3119 except Exception as e:
3120 msg, details = utils.format_exception(e)
3121 if hasattr(cls, '__name__'):
3122 msg = f'{cls.__name__} exception:\n{msg}'
3123 Interaction.critical(N_('Error'), message=msg, details=details)
3124 return None