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 ok 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()
268 self
.model
.set_commitmsg(self
.old_commitmsg
)
270 self
.model
.update_file_status()
273 class AnnexAdd(ContextCommand
):
274 """Add to Git Annex"""
276 def __init__(self
, context
):
277 super().__init
__(context
)
278 self
.filename
= self
.selection
.filename()
281 status
, out
, err
= self
.git
.annex('add', self
.filename
)
282 Interaction
.command(N_('Error'), 'git annex add', status
, out
, err
)
283 self
.model
.update_status()
286 class AnnexInit(ContextCommand
):
287 """Initialize Git Annex"""
290 status
, out
, err
= self
.git
.annex('init')
291 Interaction
.command(N_('Error'), 'git annex init', status
, out
, err
)
292 self
.model
.cfg
.reset()
293 self
.model
.emit_updated()
296 class LFSTrack(ContextCommand
):
297 """Add a file to git lfs"""
299 def __init__(self
, context
):
300 super().__init
__(context
)
301 self
.filename
= self
.selection
.filename()
302 self
.stage_cmd
= Stage(context
, [self
.filename
])
305 status
, out
, err
= self
.git
.lfs('track', self
.filename
)
306 Interaction
.command(N_('Error'), 'git lfs track', status
, out
, err
)
311 class LFSInstall(ContextCommand
):
312 """Initialize git lfs"""
315 status
, out
, err
= self
.git
.lfs('install')
316 Interaction
.command(N_('Error'), 'git lfs install', status
, out
, err
)
317 self
.model
.update_config(reset
=True, emit
=True)
320 class ApplyPatch(ContextCommand
):
321 """Apply the specfied patch to the worktree or index"""
330 super().__init
__(context
)
332 self
.encoding
= encoding
333 self
.apply_to_worktree
= apply_to_worktree
336 context
= self
.context
338 tmp_file
= utils
.tmp_filename('apply', suffix
='.patch')
340 core
.write(tmp_file
, self
.patch
.as_text(), encoding
=self
.encoding
)
341 if self
.apply_to_worktree
:
342 status
, out
, err
= gitcmds
.apply_diff_to_worktree(context
, tmp_file
)
344 status
, out
, err
= gitcmds
.apply_diff(context
, tmp_file
)
346 core
.unlink(tmp_file
)
348 Interaction
.log_status(status
, out
, err
)
349 self
.model
.update_file_status(update_index
=True)
352 class ApplyPatches(ContextCommand
):
353 """Apply patches using the "git am" command"""
355 def __init__(self
, context
, patches
):
356 super().__init
__(context
)
357 self
.patches
= patches
360 status
, output
, err
= self
.git
.am('-3', *self
.patches
)
361 out
= f
'# git am -3 {core.list2cmdline(self.patches)}\n\n{output}'
362 Interaction
.command(N_('Patch failed to apply'), 'git am -3', status
, out
, err
)
364 self
.model
.update_file_status()
366 patch_basenames
= [os
.path
.basename(p
) for p
in self
.patches
]
367 if len(patch_basenames
) > 25:
368 patch_basenames
= patch_basenames
[:25]
369 patch_basenames
.append('...')
371 basenames
= '\n'.join(patch_basenames
)
373 Interaction
.information(
374 N_('Patch(es) Applied'),
375 (N_('%d patch(es) applied.') + '\n\n%s')
376 % (len(self
.patches
), basenames
),
380 class ApplyPatchesContinue(ContextCommand
):
381 """Run "git am --continue" to continue on the next patch in a "git am" session"""
384 status
, out
, err
= self
.git
.am('--continue')
386 N_('Failed to commit and continue applying patches'),
392 self
.model
.update_status()
393 return status
, out
, err
396 class ApplyPatchesSkip(ContextCommand
):
397 """Run "git am --skip" to continue on the next patch in a "git am" session"""
400 status
, out
, err
= self
.git
.am(skip
=True)
402 N_('Failed to continue applying patches after skipping the current patch'),
408 self
.model
.update_status()
409 return status
, out
, err
412 class Archive(ContextCommand
):
413 """ "Export archives using the "git archive" command"""
415 def __init__(self
, context
, ref
, fmt
, prefix
, filename
):
416 super().__init
__(context
)
420 self
.filename
= filename
423 fp
= core
.xopen(self
.filename
, 'wb')
424 cmd
= ['git', 'archive', '--format=' + self
.fmt
]
425 if self
.fmt
in ('tgz', 'tar.gz'):
428 cmd
.append('--prefix=' + self
.prefix
)
430 proc
= core
.start_command(cmd
, stdout
=fp
)
431 out
, err
= proc
.communicate()
433 status
= proc
.returncode
434 Interaction
.log_status(status
, out
or '', err
or '')
437 class Checkout(EditModel
):
438 """A command object for git-checkout.
440 'argv' is handed off directly to git.
444 def __init__(self
, context
, argv
, checkout_branch
=False):
445 super().__init
__(context
)
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
454 status
, out
, err
= self
.git
.checkout(*self
.argv
)
455 if self
.checkout_branch
:
456 self
.model
.update_status()
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"""
468 return N_('Checkout files from their branch (MERGE_HEAD)')
472 question
= N_('Checkout files from their branch?')
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()
485 selection
= self
.selection
.selection()
486 paths
= selection
.unmerged
490 argv
= ['--theirs', '--'] + paths
491 cmd
= Checkout(self
.context
, argv
)
494 def error_message(self
):
498 return 'git checkout --theirs'
501 class CheckoutOurs(ConfirmAction
):
502 """Checkout "our" version of a file when performing a merge"""
506 return N_('Checkout files from our branch (HEAD)')
510 question
= N_('Checkout files from our branch?')
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()
523 selection
= self
.selection
.selection()
524 paths
= selection
.unmerged
528 argv
= ['--ours', '--'] + paths
529 cmd
= Checkout(self
.context
, argv
)
532 def error_message(self
):
536 return 'git checkout --ours'
539 class BlamePaths(ContextCommand
):
540 """Blame view for paths."""
544 return N_('Blame...')
546 def __init__(self
, context
, paths
=None):
547 super().__init
__(context
)
549 paths
= context
.selection
.union()
550 viewer
= utils
.shell_split(prefs
.blame_viewer(context
))
551 self
.argv
= viewer
+ list(paths
)
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(
562 Interaction
.critical(title
, message
=msg
, details
=details
)
565 class CheckoutBranch(Checkout
):
566 """Checkout a branch."""
568 def __init__(self
, context
, 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
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
)
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
= ''
615 self
.model
.update_file_status()
618 class ResetCommand(ConfirmAction
):
619 """Reset state using the "git reset" command"""
621 def __init__(self
, context
, ref
):
622 super().__init
__(context
)
631 def error_message(self
):
635 self
.model
.update_file_status()
638 raise NotImplementedError('confirm() must be overridden')
641 raise NotImplementedError('reset() must be overridden')
644 class ResetMixed(ResetCommand
):
647 tooltip
= N_('The branch will be reset using "git reset --mixed %s"')
651 title
= N_('Reset Branch and Stage (Mixed)')
652 question
= N_('Point the current branch head to a new commit?')
653 info
= self
.tooltip(self
.ref
)
654 ok_text
= N_('Reset Branch')
655 return Interaction
.confirm(title
, question
, info
, ok_text
)
658 return self
.git
.reset(self
.ref
, '--', mixed
=True)
661 class ResetKeep(ResetCommand
):
664 tooltip
= N_('The repository will be reset using "git reset --keep %s"')
668 title
= N_('Restore Worktree and Reset All (Keep Unstaged Changes)')
669 question
= N_('Restore worktree, reset, and preserve unstaged edits?')
670 info
= self
.tooltip(self
.ref
)
671 ok_text
= N_('Reset and Restore')
672 return Interaction
.confirm(title
, question
, info
, ok_text
)
675 return self
.git
.reset(self
.ref
, '--', keep
=True)
678 class ResetMerge(ResetCommand
):
681 tooltip
= N_('The repository will be reset using "git reset --merge %s"')
685 title
= N_('Restore Worktree and Reset All (Merge)')
686 question
= N_('Reset Worktree and Reset All?')
687 info
= self
.tooltip(self
.ref
)
688 ok_text
= N_('Reset and Restore')
689 return Interaction
.confirm(title
, question
, info
, ok_text
)
692 return self
.git
.reset(self
.ref
, '--', merge
=True)
695 class ResetSoft(ResetCommand
):
698 tooltip
= N_('The branch will be reset using "git reset --soft %s"')
702 title
= N_('Reset Branch (Soft)')
703 question
= N_('Reset branch?')
704 info
= self
.tooltip(self
.ref
)
705 ok_text
= N_('Reset Branch')
706 return Interaction
.confirm(title
, question
, info
, ok_text
)
709 return self
.git
.reset(self
.ref
, '--', soft
=True)
712 class ResetHard(ResetCommand
):
715 tooltip
= N_('The repository will be reset using "git reset --hard %s"')
719 title
= N_('Restore Worktree and Reset All (Hard)')
720 question
= N_('Restore Worktree and Reset All?')
721 info
= self
.tooltip(self
.ref
)
722 ok_text
= N_('Reset and Restore')
723 return Interaction
.confirm(title
, question
, info
, ok_text
)
726 return self
.git
.reset(self
.ref
, '--', hard
=True)
729 class RestoreWorktree(ConfirmAction
):
730 """Reset the worktree using the "git read-tree" command"""
735 'The worktree will be restored using "git read-tree --reset -u %s"'
739 def __init__(self
, context
, ref
):
740 super().__init
__(context
)
744 return self
.git
.read_tree(self
.ref
, reset
=True, u
=True)
747 return 'git read-tree --reset -u %s' % self
.ref
749 def error_message(self
):
753 self
.model
.update_file_status()
756 title
= N_('Restore Worktree')
757 question
= N_('Restore Worktree to %s?') % self
.ref
758 info
= self
.tooltip(self
.ref
)
759 ok_text
= N_('Restore Worktree')
760 return Interaction
.confirm(title
, question
, info
, ok_text
)
763 class UndoLastCommit(ResetCommand
):
764 """Undo the last commit"""
766 # NOTE: this is the similar to ResetSoft() with an additional check for
767 # published commits and different messages.
768 def __init__(self
, context
):
769 super().__init
__(context
, 'HEAD^')
772 check_published
= prefs
.check_published_commits(self
.context
)
773 if check_published
and self
.model
.is_commit_published():
774 return Interaction
.confirm(
775 N_('Rewrite Published Commit?'),
777 'This commit has already been published.\n'
778 'This operation will rewrite published history.\n'
779 "You probably don't want to do this."
781 N_('Undo the published commit?'),
782 N_('Undo Last Commit'),
787 title
= N_('Undo Last Commit')
788 question
= N_('Undo last commit?')
789 info
= N_('The branch will be reset using "git reset --soft %s"')
790 ok_text
= N_('Undo Last Commit')
791 info_text
= info
% self
.ref
792 return Interaction
.confirm(title
, question
, info_text
, ok_text
)
795 return self
.git
.reset('HEAD^', '--', soft
=True)
798 class Commit(ResetMode
):
799 """Attempt to create a new commit."""
801 def __init__(self
, context
, amend
, msg
, sign
, no_verify
=False):
802 super().__init
__(context
)
806 self
.no_verify
= no_verify
807 self
.old_commitmsg
= self
.model
.commitmsg
808 self
.new_commitmsg
= ''
811 # Create the commit message file
812 context
= self
.context
814 tmp_file
= utils
.tmp_filename('commit-message')
816 core
.write(tmp_file
, msg
)
818 status
, out
, err
= self
.git
.commit(
823 no_verify
=self
.no_verify
,
826 core
.unlink(tmp_file
)
829 if context
.cfg
.get(prefs
.AUTOTEMPLATE
):
830 template_loader
= LoadCommitMessageFromTemplate(context
)
833 self
.model
.set_commitmsg(self
.new_commitmsg
)
835 return status
, out
, err
838 def strip_comments(msg
, comment_char
='#'):
841 line
for line
in msg
.split('\n') if not line
.startswith(comment_char
)
843 msg
= '\n'.join(message_lines
)
844 if not msg
.endswith('\n'):
850 class CycleReferenceSort(ContextCommand
):
851 """Choose the next reference sort type"""
854 self
.model
.cycle_ref_sort()
857 class Ignore(ContextCommand
):
858 """Add files to an exclusion file"""
860 def __init__(self
, context
, filenames
, local
=False):
861 super().__init
__(context
)
862 self
.filenames
= list(filenames
)
866 if not self
.filenames
:
868 new_additions
= '\n'.join(self
.filenames
) + '\n'
869 for_status
= new_additions
871 filename
= os
.path
.join('.git', 'info', 'exclude')
873 filename
= '.gitignore'
874 if core
.exists(filename
):
875 current_list
= core
.read(filename
)
876 new_additions
= current_list
.rstrip() + '\n' + new_additions
877 core
.write(filename
, new_additions
)
878 Interaction
.log_status(0, f
'Added to {filename}:\n{for_status}', '')
879 self
.model
.update_file_status()
882 def file_summary(files
):
883 txt
= core
.list2cmdline(files
)
885 txt
= txt
[:768].rstrip() + '...'
886 wrap
= textwrap
.TextWrapper()
887 return '\n'.join(wrap
.wrap(txt
))
890 class RemoteCommand(ConfirmAction
):
891 def __init__(self
, context
, remote
):
892 super().__init
__(context
)
897 self
.model
.update_remotes()
900 class RemoteAdd(RemoteCommand
):
901 def __init__(self
, context
, remote
, url
):
902 super().__init
__(context
, remote
)
906 return self
.git
.remote('add', self
.remote
, self
.url
)
908 def error_message(self
):
909 return N_('Error creating remote "%s"') % self
.remote
912 return f
'git remote add "{self.remote}" "{self.url}"'
915 class RemoteRemove(RemoteCommand
):
917 title
= N_('Delete Remote')
918 question
= N_('Delete remote?')
919 info
= N_('Delete remote "%s"') % self
.remote
920 ok_text
= N_('Delete')
921 return Interaction
.confirm(title
, question
, info
, ok_text
)
924 return self
.git
.remote('rm', self
.remote
)
926 def error_message(self
):
927 return N_('Error deleting remote "%s"') % self
.remote
930 return 'git remote rm "%s"' % self
.remote
933 class RemoteRename(RemoteCommand
):
934 def __init__(self
, context
, remote
, new_name
):
935 super().__init
__(context
, remote
)
936 self
.new_name
= new_name
939 title
= N_('Rename Remote')
940 text
= N_('Rename remote "%(current)s" to "%(new)s"?') % {
941 'current': self
.remote
,
942 'new': self
.new_name
,
946 return Interaction
.confirm(title
, text
, info_text
, ok_text
)
949 return self
.git
.remote('rename', self
.remote
, self
.new_name
)
951 def error_message(self
):
952 return N_('Error renaming "%(name)s" to "%(new_name)s"') % {
954 'new_name': self
.new_name
,
958 return f
'git remote rename "{self.remote}" "{self.new_name}"'
961 class RemoteSetURL(RemoteCommand
):
962 def __init__(self
, context
, remote
, url
):
963 super().__init
__(context
, remote
)
967 return self
.git
.remote('set-url', self
.remote
, self
.url
)
969 def error_message(self
):
970 return N_('Unable to set URL for "%(name)s" to "%(url)s"') % {
976 return f
'git remote set-url "{self.remote}" "{self.url}"'
979 class RemoteEdit(ContextCommand
):
980 """Combine RemoteRename and RemoteSetURL"""
982 def __init__(self
, context
, old_name
, remote
, url
):
983 super().__init
__(context
)
984 self
.rename
= RemoteRename(context
, old_name
, remote
)
985 self
.set_url
= RemoteSetURL(context
, remote
, url
)
988 result
= self
.rename
.do()
992 result
= self
.set_url
.do()
994 return name_ok
, url_ok
997 class RemoveFromSettings(ConfirmAction
):
998 def __init__(self
, context
, repo
, entry
, icon
=None):
999 super().__init
__(context
)
1000 self
.context
= context
1006 self
.context
.settings
.save()
1009 class RemoveBookmark(RemoveFromSettings
):
1012 title
= msg
= N_('Delete Bookmark?')
1013 info
= N_('%s will be removed from your bookmarks.') % entry
1014 ok_text
= N_('Delete Bookmark')
1015 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
1018 self
.context
.settings
.remove_bookmark(self
.repo
, self
.entry
)
1022 class RemoveRecent(RemoveFromSettings
):
1025 title
= msg
= N_('Remove %s from the recent list?') % repo
1026 info
= N_('%s will be removed from your recent repositories.') % repo
1027 ok_text
= N_('Remove')
1028 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
1031 self
.context
.settings
.remove_recent(self
.repo
)
1035 class RemoveFiles(ContextCommand
):
1038 def __init__(self
, context
, remover
, filenames
):
1039 super().__init
__(context
)
1042 self
.remover
= remover
1043 self
.filenames
= filenames
1044 # We could git-hash-object stuff and provide undo-ability
1045 # as an option. Heh.
1048 files
= self
.filenames
1054 remove
= self
.remover
1055 for filename
in files
:
1061 bad_filenames
.append(filename
)
1064 Interaction
.information(
1065 N_('Error'), N_('Deleting "%s" failed') % file_summary(bad_filenames
)
1069 self
.model
.update_file_status()
1072 class Delete(RemoveFiles
):
1075 def __init__(self
, context
, filenames
):
1076 super().__init
__(context
, os
.remove
, filenames
)
1079 files
= self
.filenames
1083 title
= N_('Delete Files?')
1084 msg
= N_('The following files will be deleted:') + '\n\n'
1085 msg
+= file_summary(files
)
1086 info_txt
= N_('Delete %d file(s)?') % len(files
)
1087 ok_txt
= N_('Delete Files')
1089 if Interaction
.confirm(
1090 title
, msg
, info_txt
, ok_txt
, default
=True, icon
=icons
.remove()
1095 class MoveToTrash(RemoveFiles
):
1096 """Move files to the trash using send2trash"""
1098 AVAILABLE
= send2trash
is not None
1100 def __init__(self
, context
, filenames
):
1101 super().__init
__(context
, send2trash
, filenames
)
1104 class DeleteBranch(ConfirmAction
):
1105 """Delete a git branch."""
1107 def __init__(self
, context
, branch
):
1108 super().__init
__(context
)
1109 self
.branch
= branch
1112 title
= N_('Delete Branch')
1113 question
= N_('Delete branch "%s"?') % self
.branch
1114 info
= N_('The branch will be no longer available.')
1115 ok_txt
= N_('Delete Branch')
1116 return Interaction
.confirm(
1117 title
, question
, info
, ok_txt
, default
=True, icon
=icons
.discard()
1121 return self
.model
.delete_branch(self
.branch
)
1123 def error_message(self
):
1124 return N_('Error deleting branch "%s"' % self
.branch
)
1127 command
= 'git branch -D %s'
1128 return command
% self
.branch
1131 class Rename(ContextCommand
):
1132 """Rename a set of paths."""
1134 def __init__(self
, context
, paths
):
1135 super().__init
__(context
)
1139 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
1140 Interaction
.log(msg
)
1142 for path
in self
.paths
:
1143 ok
= self
.rename(path
)
1147 self
.model
.update_status()
1149 def rename(self
, path
):
1151 title
= N_('Rename "%s"') % path
1153 if os
.path
.isdir(path
):
1154 base_path
= os
.path
.dirname(path
)
1157 new_path
= Interaction
.save_as(base_path
, title
)
1161 status
, out
, err
= git
.mv(path
, new_path
, force
=True, verbose
=True)
1162 Interaction
.command(N_('Error'), 'git mv', status
, out
, err
)
1166 class RenameBranch(ContextCommand
):
1167 """Rename a git branch."""
1169 def __init__(self
, context
, branch
, new_branch
):
1170 super().__init
__(context
)
1171 self
.branch
= branch
1172 self
.new_branch
= new_branch
1175 branch
= self
.branch
1176 new_branch
= self
.new_branch
1177 status
, out
, err
= self
.model
.rename_branch(branch
, new_branch
)
1178 Interaction
.log_status(status
, out
, err
)
1181 class DeleteRemoteBranch(DeleteBranch
):
1182 """Delete a remote git branch."""
1184 def __init__(self
, context
, remote
, branch
):
1185 super().__init
__(context
, branch
)
1186 self
.remote
= remote
1189 return self
.git
.push(self
.remote
, self
.branch
, delete
=True)
1192 self
.model
.update_status()
1193 Interaction
.information(
1194 N_('Remote Branch Deleted'),
1195 N_('"%(branch)s" has been deleted from "%(remote)s".')
1197 'branch': self
.branch
,
1198 'remote': self
.remote
,
1202 def error_message(self
):
1203 return N_('Error Deleting Remote Branch')
1206 command
= 'git push --delete %s %s'
1207 return command
% (self
.remote
, self
.branch
)
1210 def get_mode(context
, filename
, staged
, modified
, unmerged
, untracked
):
1211 model
= context
.model
1213 mode
= model
.mode_index
1214 elif modified
or unmerged
:
1215 mode
= model
.mode_worktree
1217 if gitcmds
.is_binary(context
, filename
):
1218 mode
= model
.mode_untracked
1220 mode
= model
.mode_untracked_diff
1226 class DiffAgainstCommitMode(ContextCommand
):
1227 """Diff against arbitrary commits"""
1229 def __init__(self
, context
, oid
):
1230 super().__init
__(context
)
1234 self
.model
.set_mode(self
.model
.mode_diff
, head
=self
.oid
)
1235 self
.model
.update_file_status()
1238 class DiffText(EditModel
):
1239 """Set the diff type to text"""
1241 def __init__(self
, context
):
1242 super().__init
__(context
)
1243 self
.new_file_type
= main
.Types
.TEXT
1244 self
.new_diff_type
= main
.Types
.TEXT
1247 class ToggleDiffType(ContextCommand
):
1248 """Toggle the diff type between image and text"""
1250 def __init__(self
, context
):
1251 super().__init
__(context
)
1252 if self
.model
.diff_type
== main
.Types
.IMAGE
:
1253 self
.new_diff_type
= main
.Types
.TEXT
1254 self
.new_value
= False
1256 self
.new_diff_type
= main
.Types
.IMAGE
1257 self
.new_value
= True
1260 diff_type
= self
.new_diff_type
1261 value
= self
.new_value
1263 self
.model
.set_diff_type(diff_type
)
1265 filename
= self
.model
.filename
1266 _
, ext
= os
.path
.splitext(filename
)
1267 if ext
.startswith('.'):
1268 cfg
= 'cola.imagediff' + ext
1269 self
.cfg
.set_repo(cfg
, value
)
1272 class DiffImage(EditModel
):
1274 self
, context
, filename
, deleted
, staged
, modified
, unmerged
, untracked
1276 super().__init
__(context
)
1278 self
.new_filename
= filename
1279 self
.new_diff_type
= self
.get_diff_type(filename
)
1280 self
.new_file_type
= main
.Types
.IMAGE
1281 self
.new_mode
= get_mode(
1282 context
, filename
, staged
, modified
, unmerged
, untracked
1284 self
.staged
= staged
1285 self
.modified
= modified
1286 self
.unmerged
= unmerged
1287 self
.untracked
= untracked
1288 self
.deleted
= deleted
1289 self
.annex
= self
.cfg
.is_annex()
1291 def get_diff_type(self
, filename
):
1292 """Query the diff type to use based on cola.imagediff.<extension>"""
1293 _
, ext
= os
.path
.splitext(filename
)
1294 if ext
.startswith('.'):
1295 # Check eg. "cola.imagediff.svg" to see if we should imagediff.
1296 cfg
= 'cola.imagediff' + ext
1297 if self
.cfg
.get(cfg
, True):
1298 result
= main
.Types
.IMAGE
1300 result
= main
.Types
.TEXT
1302 result
= main
.Types
.IMAGE
1306 filename
= self
.new_filename
1309 images
= self
.staged_images()
1311 images
= self
.modified_images()
1313 images
= self
.unmerged_images()
1314 elif self
.untracked
:
1315 images
= [(filename
, False)]
1319 self
.model
.set_images(images
)
1322 def staged_images(self
):
1323 context
= self
.context
1325 head
= self
.model
.head
1326 filename
= self
.new_filename
1330 index
= git
.diff_index(head
, '--', filename
, cached
=True)[STDOUT
]
1333 # :100644 100644 fabadb8... 4866510... M describe.c
1334 parts
= index
.split(' ')
1339 if old_oid
!= MISSING_BLOB_OID
:
1340 # First, check if we can get a pre-image from git-annex
1343 annex_image
= gitcmds
.annex_path(context
, head
, filename
)
1345 images
.append((annex_image
, False)) # git annex HEAD
1347 image
= gitcmds
.write_blob_path(context
, head
, old_oid
, filename
)
1349 images
.append((image
, True))
1351 if new_oid
!= MISSING_BLOB_OID
:
1352 found_in_annex
= False
1353 if annex
and core
.islink(filename
):
1354 status
, out
, _
= git
.annex('status', '--', filename
)
1356 details
= out
.split(' ')
1357 if details
and details
[0] == 'A': # newly added file
1358 images
.append((filename
, False))
1359 found_in_annex
= True
1361 if not found_in_annex
:
1362 image
= gitcmds
.write_blob(context
, new_oid
, filename
)
1364 images
.append((image
, True))
1368 def unmerged_images(self
):
1369 context
= self
.context
1371 head
= self
.model
.head
1372 filename
= self
.new_filename
1375 candidate_merge_heads
= ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1378 for merge_head
in candidate_merge_heads
1379 if core
.exists(git
.git_path(merge_head
))
1382 if annex
: # Attempt to find files in git-annex
1384 for merge_head
in merge_heads
:
1385 image
= gitcmds
.annex_path(context
, merge_head
, filename
)
1387 annex_images
.append((image
, False))
1389 annex_images
.append((filename
, False))
1392 # DIFF FORMAT FOR MERGES
1393 # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1394 # can take -c or --cc option to generate diff output also
1395 # for merge commits. The output differs from the format
1396 # described above in the following way:
1398 # 1. there is a colon for each parent
1399 # 2. there are more "src" modes and "src" sha1
1400 # 3. status is concatenated status characters for each parent
1401 # 4. no optional "score" number
1402 # 5. single path, only for "dst"
1404 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1407 index
= git
.diff_index(head
, '--', filename
, cached
=True, cc
=True)[STDOUT
]
1409 parts
= index
.split(' ')
1411 first_mode
= parts
[0]
1412 num_parents
= first_mode
.count(':')
1413 # colon for each parent, but for the index, the "parents"
1414 # are really entries in stages 1,2,3 (head, base, remote)
1415 # remote, base, head
1416 for i
in range(num_parents
):
1417 offset
= num_parents
+ i
+ 1
1420 merge_head
= merge_heads
[i
]
1423 if oid
!= MISSING_BLOB_OID
:
1424 image
= gitcmds
.write_blob_path(
1425 context
, merge_head
, oid
, filename
1428 images
.append((image
, True))
1430 images
.append((filename
, False))
1433 def modified_images(self
):
1434 context
= self
.context
1436 head
= self
.model
.head
1437 filename
= self
.new_filename
1442 if annex
: # Check for a pre-image from git-annex
1443 annex_image
= gitcmds
.annex_path(context
, head
, filename
)
1445 images
.append((annex_image
, False)) # git annex HEAD
1447 worktree
= git
.diff_files('--', filename
)[STDOUT
]
1448 parts
= worktree
.split(' ')
1451 if oid
!= MISSING_BLOB_OID
:
1452 image
= gitcmds
.write_blob_path(context
, head
, oid
, filename
)
1454 images
.append((image
, True)) # HEAD
1456 images
.append((filename
, False)) # worktree
1460 class Diff(EditModel
):
1461 """Perform a diff and set the model's current text."""
1463 def __init__(self
, context
, filename
, cached
=False, deleted
=False):
1464 super().__init
__(context
)
1466 if cached
and gitcmds
.is_valid_ref(context
, self
.model
.head
):
1467 opts
['ref'] = self
.model
.head
1468 self
.new_filename
= filename
1469 self
.new_mode
= self
.model
.mode_worktree
1470 self
.new_diff_text
= gitcmds
.diff_helper(
1471 self
.context
, filename
=filename
, cached
=cached
, deleted
=deleted
, **opts
1475 class Diffstat(EditModel
):
1476 """Perform a diffstat and set the model's diff text."""
1478 def __init__(self
, context
):
1479 super().__init
__(context
)
1481 diff_context
= cfg
.get('diff.context', 3)
1482 diff
= self
.git
.diff(
1484 unified
=diff_context
,
1490 self
.new_diff_text
= diff
1491 self
.new_diff_type
= main
.Types
.TEXT
1492 self
.new_file_type
= main
.Types
.TEXT
1493 self
.new_mode
= self
.model
.mode_diffstat
1496 class DiffStaged(Diff
):
1497 """Perform a staged diff on a file."""
1499 def __init__(self
, context
, filename
, deleted
=None):
1500 super().__init
__(context
, filename
, cached
=True, deleted
=deleted
)
1501 self
.new_mode
= self
.model
.mode_index
1504 class DiffStagedSummary(EditModel
):
1505 def __init__(self
, context
):
1506 super().__init
__(context
)
1507 diff
= self
.git
.diff(
1512 patch_with_stat
=True,
1515 self
.new_diff_text
= diff
1516 self
.new_diff_type
= main
.Types
.TEXT
1517 self
.new_file_type
= main
.Types
.TEXT
1518 self
.new_mode
= self
.model
.mode_index
1521 class Edit(ContextCommand
):
1522 """Edit a file using the configured gui.editor."""
1526 return N_('Launch Editor')
1528 def __init__(self
, context
, filenames
, line_number
=None, background_editor
=False):
1529 super().__init
__(context
)
1530 self
.filenames
= filenames
1531 self
.line_number
= line_number
1532 self
.background_editor
= background_editor
1535 context
= self
.context
1536 if not self
.filenames
:
1538 filename
= self
.filenames
[0]
1539 if not core
.exists(filename
):
1541 if self
.background_editor
:
1542 editor
= prefs
.background_editor(context
)
1544 editor
= prefs
.editor(context
)
1547 if self
.line_number
is None:
1548 opts
= self
.filenames
1550 # Single-file w/ line-numbers (likely from grep)
1552 '*vim*': [filename
, '+%s' % self
.line_number
],
1553 '*emacs*': ['+%s' % self
.line_number
, filename
],
1554 '*textpad*': [f
'{filename}({self.line_number},0)'],
1555 '*notepad++*': ['-n%s' % self
.line_number
, filename
],
1556 '*subl*': [f
'{filename}:{self.line_number}'],
1559 opts
= self
.filenames
1560 for pattern
, opt
in editor_opts
.items():
1561 if fnmatch(editor
, pattern
):
1566 core
.fork(utils
.shell_split(editor
) + opts
)
1567 except (OSError, ValueError) as e
:
1568 message
= N_('Cannot exec "%s": please configure your editor') % editor
1569 _
, details
= utils
.format_exception(e
)
1570 Interaction
.critical(N_('Error Editing File'), message
, details
)
1573 class FormatPatch(ContextCommand
):
1574 """Output a patch series given all revisions and a selected subset."""
1576 def __init__(self
, context
, to_export
, revs
, output
='patches'):
1577 super().__init
__(context
)
1578 self
.to_export
= list(to_export
)
1579 self
.revs
= list(revs
)
1580 self
.output
= output
1583 context
= self
.context
1584 status
, out
, err
= gitcmds
.format_patchsets(
1585 context
, self
.to_export
, self
.revs
, self
.output
1587 Interaction
.log_status(status
, out
, err
)
1590 class LaunchTerminal(ContextCommand
):
1593 return N_('Launch Terminal')
1596 def is_available(context
):
1597 return context
.cfg
.terminal() is not None
1599 def __init__(self
, context
, path
):
1600 super().__init
__(context
)
1604 cmd
= self
.context
.cfg
.terminal()
1607 if utils
.is_win32():
1608 argv
= ['start', '', cmd
, '--login']
1611 argv
= utils
.shell_split(cmd
)
1613 shells
= ('zsh', 'fish', 'bash', 'sh')
1614 for basename
in shells
:
1615 executable
= core
.find_executable(basename
)
1617 command
= executable
1619 argv
.append(os
.getenv('SHELL', command
))
1622 core
.fork(argv
, cwd
=self
.path
, shell
=shell
)
1625 class LaunchEditor(Edit
):
1628 return N_('Launch Editor')
1630 def __init__(self
, context
):
1631 s
= context
.selection
.selection()
1632 filenames
= s
.staged
+ s
.unmerged
+ s
.modified
+ s
.untracked
1633 super().__init
__(context
, filenames
, background_editor
=True)
1636 class LaunchEditorAtLine(LaunchEditor
):
1637 """Launch an editor at the specified line"""
1639 def __init__(self
, context
):
1640 super().__init
__(context
)
1641 self
.line_number
= context
.selection
.line_number
1644 class LoadCommitMessageFromFile(ContextCommand
):
1645 """Loads a commit message from a path."""
1649 def __init__(self
, context
, path
):
1650 super().__init
__(context
)
1652 self
.old_commitmsg
= self
.model
.commitmsg
1653 self
.old_directory
= self
.model
.directory
1656 path
= os
.path
.expanduser(self
.path
)
1657 if not path
or not core
.isfile(path
):
1659 N_('Error: Cannot find commit template'),
1660 N_('%s: No such file or directory.') % path
,
1662 self
.model
.set_directory(os
.path
.dirname(path
))
1663 self
.model
.set_commitmsg(core
.read(path
))
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
):
1675 template
= cfg
.get('commit.template')
1676 super().__init
__(context
, template
)
1679 if self
.path
is None:
1681 N_('Error: Unconfigured commit template'),
1683 'A commit template has not been configured.\n'
1684 'Use "git config" to define "commit.template"\n'
1685 'so that it points to a commit template.'
1688 return LoadCommitMessageFromFile
.do(self
)
1691 class LoadCommitMessageFromOID(ContextCommand
):
1692 """Load a previous commit message"""
1696 def __init__(self
, context
, oid
, prefix
=''):
1697 super().__init
__(context
)
1699 self
.old_commitmsg
= self
.model
.commitmsg
1700 self
.new_commitmsg
= prefix
+ gitcmds
.prev_commitmsg(context
, oid
)
1703 self
.model
.set_commitmsg(self
.new_commitmsg
)
1706 self
.model
.set_commitmsg(self
.old_commitmsg
)
1709 class PrepareCommitMessageHook(ContextCommand
):
1710 """Use the cola-prepare-commit-msg hook to prepare the commit message"""
1714 def __init__(self
, context
):
1715 super().__init
__(context
)
1716 self
.old_commitmsg
= self
.model
.commitmsg
1718 def get_message(self
):
1719 title
= N_('Error running prepare-commitmsg hook')
1720 hook
= gitcmds
.prepare_commit_message_hook(self
.context
)
1722 if os
.path
.exists(hook
):
1723 filename
= self
.model
.save_commitmsg()
1724 status
, out
, err
= core
.run_command([hook
, filename
])
1727 result
= core
.read(filename
)
1729 result
= self
.old_commitmsg
1730 Interaction
.command_error(title
, hook
, status
, out
, err
)
1732 message
= N_('A hook must be provided at "%s"') % hook
1733 Interaction
.critical(title
, message
=message
)
1734 result
= self
.old_commitmsg
1739 msg
= self
.get_message()
1740 self
.model
.set_commitmsg(msg
)
1743 self
.model
.set_commitmsg(self
.old_commitmsg
)
1746 class LoadFixupMessage(LoadCommitMessageFromOID
):
1747 """Load a fixup message"""
1749 def __init__(self
, context
, oid
):
1750 super().__init
__(context
, oid
, prefix
='fixup! ')
1751 if self
.new_commitmsg
:
1752 self
.new_commitmsg
= self
.new_commitmsg
.splitlines()[0]
1755 class Merge(ContextCommand
):
1758 def __init__(self
, context
, revision
, no_commit
, squash
, no_ff
, sign
):
1759 super().__init
__(context
)
1760 self
.revision
= revision
1762 self
.no_commit
= no_commit
1763 self
.squash
= squash
1767 squash
= self
.squash
1768 revision
= self
.revision
1770 no_commit
= self
.no_commit
1773 status
, out
, err
= self
.git
.merge(
1774 revision
, gpg_sign
=sign
, no_ff
=no_ff
, no_commit
=no_commit
, squash
=squash
1776 self
.model
.update_status()
1777 title
= N_('Merge failed. Conflict resolution is required.')
1778 Interaction
.command(title
, 'git merge', status
, out
, err
)
1780 return status
, out
, err
1783 class OpenDefaultApp(ContextCommand
):
1784 """Open a file using the OS default."""
1788 return N_('Open Using Default Application')
1790 def __init__(self
, context
, filenames
):
1791 super().__init
__(context
)
1792 self
.filenames
= filenames
1795 if not self
.filenames
:
1797 utils
.launch_default_app(self
.filenames
)
1800 class OpenDir(OpenDefaultApp
):
1801 """Open directories using the OS default."""
1805 return N_('Open Directory')
1808 def _dirnames(self
):
1809 return self
.filenames
1812 dirnames
= self
._dirnames
1815 # An empty dirname defaults to CWD.
1816 dirs
= [(dirname
or core
.getcwd()) for dirname
in dirnames
]
1817 utils
.launch_default_app(dirs
)
1820 class OpenParentDir(OpenDir
):
1821 """Open parent directories using the OS default."""
1825 return N_('Open Parent Directory')
1828 def _dirnames(self
):
1829 dirnames
= list({os
.path
.dirname(x
) for x
in self
.filenames
})
1833 class OpenWorktree(OpenDir
):
1834 """Open worktree directory using the OS default."""
1838 return N_('Open Worktree')
1840 # The _unused parameter is needed by worktree_dir_action() -> common.cmd_action().
1841 def __init__(self
, context
, _unused
=None):
1842 dirnames
= [context
.git
.worktree()]
1843 super().__init
__(context
, dirnames
)
1846 class OpenNewRepo(ContextCommand
):
1847 """Launches git-cola on a repo."""
1849 def __init__(self
, context
, repo_path
):
1850 super().__init
__(context
)
1851 self
.repo_path
= repo_path
1854 self
.model
.set_directory(self
.repo_path
)
1855 core
.fork([sys
.executable
, sys
.argv
[0], '--repo', self
.repo_path
])
1858 class OpenRepo(EditModel
):
1859 def __init__(self
, context
, repo_path
):
1860 super().__init
__(context
)
1861 self
.repo_path
= repo_path
1862 self
.new_mode
= self
.model
.mode_none
1863 self
.new_diff_text
= ''
1864 self
.new_diff_type
= main
.Types
.TEXT
1865 self
.new_file_type
= main
.Types
.TEXT
1866 self
.new_commitmsg
= ''
1867 self
.new_filename
= ''
1870 old_repo
= self
.git
.getcwd()
1871 if self
.model
.set_worktree(self
.repo_path
):
1872 self
.fsmonitor
.stop()
1873 self
.fsmonitor
.start()
1874 self
.model
.update_status(reset
=True)
1875 # Check if template should be loaded
1876 if self
.context
.cfg
.get(prefs
.AUTOTEMPLATE
):
1877 template_loader
= LoadCommitMessageFromTemplate(self
.context
)
1878 template_loader
.do()
1880 self
.model
.set_commitmsg(self
.new_commitmsg
)
1881 settings
= self
.context
.settings
1883 settings
.add_recent(self
.repo_path
, prefs
.maxrecent(self
.context
))
1887 self
.model
.set_worktree(old_repo
)
1890 class OpenParentRepo(OpenRepo
):
1891 def __init__(self
, context
):
1893 if version
.check_git(context
, 'show-superproject-working-tree'):
1894 status
, out
, _
= context
.git
.rev_parse(show_superproject_working_tree
=True)
1898 path
= os
.path
.dirname(core
.getcwd())
1899 super().__init
__(context
, path
)
1902 class Clone(ContextCommand
):
1903 """Clones a repository and optionally spawns a new cola session."""
1906 self
, context
, url
, new_directory
, submodules
=False, shallow
=False, spawn
=True
1908 super().__init
__(context
)
1910 self
.new_directory
= new_directory
1911 self
.submodules
= submodules
1912 self
.shallow
= shallow
1922 recurse_submodules
= self
.submodules
1923 shallow_submodules
= self
.submodules
and self
.shallow
1925 status
, out
, err
= self
.git
.clone(
1928 recurse_submodules
=recurse_submodules
,
1929 shallow_submodules
=shallow_submodules
,
1933 self
.status
= status
1936 if status
== 0 and self
.spawn
:
1937 executable
= sys
.executable
1938 core
.fork([executable
, sys
.argv
[0], '--repo', self
.new_directory
])
1942 class NewBareRepo(ContextCommand
):
1943 """Create a new shared bare repository"""
1945 def __init__(self
, context
, path
):
1946 super().__init
__(context
)
1951 status
, out
, err
= self
.git
.init(path
, bare
=True, shared
=True)
1952 Interaction
.command(
1953 N_('Error'), 'git init --bare --shared "%s"' % path
, status
, out
, err
1958 def unix_path(path
, is_win32
=utils
.is_win32
):
1959 """Git for Windows requires unix paths, so force them here"""
1961 path
= path
.replace('\\', '/')
1964 if second
== ':': # sanity check, this better be a Windows-style path
1965 path
= '/' + first
+ path
[2:]
1970 def sequence_editor():
1971 """Set GIT_SEQUENCE_EDITOR for running git-cola-sequence-editor"""
1972 xbase
= unix_path(resources
.command('git-cola-sequence-editor'))
1973 if utils
.is_win32():
1974 editor
= core
.list2cmdline([unix_path(sys
.executable
), xbase
])
1976 editor
= core
.list2cmdline([xbase
])
1980 class SequenceEditorEnvironment
:
1981 """Set environment variables to enable git-cola-sequence-editor"""
1983 def __init__(self
, context
, **kwargs
):
1985 'GIT_EDITOR': prefs
.editor(context
),
1986 'GIT_SEQUENCE_EDITOR': sequence_editor(),
1987 'GIT_COLA_SEQ_EDITOR_CANCEL_ACTION': 'save',
1989 self
.env
.update(kwargs
)
1991 def __enter__(self
):
1992 for var
, value
in self
.env
.items():
1993 compat
.setenv(var
, value
)
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
):
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
]
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?')
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(
2049 cancel_text
=cancel_text
,
2052 kwargs
['update_refs'] = True
2055 args
.append(upstream
)
2057 args
.append(self
.branch
)
2062 (status
, out
, err
) = (1, '', '')
2063 context
= self
.context
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(
2077 N_('Select New Upstream'),
2078 N_('Interactive Rebase'),
2079 default
='@{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(
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
):
2111 (status
, out
, err
) = (1, '', '')
2112 with
SequenceEditorEnvironment(
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
):
2125 (status
, out
, err
) = (1, '', '')
2126 with
SequenceEditorEnvironment(
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
):
2139 (status
, out
, err
) = (1, '', '')
2140 with
SequenceEditorEnvironment(
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
):
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"""
2162 self
.model
.update_status()
2165 class Refresh(ContextCommand
):
2166 """Update refs, refresh the index, and update config"""
2170 return N_('Refresh')
2173 self
.model
.update_status(update_index
=True)
2175 self
.fsmonitor
.refresh()
2178 class RefreshConfig(ContextCommand
):
2179 """Refresh the git config cache"""
2185 class RevertEditsCommand(ConfirmAction
):
2186 def __init__(self
, context
):
2187 super().__init
__(context
)
2188 self
.icon
= icons
.undo()
2190 def ok_to_run(self
):
2191 return self
.model
.is_undoable()
2193 def checkout_from_head(self
):
2196 def checkout_args(self
):
2198 s
= self
.selection
.selection()
2199 if self
.checkout_from_head():
2200 args
.append(self
.model
.head
)
2212 checkout_args
= self
.checkout_args()
2213 return self
.git
.checkout(*checkout_args
)
2216 self
.model
.set_diff_type(main
.Types
.TEXT
)
2217 self
.model
.update_file_status()
2220 class RevertUnstagedEdits(RevertEditsCommand
):
2223 return N_('Revert Unstaged Edits...')
2225 def checkout_from_head(self
):
2226 # Being in amend mode should not affect the behavior of this command.
2227 # The only sensible thing to do is to checkout from the index.
2231 title
= N_('Revert Unstaged Changes?')
2233 'This operation removes unstaged edits from selected files.\n'
2234 'These changes cannot be recovered.'
2236 info
= N_('Revert the unstaged changes?')
2237 ok_text
= N_('Revert Unstaged Changes')
2238 return Interaction
.confirm(
2239 title
, text
, info
, ok_text
, default
=True, icon
=self
.icon
2243 class RevertUncommittedEdits(RevertEditsCommand
):
2246 return N_('Revert Uncommitted Edits...')
2248 def checkout_from_head(self
):
2252 """Prompt for reverting changes"""
2253 title
= N_('Revert Uncommitted Changes?')
2255 'This operation removes uncommitted edits from selected files.\n'
2256 'These changes cannot be recovered.'
2258 info
= N_('Revert the uncommitted changes?')
2259 ok_text
= N_('Revert Uncommitted Changes')
2260 return Interaction
.confirm(
2261 title
, text
, info
, ok_text
, default
=True, icon
=self
.icon
2265 class RunConfigAction(ContextCommand
):
2266 """Run a user-configured action, typically from the "Tools" menu"""
2268 def __init__(self
, context
, action_name
):
2269 super().__init
__(context
)
2270 self
.action_name
= action_name
2273 """Run the user-configured action"""
2274 for env
in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
2276 compat
.unsetenv(env
)
2281 context
= self
.context
2283 opts
= cfg
.get_guitool_opts(self
.action_name
)
2284 cmd
= opts
.get('cmd')
2285 if 'title' not in opts
:
2288 if 'prompt' not in opts
or opts
.get('prompt') is True:
2289 prompt
= N_('Run "%s"?') % cmd
2290 opts
['prompt'] = prompt
2292 if opts
.get('needsfile'):
2293 filename
= self
.selection
.filename()
2295 Interaction
.information(
2296 N_('Please select a file'),
2297 N_('"%s" requires a selected file.') % cmd
,
2300 dirname
= utils
.dirname(filename
, current_dir
='.')
2301 compat
.setenv('FILENAME', filename
)
2302 compat
.setenv('DIRNAME', dirname
)
2304 if opts
.get('revprompt') or opts
.get('argprompt'):
2306 ok
= Interaction
.confirm_config_action(context
, cmd
, opts
)
2309 rev
= opts
.get('revision')
2310 args
= opts
.get('args')
2311 if opts
.get('revprompt') and not rev
:
2312 title
= N_('Invalid Revision')
2313 msg
= N_('The revision expression cannot be empty.')
2314 Interaction
.critical(title
, msg
)
2318 elif opts
.get('confirm'):
2319 title
= os
.path
.expandvars(opts
.get('title'))
2320 prompt
= os
.path
.expandvars(opts
.get('prompt'))
2321 if not Interaction
.question(title
, prompt
):
2324 compat
.setenv('REVISION', rev
)
2326 compat
.setenv('ARGS', args
)
2327 title
= os
.path
.expandvars(cmd
)
2328 Interaction
.log(N_('Running command: %s') % title
)
2329 cmd
= ['sh', '-c', cmd
]
2331 if opts
.get('background'):
2333 status
, out
, err
= (0, '', '')
2334 elif opts
.get('noconsole'):
2335 status
, out
, err
= core
.run_command(cmd
)
2337 status
, out
, err
= Interaction
.run_command(title
, cmd
)
2339 if not opts
.get('background') and not opts
.get('norescan'):
2340 self
.model
.update_status()
2343 Interaction
.command(title
, cmd
, status
, out
, err
)
2348 class SetDefaultRepo(ContextCommand
):
2349 """Set the default repository"""
2351 def __init__(self
, context
, repo
):
2352 super().__init
__(context
)
2356 self
.cfg
.set_user('cola.defaultrepo', self
.repo
)
2359 class SetDiffText(EditModel
):
2360 """Set the diff text"""
2364 def __init__(self
, context
, text
):
2365 super().__init
__(context
)
2366 self
.new_diff_text
= text
2367 self
.new_diff_type
= main
.Types
.TEXT
2368 self
.new_file_type
= main
.Types
.TEXT
2371 class SetUpstreamBranch(ContextCommand
):
2372 """Set the upstream branch"""
2374 def __init__(self
, context
, branch
, remote
, remote_branch
):
2375 super().__init
__(context
)
2376 self
.branch
= branch
2377 self
.remote
= remote
2378 self
.remote_branch
= remote_branch
2382 remote
= self
.remote
2383 branch
= self
.branch
2384 remote_branch
= self
.remote_branch
2385 cfg
.set_repo('branch.%s.remote' % branch
, remote
)
2386 cfg
.set_repo('branch.%s.merge' % branch
, 'refs/heads/' + remote_branch
)
2389 def format_hex(data
):
2390 """Translate binary data into a hex dump"""
2391 hexdigits
= '0123456789ABCDEF'
2394 byte_offset_to_int
= compat
.byte_offset_to_int_converter()
2395 while offset
< len(data
):
2396 result
+= '%04u |' % offset
2398 for i
in range(0, 16):
2399 if i
> 0 and i
% 4 == 0:
2401 if offset
< len(data
):
2402 v
= byte_offset_to_int(data
[offset
])
2403 result
+= ' ' + hexdigits
[v
>> 4] + hexdigits
[v
& 0xF]
2404 textpart
+= chr(v
) if 32 <= v
< 127 else '.'
2409 result
+= ' | ' + textpart
+ ' |\n'
2414 class ShowUntracked(EditModel
):
2415 """Show an untracked file."""
2417 def __init__(self
, context
, filename
):
2418 super().__init
__(context
)
2419 self
.new_filename
= filename
2420 if gitcmds
.is_binary(context
, filename
):
2421 self
.new_mode
= self
.model
.mode_untracked
2422 self
.new_diff_text
= self
.read(filename
)
2424 self
.new_mode
= self
.model
.mode_untracked_diff
2425 self
.new_diff_text
= gitcmds
.diff_helper(
2426 self
.context
, filename
=filename
, cached
=False, untracked
=True
2428 self
.new_diff_type
= main
.Types
.TEXT
2429 self
.new_file_type
= main
.Types
.TEXT
2431 def read(self
, filename
):
2432 """Read file contents"""
2434 size
= cfg
.get('cola.readsize', 2048)
2436 result
= core
.read(filename
, size
=size
, encoding
='bytes')
2440 truncated
= len(result
) == size
2442 encoding
= cfg
.file_encoding(filename
) or core
.ENCODING
2444 text_result
= core
.decode_maybe(result
, encoding
)
2445 except UnicodeError:
2446 text_result
= format_hex(result
)
2449 text_result
+= '...'
2453 class SignOff(ContextCommand
):
2454 """Append a signoff to the commit message"""
2460 return N_('Sign Off')
2462 def __init__(self
, context
):
2463 super().__init
__(context
)
2464 self
.old_commitmsg
= self
.model
.commitmsg
2467 """Add a signoff to the commit message"""
2468 signoff
= self
.signoff()
2469 if signoff
in self
.model
.commitmsg
:
2471 msg
= self
.model
.commitmsg
.rstrip()
2472 self
.model
.set_commitmsg(msg
+ '\n' + signoff
)
2475 """Restore the commit message"""
2476 self
.model
.set_commitmsg(self
.old_commitmsg
)
2479 """Generate the signoff string"""
2480 name
, email
= self
.cfg
.get_author()
2481 return f
'\nSigned-off-by: {name} <{email}>'
2484 def check_conflicts(context
, unmerged
):
2485 """Check paths for conflicts
2487 Conflicting files can be filtered out one-by-one.
2490 if prefs
.check_conflicts(context
):
2491 unmerged
= [path
for path
in unmerged
if is_conflict_free(path
)]
2495 def is_conflict_free(path
):
2496 """Return True if `path` contains no conflict markers"""
2497 rgx
= re
.compile(r
'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2499 with core
.xopen(path
, 'rb') as f
:
2501 line
= core
.decode(line
, errors
='ignore')
2503 return should_stage_conflicts(path
)
2505 # We can't read this file ~ we may be staging a removal
2510 def should_stage_conflicts(path
):
2511 """Inform the user that a file contains merge conflicts
2513 Return `True` if we should stage the path nonetheless.
2516 title
= msg
= N_('Stage conflicts?')
2519 '%s appears to contain merge conflicts.\n\n'
2520 'You should probably skip this file.\n'
2525 ok_text
= N_('Stage conflicts')
2526 cancel_text
= N_('Skip')
2527 return Interaction
.confirm(
2528 title
, msg
, info
, ok_text
, default
=False, cancel_text
=cancel_text
2532 class Stage(ContextCommand
):
2533 """Stage a set of paths."""
2539 def __init__(self
, context
, paths
):
2540 super().__init
__(context
)
2544 msg
= N_('Staging: %s') % (', '.join(self
.paths
))
2545 Interaction
.log(msg
)
2546 return self
.stage_paths()
2548 def stage_paths(self
):
2549 """Stages add/removals to git."""
2550 context
= self
.context
2553 if self
.model
.cfg
.get('cola.safemode', False):
2555 return self
.stage_all()
2563 for path
in set(paths
):
2564 if core
.exists(path
) or core
.islink(path
):
2565 if path
.endswith('/'):
2566 path
= path
.rstrip('/')
2571 self
.model
.emit_about_to_update()
2573 # `git add -u` doesn't work on untracked files
2575 status
, out
, err
= gitcmds
.add(context
, add
)
2576 Interaction
.command(N_('Error'), 'git add', status
, out
, err
)
2578 # If a path doesn't exist then that means it should be removed
2579 # from the index. We use `git add -u` for that.
2581 status
, out
, err
= gitcmds
.add(context
, remove
, u
=True)
2582 Interaction
.command(N_('Error'), 'git add -u', status
, out
, err
)
2584 self
.model
.update_files(emit
=True)
2585 return status
, out
, err
2587 def stage_all(self
):
2588 """Stage all files"""
2589 status
, out
, err
= self
.git
.add(v
=True, u
=True)
2590 Interaction
.command(N_('Error'), 'git add -u', status
, out
, err
)
2591 self
.model
.update_file_status()
2592 return (status
, out
, err
)
2595 class StageCarefully(Stage
):
2596 """Only stage when the path list is non-empty
2598 We use "git add -u -- <pathspec>" to stage, and it stages everything by
2599 default when no pathspec is specified, so this class ensures that paths
2600 are specified before calling git.
2602 When no paths are specified, the command does nothing.
2606 def __init__(self
, context
):
2607 super().__init
__(context
, None)
2610 def init_paths(self
):
2611 """Initialize path data"""
2614 def ok_to_run(self
):
2615 """Prevent catch-all "git add -u" from adding unmerged files"""
2616 return self
.paths
or not self
.model
.unmerged
2619 """Stage files when ok_to_run() return True"""
2620 if self
.ok_to_run():
2625 class StageModified(StageCarefully
):
2626 """Stage all modified files."""
2630 return N_('Stage Modified')
2632 def init_paths(self
):
2633 self
.paths
= self
.model
.modified
2636 class StageUnmerged(StageCarefully
):
2637 """Stage unmerged files."""
2641 return N_('Stage Unmerged')
2643 def init_paths(self
):
2644 self
.paths
= check_conflicts(self
.context
, self
.model
.unmerged
)
2647 class StageUntracked(StageCarefully
):
2648 """Stage all untracked files."""
2652 return N_('Stage Untracked')
2654 def init_paths(self
):
2655 self
.paths
= self
.model
.untracked
2658 class StageModifiedAndUntracked(StageCarefully
):
2659 """Stage all untracked files."""
2663 return N_('Stage Modified and Untracked')
2665 def init_paths(self
):
2666 self
.paths
= self
.model
.modified
+ self
.model
.untracked
2669 class StageOrUnstageAll(ContextCommand
):
2670 """If the selection is staged, unstage it, otherwise stage"""
2674 return N_('Stage / Unstage All')
2677 if self
.model
.staged
:
2678 do(Unstage
, self
.context
, self
.model
.staged
)
2680 if self
.cfg
.get('cola.safemode', False):
2681 unstaged
= self
.model
.modified
2683 unstaged
= self
.model
.modified
+ self
.model
.untracked
2684 do(Stage
, self
.context
, unstaged
)
2687 class StageOrUnstage(ContextCommand
):
2688 """If the selection is staged, unstage it, otherwise stage"""
2692 return N_('Stage / Unstage')
2695 s
= self
.selection
.selection()
2697 do(Unstage
, self
.context
, s
.staged
)
2700 unmerged
= check_conflicts(self
.context
, s
.unmerged
)
2702 unstaged
.extend(unmerged
)
2704 unstaged
.extend(s
.modified
)
2706 unstaged
.extend(s
.untracked
)
2708 do(Stage
, self
.context
, unstaged
)
2711 class Tag(ContextCommand
):
2712 """Create a tag object."""
2714 def __init__(self
, context
, name
, revision
, sign
=False, message
=''):
2715 super().__init
__(context
)
2717 self
._message
= message
2718 self
._revision
= revision
2724 revision
= self
._revision
2725 tag_name
= self
._name
2726 tag_message
= self
._message
2729 Interaction
.critical(
2730 N_('Missing Revision'), N_('Please specify a revision to tag.')
2735 Interaction
.critical(
2736 N_('Missing Name'), N_('Please specify a name for the new tag.')
2740 title
= N_('Missing Tag Message')
2741 message
= N_('Tag-signing was requested but the tag message is empty.')
2743 'An unsigned, lightweight tag will be created instead.\n'
2744 'Create an unsigned tag?'
2746 ok_text
= N_('Create Unsigned Tag')
2748 if sign
and not tag_message
:
2749 # We require a message in order to sign the tag, so if they
2750 # choose to create an unsigned tag we have to clear the sign flag.
2751 if not Interaction
.confirm(
2752 title
, message
, info
, ok_text
, default
=False, icon
=icons
.save()
2761 tmp_file
= utils
.tmp_filename('tag-message')
2762 opts
['file'] = tmp_file
2763 core
.write(tmp_file
, tag_message
)
2768 opts
['annotate'] = True
2769 status
, out
, err
= git
.tag(tag_name
, revision
, **opts
)
2772 core
.unlink(tmp_file
)
2774 title
= N_('Error: could not create tag "%s"') % tag_name
2775 Interaction
.command(title
, 'git tag', status
, out
, err
)
2779 self
.model
.update_status()
2780 Interaction
.information(
2782 N_('Created a new tag named "%s"') % tag_name
,
2783 details
=tag_message
or None,
2789 class Unstage(ContextCommand
):
2790 """Unstage a set of paths."""
2794 return N_('Unstage')
2796 def __init__(self
, context
, paths
):
2797 super().__init
__(context
)
2802 context
= self
.context
2803 head
= self
.model
.head
2806 msg
= N_('Unstaging: %s') % (', '.join(paths
))
2807 Interaction
.log(msg
)
2809 return unstage_all(context
)
2810 status
, out
, err
= gitcmds
.unstage_paths(context
, paths
, head
=head
)
2811 Interaction
.command(N_('Error'), 'git reset', status
, out
, err
)
2812 self
.model
.update_file_status()
2813 return (status
, out
, err
)
2816 class UnstageAll(ContextCommand
):
2817 """Unstage all files; resets the index."""
2820 return unstage_all(self
.context
)
2823 def unstage_all(context
):
2824 """Unstage all files, even while amending"""
2825 model
= context
.model
2828 status
, out
, err
= git
.reset(head
, '--', '.')
2829 Interaction
.command(N_('Error'), 'git reset', status
, out
, err
)
2830 model
.update_file_status()
2831 return (status
, out
, err
)
2834 class StageSelected(ContextCommand
):
2835 """Stage selected files, or all files if no selection exists."""
2838 context
= self
.context
2839 paths
= self
.selection
.unstaged
2841 do(Stage
, context
, paths
)
2842 elif self
.cfg
.get('cola.safemode', False):
2843 do(StageModified
, context
)
2846 class UnstageSelected(Unstage
):
2847 """Unstage selected files."""
2849 def __init__(self
, context
):
2850 staged
= context
.selection
.staged
2851 super().__init
__(context
, staged
)
2854 class Untrack(ContextCommand
):
2855 """Unstage a set of paths."""
2857 def __init__(self
, context
, paths
):
2858 super().__init
__(context
)
2862 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
2863 Interaction
.log(msg
)
2864 status
, out
, err
= self
.model
.untrack_paths(self
.paths
)
2865 Interaction
.log_status(status
, out
, err
)
2868 class UnmergedSummary(EditModel
):
2869 """List unmerged files in the diff text."""
2871 def __init__(self
, context
):
2872 super().__init
__(context
)
2873 unmerged
= self
.model
.unmerged
2875 io
.write('# %s unmerged file(s)\n' % len(unmerged
))
2877 io
.write('\n'.join(unmerged
) + '\n')
2878 self
.new_diff_text
= io
.getvalue()
2879 self
.new_diff_type
= main
.Types
.TEXT
2880 self
.new_file_type
= main
.Types
.TEXT
2881 self
.new_mode
= self
.model
.mode_display
2884 class UntrackedSummary(EditModel
):
2885 """List possible .gitignore rules as the diff text."""
2887 def __init__(self
, context
):
2888 super().__init
__(context
)
2889 untracked
= self
.model
.untracked
2891 io
.write('# %s untracked file(s)\n' % len(untracked
))
2893 io
.write('# Add these lines to ".gitignore" to ignore these files:\n')
2894 io
.write('\n'.join('/' + filename
for filename
in untracked
) + '\n')
2895 self
.new_diff_text
= io
.getvalue()
2896 self
.new_diff_type
= main
.Types
.TEXT
2897 self
.new_file_type
= main
.Types
.TEXT
2898 self
.new_mode
= self
.model
.mode_display
2901 class VisualizeAll(ContextCommand
):
2902 """Visualize all branches."""
2905 context
= self
.context
2906 browser
= utils
.shell_split(prefs
.history_browser(context
))
2907 launch_history_browser(browser
+ ['--all'])
2910 class VisualizeCurrent(ContextCommand
):
2911 """Visualize all branches."""
2914 context
= self
.context
2915 browser
= utils
.shell_split(prefs
.history_browser(context
))
2916 launch_history_browser(browser
+ [self
.model
.currentbranch
] + ['--'])
2919 class VisualizePaths(ContextCommand
):
2920 """Path-limited visualization."""
2922 def __init__(self
, context
, paths
):
2923 super().__init
__(context
)
2924 context
= self
.context
2925 browser
= utils
.shell_split(prefs
.history_browser(context
))
2927 self
.argv
= browser
+ ['--'] + list(paths
)
2932 launch_history_browser(self
.argv
)
2935 class VisualizeRevision(ContextCommand
):
2936 """Visualize a specific revision."""
2938 def __init__(self
, context
, revision
, paths
=None):
2939 super().__init
__(context
)
2940 self
.revision
= revision
2944 context
= self
.context
2945 argv
= utils
.shell_split(prefs
.history_browser(context
))
2947 argv
.append(self
.revision
)
2950 argv
.extend(self
.paths
)
2951 launch_history_browser(argv
)
2954 class SubmoduleAdd(ConfirmAction
):
2955 """Add specified submodules"""
2957 def __init__(self
, context
, url
, path
, branch
, depth
, reference
):
2958 super().__init
__(context
)
2961 self
.branch
= branch
2963 self
.reference
= reference
2966 title
= N_('Add Submodule...')
2967 question
= N_('Add this submodule?')
2968 info
= N_('The submodule will be added using\n' '"%s"' % self
.command())
2969 ok_txt
= N_('Add Submodule')
2970 return Interaction
.confirm(title
, question
, info
, ok_txt
, icon
=icons
.ok())
2973 context
= self
.context
2974 args
= self
.get_args()
2975 return context
.git
.submodule('add', *args
)
2978 self
.model
.update_file_status()
2979 self
.model
.update_submodules_list()
2981 def error_message(self
):
2982 return N_('Error updating submodule %s' % self
.path
)
2985 cmd
= ['git', 'submodule', 'add']
2986 cmd
.extend(self
.get_args())
2987 return core
.list2cmdline(cmd
)
2992 args
.extend(['--branch', self
.branch
])
2994 args
.extend(['--reference', self
.reference
])
2996 args
.extend(['--depth', '%d' % self
.depth
])
2997 args
.extend(['--', self
.url
])
2999 args
.append(self
.path
)
3003 class SubmoduleUpdate(ConfirmAction
):
3004 """Update specified submodule"""
3006 def __init__(self
, context
, path
):
3007 super().__init
__(context
)
3011 title
= N_('Update Submodule...')
3012 question
= N_('Update this submodule?')
3013 info
= N_('The submodule will be updated using\n' '"%s"' % self
.command())
3014 ok_txt
= N_('Update Submodule')
3015 return Interaction
.confirm(
3016 title
, question
, info
, ok_txt
, default
=False, icon
=icons
.pull()
3020 context
= self
.context
3021 args
= self
.get_args()
3022 return context
.git
.submodule(*args
)
3025 self
.model
.update_file_status()
3027 def error_message(self
):
3028 return N_('Error updating submodule %s' % self
.path
)
3031 cmd
= ['git', 'submodule']
3032 cmd
.extend(self
.get_args())
3033 return core
.list2cmdline(cmd
)
3037 if version
.check_git(self
.context
, 'submodule-update-recursive'):
3038 cmd
.append('--recursive')
3039 cmd
.extend(['--', self
.path
])
3043 class SubmodulesUpdate(ConfirmAction
):
3044 """Update all submodules"""
3047 title
= N_('Update submodules...')
3048 question
= N_('Update all submodules?')
3049 info
= N_('All submodules will be updated using\n' '"%s"' % self
.command())
3050 ok_txt
= N_('Update Submodules')
3051 return Interaction
.confirm(
3052 title
, question
, info
, ok_txt
, default
=False, icon
=icons
.pull()
3056 context
= self
.context
3057 args
= self
.get_args()
3058 return context
.git
.submodule(*args
)
3061 self
.model
.update_file_status()
3063 def error_message(self
):
3064 return N_('Error updating submodules')
3067 cmd
= ['git', 'submodule']
3068 cmd
.extend(self
.get_args())
3069 return core
.list2cmdline(cmd
)
3073 if version
.check_git(self
.context
, 'submodule-update-recursive'):
3074 cmd
.append('--recursive')
3078 def launch_history_browser(argv
):
3079 """Launch the configured history browser"""
3082 except OSError as e
:
3083 _
, details
= utils
.format_exception(e
)
3084 title
= N_('Error Launching History Browser')
3085 msg
= N_('Cannot exec "%s": please configure a history browser') % ' '.join(
3088 Interaction
.critical(title
, message
=msg
, details
=details
)
3091 def run(cls
, *args
, **opts
):
3093 Returns a callback that runs a command
3095 If the caller of run() provides args or opts then those are
3096 used instead of the ones provided by the invoker of the callback.
3100 def runner(*local_args
, **local_opts
):
3101 """Closure return by run() which runs the command"""
3103 return do(cls
, *args
, **opts
)
3104 return do(cls
, *local_args
, **local_opts
)
3109 def do(cls
, *args
, **opts
):
3110 """Run a command in-place"""
3112 cmd
= cls(*args
, **opts
)
3114 except Exception as e
: # pylint: disable=broad-except
3115 msg
, details
= utils
.format_exception(e
)
3116 if hasattr(cls
, '__name__'):
3117 msg
= f
'{cls.__name__} exception:\n{msg}'
3118 Interaction
.critical(N_('Error'), message
=msg
, details
=details
)