2 # pylint: disable=too-many-lines
6 from fnmatch
import fnmatch
7 from io
import StringIO
10 from send2trash
import send2trash
18 from . import resources
19 from . import textwrap
22 from .cmd
import ContextCommand
23 from .git
import STDOUT
24 from .git
import MISSING_BLOB_OID
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
)
40 class EditModel(ContextCommand
):
41 """Commands that mutate the main model diff data"""
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
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
)
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"""
82 """Return True when the command is okay to run"""
86 """Prompt for confirmation"""
90 """Run the command and return (status, out, err)"""
94 """Callback run on success"""
98 """Command name, for error messages"""
101 def error_message(self
):
102 """Command error message"""
106 """Prompt for confirmation before running a command"""
109 ok
= self
.ok_to_run() and self
.confirm()
111 status
, out
, err
= self
.action()
114 title
= self
.error_message()
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"""
125 title
= N_('Abort Applying Patch...')
126 question
= N_('Aborting applying the current patch?')
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()
137 status
, out
, err
= gitcmds
.abort_apply_patch(self
.context
)
138 self
.model
.update_file_merge_status()
139 return status
, out
, err
142 self
.model
.set_commitmsg('')
144 def error_message(self
):
148 return 'git am --abort'
151 class AbortCherryPick(ConfirmAction
):
152 """Reset an in-progress cherry-pick"""
155 title
= N_('Abort Cherry-Pick...')
156 question
= N_('Aborting the current cherry-pick?')
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()
167 status
, out
, err
= gitcmds
.abort_cherry_pick(self
.context
)
168 self
.model
.update_file_merge_status()
169 return status
, out
, err
172 self
.model
.set_commitmsg('')
174 def error_message(self
):
178 return 'git cherry-pick --abort'
181 class AbortMerge(ConfirmAction
):
182 """Reset an in-progress merge back to HEAD"""
185 title
= N_('Abort Merge...')
186 question
= N_('Aborting the current merge?')
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()
198 status
, out
, err
= gitcmds
.abort_merge(self
.context
)
199 self
.model
.update_file_merge_status()
200 return status
, out
, err
203 self
.model
.set_commitmsg('')
205 def error_message(self
):
212 class AmendMode(EditModel
):
213 """Try to amend a commit."""
222 def __init__(self
, context
, amend
=True):
223 super().__init
__(context
)
225 self
.amending
= amend
226 self
.old_commitmsg
= self
.model
.commitmsg
227 self
.old_mode
= self
.model
.mode
230 self
.new_mode
= self
.model
.mode_amend
231 self
.new_commitmsg
= gitcmds
.prev_commitmsg(context
)
232 AmendMode
.LAST_MESSAGE
= self
.model
.commitmsg
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
246 """Leave/enter amend mode."""
247 # Attempt to enter amend mode. Do not allow this when merging.
249 if self
.model
.is_merging
:
251 self
.model
.set_mode(self
.old_mode
)
252 Interaction
.information(
255 'You are in the middle of a merge.\n'
256 'Cannot amend while merging.'
262 self
.model
.set_commitmsg(self
.new_commitmsg
)
263 self
.model
.update_file_status()
264 self
.context
.selection
.reset(emit
=True)
269 self
.model
.set_commitmsg(self
.old_commitmsg
)
271 self
.model
.update_file_status()
272 self
.context
.selection
.reset(emit
=True)
275 class AnnexAdd(ContextCommand
):
276 """Add to Git Annex"""
278 def __init__(self
, context
):
279 super().__init
__(context
)
280 self
.filename
= self
.selection
.filename()
283 status
, out
, err
= self
.git
.annex('add', self
.filename
)
284 Interaction
.command(N_('Error'), 'git annex add', status
, out
, err
)
285 self
.model
.update_status()
288 class AnnexInit(ContextCommand
):
289 """Initialize Git Annex"""
292 status
, out
, err
= self
.git
.annex('init')
293 Interaction
.command(N_('Error'), 'git annex init', status
, out
, err
)
294 self
.model
.cfg
.reset()
295 self
.model
.emit_updated()
298 class LFSTrack(ContextCommand
):
299 """Add a file to git lfs"""
301 def __init__(self
, context
):
302 super().__init
__(context
)
303 self
.filename
= self
.selection
.filename()
304 self
.stage_cmd
= Stage(context
, [self
.filename
])
307 status
, out
, err
= self
.git
.lfs('track', self
.filename
)
308 Interaction
.command(N_('Error'), 'git lfs track', status
, out
, err
)
313 class LFSInstall(ContextCommand
):
314 """Initialize git lfs"""
317 status
, out
, err
= self
.git
.lfs('install')
318 Interaction
.command(N_('Error'), 'git lfs install', status
, out
, err
)
319 self
.model
.update_config(reset
=True, emit
=True)
322 class ApplyPatch(ContextCommand
):
323 """Apply the specified patch to the worktree or index"""
332 super().__init
__(context
)
334 self
.encoding
= encoding
335 self
.apply_to_worktree
= apply_to_worktree
338 context
= self
.context
340 tmp_file
= utils
.tmp_filename('apply', suffix
='.patch')
342 core
.write(tmp_file
, self
.patch
.as_text(), encoding
=self
.encoding
)
343 if self
.apply_to_worktree
:
344 status
, out
, err
= gitcmds
.apply_diff_to_worktree(context
, tmp_file
)
346 status
, out
, err
= gitcmds
.apply_diff(context
, tmp_file
)
348 core
.unlink(tmp_file
)
350 Interaction
.log_status(status
, out
, err
)
351 self
.model
.update_file_status(update_index
=True)
354 class ApplyPatches(ContextCommand
):
355 """Apply patches using the "git am" command"""
357 def __init__(self
, context
, patches
):
358 super().__init
__(context
)
359 self
.patches
= patches
362 status
, output
, err
= self
.git
.am('-3', *self
.patches
)
363 out
= f
'# git am -3 {core.list2cmdline(self.patches)}\n\n{output}'
364 Interaction
.command(N_('Patch failed to apply'), 'git am -3', status
, out
, err
)
366 self
.model
.update_file_status()
368 patch_basenames
= [os
.path
.basename(p
) for p
in self
.patches
]
369 if len(patch_basenames
) > 25:
370 patch_basenames
= patch_basenames
[:25]
371 patch_basenames
.append('...')
373 basenames
= '\n'.join(patch_basenames
)
375 Interaction
.information(
376 N_('Patch(es) Applied'),
377 (N_('%d patch(es) applied.') + '\n\n%s')
378 % (len(self
.patches
), basenames
),
382 class ApplyPatchesContinue(ContextCommand
):
383 """Run "git am --continue" to continue on the next patch in a "git am" session"""
386 status
, out
, err
= self
.git
.am('--continue')
388 N_('Failed to commit and continue applying patches'),
394 self
.model
.update_status()
395 return status
, out
, err
398 class ApplyPatchesSkip(ContextCommand
):
399 """Run "git am --skip" to continue on the next patch in a "git am" session"""
402 status
, out
, err
= self
.git
.am(skip
=True)
404 N_('Failed to continue applying patches after skipping the current patch'),
410 self
.model
.update_status()
411 return status
, out
, err
414 class Archive(ContextCommand
):
415 """ "Export archives using the "git archive" command"""
417 def __init__(self
, context
, ref
, fmt
, prefix
, filename
):
418 super().__init
__(context
)
422 self
.filename
= filename
425 fp
= core
.xopen(self
.filename
, 'wb')
426 cmd
= ['git', 'archive', '--format=' + self
.fmt
]
427 if self
.fmt
in ('tgz', 'tar.gz'):
430 cmd
.append('--prefix=' + self
.prefix
)
432 proc
= core
.start_command(cmd
, stdout
=fp
)
433 out
, err
= proc
.communicate()
435 status
= proc
.returncode
436 Interaction
.log_status(status
, out
or '', err
or '')
439 class Checkout(EditModel
):
440 """A command object for git-checkout.
442 The argv list is forwarded directly to git.
445 def __init__(self
, context
, argv
, checkout_branch
=False):
446 super().__init
__(context
)
448 self
.checkout_branch
= checkout_branch
449 self
.new_diff_text
= ''
450 self
.new_diff_type
= main
.Types
.TEXT
451 self
.new_file_type
= main
.Types
.TEXT
455 status
, out
, err
= self
.git
.checkout(*self
.argv
)
456 if self
.checkout_branch
:
457 self
.model
.update_status()
459 self
.model
.update_file_status()
460 Interaction
.command(N_('Error'), 'git checkout', status
, out
, err
)
461 return status
, out
, err
464 class CheckoutTheirs(ConfirmAction
):
465 """Checkout "their" version of a file when performing a merge"""
469 return N_('Checkout files from their branch (MERGE_HEAD)')
473 question
= N_('Checkout files from their branch?')
475 'This operation will replace the selected unmerged files with content '
476 'from the branch being merged using "git checkout --theirs".\n'
477 '*ALL* uncommitted changes will be lost.\n'
478 'Recovering uncommitted changes is not possible.'
480 ok_txt
= N_('Checkout Files')
481 return Interaction
.confirm(
482 title
, question
, info
, ok_txt
, default
=True, icon
=icons
.merge()
486 selection
= self
.selection
.selection()
487 paths
= selection
.unmerged
491 argv
= ['--theirs', '--'] + paths
492 cmd
= Checkout(self
.context
, argv
)
495 def error_message(self
):
499 return 'git checkout --theirs'
502 class CheckoutOurs(ConfirmAction
):
503 """Checkout "our" version of a file when performing a merge"""
507 return N_('Checkout files from our branch (HEAD)')
511 question
= N_('Checkout files from our branch?')
513 'This operation will replace the selected unmerged files with content '
514 'from your current branch using "git checkout --ours".\n'
515 '*ALL* uncommitted changes will be lost.\n'
516 'Recovering uncommitted changes is not possible.'
518 ok_txt
= N_('Checkout Files')
519 return Interaction
.confirm(
520 title
, question
, info
, ok_txt
, default
=True, icon
=icons
.merge()
524 selection
= self
.selection
.selection()
525 paths
= selection
.unmerged
529 argv
= ['--ours', '--'] + paths
530 cmd
= Checkout(self
.context
, argv
)
533 def error_message(self
):
537 return 'git checkout --ours'
540 class BlamePaths(ContextCommand
):
541 """Blame view for paths."""
545 return N_('Blame...')
547 def __init__(self
, context
, paths
=None):
548 super().__init
__(context
)
550 paths
= context
.selection
.union()
551 viewer
= utils
.shell_split(prefs
.blame_viewer(context
))
552 self
.argv
= viewer
+ list(paths
)
558 _
, details
= utils
.format_exception(e
)
559 title
= N_('Error Launching Blame Viewer')
560 msg
= N_('Cannot exec "%s": please configure a blame viewer') % ' '.join(
563 Interaction
.critical(title
, message
=msg
, details
=details
)
566 class CheckoutBranch(Checkout
):
567 """Checkout a branch."""
569 def __init__(self
, context
, branch
):
571 super().__init
__(context
, args
, checkout_branch
=True)
574 class CherryPick(ContextCommand
):
575 """Cherry pick commits into the current branch."""
577 def __init__(self
, context
, commits
):
578 super().__init
__(context
)
579 self
.commits
= commits
582 status
, out
, err
= gitcmds
.cherry_pick(self
.context
, self
.commits
)
583 self
.model
.update_file_merge_status()
584 title
= N_('Cherry-pick failed')
585 Interaction
.command(title
, 'git cherry-pick', status
, out
, err
)
588 class Revert(ContextCommand
):
589 """Revert a commit"""
591 def __init__(self
, context
, oid
):
592 super().__init
__(context
)
596 status
, out
, err
= self
.git
.revert(self
.oid
, no_edit
=True)
597 self
.model
.update_file_status()
598 title
= N_('Revert failed')
599 out
= '# git revert %s\n\n' % self
.oid
600 Interaction
.command(title
, 'git revert', status
, out
, err
)
603 class ResetMode(EditModel
):
604 """Reset the mode and clear the model's diff text."""
606 def __init__(self
, context
):
607 super().__init
__(context
)
608 self
.new_mode
= self
.model
.mode_none
609 self
.new_diff_text
= ''
610 self
.new_diff_type
= main
.Types
.TEXT
611 self
.new_file_type
= main
.Types
.TEXT
612 self
.new_filename
= ''
616 self
.model
.update_file_status()
617 self
.context
.selection
.reset(emit
=True)
620 class ResetCommand(ConfirmAction
):
621 """Reset state using the "git reset" command"""
623 def __init__(self
, context
, ref
):
624 super().__init
__(context
)
633 def error_message(self
):
637 self
.model
.update_file_status()
640 raise NotImplementedError('confirm() must be overridden')
643 raise NotImplementedError('reset() must be overridden')
646 class ResetMixed(ResetCommand
):
649 tooltip
= N_('The branch will be reset using "git reset --mixed %s"')
653 title
= N_('Reset Branch and Stage (Mixed)')
654 question
= N_('Point the current branch head to a new commit?')
655 info
= self
.tooltip(self
.ref
)
656 ok_text
= N_('Reset Branch')
657 return Interaction
.confirm(title
, question
, info
, ok_text
)
660 return self
.git
.reset(self
.ref
, '--', mixed
=True)
663 class ResetKeep(ResetCommand
):
666 tooltip
= N_('The repository will be reset using "git reset --keep %s"')
670 title
= N_('Restore Worktree and Reset All (Keep Unstaged Changes)')
671 question
= N_('Restore worktree, reset, and preserve unstaged edits?')
672 info
= self
.tooltip(self
.ref
)
673 ok_text
= N_('Reset and Restore')
674 return Interaction
.confirm(title
, question
, info
, ok_text
)
677 return self
.git
.reset(self
.ref
, '--', keep
=True)
680 class ResetMerge(ResetCommand
):
683 tooltip
= N_('The repository will be reset using "git reset --merge %s"')
687 title
= N_('Restore Worktree and Reset All (Merge)')
688 question
= N_('Reset Worktree and Reset All?')
689 info
= self
.tooltip(self
.ref
)
690 ok_text
= N_('Reset and Restore')
691 return Interaction
.confirm(title
, question
, info
, ok_text
)
694 return self
.git
.reset(self
.ref
, '--', merge
=True)
697 class ResetSoft(ResetCommand
):
700 tooltip
= N_('The branch will be reset using "git reset --soft %s"')
704 title
= N_('Reset Branch (Soft)')
705 question
= N_('Reset branch?')
706 info
= self
.tooltip(self
.ref
)
707 ok_text
= N_('Reset Branch')
708 return Interaction
.confirm(title
, question
, info
, ok_text
)
711 return self
.git
.reset(self
.ref
, '--', soft
=True)
714 class ResetHard(ResetCommand
):
717 tooltip
= N_('The repository will be reset using "git reset --hard %s"')
721 title
= N_('Restore Worktree and Reset All (Hard)')
722 question
= N_('Restore Worktree and Reset All?')
723 info
= self
.tooltip(self
.ref
)
724 ok_text
= N_('Reset and Restore')
725 return Interaction
.confirm(title
, question
, info
, ok_text
)
728 return self
.git
.reset(self
.ref
, '--', hard
=True)
731 class RestoreWorktree(ConfirmAction
):
732 """Reset the worktree using the "git read-tree" command"""
737 'The worktree will be restored using "git read-tree --reset -u %s"'
741 def __init__(self
, context
, ref
):
742 super().__init
__(context
)
746 return self
.git
.read_tree(self
.ref
, reset
=True, u
=True)
749 return 'git read-tree --reset -u %s' % self
.ref
751 def error_message(self
):
755 self
.model
.update_file_status()
758 title
= N_('Restore Worktree')
759 question
= N_('Restore Worktree to %s?') % self
.ref
760 info
= self
.tooltip(self
.ref
)
761 ok_text
= N_('Restore Worktree')
762 return Interaction
.confirm(title
, question
, info
, ok_text
)
765 class UndoLastCommit(ResetCommand
):
766 """Undo the last commit"""
768 # NOTE: this is the similar to ResetSoft() with an additional check for
769 # published commits and different messages.
770 def __init__(self
, context
):
771 super().__init
__(context
, 'HEAD^')
774 check_published
= prefs
.check_published_commits(self
.context
)
775 if check_published
and self
.model
.is_commit_published():
776 return Interaction
.confirm(
777 N_('Rewrite Published Commit?'),
779 'This commit has already been published.\n'
780 'This operation will rewrite published history.\n'
781 "You probably don't want to do this."
783 N_('Undo the published commit?'),
784 N_('Undo Last Commit'),
789 title
= N_('Undo Last Commit')
790 question
= N_('Undo last commit?')
791 info
= N_('The branch will be reset using "git reset --soft %s"')
792 ok_text
= N_('Undo Last Commit')
793 info_text
= info
% self
.ref
794 return Interaction
.confirm(title
, question
, info_text
, ok_text
)
797 return self
.git
.reset('HEAD^', '--', soft
=True)
800 class Commit(ResetMode
):
801 """Attempt to create a new commit."""
803 def __init__(self
, context
, amend
, msg
, sign
, no_verify
=False):
804 super().__init
__(context
)
808 self
.no_verify
= no_verify
809 self
.old_commitmsg
= self
.model
.commitmsg
810 self
.new_commitmsg
= ''
813 # Create the commit message file
814 context
= self
.context
816 tmp_file
= utils
.tmp_filename('commit-message')
818 core
.write(tmp_file
, msg
)
820 status
, out
, err
= self
.git
.commit(
825 no_verify
=self
.no_verify
,
828 core
.unlink(tmp_file
)
831 if context
.cfg
.get(prefs
.AUTOTEMPLATE
):
832 template_loader
= LoadCommitMessageFromTemplate(context
)
835 self
.model
.set_commitmsg(self
.new_commitmsg
)
837 return status
, out
, err
840 def strip_comments(msg
, comment_char
='#'):
843 line
for line
in msg
.split('\n') if not line
.startswith(comment_char
)
845 msg
= '\n'.join(message_lines
)
846 if not msg
.endswith('\n'):
852 class CycleReferenceSort(ContextCommand
):
853 """Choose the next reference sort type"""
856 self
.model
.cycle_ref_sort()
859 class Ignore(ContextCommand
):
860 """Add files to an exclusion file"""
862 def __init__(self
, context
, filenames
, local
=False):
863 super().__init
__(context
)
864 self
.filenames
= list(filenames
)
868 if not self
.filenames
:
870 new_additions
= '\n'.join(self
.filenames
) + '\n'
871 for_status
= new_additions
873 filename
= os
.path
.join('.git', 'info', 'exclude')
875 filename
= '.gitignore'
876 if core
.exists(filename
):
877 current_list
= core
.read(filename
)
878 new_additions
= current_list
.rstrip() + '\n' + new_additions
879 core
.write(filename
, new_additions
)
880 Interaction
.log_status(0, f
'Added to {filename}:\n{for_status}', '')
881 self
.model
.update_file_status()
884 def file_summary(files
):
885 txt
= core
.list2cmdline(files
)
887 txt
= txt
[:768].rstrip() + '...'
888 wrap
= textwrap
.TextWrapper()
889 return '\n'.join(wrap
.wrap(txt
))
892 class RemoteCommand(ConfirmAction
):
893 def __init__(self
, context
, remote
):
894 super().__init
__(context
)
899 self
.model
.update_remotes()
902 class RemoteAdd(RemoteCommand
):
903 def __init__(self
, context
, remote
, url
):
904 super().__init
__(context
, remote
)
908 return self
.git
.remote('add', self
.remote
, self
.url
)
910 def error_message(self
):
911 return N_('Error creating remote "%s"') % self
.remote
914 return f
'git remote add "{self.remote}" "{self.url}"'
917 class RemoteRemove(RemoteCommand
):
919 title
= N_('Delete Remote')
920 question
= N_('Delete remote?')
921 info
= N_('Delete remote "%s"') % self
.remote
922 ok_text
= N_('Delete')
923 return Interaction
.confirm(title
, question
, info
, ok_text
)
926 return self
.git
.remote('rm', self
.remote
)
928 def error_message(self
):
929 return N_('Error deleting remote "%s"') % self
.remote
932 return 'git remote rm "%s"' % self
.remote
935 class RemoteRename(RemoteCommand
):
936 def __init__(self
, context
, remote
, new_name
):
937 super().__init
__(context
, remote
)
938 self
.new_name
= new_name
941 title
= N_('Rename Remote')
942 text
= N_('Rename remote "%(current)s" to "%(new)s"?') % {
943 'current': self
.remote
,
944 'new': self
.new_name
,
948 return Interaction
.confirm(title
, text
, info_text
, ok_text
)
951 return self
.git
.remote('rename', self
.remote
, self
.new_name
)
953 def error_message(self
):
954 return N_('Error renaming "%(name)s" to "%(new_name)s"') % {
956 'new_name': self
.new_name
,
960 return f
'git remote rename "{self.remote}" "{self.new_name}"'
963 class RemoteSetURL(RemoteCommand
):
964 def __init__(self
, context
, remote
, url
):
965 super().__init
__(context
, remote
)
969 return self
.git
.remote('set-url', self
.remote
, self
.url
)
971 def error_message(self
):
972 return N_('Unable to set URL for "%(name)s" to "%(url)s"') % {
978 return f
'git remote set-url "{self.remote}" "{self.url}"'
981 class RemoteEdit(ContextCommand
):
982 """Combine RemoteRename and RemoteSetURL"""
984 def __init__(self
, context
, old_name
, remote
, url
):
985 super().__init
__(context
)
986 self
.rename
= RemoteRename(context
, old_name
, remote
)
987 self
.set_url
= RemoteSetURL(context
, remote
, url
)
990 result
= self
.rename
.do()
994 result
= self
.set_url
.do()
996 return name_ok
, url_ok
999 class RemoveFromSettings(ConfirmAction
):
1000 def __init__(self
, context
, repo
, entry
, icon
=None):
1001 super().__init
__(context
)
1002 self
.context
= context
1008 self
.context
.settings
.save()
1011 class RemoveBookmark(RemoveFromSettings
):
1014 title
= msg
= N_('Delete Bookmark?')
1015 info
= N_('%s will be removed from your bookmarks.') % entry
1016 ok_text
= N_('Delete Bookmark')
1017 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
1020 self
.context
.settings
.remove_bookmark(self
.repo
, self
.entry
)
1024 class RemoveRecent(RemoveFromSettings
):
1027 title
= msg
= N_('Remove %s from the recent list?') % repo
1028 info
= N_('%s will be removed from your recent repositories.') % repo
1029 ok_text
= N_('Remove')
1030 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
1033 self
.context
.settings
.remove_recent(self
.repo
)
1037 class RemoveFiles(ContextCommand
):
1040 def __init__(self
, context
, remover
, filenames
):
1041 super().__init
__(context
)
1044 self
.remover
= remover
1045 self
.filenames
= filenames
1046 # We could git-hash-object stuff and provide undo-ability
1047 # as an option. Heh.
1050 files
= self
.filenames
1056 remove
= self
.remover
1057 for filename
in files
:
1063 bad_filenames
.append(filename
)
1066 Interaction
.information(
1067 N_('Error'), N_('Deleting "%s" failed') % file_summary(bad_filenames
)
1071 self
.model
.update_file_status()
1074 class Delete(RemoveFiles
):
1077 def __init__(self
, context
, filenames
):
1078 super().__init
__(context
, os
.remove
, filenames
)
1081 files
= self
.filenames
1085 title
= N_('Delete Files?')
1086 msg
= N_('The following files will be deleted:') + '\n\n'
1087 msg
+= file_summary(files
)
1088 info_txt
= N_('Delete %d file(s)?') % len(files
)
1089 ok_txt
= N_('Delete Files')
1091 if Interaction
.confirm(
1092 title
, msg
, info_txt
, ok_txt
, default
=True, icon
=icons
.remove()
1097 class MoveToTrash(RemoveFiles
):
1098 """Move files to the trash using send2trash"""
1100 AVAILABLE
= send2trash
is not None
1102 def __init__(self
, context
, filenames
):
1103 super().__init
__(context
, send2trash
, filenames
)
1106 class DeleteBranch(ConfirmAction
):
1107 """Delete a git branch."""
1109 def __init__(self
, context
, branch
):
1110 super().__init
__(context
)
1111 self
.branch
= branch
1114 title
= N_('Delete Branch')
1115 question
= N_('Delete branch "%s"?') % self
.branch
1116 info
= N_('The branch will be no longer available.')
1117 ok_txt
= N_('Delete Branch')
1118 return Interaction
.confirm(
1119 title
, question
, info
, ok_txt
, default
=True, icon
=icons
.discard()
1123 return self
.model
.delete_branch(self
.branch
)
1125 def error_message(self
):
1126 return N_('Error deleting branch "%s"' % self
.branch
)
1129 command
= 'git branch -D %s'
1130 return command
% self
.branch
1133 class Rename(ContextCommand
):
1134 """Rename a set of paths."""
1136 def __init__(self
, context
, paths
):
1137 super().__init
__(context
)
1141 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
1142 Interaction
.log(msg
)
1144 for path
in self
.paths
:
1145 ok
= self
.rename(path
)
1149 self
.model
.update_status()
1151 def rename(self
, path
):
1153 title
= N_('Rename "%s"') % path
1155 if os
.path
.isdir(path
):
1156 base_path
= os
.path
.dirname(path
)
1159 new_path
= Interaction
.save_as(base_path
, title
)
1163 status
, out
, err
= git
.mv(path
, new_path
, force
=True, verbose
=True)
1164 Interaction
.command(N_('Error'), 'git mv', status
, out
, err
)
1168 class RenameBranch(ContextCommand
):
1169 """Rename a git branch."""
1171 def __init__(self
, context
, branch
, new_branch
):
1172 super().__init
__(context
)
1173 self
.branch
= branch
1174 self
.new_branch
= new_branch
1177 branch
= self
.branch
1178 new_branch
= self
.new_branch
1179 status
, out
, err
= self
.model
.rename_branch(branch
, new_branch
)
1180 Interaction
.log_status(status
, out
, err
)
1183 class DeleteRemoteBranch(DeleteBranch
):
1184 """Delete a remote git branch."""
1186 def __init__(self
, context
, remote
, branch
):
1187 super().__init
__(context
, branch
)
1188 self
.remote
= remote
1191 return self
.git
.push(self
.remote
, self
.branch
, delete
=True)
1194 self
.model
.update_status()
1195 Interaction
.information(
1196 N_('Remote Branch Deleted'),
1197 N_('"%(branch)s" has been deleted from "%(remote)s".')
1199 'branch': self
.branch
,
1200 'remote': self
.remote
,
1204 def error_message(self
):
1205 return N_('Error Deleting Remote Branch')
1208 command
= 'git push --delete %s %s'
1209 return command
% (self
.remote
, self
.branch
)
1212 def get_mode(context
, filename
, staged
, modified
, unmerged
, untracked
):
1213 model
= context
.model
1215 mode
= model
.mode_index
1216 elif modified
or unmerged
:
1217 mode
= model
.mode_worktree
1219 if gitcmds
.is_binary(context
, filename
):
1220 mode
= model
.mode_untracked
1222 mode
= model
.mode_untracked_diff
1228 class DiffAgainstCommitMode(ContextCommand
):
1229 """Diff against arbitrary commits"""
1231 def __init__(self
, context
, oid
):
1232 super().__init
__(context
)
1236 self
.model
.set_mode(self
.model
.mode_diff
, head
=self
.oid
)
1237 self
.model
.update_file_status()
1240 class DiffText(EditModel
):
1241 """Set the diff type to text"""
1243 def __init__(self
, context
):
1244 super().__init
__(context
)
1245 self
.new_file_type
= main
.Types
.TEXT
1246 self
.new_diff_type
= main
.Types
.TEXT
1249 class ToggleDiffType(ContextCommand
):
1250 """Toggle the diff type between image and text"""
1252 def __init__(self
, context
):
1253 super().__init
__(context
)
1254 if self
.model
.diff_type
== main
.Types
.IMAGE
:
1255 self
.new_diff_type
= main
.Types
.TEXT
1256 self
.new_value
= False
1258 self
.new_diff_type
= main
.Types
.IMAGE
1259 self
.new_value
= True
1262 diff_type
= self
.new_diff_type
1263 value
= self
.new_value
1265 self
.model
.set_diff_type(diff_type
)
1267 filename
= self
.model
.filename
1268 _
, ext
= os
.path
.splitext(filename
)
1269 if ext
.startswith('.'):
1270 cfg
= 'cola.imagediff' + ext
1271 self
.cfg
.set_repo(cfg
, value
)
1274 class DiffImage(EditModel
):
1276 self
, context
, filename
, deleted
, staged
, modified
, unmerged
, untracked
1278 super().__init
__(context
)
1280 self
.new_filename
= filename
1281 self
.new_diff_type
= self
.get_diff_type(filename
)
1282 self
.new_file_type
= main
.Types
.IMAGE
1283 self
.new_mode
= get_mode(
1284 context
, filename
, staged
, modified
, unmerged
, untracked
1286 self
.staged
= staged
1287 self
.modified
= modified
1288 self
.unmerged
= unmerged
1289 self
.untracked
= untracked
1290 self
.deleted
= deleted
1291 self
.annex
= self
.cfg
.is_annex()
1293 def get_diff_type(self
, filename
):
1294 """Query the diff type to use based on cola.imagediff.<extension>"""
1295 _
, ext
= os
.path
.splitext(filename
)
1296 if ext
.startswith('.'):
1297 # Check e.g. "cola.imagediff.svg" to see if we should imagediff.
1298 cfg
= 'cola.imagediff' + ext
1299 if self
.cfg
.get(cfg
, True):
1300 result
= main
.Types
.IMAGE
1302 result
= main
.Types
.TEXT
1304 result
= main
.Types
.IMAGE
1308 filename
= self
.new_filename
1311 images
= self
.staged_images()
1313 images
= self
.modified_images()
1315 images
= self
.unmerged_images()
1316 elif self
.untracked
:
1317 images
= [(filename
, False)]
1321 self
.model
.set_images(images
)
1324 def staged_images(self
):
1325 context
= self
.context
1327 head
= self
.model
.head
1328 filename
= self
.new_filename
1332 index
= git
.diff_index(head
, '--', filename
, cached
=True)[STDOUT
]
1335 # :100644 100644 fabadb8... 4866510... M describe.c
1336 parts
= index
.split(' ')
1341 if old_oid
!= MISSING_BLOB_OID
:
1342 # First, check if we can get a pre-image from git-annex
1345 annex_image
= gitcmds
.annex_path(context
, head
, filename
)
1347 images
.append((annex_image
, False)) # git annex HEAD
1349 image
= gitcmds
.write_blob_path(context
, head
, old_oid
, filename
)
1351 images
.append((image
, True))
1353 if new_oid
!= MISSING_BLOB_OID
:
1354 found_in_annex
= False
1355 if annex
and core
.islink(filename
):
1356 status
, out
, _
= git
.annex('status', '--', filename
)
1358 details
= out
.split(' ')
1359 if details
and details
[0] == 'A': # newly added file
1360 images
.append((filename
, False))
1361 found_in_annex
= True
1363 if not found_in_annex
:
1364 image
= gitcmds
.write_blob(context
, new_oid
, filename
)
1366 images
.append((image
, True))
1370 def unmerged_images(self
):
1371 context
= self
.context
1373 head
= self
.model
.head
1374 filename
= self
.new_filename
1377 candidate_merge_heads
= ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1380 for merge_head
in candidate_merge_heads
1381 if core
.exists(git
.git_path(merge_head
))
1384 if annex
: # Attempt to find files in git-annex
1386 for merge_head
in merge_heads
:
1387 image
= gitcmds
.annex_path(context
, merge_head
, filename
)
1389 annex_images
.append((image
, False))
1391 annex_images
.append((filename
, False))
1394 # DIFF FORMAT FOR MERGES
1395 # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1396 # can take -c or --cc option to generate diff output also
1397 # for merge commits. The output differs from the format
1398 # described above in the following way:
1400 # 1. there is a colon for each parent
1401 # 2. there are more "src" modes and "src" sha1
1402 # 3. status is concatenated status characters for each parent
1403 # 4. no optional "score" number
1404 # 5. single path, only for "dst"
1406 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1409 index
= git
.diff_index(head
, '--', filename
, cached
=True, cc
=True)[STDOUT
]
1411 parts
= index
.split(' ')
1413 first_mode
= parts
[0]
1414 num_parents
= first_mode
.count(':')
1415 # colon for each parent, but for the index, the "parents"
1416 # are really entries in stages 1,2,3 (head, base, remote)
1417 # remote, base, head
1418 for i
in range(num_parents
):
1419 offset
= num_parents
+ i
+ 1
1422 merge_head
= merge_heads
[i
]
1425 if oid
!= MISSING_BLOB_OID
:
1426 image
= gitcmds
.write_blob_path(
1427 context
, merge_head
, oid
, filename
1430 images
.append((image
, True))
1432 images
.append((filename
, False))
1435 def modified_images(self
):
1436 context
= self
.context
1438 head
= self
.model
.head
1439 filename
= self
.new_filename
1444 if annex
: # Check for a pre-image from git-annex
1445 annex_image
= gitcmds
.annex_path(context
, head
, filename
)
1447 images
.append((annex_image
, False)) # git annex HEAD
1449 worktree
= git
.diff_files('--', filename
)[STDOUT
]
1450 parts
= worktree
.split(' ')
1453 if oid
!= MISSING_BLOB_OID
:
1454 image
= gitcmds
.write_blob_path(context
, head
, oid
, filename
)
1456 images
.append((image
, True)) # HEAD
1458 images
.append((filename
, False)) # worktree
1462 class Diff(EditModel
):
1463 """Perform a diff and set the model's current text."""
1465 def __init__(self
, context
, filename
, cached
=False, deleted
=False):
1466 super().__init
__(context
)
1468 if cached
and gitcmds
.is_valid_ref(context
, self
.model
.head
):
1469 opts
['ref'] = self
.model
.head
1470 self
.new_filename
= filename
1471 self
.new_mode
= self
.model
.mode_worktree
1472 self
.new_diff_text
= gitcmds
.diff_helper(
1473 self
.context
, filename
=filename
, cached
=cached
, deleted
=deleted
, **opts
1477 class Diffstat(EditModel
):
1478 """Perform a diffstat and set the model's diff text."""
1480 def __init__(self
, context
):
1481 super().__init
__(context
)
1483 diff_context
= cfg
.get('diff.context', 3)
1484 diff
= self
.git
.diff(
1486 unified
=diff_context
,
1492 self
.new_diff_text
= diff
1493 self
.new_diff_type
= main
.Types
.TEXT
1494 self
.new_file_type
= main
.Types
.TEXT
1495 self
.new_mode
= self
.model
.mode_diffstat
1498 class DiffStaged(Diff
):
1499 """Perform a staged diff on a file."""
1501 def __init__(self
, context
, filename
, deleted
=None):
1502 super().__init
__(context
, filename
, cached
=True, deleted
=deleted
)
1503 self
.new_mode
= self
.model
.mode_index
1506 class DiffStagedSummary(EditModel
):
1507 def __init__(self
, context
):
1508 super().__init
__(context
)
1509 diff
= self
.git
.diff(
1514 patch_with_stat
=True,
1517 self
.new_diff_text
= diff
1518 self
.new_diff_type
= main
.Types
.TEXT
1519 self
.new_file_type
= main
.Types
.TEXT
1520 self
.new_mode
= self
.model
.mode_index
1523 class Edit(ContextCommand
):
1524 """Edit a file using the configured gui.editor."""
1528 return N_('Launch Editor')
1530 def __init__(self
, context
, filenames
, line_number
=None, background_editor
=False):
1531 super().__init
__(context
)
1532 self
.filenames
= filenames
1533 self
.line_number
= line_number
1534 self
.background_editor
= background_editor
1537 context
= self
.context
1538 if not self
.filenames
:
1540 filename
= self
.filenames
[0]
1541 if not core
.exists(filename
):
1543 if self
.background_editor
:
1544 editor
= prefs
.background_editor(context
)
1546 editor
= prefs
.editor(context
)
1549 if self
.line_number
is None:
1550 opts
= self
.filenames
1552 # Single-file w/ line-numbers (likely from grep)
1554 '*vim*': [filename
, '+%s' % self
.line_number
],
1555 '*emacs*': ['+%s' % self
.line_number
, filename
],
1556 '*textpad*': [f
'{filename}({self.line_number},0)'],
1557 '*notepad++*': ['-n%s' % self
.line_number
, filename
],
1558 '*subl*': [f
'{filename}:{self.line_number}'],
1561 opts
= self
.filenames
1562 for pattern
, opt
in editor_opts
.items():
1563 if fnmatch(editor
, pattern
):
1568 core
.fork(utils
.shell_split(editor
) + opts
)
1569 except (OSError, ValueError) as e
:
1570 message
= N_('Cannot exec "%s": please configure your editor') % editor
1571 _
, details
= utils
.format_exception(e
)
1572 Interaction
.critical(N_('Error Editing File'), message
, details
)
1575 class FormatPatch(ContextCommand
):
1576 """Output a patch series given all revisions and a selected subset."""
1578 def __init__(self
, context
, to_export
, revs
, output
='patches'):
1579 super().__init
__(context
)
1580 self
.to_export
= list(to_export
)
1581 self
.revs
= list(revs
)
1582 self
.output
= output
1585 context
= self
.context
1586 status
, out
, err
= gitcmds
.format_patchsets(
1587 context
, self
.to_export
, self
.revs
, self
.output
1589 Interaction
.log_status(status
, out
, err
)
1592 class LaunchTerminal(ContextCommand
):
1595 return N_('Launch Terminal')
1598 def is_available(context
):
1599 return context
.cfg
.terminal() is not None
1601 def __init__(self
, context
, path
):
1602 super().__init
__(context
)
1606 cmd
= self
.context
.cfg
.terminal()
1609 if utils
.is_win32():
1610 argv
= ['start', '', cmd
, '--login']
1613 argv
= utils
.shell_split(cmd
)
1615 shells
= ('zsh', 'fish', 'bash', 'sh')
1616 for basename
in shells
:
1617 executable
= core
.find_executable(basename
)
1619 command
= executable
1621 argv
.append(os
.getenv('SHELL', command
))
1624 core
.fork(argv
, cwd
=self
.path
, shell
=shell
)
1627 class LaunchEditor(Edit
):
1630 return N_('Launch Editor')
1632 def __init__(self
, context
):
1633 s
= context
.selection
.selection()
1634 filenames
= s
.staged
+ s
.unmerged
+ s
.modified
+ s
.untracked
1635 super().__init
__(context
, filenames
, background_editor
=True)
1638 class LaunchEditorAtLine(LaunchEditor
):
1639 """Launch an editor at the specified line"""
1641 def __init__(self
, context
):
1642 super().__init
__(context
)
1643 self
.line_number
= context
.selection
.line_number
1646 class LoadCommitMessageFromFile(ContextCommand
):
1647 """Loads a commit message from a path."""
1651 def __init__(self
, context
, path
):
1652 super().__init
__(context
)
1654 self
.old_commitmsg
= self
.model
.commitmsg
1655 self
.old_directory
= self
.model
.directory
1658 path
= os
.path
.expanduser(self
.path
)
1659 if not path
or not core
.isfile(path
):
1661 N_('Error: Cannot find commit template'),
1662 N_('%s: No such file or directory.') % path
,
1664 self
.model
.set_directory(os
.path
.dirname(path
))
1665 self
.model
.set_commitmsg(core
.read(path
))
1668 self
.model
.set_commitmsg(self
.old_commitmsg
)
1669 self
.model
.set_directory(self
.old_directory
)
1672 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile
):
1673 """Loads the commit message template specified by commit.template."""
1675 def __init__(self
, context
):
1677 template
= cfg
.get('commit.template')
1678 super().__init
__(context
, template
)
1681 if self
.path
is None:
1683 N_('Error: Unconfigured commit template'),
1685 'A commit template has not been configured.\n'
1686 'Use "git config" to define "commit.template"\n'
1687 'so that it points to a commit template.'
1690 return LoadCommitMessageFromFile
.do(self
)
1693 class LoadCommitMessageFromOID(ContextCommand
):
1694 """Load a previous commit message"""
1698 def __init__(self
, context
, oid
, prefix
=''):
1699 super().__init
__(context
)
1701 self
.old_commitmsg
= self
.model
.commitmsg
1702 self
.new_commitmsg
= prefix
+ gitcmds
.prev_commitmsg(context
, oid
)
1705 self
.model
.set_commitmsg(self
.new_commitmsg
)
1708 self
.model
.set_commitmsg(self
.old_commitmsg
)
1711 class PrepareCommitMessageHook(ContextCommand
):
1712 """Use the cola-prepare-commit-msg hook to prepare the commit message"""
1716 def __init__(self
, context
):
1717 super().__init
__(context
)
1718 self
.old_commitmsg
= self
.model
.commitmsg
1720 def get_message(self
):
1721 title
= N_('Error running prepare-commitmsg hook')
1722 hook
= gitcmds
.prepare_commit_message_hook(self
.context
)
1724 if os
.path
.exists(hook
):
1725 filename
= self
.model
.save_commitmsg()
1726 status
, out
, err
= core
.run_command([hook
, filename
])
1729 result
= core
.read(filename
)
1731 result
= self
.old_commitmsg
1732 Interaction
.command_error(title
, hook
, status
, out
, err
)
1734 message
= N_('A hook must be provided at "%s"') % hook
1735 Interaction
.critical(title
, message
=message
)
1736 result
= self
.old_commitmsg
1741 msg
= self
.get_message()
1742 self
.model
.set_commitmsg(msg
)
1745 self
.model
.set_commitmsg(self
.old_commitmsg
)
1748 class LoadFixupMessage(LoadCommitMessageFromOID
):
1749 """Load a fixup message"""
1751 def __init__(self
, context
, oid
):
1752 super().__init
__(context
, oid
, prefix
='fixup! ')
1753 if self
.new_commitmsg
:
1754 self
.new_commitmsg
= self
.new_commitmsg
.splitlines()[0]
1757 class Merge(ContextCommand
):
1760 def __init__(self
, context
, revision
, no_commit
, squash
, no_ff
, sign
):
1761 super().__init
__(context
)
1762 self
.revision
= revision
1764 self
.no_commit
= no_commit
1765 self
.squash
= squash
1769 squash
= self
.squash
1770 revision
= self
.revision
1772 no_commit
= self
.no_commit
1775 status
, out
, err
= self
.git
.merge(
1776 revision
, gpg_sign
=sign
, no_ff
=no_ff
, no_commit
=no_commit
, squash
=squash
1778 self
.model
.update_status()
1779 title
= N_('Merge failed. Conflict resolution is required.')
1780 Interaction
.command(title
, 'git merge', status
, out
, err
)
1782 return status
, out
, err
1785 class OpenDefaultApp(ContextCommand
):
1786 """Open a file using the OS default."""
1790 return N_('Open Using Default Application')
1792 def __init__(self
, context
, filenames
):
1793 super().__init
__(context
)
1794 self
.filenames
= filenames
1797 if not self
.filenames
:
1799 utils
.launch_default_app(self
.filenames
)
1802 class OpenDir(OpenDefaultApp
):
1803 """Open directories using the OS default."""
1807 return N_('Open Directory')
1810 def _dirnames(self
):
1811 return self
.filenames
1814 dirnames
= self
._dirnames
1817 # An empty dirname defaults to to the current directory.
1818 dirs
= [(dirname
or core
.getcwd()) for dirname
in dirnames
]
1819 utils
.launch_default_app(dirs
)
1822 class OpenParentDir(OpenDir
):
1823 """Open parent directories using the OS default."""
1827 return N_('Open Parent Directory')
1830 def _dirnames(self
):
1831 dirnames
= list({os
.path
.dirname(x
) for x
in self
.filenames
})
1835 class OpenWorktree(OpenDir
):
1836 """Open worktree directory using the OS default."""
1840 return N_('Open Worktree')
1842 # The _unused parameter is needed by worktree_dir_action() -> common.cmd_action().
1843 def __init__(self
, context
, _unused
=None):
1844 dirnames
= [context
.git
.worktree()]
1845 super().__init
__(context
, dirnames
)
1848 class OpenNewRepo(ContextCommand
):
1849 """Launches git-cola on a repo."""
1851 def __init__(self
, context
, repo_path
):
1852 super().__init
__(context
)
1853 self
.repo_path
= repo_path
1856 self
.model
.set_directory(self
.repo_path
)
1857 core
.fork([sys
.executable
, sys
.argv
[0], '--repo', self
.repo_path
])
1860 class OpenRepo(EditModel
):
1861 def __init__(self
, context
, repo_path
):
1862 super().__init
__(context
)
1863 self
.repo_path
= repo_path
1864 self
.new_mode
= self
.model
.mode_none
1865 self
.new_diff_text
= ''
1866 self
.new_diff_type
= main
.Types
.TEXT
1867 self
.new_file_type
= main
.Types
.TEXT
1868 self
.new_commitmsg
= ''
1869 self
.new_filename
= ''
1872 old_repo
= self
.git
.getcwd()
1873 if self
.model
.set_worktree(self
.repo_path
):
1874 self
.fsmonitor
.stop()
1875 self
.fsmonitor
.start()
1876 self
.model
.update_status(reset
=True)
1877 # Check if template should be loaded
1878 if self
.context
.cfg
.get(prefs
.AUTOTEMPLATE
):
1879 template_loader
= LoadCommitMessageFromTemplate(self
.context
)
1880 template_loader
.do()
1882 self
.model
.set_commitmsg(self
.new_commitmsg
)
1883 settings
= self
.context
.settings
1885 settings
.add_recent(self
.repo_path
, prefs
.maxrecent(self
.context
))
1889 self
.model
.set_worktree(old_repo
)
1892 class OpenParentRepo(OpenRepo
):
1893 def __init__(self
, context
):
1895 if version
.check_git(context
, 'show-superproject-working-tree'):
1896 status
, out
, _
= context
.git
.rev_parse(show_superproject_working_tree
=True)
1900 path
= os
.path
.dirname(core
.getcwd())
1901 super().__init
__(context
, path
)
1904 class Clone(ContextCommand
):
1905 """Clones a repository and optionally spawns a new cola session."""
1908 self
, context
, url
, new_directory
, submodules
=False, shallow
=False, spawn
=True
1910 super().__init
__(context
)
1912 self
.new_directory
= new_directory
1913 self
.submodules
= submodules
1914 self
.shallow
= shallow
1924 recurse_submodules
= self
.submodules
1925 shallow_submodules
= self
.submodules
and self
.shallow
1927 status
, out
, err
= self
.git
.clone(
1930 recurse_submodules
=recurse_submodules
,
1931 shallow_submodules
=shallow_submodules
,
1935 self
.status
= status
1938 if status
== 0 and self
.spawn
:
1939 executable
= sys
.executable
1940 core
.fork([executable
, sys
.argv
[0], '--repo', self
.new_directory
])
1944 class NewBareRepo(ContextCommand
):
1945 """Create a new shared bare repository"""
1947 def __init__(self
, context
, path
):
1948 super().__init
__(context
)
1953 status
, out
, err
= self
.git
.init(path
, bare
=True, shared
=True)
1954 Interaction
.command(
1955 N_('Error'), 'git init --bare --shared "%s"' % path
, status
, out
, err
1960 def unix_path(path
, is_win32
=utils
.is_win32
):
1961 """Git for Windows requires Unix paths, so force them here"""
1963 path
= path
.replace('\\', '/')
1966 if second
== ':': # sanity check, this better be a Windows-style path
1967 path
= '/' + first
+ path
[2:]
1972 def sequence_editor():
1973 """Set GIT_SEQUENCE_EDITOR for running git-cola-sequence-editor"""
1974 xbase
= unix_path(resources
.command('git-cola-sequence-editor'))
1975 if utils
.is_win32():
1976 editor
= core
.list2cmdline([unix_path(sys
.executable
), xbase
])
1978 editor
= core
.list2cmdline([xbase
])
1982 class SequenceEditorEnvironment
:
1983 """Set environment variables to enable git-cola-sequence-editor"""
1985 def __init__(self
, context
, **kwargs
):
1987 'GIT_EDITOR': prefs
.editor(context
),
1988 'GIT_SEQUENCE_EDITOR': sequence_editor(),
1990 self
.env
.update(kwargs
)
1992 def __enter__(self
):
1993 for var
, value
in self
.env
.items():
1994 compat
.setenv(var
, value
)
1997 def __exit__(self
, exc_type
, exc_val
, exc_tb
):
1998 for var
in self
.env
:
1999 compat
.unsetenv(var
)
2002 class Rebase(ContextCommand
):
2003 def __init__(self
, context
, upstream
=None, branch
=None, **kwargs
):
2004 """Start an interactive rebase session
2006 :param upstream: upstream branch
2007 :param branch: optional branch to checkout
2008 :param kwargs: forwarded directly to `git.rebase()`
2011 super().__init
__(context
)
2013 self
.upstream
= upstream
2014 self
.branch
= branch
2015 self
.kwargs
= kwargs
2017 def prepare_arguments(self
, upstream
):
2021 # Rebase actions must be the only option specified
2022 for action
in ('continue', 'abort', 'skip', 'edit_todo'):
2023 if self
.kwargs
.get(action
, False):
2024 kwargs
[action
] = self
.kwargs
[action
]
2027 kwargs
['interactive'] = True
2028 kwargs
['autosquash'] = self
.kwargs
.get('autosquash', True)
2029 kwargs
.update(self
.kwargs
)
2031 # Prompt to determine whether or not to use "git rebase --update-refs".
2032 has_update_refs
= version
.check_git(self
.context
, 'rebase-update-refs')
2033 if has_update_refs
and not kwargs
.get('update_refs', False):
2034 title
= N_('Update stacked branches when rebasing?')
2036 '"git rebase --update-refs" automatically force-updates any\n'
2037 'branches that point to commits that are being rebased.\n\n'
2038 'Any branches that are checked out in a worktree are not updated.\n\n'
2039 'Using this feature is helpful for "stacked" branch workflows.'
2041 info
= N_('Update stacked branches when rebasing?')
2042 ok_text
= N_('Update stacked branches')
2043 cancel_text
= N_('Do not update stacked branches')
2044 update_refs
= Interaction
.confirm(
2050 cancel_text
=cancel_text
,
2053 kwargs
['update_refs'] = True
2056 args
.append(upstream
)
2058 args
.append(self
.branch
)
2063 (status
, out
, err
) = (1, '', '')
2064 context
= self
.context
2068 if not cfg
.get('rebase.autostash', False):
2069 if model
.staged
or model
.unmerged
or model
.modified
:
2070 Interaction
.information(
2071 N_('Unable to rebase'),
2072 N_('You cannot rebase with uncommitted changes.'),
2074 return status
, out
, err
2076 upstream
= self
.upstream
or Interaction
.choose_ref(
2078 N_('Select New Upstream'),
2079 N_('Interactive Rebase'),
2080 default
='@{upstream}',
2083 return status
, out
, err
2085 self
.model
.is_rebasing
= True
2086 self
.model
.emit_updated()
2088 args
, kwargs
= self
.prepare_arguments(upstream
)
2089 upstream_title
= upstream
or '@{upstream}'
2090 with
SequenceEditorEnvironment(
2092 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Rebase onto %s') % upstream_title
,
2093 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Rebase'),
2095 # This blocks the user interface window for the duration
2096 # of git-cola-sequence-editor. We would need to run the command
2097 # in a QRunnable task to avoid blocking the main thread.
2098 # Alternatively, we can hide the main window while rebasing,
2099 # which doesn't require as much effort.
2100 status
, out
, err
= self
.git
.rebase(
2101 *args
, _no_win32_startupinfo
=True, **kwargs
2103 self
.model
.update_status()
2104 if err
.strip() != 'Nothing to do':
2105 title
= N_('Rebase stopped')
2106 Interaction
.command(title
, 'git rebase', status
, out
, err
)
2107 return status
, out
, err
2110 class RebaseEditTodo(ContextCommand
):
2112 (status
, out
, err
) = (1, '', '')
2113 with
SequenceEditorEnvironment(
2115 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Edit Rebase'),
2116 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Save'),
2118 status
, out
, err
= self
.git
.rebase(edit_todo
=True)
2119 Interaction
.log_status(status
, out
, err
)
2120 self
.model
.update_status()
2121 return status
, out
, err
2124 class RebaseContinue(ContextCommand
):
2126 (status
, out
, err
) = (1, '', '')
2127 with
SequenceEditorEnvironment(
2129 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Rebase'),
2130 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Rebase'),
2132 status
, out
, err
= self
.git
.rebase('--continue')
2133 Interaction
.log_status(status
, out
, err
)
2134 self
.model
.update_status()
2135 return status
, out
, err
2138 class RebaseSkip(ContextCommand
):
2140 (status
, out
, err
) = (1, '', '')
2141 with
SequenceEditorEnvironment(
2143 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Rebase'),
2144 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Rebase'),
2146 status
, out
, err
= self
.git
.rebase(skip
=True)
2147 Interaction
.log_status(status
, out
, err
)
2148 self
.model
.update_status()
2149 return status
, out
, err
2152 class RebaseAbort(ContextCommand
):
2154 status
, out
, err
= self
.git
.rebase(abort
=True)
2155 Interaction
.log_status(status
, out
, err
)
2156 self
.model
.update_status()
2159 class Rescan(ContextCommand
):
2160 """Rescan for changes"""
2163 self
.model
.update_status()
2166 class Refresh(ContextCommand
):
2167 """Update refs, refresh the index, and update config"""
2171 return N_('Refresh')
2174 self
.model
.update_status(update_index
=True)
2176 self
.fsmonitor
.refresh()
2179 class RefreshConfig(ContextCommand
):
2180 """Refresh the git config cache"""
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
):
2197 def checkout_args(self
):
2199 s
= self
.selection
.selection()
2200 if self
.checkout_from_head():
2201 args
.append(self
.model
.head
)
2213 checkout_args
= self
.checkout_args()
2214 return self
.git
.checkout(*checkout_args
)
2217 self
.model
.set_diff_type(main
.Types
.TEXT
)
2218 self
.model
.update_file_status()
2221 class RevertUnstagedEdits(RevertEditsCommand
):
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.
2232 title
= N_('Revert Unstaged Changes?')
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
):
2247 return N_('Revert Uncommitted Edits...')
2249 def checkout_from_head(self
):
2253 """Prompt for reverting changes"""
2254 title
= N_('Revert Uncommitted Changes?')
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
2274 """Run the user-configured action"""
2275 for env
in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
2277 compat
.unsetenv(env
)
2282 context
= self
.context
2284 opts
= cfg
.get_guitool_opts(self
.action_name
)
2285 cmd
= opts
.get('cmd')
2286 if 'title' not in opts
:
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()
2296 Interaction
.information(
2297 N_('Please select a file'),
2298 N_('"%s" requires a selected file.') % cmd
,
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'):
2307 ok
= Interaction
.confirm_config_action(context
, cmd
, opts
)
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
)
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
):
2325 compat
.setenv('REVISION', rev
)
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'):
2334 status
, out
, err
= (0, '', '')
2335 elif opts
.get('noconsole'):
2336 status
, out
, err
= core
.run_command(cmd
)
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()
2344 Interaction
.command(title
, cmd
, status
, out
, err
)
2349 class SetDefaultRepo(ContextCommand
):
2350 """Set the default repository"""
2352 def __init__(self
, context
, repo
):
2353 super().__init
__(context
)
2357 self
.cfg
.set_user('cola.defaultrepo', self
.repo
)
2360 class SetDiffText(EditModel
):
2361 """Set the diff text"""
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
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'
2395 byte_offset_to_int
= compat
.byte_offset_to_int_converter()
2396 while offset
< len(data
):
2397 result
+= '%04u |' % offset
2399 for i
in range(0, 16):
2400 if i
> 0 and i
% 4 == 0:
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 '.'
2410 result
+= ' | ' + textpart
+ ' |\n'
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
)
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"""
2435 size
= cfg
.get('cola.readsize', 2048)
2437 result
= core
.read(filename
, size
=size
, encoding
='bytes')
2441 truncated
= len(result
) == size
2443 encoding
= cfg
.file_encoding(filename
) or core
.ENCODING
2445 text_result
= core
.decode_maybe(result
, encoding
)
2446 except UnicodeError:
2447 text_result
= format_hex(result
)
2450 text_result
+= '...'
2454 class SignOff(ContextCommand
):
2455 """Append a sign-off to the commit message"""
2461 return N_('Sign Off')
2463 def __init__(self
, context
):
2464 super().__init
__(context
)
2465 self
.old_commitmsg
= self
.model
.commitmsg
2468 """Add a sign-off to the commit message"""
2469 signoff
= self
.signoff()
2470 if signoff
in self
.model
.commitmsg
:
2472 msg
= self
.model
.commitmsg
.rstrip()
2473 self
.model
.set_commitmsg(msg
+ '\n' + signoff
)
2476 """Restore the commit message"""
2477 self
.model
.set_commitmsg(self
.old_commitmsg
)
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
)]
2496 def is_conflict_free(path
):
2497 """Return True if `path` contains no conflict markers"""
2498 rgx
= re
.compile(r
'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2500 with core
.xopen(path
, 'rb') as f
:
2502 line
= core
.decode(line
, errors
='ignore')
2504 return should_stage_conflicts(path
)
2506 # We can't read this file ~ we may be staging a removal
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?')
2520 '%s appears to contain merge conflicts.\n\n'
2521 'You should probably skip this file.\n'
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."""
2540 def __init__(self
, context
, paths
):
2541 super().__init
__(context
)
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
2554 if self
.model
.cfg
.get('cola.safemode', False):
2556 return self
.stage_all()
2564 for path
in set(paths
):
2565 if core
.exists(path
) or core
.islink(path
):
2566 if path
.endswith('/'):
2567 path
= path
.rstrip('/')
2572 self
.model
.emit_about_to_update()
2574 # `git add -u` doesn't work on untracked files
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.
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)
2611 def init_paths(self
):
2612 """Initialize path data"""
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
2620 """Stage files when ok_to_run() return True"""
2621 if self
.ok_to_run():
2626 class StageModified(StageCarefully
):
2627 """Stage all modified files."""
2631 return N_('Stage Modified')
2633 def init_paths(self
):
2634 self
.paths
= self
.model
.modified
2637 class StageUnmerged(StageCarefully
):
2638 """Stage unmerged files."""
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."""
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"""
2663 class StageModifiedAndUntracked(StageCarefully
):
2664 """Stage all untracked files."""
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"""
2679 return N_('Stage / Unstage All')
2682 if self
.model
.staged
:
2683 do(Unstage
, self
.context
, self
.model
.staged
)
2685 if self
.cfg
.get('cola.safemode', False):
2686 unstaged
= self
.model
.modified
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"""
2697 return N_('Stage / Unstage')
2700 s
= self
.selection
.selection()
2702 do(Unstage
, self
.context
, s
.staged
)
2705 unmerged
= check_conflicts(self
.context
, s
.unmerged
)
2707 unstaged
.extend(unmerged
)
2709 unstaged
.extend(s
.modified
)
2711 unstaged
.extend(s
.untracked
)
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
)
2722 self
._message
= message
2723 self
._revision
= revision
2729 revision
= self
._revision
2730 tag_name
= self
._name
2731 tag_message
= self
._message
2734 Interaction
.critical(
2735 N_('Missing Revision'), N_('Please specify a revision to tag.')
2740 Interaction
.critical(
2741 N_('Missing Name'), N_('Please specify a name for the new tag.')
2745 title
= N_('Missing Tag Message')
2746 message
= N_('Tag-signing was requested but the tag message is empty.')
2748 'An unsigned, lightweight tag will be created instead.\n'
2749 'Create an unsigned tag?'
2751 ok_text
= N_('Create Unsigned Tag')
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()
2766 tmp_file
= utils
.tmp_filename('tag-message')
2767 opts
['file'] = tmp_file
2768 core
.write(tmp_file
, tag_message
)
2773 opts
['annotate'] = True
2774 status
, out
, err
= git
.tag(tag_name
, revision
, **opts
)
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
)
2784 self
.model
.update_status()
2785 Interaction
.information(
2787 N_('Created a new tag named "%s"') % tag_name
,
2788 details
=tag_message
or None,
2794 class Unstage(ContextCommand
):
2795 """Unstage a set of paths."""
2799 return N_('Unstage')
2801 def __init__(self
, context
, paths
):
2802 super().__init
__(context
)
2807 context
= self
.context
2808 head
= self
.model
.head
2811 msg
= N_('Unstaging: %s') % (', '.join(paths
))
2812 Interaction
.log(msg
)
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."""
2825 return unstage_all(self
.context
)
2828 def unstage_all(context
):
2829 """Unstage all files, even while amending"""
2830 model
= context
.model
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."""
2843 context
= self
.context
2844 paths
= self
.selection
.unstaged
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
)
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
2880 io
.write('# %s unmerged file(s)\n' % len(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
2896 io
.write('# %s untracked file(s)\n' % len(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."""
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."""
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
))
2932 self
.argv
= browser
+ ['--'] + list(paths
)
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
2949 context
= self
.context
2950 argv
= utils
.shell_split(prefs
.history_browser(context
))
2952 argv
.append(self
.revision
)
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
)
2966 self
.branch
= branch
2968 self
.reference
= reference
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())
2978 context
= self
.context
2979 args
= self
.get_args()
2980 return context
.git
.submodule('add', *args
)
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
)
2990 cmd
= ['git', 'submodule', 'add']
2991 cmd
.extend(self
.get_args())
2992 return core
.list2cmdline(cmd
)
2997 args
.extend(['--branch', self
.branch
])
2999 args
.extend(['--reference', self
.reference
])
3001 args
.extend(['--depth', '%d' % self
.depth
])
3002 args
.extend(['--', self
.url
])
3004 args
.append(self
.path
)
3008 class SubmoduleUpdate(ConfirmAction
):
3009 """Update specified submodule"""
3011 def __init__(self
, context
, path
):
3012 super().__init
__(context
)
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()
3025 context
= self
.context
3026 args
= self
.get_args()
3027 return context
.git
.submodule(*args
)
3030 self
.model
.update_file_status()
3032 def error_message(self
):
3033 return N_('Error updating submodule %s' % self
.path
)
3036 cmd
= ['git', 'submodule']
3037 cmd
.extend(self
.get_args())
3038 return core
.list2cmdline(cmd
)
3042 if version
.check_git(self
.context
, 'submodule-update-recursive'):
3043 cmd
.append('--recursive')
3044 cmd
.extend(['--', self
.path
])
3048 class SubmodulesUpdate(ConfirmAction
):
3049 """Update all submodules"""
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()
3061 context
= self
.context
3062 args
= self
.get_args()
3063 return context
.git
.submodule(*args
)
3066 self
.model
.update_file_status()
3068 def error_message(self
):
3069 return N_('Error updating submodules')
3072 cmd
= ['git', 'submodule']
3073 cmd
.extend(self
.get_args())
3074 return core
.list2cmdline(cmd
)
3078 if version
.check_git(self
.context
, 'submodule-update-recursive'):
3079 cmd
.append('--recursive')
3083 def launch_history_browser(argv
):
3084 """Launch the configured history browser"""
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(
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"""
3108 return do(cls
, *args
, **opts
)
3109 return do(cls
, *local_args
, **local_opts
)
3114 def do(cls
, *args
, **opts
):
3115 """Run a command in-place"""
3117 cmd
= cls(*args
, **opts
)
3119 except Exception as e
: # pylint: disable=broad-except
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
)