5 from fnmatch
import fnmatch
6 from io
import StringIO
9 from send2trash
import send2trash
17 from . import resources
18 from . import textwrap
21 from .cmd
import ContextCommand
22 from .git
import STDOUT
23 from .git
import MISSING_BLOB_OID
25 from .interaction
import Interaction
26 from .models
import main
27 from .models
import prefs
30 class UsageError(Exception):
31 """Exception class for usage errors."""
33 def __init__(self
, title
, message
):
34 Exception.__init
__(self
, message
)
39 class EditModel(ContextCommand
):
40 """Commands that mutate the main model diff data"""
44 def __init__(self
, context
):
45 """Common edit operations on the main model"""
46 super().__init
__(context
)
48 self
.old_diff_text
= self
.model
.diff_text
49 self
.old_filename
= self
.model
.filename
50 self
.old_mode
= self
.model
.mode
51 self
.old_diff_type
= self
.model
.diff_type
52 self
.old_file_type
= self
.model
.file_type
54 self
.new_diff_text
= self
.old_diff_text
55 self
.new_filename
= self
.old_filename
56 self
.new_mode
= self
.old_mode
57 self
.new_diff_type
= self
.old_diff_type
58 self
.new_file_type
= self
.old_file_type
61 """Perform the operation."""
62 self
.model
.filename
= self
.new_filename
63 self
.model
.set_mode(self
.new_mode
)
64 self
.model
.set_diff_text(self
.new_diff_text
)
65 self
.model
.set_diff_type(self
.new_diff_type
)
66 self
.model
.set_file_type(self
.new_file_type
)
69 """Undo the operation."""
70 self
.model
.filename
= self
.old_filename
71 self
.model
.set_mode(self
.old_mode
)
72 self
.model
.set_diff_text(self
.old_diff_text
)
73 self
.model
.set_diff_type(self
.old_diff_type
)
74 self
.model
.set_file_type(self
.old_file_type
)
77 class ConfirmAction(ContextCommand
):
78 """Confirm an action before running it"""
81 """Return True when the command is okay to run"""
85 """Prompt for confirmation"""
89 """Run the command and return (status, out, err)"""
93 """Callback run on success"""
97 """Command name, for error messages"""
100 def error_message(self
):
101 """Command error message"""
105 """Prompt for confirmation before running a command"""
108 ok
= self
.ok_to_run() and self
.confirm()
110 status
, out
, err
= self
.action()
113 title
= self
.error_message()
115 Interaction
.command(title
, cmd
, status
, out
, err
)
117 return ok
, status
, out
, err
120 class AbortApplyPatch(ConfirmAction
):
121 """Reset an in-progress "git am" patch application"""
124 title
= N_('Abort Applying Patch...')
125 question
= N_('Aborting applying the current patch?')
127 'Aborting a patch can cause uncommitted changes to be lost.\n'
128 'Recovering uncommitted changes is not possible.'
130 ok_txt
= N_('Abort Applying Patch')
131 return Interaction
.confirm(
132 title
, question
, info
, ok_txt
, default
=False, icon
=icons
.undo()
136 status
, out
, err
= gitcmds
.abort_apply_patch(self
.context
)
137 self
.model
.update_file_merge_status()
138 return status
, out
, err
141 self
.model
.set_commitmsg('')
143 def error_message(self
):
147 return 'git am --abort'
150 class AbortCherryPick(ConfirmAction
):
151 """Reset an in-progress cherry-pick"""
154 title
= N_('Abort Cherry-Pick...')
155 question
= N_('Aborting the current cherry-pick?')
157 'Aborting a cherry-pick can cause uncommitted changes to be lost.\n'
158 'Recovering uncommitted changes is not possible.'
160 ok_txt
= N_('Abort Cherry-Pick')
161 return Interaction
.confirm(
162 title
, question
, info
, ok_txt
, default
=False, icon
=icons
.undo()
166 status
, out
, err
= gitcmds
.abort_cherry_pick(self
.context
)
167 self
.model
.update_file_merge_status()
168 return status
, out
, err
171 self
.model
.set_commitmsg('')
173 def error_message(self
):
177 return 'git cherry-pick --abort'
180 class AbortMerge(ConfirmAction
):
181 """Reset an in-progress merge back to HEAD"""
184 title
= N_('Abort Merge...')
185 question
= N_('Aborting the current merge?')
187 'Aborting the current merge will cause '
188 '*ALL* uncommitted changes to be lost.\n'
189 'Recovering uncommitted changes is not possible.'
191 ok_txt
= N_('Abort Merge')
192 return Interaction
.confirm(
193 title
, question
, info
, ok_txt
, default
=False, icon
=icons
.undo()
197 status
, out
, err
= gitcmds
.abort_merge(self
.context
)
198 self
.model
.update_file_merge_status()
199 return status
, out
, err
202 self
.model
.set_commitmsg('')
204 def error_message(self
):
211 class AmendMode(EditModel
):
212 """Try to amend a commit."""
221 def __init__(self
, context
, amend
=True):
222 super().__init
__(context
)
224 self
.amending
= amend
225 self
.old_commitmsg
= self
.model
.commitmsg
226 self
.old_mode
= self
.model
.mode
229 self
.new_mode
= self
.model
.mode_amend
230 self
.new_commitmsg
= gitcmds
.prev_commitmsg(context
)
231 AmendMode
.LAST_MESSAGE
= self
.model
.commitmsg
233 # else, amend unchecked, regular commit
234 self
.new_mode
= self
.model
.mode_none
235 self
.new_diff_text
= ''
236 self
.new_commitmsg
= self
.model
.commitmsg
237 # If we're going back into new-commit-mode then search the
238 # undo stack for a previous amend-commit-mode and grab the
239 # commit message at that point in time.
240 if AmendMode
.LAST_MESSAGE
is not None:
241 self
.new_commitmsg
= AmendMode
.LAST_MESSAGE
242 AmendMode
.LAST_MESSAGE
= None
245 """Leave/enter amend mode."""
246 # Attempt to enter amend mode. Do not allow this when merging.
248 if self
.model
.is_merging
:
250 self
.model
.set_mode(self
.old_mode
)
251 Interaction
.information(
254 'You are in the middle of a merge.\n'
255 'Cannot amend while merging.'
261 self
.model
.set_commitmsg(self
.new_commitmsg
)
262 self
.model
.update_file_status()
263 self
.context
.selection
.reset(emit
=True)
268 self
.model
.set_commitmsg(self
.old_commitmsg
)
270 self
.model
.update_file_status()
271 self
.context
.selection
.reset(emit
=True)
274 class AnnexAdd(ContextCommand
):
275 """Add to Git Annex"""
277 def __init__(self
, context
):
278 super().__init
__(context
)
279 self
.filename
= self
.selection
.filename()
282 status
, out
, err
= self
.git
.annex('add', self
.filename
)
283 Interaction
.command(N_('Error'), 'git annex add', status
, out
, err
)
284 self
.model
.update_status()
287 class AnnexInit(ContextCommand
):
288 """Initialize Git Annex"""
291 status
, out
, err
= self
.git
.annex('init')
292 Interaction
.command(N_('Error'), 'git annex init', status
, out
, err
)
293 self
.model
.cfg
.reset()
294 self
.model
.emit_updated()
297 class LFSTrack(ContextCommand
):
298 """Add a file to git lfs"""
300 def __init__(self
, context
):
301 super().__init
__(context
)
302 self
.filename
= self
.selection
.filename()
303 self
.stage_cmd
= Stage(context
, [self
.filename
])
306 status
, out
, err
= self
.git
.lfs('track', self
.filename
)
307 Interaction
.command(N_('Error'), 'git lfs track', status
, out
, err
)
312 class LFSInstall(ContextCommand
):
313 """Initialize git lfs"""
316 status
, out
, err
= self
.git
.lfs('install')
317 Interaction
.command(N_('Error'), 'git lfs install', status
, out
, err
)
318 self
.model
.update_config(reset
=True, emit
=True)
321 class ApplyPatch(ContextCommand
):
322 """Apply the specified patch to the worktree or index"""
331 super().__init
__(context
)
333 self
.encoding
= encoding
334 self
.apply_to_worktree
= apply_to_worktree
337 context
= self
.context
339 tmp_file
= utils
.tmp_filename('apply', suffix
='.patch')
341 core
.write(tmp_file
, self
.patch
.as_text(), encoding
=self
.encoding
)
342 if self
.apply_to_worktree
:
343 status
, out
, err
= gitcmds
.apply_diff_to_worktree(context
, tmp_file
)
345 status
, out
, err
= gitcmds
.apply_diff(context
, tmp_file
)
347 core
.unlink(tmp_file
)
349 Interaction
.log_status(status
, out
, err
)
350 self
.model
.update_file_status(update_index
=True)
353 class ApplyPatches(ContextCommand
):
354 """Apply patches using the "git am" command"""
356 def __init__(self
, context
, patches
):
357 super().__init
__(context
)
358 self
.patches
= patches
361 status
, output
, err
= self
.git
.am('-3', *self
.patches
)
362 out
= f
'# git am -3 {core.list2cmdline(self.patches)}\n\n{output}'
363 Interaction
.command(N_('Patch failed to apply'), 'git am -3', status
, out
, err
)
365 self
.model
.update_file_status()
367 patch_basenames
= [os
.path
.basename(p
) for p
in self
.patches
]
368 if len(patch_basenames
) > 25:
369 patch_basenames
= patch_basenames
[:25]
370 patch_basenames
.append('...')
372 basenames
= '\n'.join(patch_basenames
)
374 Interaction
.information(
375 N_('Patch(es) Applied'),
376 (N_('%d patch(es) applied.') + '\n\n%s')
377 % (len(self
.patches
), basenames
),
381 class ApplyPatchesContinue(ContextCommand
):
382 """Run "git am --continue" to continue on the next patch in a "git am" session"""
385 status
, out
, err
= self
.git
.am('--continue')
387 N_('Failed to commit and continue applying patches'),
393 self
.model
.update_status()
394 return status
, out
, err
397 class ApplyPatchesSkip(ContextCommand
):
398 """Run "git am --skip" to continue on the next patch in a "git am" session"""
401 status
, out
, err
= self
.git
.am(skip
=True)
403 N_('Failed to continue applying patches after skipping the current patch'),
409 self
.model
.update_status()
410 return status
, out
, err
413 class Archive(ContextCommand
):
414 """ "Export archives using the "git archive" command"""
416 def __init__(self
, context
, ref
, fmt
, prefix
, filename
):
417 super().__init
__(context
)
421 self
.filename
= filename
424 fp
= core
.xopen(self
.filename
, 'wb')
425 cmd
= ['git', 'archive', '--format=' + self
.fmt
]
426 if self
.fmt
in ('tgz', 'tar.gz'):
429 cmd
.append('--prefix=' + self
.prefix
)
431 proc
= core
.start_command(cmd
, stdout
=fp
)
432 out
, err
= proc
.communicate()
434 status
= proc
.returncode
435 Interaction
.log_status(status
, out
or '', err
or '')
438 class Checkout(EditModel
):
439 """A command object for git-checkout.
441 The argv list is forwarded directly to git.
444 def __init__(self
, context
, argv
, checkout_branch
=False):
445 super().__init
__(context
)
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()
616 self
.context
.selection
.reset(emit
=True)
619 class ResetCommand(ConfirmAction
):
620 """Reset state using the "git reset" command"""
622 def __init__(self
, context
, ref
):
623 super().__init
__(context
)
632 def error_message(self
):
636 self
.model
.update_file_status()
639 raise NotImplementedError('confirm() must be overridden')
642 raise NotImplementedError('reset() must be overridden')
645 class ResetMixed(ResetCommand
):
648 tooltip
= N_('The branch will be reset using "git reset --mixed %s"')
652 title
= N_('Reset Branch and Stage (Mixed)')
653 question
= N_('Point the current branch head to a new commit?')
654 info
= self
.tooltip(self
.ref
)
655 ok_text
= N_('Reset Branch')
656 return Interaction
.confirm(title
, question
, info
, ok_text
)
659 return self
.git
.reset(self
.ref
, '--', mixed
=True)
662 class ResetKeep(ResetCommand
):
665 tooltip
= N_('The repository will be reset using "git reset --keep %s"')
669 title
= N_('Restore Worktree and Reset All (Keep Unstaged Changes)')
670 question
= N_('Restore worktree, reset, and preserve unstaged edits?')
671 info
= self
.tooltip(self
.ref
)
672 ok_text
= N_('Reset and Restore')
673 return Interaction
.confirm(title
, question
, info
, ok_text
)
676 return self
.git
.reset(self
.ref
, '--', keep
=True)
679 class ResetMerge(ResetCommand
):
682 tooltip
= N_('The repository will be reset using "git reset --merge %s"')
686 title
= N_('Restore Worktree and Reset All (Merge)')
687 question
= N_('Reset Worktree and Reset All?')
688 info
= self
.tooltip(self
.ref
)
689 ok_text
= N_('Reset and Restore')
690 return Interaction
.confirm(title
, question
, info
, ok_text
)
693 return self
.git
.reset(self
.ref
, '--', merge
=True)
696 class ResetSoft(ResetCommand
):
699 tooltip
= N_('The branch will be reset using "git reset --soft %s"')
703 title
= N_('Reset Branch (Soft)')
704 question
= N_('Reset branch?')
705 info
= self
.tooltip(self
.ref
)
706 ok_text
= N_('Reset Branch')
707 return Interaction
.confirm(title
, question
, info
, ok_text
)
710 return self
.git
.reset(self
.ref
, '--', soft
=True)
713 class ResetHard(ResetCommand
):
716 tooltip
= N_('The repository will be reset using "git reset --hard %s"')
720 title
= N_('Restore Worktree and Reset All (Hard)')
721 question
= N_('Restore Worktree and Reset All?')
722 info
= self
.tooltip(self
.ref
)
723 ok_text
= N_('Reset and Restore')
724 return Interaction
.confirm(title
, question
, info
, ok_text
)
727 return self
.git
.reset(self
.ref
, '--', hard
=True)
730 class RestoreWorktree(ConfirmAction
):
731 """Reset the worktree using the "git read-tree" command"""
736 'The worktree will be restored using "git read-tree --reset -u %s"'
740 def __init__(self
, context
, ref
):
741 super().__init
__(context
)
745 return self
.git
.read_tree(self
.ref
, reset
=True, u
=True)
748 return 'git read-tree --reset -u %s' % self
.ref
750 def error_message(self
):
754 self
.model
.update_file_status()
757 title
= N_('Restore Worktree')
758 question
= N_('Restore Worktree to %s?') % self
.ref
759 info
= self
.tooltip(self
.ref
)
760 ok_text
= N_('Restore Worktree')
761 return Interaction
.confirm(title
, question
, info
, ok_text
)
764 class UndoLastCommit(ResetCommand
):
765 """Undo the last commit"""
767 # NOTE: this is the similar to ResetSoft() with an additional check for
768 # published commits and different messages.
769 def __init__(self
, context
):
770 super().__init
__(context
, 'HEAD^')
773 check_published
= prefs
.check_published_commits(self
.context
)
774 if check_published
and self
.model
.is_commit_published():
775 return Interaction
.confirm(
776 N_('Rewrite Published Commit?'),
778 'This commit has already been published.\n'
779 'This operation will rewrite published history.\n'
780 "You probably don't want to do this."
782 N_('Undo the published commit?'),
783 N_('Undo Last Commit'),
788 title
= N_('Undo Last Commit')
789 question
= N_('Undo last commit?')
790 info
= N_('The branch will be reset using "git reset --soft %s"')
791 ok_text
= N_('Undo Last Commit')
792 info_text
= info
% self
.ref
793 return Interaction
.confirm(title
, question
, info_text
, ok_text
)
796 return self
.git
.reset('HEAD^', '--', soft
=True)
799 class Commit(ResetMode
):
800 """Attempt to create a new commit."""
802 def __init__(self
, context
, amend
, msg
, sign
, no_verify
=False):
803 super().__init
__(context
)
807 self
.no_verify
= no_verify
808 self
.old_commitmsg
= self
.model
.commitmsg
809 self
.new_commitmsg
= ''
812 # Create the commit message file
813 context
= self
.context
815 tmp_file
= utils
.tmp_filename('commit-message')
817 core
.write(tmp_file
, msg
)
819 status
, out
, err
= self
.git
.commit(
824 no_verify
=self
.no_verify
,
827 core
.unlink(tmp_file
)
830 if context
.cfg
.get(prefs
.AUTOTEMPLATE
):
831 template_loader
= LoadCommitMessageFromTemplate(context
)
834 self
.model
.set_commitmsg(self
.new_commitmsg
)
836 return status
, out
, err
839 def strip_comments(msg
, comment_char
='#'):
842 line
for line
in msg
.split('\n') if not line
.startswith(comment_char
)
844 msg
= '\n'.join(message_lines
)
845 if not msg
.endswith('\n'):
851 class CycleReferenceSort(ContextCommand
):
852 """Choose the next reference sort type"""
855 self
.model
.cycle_ref_sort()
858 class Ignore(ContextCommand
):
859 """Add files to an exclusion file"""
861 def __init__(self
, context
, filenames
, local
=False):
862 super().__init
__(context
)
863 self
.filenames
= list(filenames
)
867 if not self
.filenames
:
869 new_additions
= '\n'.join(self
.filenames
) + '\n'
870 for_status
= new_additions
872 filename
= os
.path
.join('.git', 'info', 'exclude')
874 filename
= '.gitignore'
875 if core
.exists(filename
):
876 current_list
= core
.read(filename
)
877 new_additions
= current_list
.rstrip() + '\n' + new_additions
878 core
.write(filename
, new_additions
)
879 Interaction
.log_status(0, f
'Added to {filename}:\n{for_status}', '')
880 self
.model
.update_file_status()
883 def file_summary(files
):
884 txt
= core
.list2cmdline(files
)
886 txt
= txt
[:768].rstrip() + '...'
887 wrap
= textwrap
.TextWrapper()
888 return '\n'.join(wrap
.wrap(txt
))
891 class RemoteCommand(ConfirmAction
):
892 def __init__(self
, context
, remote
):
893 super().__init
__(context
)
898 self
.model
.update_remotes()
901 class RemoteAdd(RemoteCommand
):
902 def __init__(self
, context
, remote
, url
):
903 super().__init
__(context
, remote
)
907 return self
.git
.remote('add', self
.remote
, self
.url
)
909 def error_message(self
):
910 return N_('Error creating remote "%s"') % self
.remote
913 return f
'git remote add "{self.remote}" "{self.url}"'
916 class RemoteRemove(RemoteCommand
):
918 title
= N_('Delete Remote')
919 question
= N_('Delete remote?')
920 info
= N_('Delete remote "%s"') % self
.remote
921 ok_text
= N_('Delete')
922 return Interaction
.confirm(title
, question
, info
, ok_text
)
925 return self
.git
.remote('rm', self
.remote
)
927 def error_message(self
):
928 return N_('Error deleting remote "%s"') % self
.remote
931 return 'git remote rm "%s"' % self
.remote
934 class RemoteRename(RemoteCommand
):
935 def __init__(self
, context
, remote
, new_name
):
936 super().__init
__(context
, remote
)
937 self
.new_name
= new_name
940 title
= N_('Rename Remote')
941 text
= N_('Rename remote "%(current)s" to "%(new)s"?') % {
942 'current': self
.remote
,
943 'new': self
.new_name
,
947 return Interaction
.confirm(title
, text
, info_text
, ok_text
)
950 return self
.git
.remote('rename', self
.remote
, self
.new_name
)
952 def error_message(self
):
953 return N_('Error renaming "%(name)s" to "%(new_name)s"') % {
955 'new_name': self
.new_name
,
959 return f
'git remote rename "{self.remote}" "{self.new_name}"'
962 class RemoteSetURL(RemoteCommand
):
963 def __init__(self
, context
, remote
, url
):
964 super().__init
__(context
, remote
)
968 return self
.git
.remote('set-url', self
.remote
, self
.url
)
970 def error_message(self
):
971 return N_('Unable to set URL for "%(name)s" to "%(url)s"') % {
977 return f
'git remote set-url "{self.remote}" "{self.url}"'
980 class RemoteEdit(ContextCommand
):
981 """Combine RemoteRename and RemoteSetURL"""
983 def __init__(self
, context
, old_name
, remote
, url
):
984 super().__init
__(context
)
985 self
.rename
= RemoteRename(context
, old_name
, remote
)
986 self
.set_url
= RemoteSetURL(context
, remote
, url
)
989 result
= self
.rename
.do()
993 result
= self
.set_url
.do()
995 return name_ok
, url_ok
998 class RemoveFromSettings(ConfirmAction
):
999 def __init__(self
, context
, repo
, entry
, icon
=None):
1000 super().__init
__(context
)
1001 self
.context
= context
1007 self
.context
.settings
.save()
1010 class RemoveBookmark(RemoveFromSettings
):
1013 title
= msg
= N_('Delete Bookmark?')
1014 info
= N_('%s will be removed from your bookmarks.') % entry
1015 ok_text
= N_('Delete Bookmark')
1016 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
1019 self
.context
.settings
.remove_bookmark(self
.repo
, self
.entry
)
1023 class RemoveRecent(RemoveFromSettings
):
1026 title
= msg
= N_('Remove %s from the recent list?') % repo
1027 info
= N_('%s will be removed from your recent repositories.') % repo
1028 ok_text
= N_('Remove')
1029 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
1032 self
.context
.settings
.remove_recent(self
.repo
)
1036 class RemoveFiles(ContextCommand
):
1039 def __init__(self
, context
, remover
, filenames
):
1040 super().__init
__(context
)
1043 self
.remover
= remover
1044 self
.filenames
= filenames
1045 # We could git-hash-object stuff and provide undo-ability
1046 # as an option. Heh.
1049 files
= self
.filenames
1055 remove
= self
.remover
1056 for filename
in files
:
1062 bad_filenames
.append(filename
)
1065 Interaction
.information(
1066 N_('Error'), N_('Deleting "%s" failed') % file_summary(bad_filenames
)
1070 self
.model
.update_file_status()
1073 class Delete(RemoveFiles
):
1076 def __init__(self
, context
, filenames
):
1077 super().__init
__(context
, os
.remove
, filenames
)
1080 files
= self
.filenames
1084 title
= N_('Delete Files?')
1085 msg
= N_('The following files will be deleted:') + '\n\n'
1086 msg
+= file_summary(files
)
1087 info_txt
= N_('Delete %d file(s)?') % len(files
)
1088 ok_txt
= N_('Delete Files')
1090 if Interaction
.confirm(
1091 title
, msg
, info_txt
, ok_txt
, default
=True, icon
=icons
.remove()
1096 class MoveToTrash(RemoveFiles
):
1097 """Move files to the trash using send2trash"""
1099 AVAILABLE
= send2trash
is not None
1101 def __init__(self
, context
, filenames
):
1102 super().__init
__(context
, send2trash
, filenames
)
1105 class DeleteBranch(ConfirmAction
):
1106 """Delete a git branch."""
1108 def __init__(self
, context
, branch
):
1109 super().__init
__(context
)
1110 self
.branch
= branch
1113 title
= N_('Delete Branch')
1114 question
= N_('Delete branch "%s"?') % self
.branch
1115 info
= N_('The branch will be no longer available.')
1116 ok_txt
= N_('Delete Branch')
1117 return Interaction
.confirm(
1118 title
, question
, info
, ok_txt
, default
=True, icon
=icons
.discard()
1122 return self
.model
.delete_branch(self
.branch
)
1124 def error_message(self
):
1125 return N_('Error deleting branch "%s"' % self
.branch
)
1128 command
= 'git branch -D %s'
1129 return command
% self
.branch
1132 class Rename(ContextCommand
):
1133 """Rename a set of paths."""
1135 def __init__(self
, context
, paths
):
1136 super().__init
__(context
)
1140 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
1141 Interaction
.log(msg
)
1143 for path
in self
.paths
:
1144 ok
= self
.rename(path
)
1148 self
.model
.update_status()
1150 def rename(self
, path
):
1152 title
= N_('Rename "%s"') % path
1154 if os
.path
.isdir(path
):
1155 base_path
= os
.path
.dirname(path
)
1158 new_path
= Interaction
.save_as(base_path
, title
)
1162 status
, out
, err
= git
.mv(path
, new_path
, force
=True, verbose
=True)
1163 Interaction
.command(N_('Error'), 'git mv', status
, out
, err
)
1167 class RenameBranch(ContextCommand
):
1168 """Rename a git branch."""
1170 def __init__(self
, context
, branch
, new_branch
):
1171 super().__init
__(context
)
1172 self
.branch
= branch
1173 self
.new_branch
= new_branch
1176 branch
= self
.branch
1177 new_branch
= self
.new_branch
1178 status
, out
, err
= self
.model
.rename_branch(branch
, new_branch
)
1179 Interaction
.log_status(status
, out
, err
)
1182 class DeleteRemoteBranch(DeleteBranch
):
1183 """Delete a remote git branch."""
1185 def __init__(self
, context
, remote
, branch
):
1186 super().__init
__(context
, branch
)
1187 self
.remote
= remote
1190 return self
.git
.push(self
.remote
, self
.branch
, delete
=True)
1193 self
.model
.update_status()
1194 Interaction
.information(
1195 N_('Remote Branch Deleted'),
1196 N_('"%(branch)s" has been deleted from "%(remote)s".')
1198 'branch': self
.branch
,
1199 'remote': self
.remote
,
1203 def error_message(self
):
1204 return N_('Error Deleting Remote Branch')
1207 command
= 'git push --delete %s %s'
1208 return command
% (self
.remote
, self
.branch
)
1211 def get_mode(context
, filename
, staged
, modified
, unmerged
, untracked
):
1212 model
= context
.model
1214 mode
= model
.mode_index
1215 elif modified
or unmerged
:
1216 mode
= model
.mode_worktree
1218 if gitcmds
.is_binary(context
, filename
):
1219 mode
= model
.mode_untracked
1221 mode
= model
.mode_untracked_diff
1227 class DiffAgainstCommitMode(ContextCommand
):
1228 """Diff against arbitrary commits"""
1230 def __init__(self
, context
, oid
):
1231 super().__init
__(context
)
1235 self
.model
.set_mode(self
.model
.mode_diff
, head
=self
.oid
)
1236 self
.model
.update_file_status()
1239 class DiffText(EditModel
):
1240 """Set the diff type to text"""
1242 def __init__(self
, context
):
1243 super().__init
__(context
)
1244 self
.new_file_type
= main
.Types
.TEXT
1245 self
.new_diff_type
= main
.Types
.TEXT
1248 class ToggleDiffType(ContextCommand
):
1249 """Toggle the diff type between image and text"""
1251 def __init__(self
, context
):
1252 super().__init
__(context
)
1253 if self
.model
.diff_type
== main
.Types
.IMAGE
:
1254 self
.new_diff_type
= main
.Types
.TEXT
1255 self
.new_value
= False
1257 self
.new_diff_type
= main
.Types
.IMAGE
1258 self
.new_value
= True
1261 diff_type
= self
.new_diff_type
1262 value
= self
.new_value
1264 self
.model
.set_diff_type(diff_type
)
1266 filename
= self
.model
.filename
1267 _
, ext
= os
.path
.splitext(filename
)
1268 if ext
.startswith('.'):
1269 cfg
= 'cola.imagediff' + ext
1270 self
.cfg
.set_repo(cfg
, value
)
1273 class DiffImage(EditModel
):
1275 self
, context
, filename
, deleted
, staged
, modified
, unmerged
, untracked
1277 super().__init
__(context
)
1279 self
.new_filename
= filename
1280 self
.new_diff_type
= self
.get_diff_type(filename
)
1281 self
.new_file_type
= main
.Types
.IMAGE
1282 self
.new_mode
= get_mode(
1283 context
, filename
, staged
, modified
, unmerged
, untracked
1285 self
.staged
= staged
1286 self
.modified
= modified
1287 self
.unmerged
= unmerged
1288 self
.untracked
= untracked
1289 self
.deleted
= deleted
1290 self
.annex
= self
.cfg
.is_annex()
1292 def get_diff_type(self
, filename
):
1293 """Query the diff type to use based on cola.imagediff.<extension>"""
1294 _
, ext
= os
.path
.splitext(filename
)
1295 if ext
.startswith('.'):
1296 # Check e.g. "cola.imagediff.svg" to see if we should imagediff.
1297 cfg
= 'cola.imagediff' + ext
1298 if self
.cfg
.get(cfg
, True):
1299 result
= main
.Types
.IMAGE
1301 result
= main
.Types
.TEXT
1303 result
= main
.Types
.IMAGE
1307 filename
= self
.new_filename
1310 images
= self
.staged_images()
1312 images
= self
.modified_images()
1314 images
= self
.unmerged_images()
1315 elif self
.untracked
:
1316 images
= [(filename
, False)]
1320 self
.model
.set_images(images
)
1323 def staged_images(self
):
1324 context
= self
.context
1326 head
= self
.model
.head
1327 filename
= self
.new_filename
1331 index
= git
.diff_index(head
, '--', filename
, cached
=True)[STDOUT
]
1334 # :100644 100644 fabadb8... 4866510... M describe.c
1335 parts
= index
.split(' ')
1340 if old_oid
!= MISSING_BLOB_OID
:
1341 # First, check if we can get a pre-image from git-annex
1344 annex_image
= gitcmds
.annex_path(context
, head
, filename
)
1346 images
.append((annex_image
, False)) # git annex HEAD
1348 image
= gitcmds
.write_blob_path(context
, head
, old_oid
, filename
)
1350 images
.append((image
, True))
1352 if new_oid
!= MISSING_BLOB_OID
:
1353 found_in_annex
= False
1354 if annex
and core
.islink(filename
):
1355 status
, out
, _
= git
.annex('status', '--', filename
)
1357 details
= out
.split(' ')
1358 if details
and details
[0] == 'A': # newly added file
1359 images
.append((filename
, False))
1360 found_in_annex
= True
1362 if not found_in_annex
:
1363 image
= gitcmds
.write_blob(context
, new_oid
, filename
)
1365 images
.append((image
, True))
1369 def unmerged_images(self
):
1370 context
= self
.context
1372 head
= self
.model
.head
1373 filename
= self
.new_filename
1376 candidate_merge_heads
= ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1379 for merge_head
in candidate_merge_heads
1380 if core
.exists(git
.git_path(merge_head
))
1383 if annex
: # Attempt to find files in git-annex
1385 for merge_head
in merge_heads
:
1386 image
= gitcmds
.annex_path(context
, merge_head
, filename
)
1388 annex_images
.append((image
, False))
1390 annex_images
.append((filename
, False))
1393 # DIFF FORMAT FOR MERGES
1394 # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1395 # can take -c or --cc option to generate diff output also
1396 # for merge commits. The output differs from the format
1397 # described above in the following way:
1399 # 1. there is a colon for each parent
1400 # 2. there are more "src" modes and "src" sha1
1401 # 3. status is concatenated status characters for each parent
1402 # 4. no optional "score" number
1403 # 5. single path, only for "dst"
1405 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1408 index
= git
.diff_index(head
, '--', filename
, cached
=True, cc
=True)[STDOUT
]
1410 parts
= index
.split(' ')
1412 first_mode
= parts
[0]
1413 num_parents
= first_mode
.count(':')
1414 # colon for each parent, but for the index, the "parents"
1415 # are really entries in stages 1,2,3 (head, base, remote)
1416 # remote, base, head
1417 for i
in range(num_parents
):
1418 offset
= num_parents
+ i
+ 1
1421 merge_head
= merge_heads
[i
]
1424 if oid
!= MISSING_BLOB_OID
:
1425 image
= gitcmds
.write_blob_path(
1426 context
, merge_head
, oid
, filename
1429 images
.append((image
, True))
1431 images
.append((filename
, False))
1434 def modified_images(self
):
1435 context
= self
.context
1437 head
= self
.model
.head
1438 filename
= self
.new_filename
1443 if annex
: # Check for a pre-image from git-annex
1444 annex_image
= gitcmds
.annex_path(context
, head
, filename
)
1446 images
.append((annex_image
, False)) # git annex HEAD
1448 worktree
= git
.diff_files('--', filename
)[STDOUT
]
1449 parts
= worktree
.split(' ')
1452 if oid
!= MISSING_BLOB_OID
:
1453 image
= gitcmds
.write_blob_path(context
, head
, oid
, filename
)
1455 images
.append((image
, True)) # HEAD
1457 images
.append((filename
, False)) # worktree
1461 class Diff(EditModel
):
1462 """Perform a diff and set the model's current text."""
1464 def __init__(self
, context
, filename
, cached
=False, deleted
=False):
1465 super().__init
__(context
)
1467 if cached
and gitcmds
.is_valid_ref(context
, self
.model
.head
):
1468 opts
['ref'] = self
.model
.head
1469 self
.new_filename
= filename
1470 self
.new_mode
= self
.model
.mode_worktree
1471 self
.new_diff_text
= gitcmds
.diff_helper(
1472 self
.context
, filename
=filename
, cached
=cached
, deleted
=deleted
, **opts
1476 class Diffstat(EditModel
):
1477 """Perform a diffstat and set the model's diff text."""
1479 def __init__(self
, context
):
1480 super().__init
__(context
)
1482 diff_context
= cfg
.get('diff.context', 3)
1483 diff
= self
.git
.diff(
1485 unified
=diff_context
,
1491 self
.new_diff_text
= diff
1492 self
.new_diff_type
= main
.Types
.TEXT
1493 self
.new_file_type
= main
.Types
.TEXT
1494 self
.new_mode
= self
.model
.mode_diffstat
1497 class DiffStaged(Diff
):
1498 """Perform a staged diff on a file."""
1500 def __init__(self
, context
, filename
, deleted
=None):
1501 super().__init
__(context
, filename
, cached
=True, deleted
=deleted
)
1502 self
.new_mode
= self
.model
.mode_index
1505 class DiffStagedSummary(EditModel
):
1506 def __init__(self
, context
):
1507 super().__init
__(context
)
1508 diff
= self
.git
.diff(
1513 patch_with_stat
=True,
1516 self
.new_diff_text
= diff
1517 self
.new_diff_type
= main
.Types
.TEXT
1518 self
.new_file_type
= main
.Types
.TEXT
1519 self
.new_mode
= self
.model
.mode_index
1522 class Edit(ContextCommand
):
1523 """Edit a file using the configured gui.editor."""
1527 return N_('Launch Editor')
1529 def __init__(self
, context
, filenames
, line_number
=None, background_editor
=False):
1530 super().__init
__(context
)
1531 self
.filenames
= filenames
1532 self
.line_number
= line_number
1533 self
.background_editor
= background_editor
1536 context
= self
.context
1537 if not self
.filenames
:
1539 filename
= self
.filenames
[0]
1540 if not core
.exists(filename
):
1542 if self
.background_editor
:
1543 editor
= prefs
.background_editor(context
)
1545 editor
= prefs
.editor(context
)
1548 if self
.line_number
is None:
1549 opts
= self
.filenames
1551 # Single-file w/ line-numbers (likely from grep)
1553 '*vim*': [filename
, '+%s' % self
.line_number
],
1554 '*emacs*': ['+%s' % self
.line_number
, filename
],
1555 '*textpad*': [f
'{filename}({self.line_number},0)'],
1556 '*notepad++*': ['-n%s' % self
.line_number
, filename
],
1557 '*subl*': [f
'{filename}:{self.line_number}'],
1560 opts
= self
.filenames
1561 for pattern
, opt
in editor_opts
.items():
1562 if fnmatch(editor
, pattern
):
1567 core
.fork(utils
.shell_split(editor
) + opts
)
1568 except (OSError, ValueError) as e
:
1569 message
= N_('Cannot exec "%s": please configure your editor') % editor
1570 _
, details
= utils
.format_exception(e
)
1571 Interaction
.critical(N_('Error Editing File'), message
, details
)
1574 class FormatPatch(ContextCommand
):
1575 """Output a patch series given all revisions and a selected subset."""
1577 def __init__(self
, context
, to_export
, revs
, output
='patches'):
1578 super().__init
__(context
)
1579 self
.to_export
= list(to_export
)
1580 self
.revs
= list(revs
)
1581 self
.output
= output
1584 context
= self
.context
1585 status
, out
, err
= gitcmds
.format_patchsets(
1586 context
, self
.to_export
, self
.revs
, self
.output
1588 Interaction
.log_status(status
, out
, err
)
1591 class LaunchTerminal(ContextCommand
):
1594 return N_('Launch Terminal')
1597 def is_available(context
):
1598 return context
.cfg
.terminal() is not None
1600 def __init__(self
, context
, path
):
1601 super().__init
__(context
)
1605 cmd
= self
.context
.cfg
.terminal()
1608 if utils
.is_win32():
1609 argv
= ['start', '', cmd
, '--login']
1612 argv
= utils
.shell_split(cmd
)
1614 shells
= ('zsh', 'fish', 'bash', 'sh')
1615 for basename
in shells
:
1616 executable
= core
.find_executable(basename
)
1618 command
= executable
1620 argv
.append(os
.getenv('SHELL', command
))
1623 core
.fork(argv
, cwd
=self
.path
, shell
=shell
)
1626 class LaunchEditor(Edit
):
1629 return N_('Launch Editor')
1631 def __init__(self
, context
):
1632 s
= context
.selection
.selection()
1633 filenames
= s
.staged
+ s
.unmerged
+ s
.modified
+ s
.untracked
1634 super().__init
__(context
, filenames
, background_editor
=True)
1637 class LaunchEditorAtLine(LaunchEditor
):
1638 """Launch an editor at the specified line"""
1640 def __init__(self
, context
):
1641 super().__init
__(context
)
1642 self
.line_number
= context
.selection
.line_number
1645 class LoadCommitMessageFromFile(ContextCommand
):
1646 """Loads a commit message from a path."""
1650 def __init__(self
, context
, path
):
1651 super().__init
__(context
)
1653 self
.old_commitmsg
= self
.model
.commitmsg
1654 self
.old_directory
= self
.model
.directory
1657 path
= os
.path
.expanduser(self
.path
)
1658 if not path
or not core
.isfile(path
):
1659 Interaction
.log(N_('Error: Cannot find commit template'))
1660 Interaction
.log(N_('%s: No such file or directory.') % path
)
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:
1680 Interaction
.log(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.'
1689 return LoadCommitMessageFromFile
.do(self
)
1692 class LoadCommitMessageFromOID(ContextCommand
):
1693 """Load a previous commit message"""
1697 def __init__(self
, context
, oid
, prefix
=''):
1698 super().__init
__(context
)
1700 self
.old_commitmsg
= self
.model
.commitmsg
1701 self
.new_commitmsg
= prefix
+ gitcmds
.prev_commitmsg(context
, oid
)
1704 self
.model
.set_commitmsg(self
.new_commitmsg
)
1707 self
.model
.set_commitmsg(self
.old_commitmsg
)
1710 class PrepareCommitMessageHook(ContextCommand
):
1711 """Use the cola-prepare-commit-msg hook to prepare the commit message"""
1715 def __init__(self
, context
):
1716 super().__init
__(context
)
1717 self
.old_commitmsg
= self
.model
.commitmsg
1719 def get_message(self
):
1720 title
= N_('Error running prepare-commitmsg hook')
1721 hook
= gitcmds
.prepare_commit_message_hook(self
.context
)
1723 if os
.path
.exists(hook
):
1724 filename
= self
.model
.save_commitmsg()
1725 status
, out
, err
= core
.run_command([hook
, filename
])
1728 result
= core
.read(filename
)
1730 result
= self
.old_commitmsg
1731 Interaction
.command_error(title
, hook
, status
, out
, err
)
1733 message
= N_('A hook must be provided at "%s"') % hook
1734 Interaction
.critical(title
, message
=message
)
1735 result
= self
.old_commitmsg
1740 msg
= self
.get_message()
1741 self
.model
.set_commitmsg(msg
)
1744 self
.model
.set_commitmsg(self
.old_commitmsg
)
1747 class LoadFixupMessage(LoadCommitMessageFromOID
):
1748 """Load a fixup message"""
1750 def __init__(self
, context
, oid
):
1751 super().__init
__(context
, oid
, prefix
='fixup! ')
1752 if self
.new_commitmsg
:
1753 self
.new_commitmsg
= self
.new_commitmsg
.splitlines()[0]
1756 class Merge(ContextCommand
):
1759 def __init__(self
, context
, revision
, no_commit
, squash
, no_ff
, sign
):
1760 super().__init
__(context
)
1761 self
.revision
= revision
1763 self
.no_commit
= no_commit
1764 self
.squash
= squash
1768 squash
= self
.squash
1769 revision
= self
.revision
1771 no_commit
= self
.no_commit
1774 status
, out
, err
= self
.git
.merge(
1775 revision
, gpg_sign
=sign
, no_ff
=no_ff
, no_commit
=no_commit
, squash
=squash
1777 self
.model
.update_status()
1778 title
= N_('Merge failed. Conflict resolution is required.')
1779 Interaction
.command(title
, 'git merge', status
, out
, err
)
1781 return status
, out
, err
1784 class OpenDefaultApp(ContextCommand
):
1785 """Open a file using the OS default."""
1789 return N_('Open Using Default Application')
1791 def __init__(self
, context
, filenames
):
1792 super().__init
__(context
)
1793 self
.filenames
= filenames
1796 if not self
.filenames
:
1798 utils
.launch_default_app(self
.filenames
)
1801 class OpenDir(OpenDefaultApp
):
1802 """Open directories using the OS default."""
1806 return N_('Open Directory')
1809 def _dirnames(self
):
1810 return self
.filenames
1813 dirnames
= self
._dirnames
1816 # An empty dirname defaults to to the current directory.
1817 dirs
= [(dirname
or core
.getcwd()) for dirname
in dirnames
]
1818 utils
.launch_default_app(dirs
)
1821 class OpenParentDir(OpenDir
):
1822 """Open parent directories using the OS default."""
1826 return N_('Open Parent Directory')
1829 def _dirnames(self
):
1830 dirnames
= list({os
.path
.dirname(x
) for x
in self
.filenames
})
1834 class OpenWorktree(OpenDir
):
1835 """Open worktree directory using the OS default."""
1839 return N_('Open Worktree')
1841 # The _unused parameter is needed by worktree_dir_action() -> common.cmd_action().
1842 def __init__(self
, context
, _unused
=None):
1843 dirnames
= [context
.git
.worktree()]
1844 super().__init
__(context
, dirnames
)
1847 class OpenNewRepo(ContextCommand
):
1848 """Launches git-cola on a repo."""
1850 def __init__(self
, context
, repo_path
):
1851 super().__init
__(context
)
1852 self
.repo_path
= repo_path
1855 self
.model
.set_directory(self
.repo_path
)
1856 core
.fork([sys
.executable
, sys
.argv
[0], '--repo', self
.repo_path
])
1859 class OpenRepo(EditModel
):
1860 def __init__(self
, context
, repo_path
):
1861 super().__init
__(context
)
1862 self
.repo_path
= repo_path
1863 self
.new_mode
= self
.model
.mode_none
1864 self
.new_diff_text
= ''
1865 self
.new_diff_type
= main
.Types
.TEXT
1866 self
.new_file_type
= main
.Types
.TEXT
1867 self
.new_commitmsg
= ''
1868 self
.new_filename
= ''
1871 old_repo
= self
.git
.getcwd()
1872 if self
.model
.set_worktree(self
.repo_path
):
1873 self
.fsmonitor
.stop()
1874 self
.fsmonitor
.start()
1875 self
.model
.update_status(reset
=True)
1876 # Check if template should be loaded
1877 if self
.context
.cfg
.get(prefs
.AUTOTEMPLATE
):
1878 template_loader
= LoadCommitMessageFromTemplate(self
.context
)
1879 template_loader
.do()
1881 self
.model
.set_commitmsg(self
.new_commitmsg
)
1882 settings
= self
.context
.settings
1884 settings
.add_recent(self
.repo_path
, prefs
.maxrecent(self
.context
))
1888 self
.model
.set_worktree(old_repo
)
1891 class OpenParentRepo(OpenRepo
):
1892 def __init__(self
, context
):
1894 if version
.check_git(context
, 'show-superproject-working-tree'):
1895 status
, out
, _
= context
.git
.rev_parse(show_superproject_working_tree
=True)
1899 path
= os
.path
.dirname(core
.getcwd())
1900 super().__init
__(context
, path
)
1903 class Clone(ContextCommand
):
1904 """Clones a repository and optionally spawns a new cola session."""
1907 self
, context
, url
, new_directory
, submodules
=False, shallow
=False, spawn
=True
1909 super().__init
__(context
)
1911 self
.new_directory
= new_directory
1912 self
.submodules
= submodules
1913 self
.shallow
= shallow
1923 recurse_submodules
= self
.submodules
1924 shallow_submodules
= self
.submodules
and self
.shallow
1926 status
, out
, err
= self
.git
.clone(
1929 recurse_submodules
=recurse_submodules
,
1930 shallow_submodules
=shallow_submodules
,
1934 self
.status
= status
1937 if status
== 0 and self
.spawn
:
1938 executable
= sys
.executable
1939 core
.fork([executable
, sys
.argv
[0], '--repo', self
.new_directory
])
1943 class NewBareRepo(ContextCommand
):
1944 """Create a new shared bare repository"""
1946 def __init__(self
, context
, path
):
1947 super().__init
__(context
)
1952 status
, out
, err
= self
.git
.init(path
, bare
=True, shared
=True)
1953 Interaction
.command(
1954 N_('Error'), 'git init --bare --shared "%s"' % path
, status
, out
, err
1959 def unix_path(path
, is_win32
=utils
.is_win32
):
1960 """Git for Windows requires Unix paths, so force them here"""
1962 path
= path
.replace('\\', '/')
1965 if second
== ':': # sanity check, this better be a Windows-style path
1966 path
= '/' + first
+ path
[2:]
1971 def sequence_editor():
1972 """Set GIT_SEQUENCE_EDITOR for running git-cola-sequence-editor"""
1973 xbase
= unix_path(resources
.command('git-cola-sequence-editor'))
1974 if utils
.is_win32():
1975 editor
= core
.list2cmdline([unix_path(sys
.executable
), xbase
])
1977 editor
= core
.list2cmdline([xbase
])
1981 class SequenceEditorEnvironment
:
1982 """Set environment variables to enable git-cola-sequence-editor"""
1984 def __init__(self
, context
, **kwargs
):
1986 'GIT_EDITOR': prefs
.editor(context
),
1987 'GIT_SEQUENCE_EDITOR': sequence_editor(),
1989 self
.env
.update(kwargs
)
1991 def __enter__(self
):
1992 for var
, value
in self
.env
.items():
1993 compat
.setenv(var
, value
)
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 sign-off 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 sign-off 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 sign-off 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
2657 def stage_all(self
):
2658 """Disable the stage_all() behavior for untracked files"""
2662 class StageModifiedAndUntracked(StageCarefully
):
2663 """Stage all untracked files."""
2667 return N_('Stage Modified and Untracked')
2669 def init_paths(self
):
2670 self
.paths
= self
.model
.modified
+ self
.model
.untracked
2673 class StageOrUnstageAll(ContextCommand
):
2674 """If the selection is staged, unstage it, otherwise stage"""
2678 return N_('Stage / Unstage All')
2681 if self
.model
.staged
:
2682 do(Unstage
, self
.context
, self
.model
.staged
)
2684 if self
.cfg
.get('cola.safemode', False):
2685 unstaged
= self
.model
.modified
2687 unstaged
= self
.model
.modified
+ self
.model
.untracked
2688 do(Stage
, self
.context
, unstaged
)
2691 class StageOrUnstage(ContextCommand
):
2692 """If the selection is staged, unstage it, otherwise stage"""
2696 return N_('Stage / Unstage')
2699 s
= self
.selection
.selection()
2701 do(Unstage
, self
.context
, s
.staged
)
2704 unmerged
= check_conflicts(self
.context
, s
.unmerged
)
2706 unstaged
.extend(unmerged
)
2708 unstaged
.extend(s
.modified
)
2710 unstaged
.extend(s
.untracked
)
2712 do(Stage
, self
.context
, unstaged
)
2715 class Tag(ContextCommand
):
2716 """Create a tag object."""
2718 def __init__(self
, context
, name
, revision
, sign
=False, message
=''):
2719 super().__init
__(context
)
2721 self
._message
= message
2722 self
._revision
= revision
2728 revision
= self
._revision
2729 tag_name
= self
._name
2730 tag_message
= self
._message
2733 Interaction
.critical(
2734 N_('Missing Revision'), N_('Please specify a revision to tag.')
2739 Interaction
.critical(
2740 N_('Missing Name'), N_('Please specify a name for the new tag.')
2744 title
= N_('Missing Tag Message')
2745 message
= N_('Tag-signing was requested but the tag message is empty.')
2747 'An unsigned, lightweight tag will be created instead.\n'
2748 'Create an unsigned tag?'
2750 ok_text
= N_('Create Unsigned Tag')
2752 if sign
and not tag_message
:
2753 # We require a message in order to sign the tag, so if they
2754 # choose to create an unsigned tag we have to clear the sign flag.
2755 if not Interaction
.confirm(
2756 title
, message
, info
, ok_text
, default
=False, icon
=icons
.save()
2765 tmp_file
= utils
.tmp_filename('tag-message')
2766 opts
['file'] = tmp_file
2767 core
.write(tmp_file
, tag_message
)
2772 opts
['annotate'] = True
2773 status
, out
, err
= git
.tag(tag_name
, revision
, **opts
)
2776 core
.unlink(tmp_file
)
2778 title
= N_('Error: could not create tag "%s"') % tag_name
2779 Interaction
.command(title
, 'git tag', status
, out
, err
)
2783 self
.model
.update_status()
2784 Interaction
.information(
2786 N_('Created a new tag named "%s"') % tag_name
,
2787 details
=tag_message
or None,
2793 class Unstage(ContextCommand
):
2794 """Unstage a set of paths."""
2798 return N_('Unstage')
2800 def __init__(self
, context
, paths
):
2801 super().__init
__(context
)
2806 context
= self
.context
2807 head
= self
.model
.head
2810 msg
= N_('Unstaging: %s') % (', '.join(paths
))
2811 Interaction
.log(msg
)
2813 return unstage_all(context
)
2814 status
, out
, err
= gitcmds
.unstage_paths(context
, paths
, head
=head
)
2815 Interaction
.command(N_('Error'), 'git reset', status
, out
, err
)
2816 self
.model
.update_file_status()
2817 return (status
, out
, err
)
2820 class UnstageAll(ContextCommand
):
2821 """Unstage all files; resets the index."""
2824 return unstage_all(self
.context
)
2827 def unstage_all(context
):
2828 """Unstage all files, even while amending"""
2829 model
= context
.model
2832 status
, out
, err
= git
.reset(head
, '--', '.')
2833 Interaction
.command(N_('Error'), 'git reset', status
, out
, err
)
2834 model
.update_file_status()
2835 return (status
, out
, err
)
2838 class StageSelected(ContextCommand
):
2839 """Stage selected files, or all files if no selection exists."""
2842 context
= self
.context
2843 paths
= self
.selection
.unstaged
2845 do(Stage
, context
, paths
)
2846 elif self
.cfg
.get('cola.safemode', False):
2847 do(StageModified
, context
)
2850 class UnstageSelected(Unstage
):
2851 """Unstage selected files."""
2853 def __init__(self
, context
):
2854 staged
= context
.selection
.staged
2855 super().__init
__(context
, staged
)
2858 class Untrack(ContextCommand
):
2859 """Unstage a set of paths."""
2861 def __init__(self
, context
, paths
):
2862 super().__init
__(context
)
2866 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
2867 Interaction
.log(msg
)
2868 status
, out
, err
= self
.model
.untrack_paths(self
.paths
)
2869 Interaction
.log_status(status
, out
, err
)
2872 class UnmergedSummary(EditModel
):
2873 """List unmerged files in the diff text."""
2875 def __init__(self
, context
):
2876 super().__init
__(context
)
2877 unmerged
= self
.model
.unmerged
2879 io
.write('# %s unmerged file(s)\n' % len(unmerged
))
2881 io
.write('\n'.join(unmerged
) + '\n')
2882 self
.new_diff_text
= io
.getvalue()
2883 self
.new_diff_type
= main
.Types
.TEXT
2884 self
.new_file_type
= main
.Types
.TEXT
2885 self
.new_mode
= self
.model
.mode_display
2888 class UntrackedSummary(EditModel
):
2889 """List possible .gitignore rules as the diff text."""
2891 def __init__(self
, context
):
2892 super().__init
__(context
)
2893 untracked
= self
.model
.untracked
2895 io
.write('# %s untracked file(s)\n' % len(untracked
))
2897 io
.write('# Add these lines to ".gitignore" to ignore these files:\n')
2898 io
.write('\n'.join('/' + filename
for filename
in untracked
) + '\n')
2899 self
.new_diff_text
= io
.getvalue()
2900 self
.new_diff_type
= main
.Types
.TEXT
2901 self
.new_file_type
= main
.Types
.TEXT
2902 self
.new_mode
= self
.model
.mode_display
2905 class VisualizeAll(ContextCommand
):
2906 """Visualize all branches."""
2909 context
= self
.context
2910 browser
= utils
.shell_split(prefs
.history_browser(context
))
2911 launch_history_browser(browser
+ ['--all'])
2914 class VisualizeCurrent(ContextCommand
):
2915 """Visualize all branches."""
2918 context
= self
.context
2919 browser
= utils
.shell_split(prefs
.history_browser(context
))
2920 launch_history_browser(browser
+ [self
.model
.currentbranch
] + ['--'])
2923 class VisualizePaths(ContextCommand
):
2924 """Path-limited visualization."""
2926 def __init__(self
, context
, paths
):
2927 super().__init
__(context
)
2928 context
= self
.context
2929 browser
= utils
.shell_split(prefs
.history_browser(context
))
2931 self
.argv
= browser
+ ['--'] + list(paths
)
2936 launch_history_browser(self
.argv
)
2939 class VisualizeRevision(ContextCommand
):
2940 """Visualize a specific revision."""
2942 def __init__(self
, context
, revision
, paths
=None):
2943 super().__init
__(context
)
2944 self
.revision
= revision
2948 context
= self
.context
2949 argv
= utils
.shell_split(prefs
.history_browser(context
))
2951 argv
.append(self
.revision
)
2954 argv
.extend(self
.paths
)
2955 launch_history_browser(argv
)
2958 class SubmoduleAdd(ConfirmAction
):
2959 """Add specified submodules"""
2961 def __init__(self
, context
, url
, path
, branch
, depth
, reference
):
2962 super().__init
__(context
)
2965 self
.branch
= branch
2967 self
.reference
= reference
2970 title
= N_('Add Submodule...')
2971 question
= N_('Add this submodule?')
2972 info
= N_('The submodule will be added using\n' '"%s"' % self
.command())
2973 ok_txt
= N_('Add Submodule')
2974 return Interaction
.confirm(title
, question
, info
, ok_txt
, icon
=icons
.ok())
2977 context
= self
.context
2978 args
= self
.get_args()
2979 return context
.git
.submodule('add', *args
)
2982 self
.model
.update_file_status()
2983 self
.model
.update_submodules_list()
2985 def error_message(self
):
2986 return N_('Error updating submodule %s' % self
.path
)
2989 cmd
= ['git', 'submodule', 'add']
2990 cmd
.extend(self
.get_args())
2991 return core
.list2cmdline(cmd
)
2996 args
.extend(['--branch', self
.branch
])
2998 args
.extend(['--reference', self
.reference
])
3000 args
.extend(['--depth', '%d' % self
.depth
])
3001 args
.extend(['--', self
.url
])
3003 args
.append(self
.path
)
3007 class SubmoduleUpdate(ConfirmAction
):
3008 """Update specified submodule"""
3010 def __init__(self
, context
, path
):
3011 super().__init
__(context
)
3015 title
= N_('Update Submodule...')
3016 question
= N_('Update this submodule?')
3017 info
= N_('The submodule will be updated using\n' '"%s"' % self
.command())
3018 ok_txt
= N_('Update Submodule')
3019 return Interaction
.confirm(
3020 title
, question
, info
, ok_txt
, default
=False, icon
=icons
.pull()
3024 context
= self
.context
3025 args
= self
.get_args()
3026 return context
.git
.submodule(*args
)
3029 self
.model
.update_file_status()
3031 def error_message(self
):
3032 return N_('Error updating submodule %s' % self
.path
)
3035 cmd
= ['git', 'submodule']
3036 cmd
.extend(self
.get_args())
3037 return core
.list2cmdline(cmd
)
3041 if version
.check_git(self
.context
, 'submodule-update-recursive'):
3042 cmd
.append('--recursive')
3043 cmd
.extend(['--', self
.path
])
3047 class SubmodulesUpdate(ConfirmAction
):
3048 """Update all submodules"""
3051 title
= N_('Update submodules...')
3052 question
= N_('Update all submodules?')
3053 info
= N_('All submodules will be updated using\n' '"%s"' % self
.command())
3054 ok_txt
= N_('Update Submodules')
3055 return Interaction
.confirm(
3056 title
, question
, info
, ok_txt
, default
=False, icon
=icons
.pull()
3060 context
= self
.context
3061 args
= self
.get_args()
3062 return context
.git
.submodule(*args
)
3065 self
.model
.update_file_status()
3067 def error_message(self
):
3068 return N_('Error updating submodules')
3071 cmd
= ['git', 'submodule']
3072 cmd
.extend(self
.get_args())
3073 return core
.list2cmdline(cmd
)
3077 if version
.check_git(self
.context
, 'submodule-update-recursive'):
3078 cmd
.append('--recursive')
3082 def launch_history_browser(argv
):
3083 """Launch the configured history browser"""
3086 except OSError as e
:
3087 _
, details
= utils
.format_exception(e
)
3088 title
= N_('Error Launching History Browser')
3089 msg
= N_('Cannot exec "%s": please configure a history browser') % ' '.join(
3092 Interaction
.critical(title
, message
=msg
, details
=details
)
3095 def run(cls
, *args
, **opts
):
3097 Returns a callback that runs a command
3099 If the caller of run() provides args or opts then those are
3100 used instead of the ones provided by the invoker of the callback.
3104 def runner(*local_args
, **local_opts
):
3105 """Closure return by run() which runs the command"""
3107 return do(cls
, *args
, **opts
)
3108 return do(cls
, *local_args
, **local_opts
)
3113 def do(cls
, *args
, **opts
):
3114 """Run a command in-place"""
3116 cmd
= cls(*args
, **opts
)
3118 except Exception as e
:
3119 msg
, details
= utils
.format_exception(e
)
3120 if hasattr(cls
, '__name__'):
3121 msg
= f
'{cls.__name__} exception:\n{msg}'
3122 Interaction
.critical(N_('Error'), message
=msg
, details
=details
)