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()
264 self
.context
.selection
.reset(emit
=True)
269 self
.model
.set_commitmsg(self
.old_commitmsg
)
271 self
.model
.update_file_status()
272 self
.context
.selection
.reset(emit
=True)
275 class AnnexAdd(ContextCommand
):
276 """Add to Git Annex"""
278 def __init__(self
, context
):
279 super().__init
__(context
)
280 self
.filename
= self
.selection
.filename()
283 status
, out
, err
= self
.git
.annex('add', self
.filename
)
284 Interaction
.command(N_('Error'), 'git annex add', status
, out
, err
)
285 self
.model
.update_status()
288 class AnnexInit(ContextCommand
):
289 """Initialize Git Annex"""
292 status
, out
, err
= self
.git
.annex('init')
293 Interaction
.command(N_('Error'), 'git annex init', status
, out
, err
)
294 self
.model
.cfg
.reset()
295 self
.model
.emit_updated()
298 class LFSTrack(ContextCommand
):
299 """Add a file to git lfs"""
301 def __init__(self
, context
):
302 super().__init
__(context
)
303 self
.filename
= self
.selection
.filename()
304 self
.stage_cmd
= Stage(context
, [self
.filename
])
307 status
, out
, err
= self
.git
.lfs('track', self
.filename
)
308 Interaction
.command(N_('Error'), 'git lfs track', status
, out
, err
)
313 class LFSInstall(ContextCommand
):
314 """Initialize git lfs"""
317 status
, out
, err
= self
.git
.lfs('install')
318 Interaction
.command(N_('Error'), 'git lfs install', status
, out
, err
)
319 self
.model
.update_config(reset
=True, emit
=True)
322 class ApplyPatch(ContextCommand
):
323 """Apply the specfied patch to the worktree or index"""
332 super().__init
__(context
)
334 self
.encoding
= encoding
335 self
.apply_to_worktree
= apply_to_worktree
338 context
= self
.context
340 tmp_file
= utils
.tmp_filename('apply', suffix
='.patch')
342 core
.write(tmp_file
, self
.patch
.as_text(), encoding
=self
.encoding
)
343 if self
.apply_to_worktree
:
344 status
, out
, err
= gitcmds
.apply_diff_to_worktree(context
, tmp_file
)
346 status
, out
, err
= gitcmds
.apply_diff(context
, tmp_file
)
348 core
.unlink(tmp_file
)
350 Interaction
.log_status(status
, out
, err
)
351 self
.model
.update_file_status(update_index
=True)
354 class ApplyPatches(ContextCommand
):
355 """Apply patches using the "git am" command"""
357 def __init__(self
, context
, patches
):
358 super().__init
__(context
)
359 self
.patches
= patches
362 status
, output
, err
= self
.git
.am('-3', *self
.patches
)
363 out
= f
'# git am -3 {core.list2cmdline(self.patches)}\n\n{output}'
364 Interaction
.command(N_('Patch failed to apply'), 'git am -3', status
, out
, err
)
366 self
.model
.update_file_status()
368 patch_basenames
= [os
.path
.basename(p
) for p
in self
.patches
]
369 if len(patch_basenames
) > 25:
370 patch_basenames
= patch_basenames
[:25]
371 patch_basenames
.append('...')
373 basenames
= '\n'.join(patch_basenames
)
375 Interaction
.information(
376 N_('Patch(es) Applied'),
377 (N_('%d patch(es) applied.') + '\n\n%s')
378 % (len(self
.patches
), basenames
),
382 class ApplyPatchesContinue(ContextCommand
):
383 """Run "git am --continue" to continue on the next patch in a "git am" session"""
386 status
, out
, err
= self
.git
.am('--continue')
388 N_('Failed to commit and continue applying patches'),
394 self
.model
.update_status()
395 return status
, out
, err
398 class ApplyPatchesSkip(ContextCommand
):
399 """Run "git am --skip" to continue on the next patch in a "git am" session"""
402 status
, out
, err
= self
.git
.am(skip
=True)
404 N_('Failed to continue applying patches after skipping the current patch'),
410 self
.model
.update_status()
411 return status
, out
, err
414 class Archive(ContextCommand
):
415 """ "Export archives using the "git archive" command"""
417 def __init__(self
, context
, ref
, fmt
, prefix
, filename
):
418 super().__init
__(context
)
422 self
.filename
= filename
425 fp
= core
.xopen(self
.filename
, 'wb')
426 cmd
= ['git', 'archive', '--format=' + self
.fmt
]
427 if self
.fmt
in ('tgz', 'tar.gz'):
430 cmd
.append('--prefix=' + self
.prefix
)
432 proc
= core
.start_command(cmd
, stdout
=fp
)
433 out
, err
= proc
.communicate()
435 status
= proc
.returncode
436 Interaction
.log_status(status
, out
or '', err
or '')
439 class Checkout(EditModel
):
440 """A command object for git-checkout.
442 'argv' is handed off directly to git.
446 def __init__(self
, context
, argv
, checkout_branch
=False):
447 super().__init
__(context
)
449 self
.checkout_branch
= checkout_branch
450 self
.new_diff_text
= ''
451 self
.new_diff_type
= main
.Types
.TEXT
452 self
.new_file_type
= main
.Types
.TEXT
456 status
, out
, err
= self
.git
.checkout(*self
.argv
)
457 if self
.checkout_branch
:
458 self
.model
.update_status()
460 self
.model
.update_file_status()
461 Interaction
.command(N_('Error'), 'git checkout', status
, out
, err
)
462 return status
, out
, err
465 class CheckoutTheirs(ConfirmAction
):
466 """Checkout "their" version of a file when performing a merge"""
470 return N_('Checkout files from their branch (MERGE_HEAD)')
474 question
= N_('Checkout files from their branch?')
476 'This operation will replace the selected unmerged files with content '
477 'from the branch being merged using "git checkout --theirs".\n'
478 '*ALL* uncommitted changes will be lost.\n'
479 'Recovering uncommitted changes is not possible.'
481 ok_txt
= N_('Checkout Files')
482 return Interaction
.confirm(
483 title
, question
, info
, ok_txt
, default
=True, icon
=icons
.merge()
487 selection
= self
.selection
.selection()
488 paths
= selection
.unmerged
492 argv
= ['--theirs', '--'] + paths
493 cmd
= Checkout(self
.context
, argv
)
496 def error_message(self
):
500 return 'git checkout --theirs'
503 class CheckoutOurs(ConfirmAction
):
504 """Checkout "our" version of a file when performing a merge"""
508 return N_('Checkout files from our branch (HEAD)')
512 question
= N_('Checkout files from our branch?')
514 'This operation will replace the selected unmerged files with content '
515 'from your current branch using "git checkout --ours".\n'
516 '*ALL* uncommitted changes will be lost.\n'
517 'Recovering uncommitted changes is not possible.'
519 ok_txt
= N_('Checkout Files')
520 return Interaction
.confirm(
521 title
, question
, info
, ok_txt
, default
=True, icon
=icons
.merge()
525 selection
= self
.selection
.selection()
526 paths
= selection
.unmerged
530 argv
= ['--ours', '--'] + paths
531 cmd
= Checkout(self
.context
, argv
)
534 def error_message(self
):
538 return 'git checkout --ours'
541 class BlamePaths(ContextCommand
):
542 """Blame view for paths."""
546 return N_('Blame...')
548 def __init__(self
, context
, paths
=None):
549 super().__init
__(context
)
551 paths
= context
.selection
.union()
552 viewer
= utils
.shell_split(prefs
.blame_viewer(context
))
553 self
.argv
= viewer
+ list(paths
)
559 _
, details
= utils
.format_exception(e
)
560 title
= N_('Error Launching Blame Viewer')
561 msg
= N_('Cannot exec "%s": please configure a blame viewer') % ' '.join(
564 Interaction
.critical(title
, message
=msg
, details
=details
)
567 class CheckoutBranch(Checkout
):
568 """Checkout a branch."""
570 def __init__(self
, context
, branch
):
572 super().__init
__(context
, args
, checkout_branch
=True)
575 class CherryPick(ContextCommand
):
576 """Cherry pick commits into the current branch."""
578 def __init__(self
, context
, commits
):
579 super().__init
__(context
)
580 self
.commits
= commits
583 status
, out
, err
= gitcmds
.cherry_pick(self
.context
, self
.commits
)
584 self
.model
.update_file_merge_status()
585 title
= N_('Cherry-pick failed')
586 Interaction
.command(title
, 'git cherry-pick', status
, out
, err
)
589 class Revert(ContextCommand
):
590 """Revert a commit"""
592 def __init__(self
, context
, oid
):
593 super().__init
__(context
)
597 status
, out
, err
= self
.git
.revert(self
.oid
, no_edit
=True)
598 self
.model
.update_file_status()
599 title
= N_('Revert failed')
600 out
= '# git revert %s\n\n' % self
.oid
601 Interaction
.command(title
, 'git revert', status
, out
, err
)
604 class ResetMode(EditModel
):
605 """Reset the mode and clear the model's diff text."""
607 def __init__(self
, context
):
608 super().__init
__(context
)
609 self
.new_mode
= self
.model
.mode_none
610 self
.new_diff_text
= ''
611 self
.new_diff_type
= main
.Types
.TEXT
612 self
.new_file_type
= main
.Types
.TEXT
613 self
.new_filename
= ''
617 self
.model
.update_file_status()
618 self
.context
.selection
.reset(emit
=True)
621 class ResetCommand(ConfirmAction
):
622 """Reset state using the "git reset" command"""
624 def __init__(self
, context
, ref
):
625 super().__init
__(context
)
634 def error_message(self
):
638 self
.model
.update_file_status()
641 raise NotImplementedError('confirm() must be overridden')
644 raise NotImplementedError('reset() must be overridden')
647 class ResetMixed(ResetCommand
):
650 tooltip
= N_('The branch will be reset using "git reset --mixed %s"')
654 title
= N_('Reset Branch and Stage (Mixed)')
655 question
= N_('Point the current branch head to a new commit?')
656 info
= self
.tooltip(self
.ref
)
657 ok_text
= N_('Reset Branch')
658 return Interaction
.confirm(title
, question
, info
, ok_text
)
661 return self
.git
.reset(self
.ref
, '--', mixed
=True)
664 class ResetKeep(ResetCommand
):
667 tooltip
= N_('The repository will be reset using "git reset --keep %s"')
671 title
= N_('Restore Worktree and Reset All (Keep Unstaged Changes)')
672 question
= N_('Restore worktree, reset, and preserve unstaged edits?')
673 info
= self
.tooltip(self
.ref
)
674 ok_text
= N_('Reset and Restore')
675 return Interaction
.confirm(title
, question
, info
, ok_text
)
678 return self
.git
.reset(self
.ref
, '--', keep
=True)
681 class ResetMerge(ResetCommand
):
684 tooltip
= N_('The repository will be reset using "git reset --merge %s"')
688 title
= N_('Restore Worktree and Reset All (Merge)')
689 question
= N_('Reset Worktree and Reset All?')
690 info
= self
.tooltip(self
.ref
)
691 ok_text
= N_('Reset and Restore')
692 return Interaction
.confirm(title
, question
, info
, ok_text
)
695 return self
.git
.reset(self
.ref
, '--', merge
=True)
698 class ResetSoft(ResetCommand
):
701 tooltip
= N_('The branch will be reset using "git reset --soft %s"')
705 title
= N_('Reset Branch (Soft)')
706 question
= N_('Reset branch?')
707 info
= self
.tooltip(self
.ref
)
708 ok_text
= N_('Reset Branch')
709 return Interaction
.confirm(title
, question
, info
, ok_text
)
712 return self
.git
.reset(self
.ref
, '--', soft
=True)
715 class ResetHard(ResetCommand
):
718 tooltip
= N_('The repository will be reset using "git reset --hard %s"')
722 title
= N_('Restore Worktree and Reset All (Hard)')
723 question
= N_('Restore Worktree and Reset All?')
724 info
= self
.tooltip(self
.ref
)
725 ok_text
= N_('Reset and Restore')
726 return Interaction
.confirm(title
, question
, info
, ok_text
)
729 return self
.git
.reset(self
.ref
, '--', hard
=True)
732 class RestoreWorktree(ConfirmAction
):
733 """Reset the worktree using the "git read-tree" command"""
738 'The worktree will be restored using "git read-tree --reset -u %s"'
742 def __init__(self
, context
, ref
):
743 super().__init
__(context
)
747 return self
.git
.read_tree(self
.ref
, reset
=True, u
=True)
750 return 'git read-tree --reset -u %s' % self
.ref
752 def error_message(self
):
756 self
.model
.update_file_status()
759 title
= N_('Restore Worktree')
760 question
= N_('Restore Worktree to %s?') % self
.ref
761 info
= self
.tooltip(self
.ref
)
762 ok_text
= N_('Restore Worktree')
763 return Interaction
.confirm(title
, question
, info
, ok_text
)
766 class UndoLastCommit(ResetCommand
):
767 """Undo the last commit"""
769 # NOTE: this is the similar to ResetSoft() with an additional check for
770 # published commits and different messages.
771 def __init__(self
, context
):
772 super().__init
__(context
, 'HEAD^')
775 check_published
= prefs
.check_published_commits(self
.context
)
776 if check_published
and self
.model
.is_commit_published():
777 return Interaction
.confirm(
778 N_('Rewrite Published Commit?'),
780 'This commit has already been published.\n'
781 'This operation will rewrite published history.\n'
782 "You probably don't want to do this."
784 N_('Undo the published commit?'),
785 N_('Undo Last Commit'),
790 title
= N_('Undo Last Commit')
791 question
= N_('Undo last commit?')
792 info
= N_('The branch will be reset using "git reset --soft %s"')
793 ok_text
= N_('Undo Last Commit')
794 info_text
= info
% self
.ref
795 return Interaction
.confirm(title
, question
, info_text
, ok_text
)
798 return self
.git
.reset('HEAD^', '--', soft
=True)
801 class Commit(ResetMode
):
802 """Attempt to create a new commit."""
804 def __init__(self
, context
, amend
, msg
, sign
, no_verify
=False):
805 super().__init
__(context
)
809 self
.no_verify
= no_verify
810 self
.old_commitmsg
= self
.model
.commitmsg
811 self
.new_commitmsg
= ''
814 # Create the commit message file
815 context
= self
.context
817 tmp_file
= utils
.tmp_filename('commit-message')
819 core
.write(tmp_file
, msg
)
821 status
, out
, err
= self
.git
.commit(
826 no_verify
=self
.no_verify
,
829 core
.unlink(tmp_file
)
832 if context
.cfg
.get(prefs
.AUTOTEMPLATE
):
833 template_loader
= LoadCommitMessageFromTemplate(context
)
836 self
.model
.set_commitmsg(self
.new_commitmsg
)
838 return status
, out
, err
841 def strip_comments(msg
, comment_char
='#'):
844 line
for line
in msg
.split('\n') if not line
.startswith(comment_char
)
846 msg
= '\n'.join(message_lines
)
847 if not msg
.endswith('\n'):
853 class CycleReferenceSort(ContextCommand
):
854 """Choose the next reference sort type"""
857 self
.model
.cycle_ref_sort()
860 class Ignore(ContextCommand
):
861 """Add files to an exclusion file"""
863 def __init__(self
, context
, filenames
, local
=False):
864 super().__init
__(context
)
865 self
.filenames
= list(filenames
)
869 if not self
.filenames
:
871 new_additions
= '\n'.join(self
.filenames
) + '\n'
872 for_status
= new_additions
874 filename
= os
.path
.join('.git', 'info', 'exclude')
876 filename
= '.gitignore'
877 if core
.exists(filename
):
878 current_list
= core
.read(filename
)
879 new_additions
= current_list
.rstrip() + '\n' + new_additions
880 core
.write(filename
, new_additions
)
881 Interaction
.log_status(0, f
'Added to {filename}:\n{for_status}', '')
882 self
.model
.update_file_status()
885 def file_summary(files
):
886 txt
= core
.list2cmdline(files
)
888 txt
= txt
[:768].rstrip() + '...'
889 wrap
= textwrap
.TextWrapper()
890 return '\n'.join(wrap
.wrap(txt
))
893 class RemoteCommand(ConfirmAction
):
894 def __init__(self
, context
, remote
):
895 super().__init
__(context
)
900 self
.model
.update_remotes()
903 class RemoteAdd(RemoteCommand
):
904 def __init__(self
, context
, remote
, url
):
905 super().__init
__(context
, remote
)
909 return self
.git
.remote('add', self
.remote
, self
.url
)
911 def error_message(self
):
912 return N_('Error creating remote "%s"') % self
.remote
915 return f
'git remote add "{self.remote}" "{self.url}"'
918 class RemoteRemove(RemoteCommand
):
920 title
= N_('Delete Remote')
921 question
= N_('Delete remote?')
922 info
= N_('Delete remote "%s"') % self
.remote
923 ok_text
= N_('Delete')
924 return Interaction
.confirm(title
, question
, info
, ok_text
)
927 return self
.git
.remote('rm', self
.remote
)
929 def error_message(self
):
930 return N_('Error deleting remote "%s"') % self
.remote
933 return 'git remote rm "%s"' % self
.remote
936 class RemoteRename(RemoteCommand
):
937 def __init__(self
, context
, remote
, new_name
):
938 super().__init
__(context
, remote
)
939 self
.new_name
= new_name
942 title
= N_('Rename Remote')
943 text
= N_('Rename remote "%(current)s" to "%(new)s"?') % {
944 'current': self
.remote
,
945 'new': self
.new_name
,
949 return Interaction
.confirm(title
, text
, info_text
, ok_text
)
952 return self
.git
.remote('rename', self
.remote
, self
.new_name
)
954 def error_message(self
):
955 return N_('Error renaming "%(name)s" to "%(new_name)s"') % {
957 'new_name': self
.new_name
,
961 return f
'git remote rename "{self.remote}" "{self.new_name}"'
964 class RemoteSetURL(RemoteCommand
):
965 def __init__(self
, context
, remote
, url
):
966 super().__init
__(context
, remote
)
970 return self
.git
.remote('set-url', self
.remote
, self
.url
)
972 def error_message(self
):
973 return N_('Unable to set URL for "%(name)s" to "%(url)s"') % {
979 return f
'git remote set-url "{self.remote}" "{self.url}"'
982 class RemoteEdit(ContextCommand
):
983 """Combine RemoteRename and RemoteSetURL"""
985 def __init__(self
, context
, old_name
, remote
, url
):
986 super().__init
__(context
)
987 self
.rename
= RemoteRename(context
, old_name
, remote
)
988 self
.set_url
= RemoteSetURL(context
, remote
, url
)
991 result
= self
.rename
.do()
995 result
= self
.set_url
.do()
997 return name_ok
, url_ok
1000 class RemoveFromSettings(ConfirmAction
):
1001 def __init__(self
, context
, repo
, entry
, icon
=None):
1002 super().__init
__(context
)
1003 self
.context
= context
1009 self
.context
.settings
.save()
1012 class RemoveBookmark(RemoveFromSettings
):
1015 title
= msg
= N_('Delete Bookmark?')
1016 info
= N_('%s will be removed from your bookmarks.') % entry
1017 ok_text
= N_('Delete Bookmark')
1018 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
1021 self
.context
.settings
.remove_bookmark(self
.repo
, self
.entry
)
1025 class RemoveRecent(RemoveFromSettings
):
1028 title
= msg
= N_('Remove %s from the recent list?') % repo
1029 info
= N_('%s will be removed from your recent repositories.') % repo
1030 ok_text
= N_('Remove')
1031 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
1034 self
.context
.settings
.remove_recent(self
.repo
)
1038 class RemoveFiles(ContextCommand
):
1041 def __init__(self
, context
, remover
, filenames
):
1042 super().__init
__(context
)
1045 self
.remover
= remover
1046 self
.filenames
= filenames
1047 # We could git-hash-object stuff and provide undo-ability
1048 # as an option. Heh.
1051 files
= self
.filenames
1057 remove
= self
.remover
1058 for filename
in files
:
1064 bad_filenames
.append(filename
)
1067 Interaction
.information(
1068 N_('Error'), N_('Deleting "%s" failed') % file_summary(bad_filenames
)
1072 self
.model
.update_file_status()
1075 class Delete(RemoveFiles
):
1078 def __init__(self
, context
, filenames
):
1079 super().__init
__(context
, os
.remove
, filenames
)
1082 files
= self
.filenames
1086 title
= N_('Delete Files?')
1087 msg
= N_('The following files will be deleted:') + '\n\n'
1088 msg
+= file_summary(files
)
1089 info_txt
= N_('Delete %d file(s)?') % len(files
)
1090 ok_txt
= N_('Delete Files')
1092 if Interaction
.confirm(
1093 title
, msg
, info_txt
, ok_txt
, default
=True, icon
=icons
.remove()
1098 class MoveToTrash(RemoveFiles
):
1099 """Move files to the trash using send2trash"""
1101 AVAILABLE
= send2trash
is not None
1103 def __init__(self
, context
, filenames
):
1104 super().__init
__(context
, send2trash
, filenames
)
1107 class DeleteBranch(ConfirmAction
):
1108 """Delete a git branch."""
1110 def __init__(self
, context
, branch
):
1111 super().__init
__(context
)
1112 self
.branch
= branch
1115 title
= N_('Delete Branch')
1116 question
= N_('Delete branch "%s"?') % self
.branch
1117 info
= N_('The branch will be no longer available.')
1118 ok_txt
= N_('Delete Branch')
1119 return Interaction
.confirm(
1120 title
, question
, info
, ok_txt
, default
=True, icon
=icons
.discard()
1124 return self
.model
.delete_branch(self
.branch
)
1126 def error_message(self
):
1127 return N_('Error deleting branch "%s"' % self
.branch
)
1130 command
= 'git branch -D %s'
1131 return command
% self
.branch
1134 class Rename(ContextCommand
):
1135 """Rename a set of paths."""
1137 def __init__(self
, context
, paths
):
1138 super().__init
__(context
)
1142 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
1143 Interaction
.log(msg
)
1145 for path
in self
.paths
:
1146 ok
= self
.rename(path
)
1150 self
.model
.update_status()
1152 def rename(self
, path
):
1154 title
= N_('Rename "%s"') % path
1156 if os
.path
.isdir(path
):
1157 base_path
= os
.path
.dirname(path
)
1160 new_path
= Interaction
.save_as(base_path
, title
)
1164 status
, out
, err
= git
.mv(path
, new_path
, force
=True, verbose
=True)
1165 Interaction
.command(N_('Error'), 'git mv', status
, out
, err
)
1169 class RenameBranch(ContextCommand
):
1170 """Rename a git branch."""
1172 def __init__(self
, context
, branch
, new_branch
):
1173 super().__init
__(context
)
1174 self
.branch
= branch
1175 self
.new_branch
= new_branch
1178 branch
= self
.branch
1179 new_branch
= self
.new_branch
1180 status
, out
, err
= self
.model
.rename_branch(branch
, new_branch
)
1181 Interaction
.log_status(status
, out
, err
)
1184 class DeleteRemoteBranch(DeleteBranch
):
1185 """Delete a remote git branch."""
1187 def __init__(self
, context
, remote
, branch
):
1188 super().__init
__(context
, branch
)
1189 self
.remote
= remote
1192 return self
.git
.push(self
.remote
, self
.branch
, delete
=True)
1195 self
.model
.update_status()
1196 Interaction
.information(
1197 N_('Remote Branch Deleted'),
1198 N_('"%(branch)s" has been deleted from "%(remote)s".')
1200 'branch': self
.branch
,
1201 'remote': self
.remote
,
1205 def error_message(self
):
1206 return N_('Error Deleting Remote Branch')
1209 command
= 'git push --delete %s %s'
1210 return command
% (self
.remote
, self
.branch
)
1213 def get_mode(context
, filename
, staged
, modified
, unmerged
, untracked
):
1214 model
= context
.model
1216 mode
= model
.mode_index
1217 elif modified
or unmerged
:
1218 mode
= model
.mode_worktree
1220 if gitcmds
.is_binary(context
, filename
):
1221 mode
= model
.mode_untracked
1223 mode
= model
.mode_untracked_diff
1229 class DiffAgainstCommitMode(ContextCommand
):
1230 """Diff against arbitrary commits"""
1232 def __init__(self
, context
, oid
):
1233 super().__init
__(context
)
1237 self
.model
.set_mode(self
.model
.mode_diff
, head
=self
.oid
)
1238 self
.model
.update_file_status()
1241 class DiffText(EditModel
):
1242 """Set the diff type to text"""
1244 def __init__(self
, context
):
1245 super().__init
__(context
)
1246 self
.new_file_type
= main
.Types
.TEXT
1247 self
.new_diff_type
= main
.Types
.TEXT
1250 class ToggleDiffType(ContextCommand
):
1251 """Toggle the diff type between image and text"""
1253 def __init__(self
, context
):
1254 super().__init
__(context
)
1255 if self
.model
.diff_type
== main
.Types
.IMAGE
:
1256 self
.new_diff_type
= main
.Types
.TEXT
1257 self
.new_value
= False
1259 self
.new_diff_type
= main
.Types
.IMAGE
1260 self
.new_value
= True
1263 diff_type
= self
.new_diff_type
1264 value
= self
.new_value
1266 self
.model
.set_diff_type(diff_type
)
1268 filename
= self
.model
.filename
1269 _
, ext
= os
.path
.splitext(filename
)
1270 if ext
.startswith('.'):
1271 cfg
= 'cola.imagediff' + ext
1272 self
.cfg
.set_repo(cfg
, value
)
1275 class DiffImage(EditModel
):
1277 self
, context
, filename
, deleted
, staged
, modified
, unmerged
, untracked
1279 super().__init
__(context
)
1281 self
.new_filename
= filename
1282 self
.new_diff_type
= self
.get_diff_type(filename
)
1283 self
.new_file_type
= main
.Types
.IMAGE
1284 self
.new_mode
= get_mode(
1285 context
, filename
, staged
, modified
, unmerged
, untracked
1287 self
.staged
= staged
1288 self
.modified
= modified
1289 self
.unmerged
= unmerged
1290 self
.untracked
= untracked
1291 self
.deleted
= deleted
1292 self
.annex
= self
.cfg
.is_annex()
1294 def get_diff_type(self
, filename
):
1295 """Query the diff type to use based on cola.imagediff.<extension>"""
1296 _
, ext
= os
.path
.splitext(filename
)
1297 if ext
.startswith('.'):
1298 # Check eg. "cola.imagediff.svg" to see if we should imagediff.
1299 cfg
= 'cola.imagediff' + ext
1300 if self
.cfg
.get(cfg
, True):
1301 result
= main
.Types
.IMAGE
1303 result
= main
.Types
.TEXT
1305 result
= main
.Types
.IMAGE
1309 filename
= self
.new_filename
1312 images
= self
.staged_images()
1314 images
= self
.modified_images()
1316 images
= self
.unmerged_images()
1317 elif self
.untracked
:
1318 images
= [(filename
, False)]
1322 self
.model
.set_images(images
)
1325 def staged_images(self
):
1326 context
= self
.context
1328 head
= self
.model
.head
1329 filename
= self
.new_filename
1333 index
= git
.diff_index(head
, '--', filename
, cached
=True)[STDOUT
]
1336 # :100644 100644 fabadb8... 4866510... M describe.c
1337 parts
= index
.split(' ')
1342 if old_oid
!= MISSING_BLOB_OID
:
1343 # First, check if we can get a pre-image from git-annex
1346 annex_image
= gitcmds
.annex_path(context
, head
, filename
)
1348 images
.append((annex_image
, False)) # git annex HEAD
1350 image
= gitcmds
.write_blob_path(context
, head
, old_oid
, filename
)
1352 images
.append((image
, True))
1354 if new_oid
!= MISSING_BLOB_OID
:
1355 found_in_annex
= False
1356 if annex
and core
.islink(filename
):
1357 status
, out
, _
= git
.annex('status', '--', filename
)
1359 details
= out
.split(' ')
1360 if details
and details
[0] == 'A': # newly added file
1361 images
.append((filename
, False))
1362 found_in_annex
= True
1364 if not found_in_annex
:
1365 image
= gitcmds
.write_blob(context
, new_oid
, filename
)
1367 images
.append((image
, True))
1371 def unmerged_images(self
):
1372 context
= self
.context
1374 head
= self
.model
.head
1375 filename
= self
.new_filename
1378 candidate_merge_heads
= ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1381 for merge_head
in candidate_merge_heads
1382 if core
.exists(git
.git_path(merge_head
))
1385 if annex
: # Attempt to find files in git-annex
1387 for merge_head
in merge_heads
:
1388 image
= gitcmds
.annex_path(context
, merge_head
, filename
)
1390 annex_images
.append((image
, False))
1392 annex_images
.append((filename
, False))
1395 # DIFF FORMAT FOR MERGES
1396 # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1397 # can take -c or --cc option to generate diff output also
1398 # for merge commits. The output differs from the format
1399 # described above in the following way:
1401 # 1. there is a colon for each parent
1402 # 2. there are more "src" modes and "src" sha1
1403 # 3. status is concatenated status characters for each parent
1404 # 4. no optional "score" number
1405 # 5. single path, only for "dst"
1407 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1410 index
= git
.diff_index(head
, '--', filename
, cached
=True, cc
=True)[STDOUT
]
1412 parts
= index
.split(' ')
1414 first_mode
= parts
[0]
1415 num_parents
= first_mode
.count(':')
1416 # colon for each parent, but for the index, the "parents"
1417 # are really entries in stages 1,2,3 (head, base, remote)
1418 # remote, base, head
1419 for i
in range(num_parents
):
1420 offset
= num_parents
+ i
+ 1
1423 merge_head
= merge_heads
[i
]
1426 if oid
!= MISSING_BLOB_OID
:
1427 image
= gitcmds
.write_blob_path(
1428 context
, merge_head
, oid
, filename
1431 images
.append((image
, True))
1433 images
.append((filename
, False))
1436 def modified_images(self
):
1437 context
= self
.context
1439 head
= self
.model
.head
1440 filename
= self
.new_filename
1445 if annex
: # Check for a pre-image from git-annex
1446 annex_image
= gitcmds
.annex_path(context
, head
, filename
)
1448 images
.append((annex_image
, False)) # git annex HEAD
1450 worktree
= git
.diff_files('--', filename
)[STDOUT
]
1451 parts
= worktree
.split(' ')
1454 if oid
!= MISSING_BLOB_OID
:
1455 image
= gitcmds
.write_blob_path(context
, head
, oid
, filename
)
1457 images
.append((image
, True)) # HEAD
1459 images
.append((filename
, False)) # worktree
1463 class Diff(EditModel
):
1464 """Perform a diff and set the model's current text."""
1466 def __init__(self
, context
, filename
, cached
=False, deleted
=False):
1467 super().__init
__(context
)
1469 if cached
and gitcmds
.is_valid_ref(context
, self
.model
.head
):
1470 opts
['ref'] = self
.model
.head
1471 self
.new_filename
= filename
1472 self
.new_mode
= self
.model
.mode_worktree
1473 self
.new_diff_text
= gitcmds
.diff_helper(
1474 self
.context
, filename
=filename
, cached
=cached
, deleted
=deleted
, **opts
1478 class Diffstat(EditModel
):
1479 """Perform a diffstat and set the model's diff text."""
1481 def __init__(self
, context
):
1482 super().__init
__(context
)
1484 diff_context
= cfg
.get('diff.context', 3)
1485 diff
= self
.git
.diff(
1487 unified
=diff_context
,
1493 self
.new_diff_text
= diff
1494 self
.new_diff_type
= main
.Types
.TEXT
1495 self
.new_file_type
= main
.Types
.TEXT
1496 self
.new_mode
= self
.model
.mode_diffstat
1499 class DiffStaged(Diff
):
1500 """Perform a staged diff on a file."""
1502 def __init__(self
, context
, filename
, deleted
=None):
1503 super().__init
__(context
, filename
, cached
=True, deleted
=deleted
)
1504 self
.new_mode
= self
.model
.mode_index
1507 class DiffStagedSummary(EditModel
):
1508 def __init__(self
, context
):
1509 super().__init
__(context
)
1510 diff
= self
.git
.diff(
1515 patch_with_stat
=True,
1518 self
.new_diff_text
= diff
1519 self
.new_diff_type
= main
.Types
.TEXT
1520 self
.new_file_type
= main
.Types
.TEXT
1521 self
.new_mode
= self
.model
.mode_index
1524 class Edit(ContextCommand
):
1525 """Edit a file using the configured gui.editor."""
1529 return N_('Launch Editor')
1531 def __init__(self
, context
, filenames
, line_number
=None, background_editor
=False):
1532 super().__init
__(context
)
1533 self
.filenames
= filenames
1534 self
.line_number
= line_number
1535 self
.background_editor
= background_editor
1538 context
= self
.context
1539 if not self
.filenames
:
1541 filename
= self
.filenames
[0]
1542 if not core
.exists(filename
):
1544 if self
.background_editor
:
1545 editor
= prefs
.background_editor(context
)
1547 editor
= prefs
.editor(context
)
1550 if self
.line_number
is None:
1551 opts
= self
.filenames
1553 # Single-file w/ line-numbers (likely from grep)
1555 '*vim*': [filename
, '+%s' % self
.line_number
],
1556 '*emacs*': ['+%s' % self
.line_number
, filename
],
1557 '*textpad*': [f
'{filename}({self.line_number},0)'],
1558 '*notepad++*': ['-n%s' % self
.line_number
, filename
],
1559 '*subl*': [f
'{filename}:{self.line_number}'],
1562 opts
= self
.filenames
1563 for pattern
, opt
in editor_opts
.items():
1564 if fnmatch(editor
, pattern
):
1569 core
.fork(utils
.shell_split(editor
) + opts
)
1570 except (OSError, ValueError) as e
:
1571 message
= N_('Cannot exec "%s": please configure your editor') % editor
1572 _
, details
= utils
.format_exception(e
)
1573 Interaction
.critical(N_('Error Editing File'), message
, details
)
1576 class FormatPatch(ContextCommand
):
1577 """Output a patch series given all revisions and a selected subset."""
1579 def __init__(self
, context
, to_export
, revs
, output
='patches'):
1580 super().__init
__(context
)
1581 self
.to_export
= list(to_export
)
1582 self
.revs
= list(revs
)
1583 self
.output
= output
1586 context
= self
.context
1587 status
, out
, err
= gitcmds
.format_patchsets(
1588 context
, self
.to_export
, self
.revs
, self
.output
1590 Interaction
.log_status(status
, out
, err
)
1593 class LaunchTerminal(ContextCommand
):
1596 return N_('Launch Terminal')
1599 def is_available(context
):
1600 return context
.cfg
.terminal() is not None
1602 def __init__(self
, context
, path
):
1603 super().__init
__(context
)
1607 cmd
= self
.context
.cfg
.terminal()
1610 if utils
.is_win32():
1611 argv
= ['start', '', cmd
, '--login']
1614 argv
= utils
.shell_split(cmd
)
1616 shells
= ('zsh', 'fish', 'bash', 'sh')
1617 for basename
in shells
:
1618 executable
= core
.find_executable(basename
)
1620 command
= executable
1622 argv
.append(os
.getenv('SHELL', command
))
1625 core
.fork(argv
, cwd
=self
.path
, shell
=shell
)
1628 class LaunchEditor(Edit
):
1631 return N_('Launch Editor')
1633 def __init__(self
, context
):
1634 s
= context
.selection
.selection()
1635 filenames
= s
.staged
+ s
.unmerged
+ s
.modified
+ s
.untracked
1636 super().__init
__(context
, filenames
, background_editor
=True)
1639 class LaunchEditorAtLine(LaunchEditor
):
1640 """Launch an editor at the specified line"""
1642 def __init__(self
, context
):
1643 super().__init
__(context
)
1644 self
.line_number
= context
.selection
.line_number
1647 class LoadCommitMessageFromFile(ContextCommand
):
1648 """Loads a commit message from a path."""
1652 def __init__(self
, context
, path
):
1653 super().__init
__(context
)
1655 self
.old_commitmsg
= self
.model
.commitmsg
1656 self
.old_directory
= self
.model
.directory
1659 path
= os
.path
.expanduser(self
.path
)
1660 if not path
or not core
.isfile(path
):
1662 N_('Error: Cannot find commit template'),
1663 N_('%s: No such file or directory.') % path
,
1665 self
.model
.set_directory(os
.path
.dirname(path
))
1666 self
.model
.set_commitmsg(core
.read(path
))
1669 self
.model
.set_commitmsg(self
.old_commitmsg
)
1670 self
.model
.set_directory(self
.old_directory
)
1673 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile
):
1674 """Loads the commit message template specified by commit.template."""
1676 def __init__(self
, context
):
1678 template
= cfg
.get('commit.template')
1679 super().__init
__(context
, template
)
1682 if self
.path
is None:
1684 N_('Error: Unconfigured commit template'),
1686 'A commit template has not been configured.\n'
1687 'Use "git config" to define "commit.template"\n'
1688 'so that it points to a commit template.'
1691 return LoadCommitMessageFromFile
.do(self
)
1694 class LoadCommitMessageFromOID(ContextCommand
):
1695 """Load a previous commit message"""
1699 def __init__(self
, context
, oid
, prefix
=''):
1700 super().__init
__(context
)
1702 self
.old_commitmsg
= self
.model
.commitmsg
1703 self
.new_commitmsg
= prefix
+ gitcmds
.prev_commitmsg(context
, oid
)
1706 self
.model
.set_commitmsg(self
.new_commitmsg
)
1709 self
.model
.set_commitmsg(self
.old_commitmsg
)
1712 class PrepareCommitMessageHook(ContextCommand
):
1713 """Use the cola-prepare-commit-msg hook to prepare the commit message"""
1717 def __init__(self
, context
):
1718 super().__init
__(context
)
1719 self
.old_commitmsg
= self
.model
.commitmsg
1721 def get_message(self
):
1722 title
= N_('Error running prepare-commitmsg hook')
1723 hook
= gitcmds
.prepare_commit_message_hook(self
.context
)
1725 if os
.path
.exists(hook
):
1726 filename
= self
.model
.save_commitmsg()
1727 status
, out
, err
= core
.run_command([hook
, filename
])
1730 result
= core
.read(filename
)
1732 result
= self
.old_commitmsg
1733 Interaction
.command_error(title
, hook
, status
, out
, err
)
1735 message
= N_('A hook must be provided at "%s"') % hook
1736 Interaction
.critical(title
, message
=message
)
1737 result
= self
.old_commitmsg
1742 msg
= self
.get_message()
1743 self
.model
.set_commitmsg(msg
)
1746 self
.model
.set_commitmsg(self
.old_commitmsg
)
1749 class LoadFixupMessage(LoadCommitMessageFromOID
):
1750 """Load a fixup message"""
1752 def __init__(self
, context
, oid
):
1753 super().__init
__(context
, oid
, prefix
='fixup! ')
1754 if self
.new_commitmsg
:
1755 self
.new_commitmsg
= self
.new_commitmsg
.splitlines()[0]
1758 class Merge(ContextCommand
):
1761 def __init__(self
, context
, revision
, no_commit
, squash
, no_ff
, sign
):
1762 super().__init
__(context
)
1763 self
.revision
= revision
1765 self
.no_commit
= no_commit
1766 self
.squash
= squash
1770 squash
= self
.squash
1771 revision
= self
.revision
1773 no_commit
= self
.no_commit
1776 status
, out
, err
= self
.git
.merge(
1777 revision
, gpg_sign
=sign
, no_ff
=no_ff
, no_commit
=no_commit
, squash
=squash
1779 self
.model
.update_status()
1780 title
= N_('Merge failed. Conflict resolution is required.')
1781 Interaction
.command(title
, 'git merge', status
, out
, err
)
1783 return status
, out
, err
1786 class OpenDefaultApp(ContextCommand
):
1787 """Open a file using the OS default."""
1791 return N_('Open Using Default Application')
1793 def __init__(self
, context
, filenames
):
1794 super().__init
__(context
)
1795 self
.filenames
= filenames
1798 if not self
.filenames
:
1800 utils
.launch_default_app(self
.filenames
)
1803 class OpenDir(OpenDefaultApp
):
1804 """Open directories using the OS default."""
1808 return N_('Open Directory')
1811 def _dirnames(self
):
1812 return self
.filenames
1815 dirnames
= self
._dirnames
1818 # An empty dirname defaults to CWD.
1819 dirs
= [(dirname
or core
.getcwd()) for dirname
in dirnames
]
1820 utils
.launch_default_app(dirs
)
1823 class OpenParentDir(OpenDir
):
1824 """Open parent directories using the OS default."""
1828 return N_('Open Parent Directory')
1831 def _dirnames(self
):
1832 dirnames
= list({os
.path
.dirname(x
) for x
in self
.filenames
})
1836 class OpenWorktree(OpenDir
):
1837 """Open worktree directory using the OS default."""
1841 return N_('Open Worktree')
1843 # The _unused parameter is needed by worktree_dir_action() -> common.cmd_action().
1844 def __init__(self
, context
, _unused
=None):
1845 dirnames
= [context
.git
.worktree()]
1846 super().__init
__(context
, dirnames
)
1849 class OpenNewRepo(ContextCommand
):
1850 """Launches git-cola on a repo."""
1852 def __init__(self
, context
, repo_path
):
1853 super().__init
__(context
)
1854 self
.repo_path
= repo_path
1857 self
.model
.set_directory(self
.repo_path
)
1858 core
.fork([sys
.executable
, sys
.argv
[0], '--repo', self
.repo_path
])
1861 class OpenRepo(EditModel
):
1862 def __init__(self
, context
, repo_path
):
1863 super().__init
__(context
)
1864 self
.repo_path
= repo_path
1865 self
.new_mode
= self
.model
.mode_none
1866 self
.new_diff_text
= ''
1867 self
.new_diff_type
= main
.Types
.TEXT
1868 self
.new_file_type
= main
.Types
.TEXT
1869 self
.new_commitmsg
= ''
1870 self
.new_filename
= ''
1873 old_repo
= self
.git
.getcwd()
1874 if self
.model
.set_worktree(self
.repo_path
):
1875 self
.fsmonitor
.stop()
1876 self
.fsmonitor
.start()
1877 self
.model
.update_status(reset
=True)
1878 # Check if template should be loaded
1879 if self
.context
.cfg
.get(prefs
.AUTOTEMPLATE
):
1880 template_loader
= LoadCommitMessageFromTemplate(self
.context
)
1881 template_loader
.do()
1883 self
.model
.set_commitmsg(self
.new_commitmsg
)
1884 settings
= self
.context
.settings
1886 settings
.add_recent(self
.repo_path
, prefs
.maxrecent(self
.context
))
1890 self
.model
.set_worktree(old_repo
)
1893 class OpenParentRepo(OpenRepo
):
1894 def __init__(self
, context
):
1896 if version
.check_git(context
, 'show-superproject-working-tree'):
1897 status
, out
, _
= context
.git
.rev_parse(show_superproject_working_tree
=True)
1901 path
= os
.path
.dirname(core
.getcwd())
1902 super().__init
__(context
, path
)
1905 class Clone(ContextCommand
):
1906 """Clones a repository and optionally spawns a new cola session."""
1909 self
, context
, url
, new_directory
, submodules
=False, shallow
=False, spawn
=True
1911 super().__init
__(context
)
1913 self
.new_directory
= new_directory
1914 self
.submodules
= submodules
1915 self
.shallow
= shallow
1925 recurse_submodules
= self
.submodules
1926 shallow_submodules
= self
.submodules
and self
.shallow
1928 status
, out
, err
= self
.git
.clone(
1931 recurse_submodules
=recurse_submodules
,
1932 shallow_submodules
=shallow_submodules
,
1936 self
.status
= status
1939 if status
== 0 and self
.spawn
:
1940 executable
= sys
.executable
1941 core
.fork([executable
, sys
.argv
[0], '--repo', self
.new_directory
])
1945 class NewBareRepo(ContextCommand
):
1946 """Create a new shared bare repository"""
1948 def __init__(self
, context
, path
):
1949 super().__init
__(context
)
1954 status
, out
, err
= self
.git
.init(path
, bare
=True, shared
=True)
1955 Interaction
.command(
1956 N_('Error'), 'git init --bare --shared "%s"' % path
, status
, out
, err
1961 def unix_path(path
, is_win32
=utils
.is_win32
):
1962 """Git for Windows requires unix paths, so force them here"""
1964 path
= path
.replace('\\', '/')
1967 if second
== ':': # sanity check, this better be a Windows-style path
1968 path
= '/' + first
+ path
[2:]
1973 def sequence_editor():
1974 """Set GIT_SEQUENCE_EDITOR for running git-cola-sequence-editor"""
1975 xbase
= unix_path(resources
.command('git-cola-sequence-editor'))
1976 if utils
.is_win32():
1977 editor
= core
.list2cmdline([unix_path(sys
.executable
), xbase
])
1979 editor
= core
.list2cmdline([xbase
])
1983 class SequenceEditorEnvironment
:
1984 """Set environment variables to enable git-cola-sequence-editor"""
1986 def __init__(self
, context
, **kwargs
):
1988 'GIT_EDITOR': prefs
.editor(context
),
1989 'GIT_SEQUENCE_EDITOR': sequence_editor(),
1990 'GIT_COLA_SEQ_EDITOR_CANCEL_ACTION': 'save',
1992 self
.env
.update(kwargs
)
1994 def __enter__(self
):
1995 for var
, value
in self
.env
.items():
1996 compat
.setenv(var
, value
)
1999 def __exit__(self
, exc_type
, exc_val
, exc_tb
):
2000 for var
in self
.env
:
2001 compat
.unsetenv(var
)
2004 class Rebase(ContextCommand
):
2005 def __init__(self
, context
, upstream
=None, branch
=None, **kwargs
):
2006 """Start an interactive rebase session
2008 :param upstream: upstream branch
2009 :param branch: optional branch to checkout
2010 :param kwargs: forwarded directly to `git.rebase()`
2013 super().__init
__(context
)
2015 self
.upstream
= upstream
2016 self
.branch
= branch
2017 self
.kwargs
= kwargs
2019 def prepare_arguments(self
, upstream
):
2023 # Rebase actions must be the only option specified
2024 for action
in ('continue', 'abort', 'skip', 'edit_todo'):
2025 if self
.kwargs
.get(action
, False):
2026 kwargs
[action
] = self
.kwargs
[action
]
2029 kwargs
['interactive'] = True
2030 kwargs
['autosquash'] = self
.kwargs
.get('autosquash', True)
2031 kwargs
.update(self
.kwargs
)
2033 # Prompt to determine whether or not to use "git rebase --update-refs".
2034 has_update_refs
= version
.check_git(self
.context
, 'rebase-update-refs')
2035 if has_update_refs
and not kwargs
.get('update_refs', False):
2036 title
= N_('Update stacked branches when rebasing?')
2038 '"git rebase --update-refs" automatically force-updates any\n'
2039 'branches that point to commits that are being rebased.\n\n'
2040 'Any branches that are checked out in a worktree are not updated.\n\n'
2041 'Using this feature is helpful for "stacked" branch workflows.'
2043 info
= N_('Update stacked branches when rebasing?')
2044 ok_text
= N_('Update stacked branches')
2045 cancel_text
= N_('Do not update stacked branches')
2046 update_refs
= Interaction
.confirm(
2052 cancel_text
=cancel_text
,
2055 kwargs
['update_refs'] = True
2058 args
.append(upstream
)
2060 args
.append(self
.branch
)
2065 (status
, out
, err
) = (1, '', '')
2066 context
= self
.context
2070 if not cfg
.get('rebase.autostash', False):
2071 if model
.staged
or model
.unmerged
or model
.modified
:
2072 Interaction
.information(
2073 N_('Unable to rebase'),
2074 N_('You cannot rebase with uncommitted changes.'),
2076 return status
, out
, err
2078 upstream
= self
.upstream
or Interaction
.choose_ref(
2080 N_('Select New Upstream'),
2081 N_('Interactive Rebase'),
2082 default
='@{upstream}',
2085 return status
, out
, err
2087 self
.model
.is_rebasing
= True
2088 self
.model
.emit_updated()
2090 args
, kwargs
= self
.prepare_arguments(upstream
)
2091 upstream_title
= upstream
or '@{upstream}'
2092 with
SequenceEditorEnvironment(
2094 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Rebase onto %s') % upstream_title
,
2095 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Rebase'),
2097 # This blocks the user interface window for the duration
2098 # of git-cola-sequence-editor. We would need to run the command
2099 # in a QRunnable task to avoid blocking the main thread.
2100 # Alternatively, we can hide the main window while rebasing,
2101 # which doesn't require as much effort.
2102 status
, out
, err
= self
.git
.rebase(
2103 *args
, _no_win32_startupinfo
=True, **kwargs
2105 self
.model
.update_status()
2106 if err
.strip() != 'Nothing to do':
2107 title
= N_('Rebase stopped')
2108 Interaction
.command(title
, 'git rebase', status
, out
, err
)
2109 return status
, out
, err
2112 class RebaseEditTodo(ContextCommand
):
2114 (status
, out
, err
) = (1, '', '')
2115 with
SequenceEditorEnvironment(
2117 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Edit Rebase'),
2118 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Save'),
2120 status
, out
, err
= self
.git
.rebase(edit_todo
=True)
2121 Interaction
.log_status(status
, out
, err
)
2122 self
.model
.update_status()
2123 return status
, out
, err
2126 class RebaseContinue(ContextCommand
):
2128 (status
, out
, err
) = (1, '', '')
2129 with
SequenceEditorEnvironment(
2131 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Rebase'),
2132 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Rebase'),
2134 status
, out
, err
= self
.git
.rebase('--continue')
2135 Interaction
.log_status(status
, out
, err
)
2136 self
.model
.update_status()
2137 return status
, out
, err
2140 class RebaseSkip(ContextCommand
):
2142 (status
, out
, err
) = (1, '', '')
2143 with
SequenceEditorEnvironment(
2145 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Rebase'),
2146 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Rebase'),
2148 status
, out
, err
= self
.git
.rebase(skip
=True)
2149 Interaction
.log_status(status
, out
, err
)
2150 self
.model
.update_status()
2151 return status
, out
, err
2154 class RebaseAbort(ContextCommand
):
2156 status
, out
, err
= self
.git
.rebase(abort
=True)
2157 Interaction
.log_status(status
, out
, err
)
2158 self
.model
.update_status()
2161 class Rescan(ContextCommand
):
2162 """Rescan for changes"""
2165 self
.model
.update_status()
2168 class Refresh(ContextCommand
):
2169 """Update refs, refresh the index, and update config"""
2173 return N_('Refresh')
2176 self
.model
.update_status(update_index
=True)
2178 self
.fsmonitor
.refresh()
2181 class RefreshConfig(ContextCommand
):
2182 """Refresh the git config cache"""
2188 class RevertEditsCommand(ConfirmAction
):
2189 def __init__(self
, context
):
2190 super().__init
__(context
)
2191 self
.icon
= icons
.undo()
2193 def ok_to_run(self
):
2194 return self
.model
.is_undoable()
2196 def checkout_from_head(self
):
2199 def checkout_args(self
):
2201 s
= self
.selection
.selection()
2202 if self
.checkout_from_head():
2203 args
.append(self
.model
.head
)
2215 checkout_args
= self
.checkout_args()
2216 return self
.git
.checkout(*checkout_args
)
2219 self
.model
.set_diff_type(main
.Types
.TEXT
)
2220 self
.model
.update_file_status()
2223 class RevertUnstagedEdits(RevertEditsCommand
):
2226 return N_('Revert Unstaged Edits...')
2228 def checkout_from_head(self
):
2229 # Being in amend mode should not affect the behavior of this command.
2230 # The only sensible thing to do is to checkout from the index.
2234 title
= N_('Revert Unstaged Changes?')
2236 'This operation removes unstaged edits from selected files.\n'
2237 'These changes cannot be recovered.'
2239 info
= N_('Revert the unstaged changes?')
2240 ok_text
= N_('Revert Unstaged Changes')
2241 return Interaction
.confirm(
2242 title
, text
, info
, ok_text
, default
=True, icon
=self
.icon
2246 class RevertUncommittedEdits(RevertEditsCommand
):
2249 return N_('Revert Uncommitted Edits...')
2251 def checkout_from_head(self
):
2255 """Prompt for reverting changes"""
2256 title
= N_('Revert Uncommitted Changes?')
2258 'This operation removes uncommitted edits from selected files.\n'
2259 'These changes cannot be recovered.'
2261 info
= N_('Revert the uncommitted changes?')
2262 ok_text
= N_('Revert Uncommitted Changes')
2263 return Interaction
.confirm(
2264 title
, text
, info
, ok_text
, default
=True, icon
=self
.icon
2268 class RunConfigAction(ContextCommand
):
2269 """Run a user-configured action, typically from the "Tools" menu"""
2271 def __init__(self
, context
, action_name
):
2272 super().__init
__(context
)
2273 self
.action_name
= action_name
2276 """Run the user-configured action"""
2277 for env
in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
2279 compat
.unsetenv(env
)
2284 context
= self
.context
2286 opts
= cfg
.get_guitool_opts(self
.action_name
)
2287 cmd
= opts
.get('cmd')
2288 if 'title' not in opts
:
2291 if 'prompt' not in opts
or opts
.get('prompt') is True:
2292 prompt
= N_('Run "%s"?') % cmd
2293 opts
['prompt'] = prompt
2295 if opts
.get('needsfile'):
2296 filename
= self
.selection
.filename()
2298 Interaction
.information(
2299 N_('Please select a file'),
2300 N_('"%s" requires a selected file.') % cmd
,
2303 dirname
= utils
.dirname(filename
, current_dir
='.')
2304 compat
.setenv('FILENAME', filename
)
2305 compat
.setenv('DIRNAME', dirname
)
2307 if opts
.get('revprompt') or opts
.get('argprompt'):
2309 ok
= Interaction
.confirm_config_action(context
, cmd
, opts
)
2312 rev
= opts
.get('revision')
2313 args
= opts
.get('args')
2314 if opts
.get('revprompt') and not rev
:
2315 title
= N_('Invalid Revision')
2316 msg
= N_('The revision expression cannot be empty.')
2317 Interaction
.critical(title
, msg
)
2321 elif opts
.get('confirm'):
2322 title
= os
.path
.expandvars(opts
.get('title'))
2323 prompt
= os
.path
.expandvars(opts
.get('prompt'))
2324 if not Interaction
.question(title
, prompt
):
2327 compat
.setenv('REVISION', rev
)
2329 compat
.setenv('ARGS', args
)
2330 title
= os
.path
.expandvars(cmd
)
2331 Interaction
.log(N_('Running command: %s') % title
)
2332 cmd
= ['sh', '-c', cmd
]
2334 if opts
.get('background'):
2336 status
, out
, err
= (0, '', '')
2337 elif opts
.get('noconsole'):
2338 status
, out
, err
= core
.run_command(cmd
)
2340 status
, out
, err
= Interaction
.run_command(title
, cmd
)
2342 if not opts
.get('background') and not opts
.get('norescan'):
2343 self
.model
.update_status()
2346 Interaction
.command(title
, cmd
, status
, out
, err
)
2351 class SetDefaultRepo(ContextCommand
):
2352 """Set the default repository"""
2354 def __init__(self
, context
, repo
):
2355 super().__init
__(context
)
2359 self
.cfg
.set_user('cola.defaultrepo', self
.repo
)
2362 class SetDiffText(EditModel
):
2363 """Set the diff text"""
2367 def __init__(self
, context
, text
):
2368 super().__init
__(context
)
2369 self
.new_diff_text
= text
2370 self
.new_diff_type
= main
.Types
.TEXT
2371 self
.new_file_type
= main
.Types
.TEXT
2374 class SetUpstreamBranch(ContextCommand
):
2375 """Set the upstream branch"""
2377 def __init__(self
, context
, branch
, remote
, remote_branch
):
2378 super().__init
__(context
)
2379 self
.branch
= branch
2380 self
.remote
= remote
2381 self
.remote_branch
= remote_branch
2385 remote
= self
.remote
2386 branch
= self
.branch
2387 remote_branch
= self
.remote_branch
2388 cfg
.set_repo('branch.%s.remote' % branch
, remote
)
2389 cfg
.set_repo('branch.%s.merge' % branch
, 'refs/heads/' + remote_branch
)
2392 def format_hex(data
):
2393 """Translate binary data into a hex dump"""
2394 hexdigits
= '0123456789ABCDEF'
2397 byte_offset_to_int
= compat
.byte_offset_to_int_converter()
2398 while offset
< len(data
):
2399 result
+= '%04u |' % offset
2401 for i
in range(0, 16):
2402 if i
> 0 and i
% 4 == 0:
2404 if offset
< len(data
):
2405 v
= byte_offset_to_int(data
[offset
])
2406 result
+= ' ' + hexdigits
[v
>> 4] + hexdigits
[v
& 0xF]
2407 textpart
+= chr(v
) if 32 <= v
< 127 else '.'
2412 result
+= ' | ' + textpart
+ ' |\n'
2417 class ShowUntracked(EditModel
):
2418 """Show an untracked file."""
2420 def __init__(self
, context
, filename
):
2421 super().__init
__(context
)
2422 self
.new_filename
= filename
2423 if gitcmds
.is_binary(context
, filename
):
2424 self
.new_mode
= self
.model
.mode_untracked
2425 self
.new_diff_text
= self
.read(filename
)
2427 self
.new_mode
= self
.model
.mode_untracked_diff
2428 self
.new_diff_text
= gitcmds
.diff_helper(
2429 self
.context
, filename
=filename
, cached
=False, untracked
=True
2431 self
.new_diff_type
= main
.Types
.TEXT
2432 self
.new_file_type
= main
.Types
.TEXT
2434 def read(self
, filename
):
2435 """Read file contents"""
2437 size
= cfg
.get('cola.readsize', 2048)
2439 result
= core
.read(filename
, size
=size
, encoding
='bytes')
2443 truncated
= len(result
) == size
2445 encoding
= cfg
.file_encoding(filename
) or core
.ENCODING
2447 text_result
= core
.decode_maybe(result
, encoding
)
2448 except UnicodeError:
2449 text_result
= format_hex(result
)
2452 text_result
+= '...'
2456 class SignOff(ContextCommand
):
2457 """Append a signoff to the commit message"""
2463 return N_('Sign Off')
2465 def __init__(self
, context
):
2466 super().__init
__(context
)
2467 self
.old_commitmsg
= self
.model
.commitmsg
2470 """Add a signoff to the commit message"""
2471 signoff
= self
.signoff()
2472 if signoff
in self
.model
.commitmsg
:
2474 msg
= self
.model
.commitmsg
.rstrip()
2475 self
.model
.set_commitmsg(msg
+ '\n' + signoff
)
2478 """Restore the commit message"""
2479 self
.model
.set_commitmsg(self
.old_commitmsg
)
2482 """Generate the signoff string"""
2483 name
, email
= self
.cfg
.get_author()
2484 return f
'\nSigned-off-by: {name} <{email}>'
2487 def check_conflicts(context
, unmerged
):
2488 """Check paths for conflicts
2490 Conflicting files can be filtered out one-by-one.
2493 if prefs
.check_conflicts(context
):
2494 unmerged
= [path
for path
in unmerged
if is_conflict_free(path
)]
2498 def is_conflict_free(path
):
2499 """Return True if `path` contains no conflict markers"""
2500 rgx
= re
.compile(r
'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2502 with core
.xopen(path
, 'rb') as f
:
2504 line
= core
.decode(line
, errors
='ignore')
2506 return should_stage_conflicts(path
)
2508 # We can't read this file ~ we may be staging a removal
2513 def should_stage_conflicts(path
):
2514 """Inform the user that a file contains merge conflicts
2516 Return `True` if we should stage the path nonetheless.
2519 title
= msg
= N_('Stage conflicts?')
2522 '%s appears to contain merge conflicts.\n\n'
2523 'You should probably skip this file.\n'
2528 ok_text
= N_('Stage conflicts')
2529 cancel_text
= N_('Skip')
2530 return Interaction
.confirm(
2531 title
, msg
, info
, ok_text
, default
=False, cancel_text
=cancel_text
2535 class Stage(ContextCommand
):
2536 """Stage a set of paths."""
2542 def __init__(self
, context
, paths
):
2543 super().__init
__(context
)
2547 msg
= N_('Staging: %s') % (', '.join(self
.paths
))
2548 Interaction
.log(msg
)
2549 return self
.stage_paths()
2551 def stage_paths(self
):
2552 """Stages add/removals to git."""
2553 context
= self
.context
2556 if self
.model
.cfg
.get('cola.safemode', False):
2558 return self
.stage_all()
2566 for path
in set(paths
):
2567 if core
.exists(path
) or core
.islink(path
):
2568 if path
.endswith('/'):
2569 path
= path
.rstrip('/')
2574 self
.model
.emit_about_to_update()
2576 # `git add -u` doesn't work on untracked files
2578 status
, out
, err
= gitcmds
.add(context
, add
)
2579 Interaction
.command(N_('Error'), 'git add', status
, out
, err
)
2581 # If a path doesn't exist then that means it should be removed
2582 # from the index. We use `git add -u` for that.
2584 status
, out
, err
= gitcmds
.add(context
, remove
, u
=True)
2585 Interaction
.command(N_('Error'), 'git add -u', status
, out
, err
)
2587 self
.model
.update_files(emit
=True)
2588 return status
, out
, err
2590 def stage_all(self
):
2591 """Stage all files"""
2592 status
, out
, err
= self
.git
.add(v
=True, u
=True)
2593 Interaction
.command(N_('Error'), 'git add -u', status
, out
, err
)
2594 self
.model
.update_file_status()
2595 return (status
, out
, err
)
2598 class StageCarefully(Stage
):
2599 """Only stage when the path list is non-empty
2601 We use "git add -u -- <pathspec>" to stage, and it stages everything by
2602 default when no pathspec is specified, so this class ensures that paths
2603 are specified before calling git.
2605 When no paths are specified, the command does nothing.
2609 def __init__(self
, context
):
2610 super().__init
__(context
, None)
2613 def init_paths(self
):
2614 """Initialize path data"""
2617 def ok_to_run(self
):
2618 """Prevent catch-all "git add -u" from adding unmerged files"""
2619 return self
.paths
or not self
.model
.unmerged
2622 """Stage files when ok_to_run() return True"""
2623 if self
.ok_to_run():
2628 class StageModified(StageCarefully
):
2629 """Stage all modified files."""
2633 return N_('Stage Modified')
2635 def init_paths(self
):
2636 self
.paths
= self
.model
.modified
2639 class StageUnmerged(StageCarefully
):
2640 """Stage unmerged files."""
2644 return N_('Stage Unmerged')
2646 def init_paths(self
):
2647 self
.paths
= check_conflicts(self
.context
, self
.model
.unmerged
)
2650 class StageUntracked(StageCarefully
):
2651 """Stage all untracked files."""
2655 return N_('Stage Untracked')
2657 def init_paths(self
):
2658 self
.paths
= self
.model
.untracked
2661 class StageModifiedAndUntracked(StageCarefully
):
2662 """Stage all untracked files."""
2666 return N_('Stage Modified and Untracked')
2668 def init_paths(self
):
2669 self
.paths
= self
.model
.modified
+ self
.model
.untracked
2672 class StageOrUnstageAll(ContextCommand
):
2673 """If the selection is staged, unstage it, otherwise stage"""
2677 return N_('Stage / Unstage All')
2680 if self
.model
.staged
:
2681 do(Unstage
, self
.context
, self
.model
.staged
)
2683 if self
.cfg
.get('cola.safemode', False):
2684 unstaged
= self
.model
.modified
2686 unstaged
= self
.model
.modified
+ self
.model
.untracked
2687 do(Stage
, self
.context
, unstaged
)
2690 class StageOrUnstage(ContextCommand
):
2691 """If the selection is staged, unstage it, otherwise stage"""
2695 return N_('Stage / Unstage')
2698 s
= self
.selection
.selection()
2700 do(Unstage
, self
.context
, s
.staged
)
2703 unmerged
= check_conflicts(self
.context
, s
.unmerged
)
2705 unstaged
.extend(unmerged
)
2707 unstaged
.extend(s
.modified
)
2709 unstaged
.extend(s
.untracked
)
2711 do(Stage
, self
.context
, unstaged
)
2714 class Tag(ContextCommand
):
2715 """Create a tag object."""
2717 def __init__(self
, context
, name
, revision
, sign
=False, message
=''):
2718 super().__init
__(context
)
2720 self
._message
= message
2721 self
._revision
= revision
2727 revision
= self
._revision
2728 tag_name
= self
._name
2729 tag_message
= self
._message
2732 Interaction
.critical(
2733 N_('Missing Revision'), N_('Please specify a revision to tag.')
2738 Interaction
.critical(
2739 N_('Missing Name'), N_('Please specify a name for the new tag.')
2743 title
= N_('Missing Tag Message')
2744 message
= N_('Tag-signing was requested but the tag message is empty.')
2746 'An unsigned, lightweight tag will be created instead.\n'
2747 'Create an unsigned tag?'
2749 ok_text
= N_('Create Unsigned Tag')
2751 if sign
and not tag_message
:
2752 # We require a message in order to sign the tag, so if they
2753 # choose to create an unsigned tag we have to clear the sign flag.
2754 if not Interaction
.confirm(
2755 title
, message
, info
, ok_text
, default
=False, icon
=icons
.save()
2764 tmp_file
= utils
.tmp_filename('tag-message')
2765 opts
['file'] = tmp_file
2766 core
.write(tmp_file
, tag_message
)
2771 opts
['annotate'] = True
2772 status
, out
, err
= git
.tag(tag_name
, revision
, **opts
)
2775 core
.unlink(tmp_file
)
2777 title
= N_('Error: could not create tag "%s"') % tag_name
2778 Interaction
.command(title
, 'git tag', status
, out
, err
)
2782 self
.model
.update_status()
2783 Interaction
.information(
2785 N_('Created a new tag named "%s"') % tag_name
,
2786 details
=tag_message
or None,
2792 class Unstage(ContextCommand
):
2793 """Unstage a set of paths."""
2797 return N_('Unstage')
2799 def __init__(self
, context
, paths
):
2800 super().__init
__(context
)
2805 context
= self
.context
2806 head
= self
.model
.head
2809 msg
= N_('Unstaging: %s') % (', '.join(paths
))
2810 Interaction
.log(msg
)
2812 return unstage_all(context
)
2813 status
, out
, err
= gitcmds
.unstage_paths(context
, paths
, head
=head
)
2814 Interaction
.command(N_('Error'), 'git reset', status
, out
, err
)
2815 self
.model
.update_file_status()
2816 return (status
, out
, err
)
2819 class UnstageAll(ContextCommand
):
2820 """Unstage all files; resets the index."""
2823 return unstage_all(self
.context
)
2826 def unstage_all(context
):
2827 """Unstage all files, even while amending"""
2828 model
= context
.model
2831 status
, out
, err
= git
.reset(head
, '--', '.')
2832 Interaction
.command(N_('Error'), 'git reset', status
, out
, err
)
2833 model
.update_file_status()
2834 return (status
, out
, err
)
2837 class StageSelected(ContextCommand
):
2838 """Stage selected files, or all files if no selection exists."""
2841 context
= self
.context
2842 paths
= self
.selection
.unstaged
2844 do(Stage
, context
, paths
)
2845 elif self
.cfg
.get('cola.safemode', False):
2846 do(StageModified
, context
)
2849 class UnstageSelected(Unstage
):
2850 """Unstage selected files."""
2852 def __init__(self
, context
):
2853 staged
= context
.selection
.staged
2854 super().__init
__(context
, staged
)
2857 class Untrack(ContextCommand
):
2858 """Unstage a set of paths."""
2860 def __init__(self
, context
, paths
):
2861 super().__init
__(context
)
2865 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
2866 Interaction
.log(msg
)
2867 status
, out
, err
= self
.model
.untrack_paths(self
.paths
)
2868 Interaction
.log_status(status
, out
, err
)
2871 class UnmergedSummary(EditModel
):
2872 """List unmerged files in the diff text."""
2874 def __init__(self
, context
):
2875 super().__init
__(context
)
2876 unmerged
= self
.model
.unmerged
2878 io
.write('# %s unmerged file(s)\n' % len(unmerged
))
2880 io
.write('\n'.join(unmerged
) + '\n')
2881 self
.new_diff_text
= io
.getvalue()
2882 self
.new_diff_type
= main
.Types
.TEXT
2883 self
.new_file_type
= main
.Types
.TEXT
2884 self
.new_mode
= self
.model
.mode_display
2887 class UntrackedSummary(EditModel
):
2888 """List possible .gitignore rules as the diff text."""
2890 def __init__(self
, context
):
2891 super().__init
__(context
)
2892 untracked
= self
.model
.untracked
2894 io
.write('# %s untracked file(s)\n' % len(untracked
))
2896 io
.write('# Add these lines to ".gitignore" to ignore these files:\n')
2897 io
.write('\n'.join('/' + filename
for filename
in untracked
) + '\n')
2898 self
.new_diff_text
= io
.getvalue()
2899 self
.new_diff_type
= main
.Types
.TEXT
2900 self
.new_file_type
= main
.Types
.TEXT
2901 self
.new_mode
= self
.model
.mode_display
2904 class VisualizeAll(ContextCommand
):
2905 """Visualize all branches."""
2908 context
= self
.context
2909 browser
= utils
.shell_split(prefs
.history_browser(context
))
2910 launch_history_browser(browser
+ ['--all'])
2913 class VisualizeCurrent(ContextCommand
):
2914 """Visualize all branches."""
2917 context
= self
.context
2918 browser
= utils
.shell_split(prefs
.history_browser(context
))
2919 launch_history_browser(browser
+ [self
.model
.currentbranch
] + ['--'])
2922 class VisualizePaths(ContextCommand
):
2923 """Path-limited visualization."""
2925 def __init__(self
, context
, paths
):
2926 super().__init
__(context
)
2927 context
= self
.context
2928 browser
= utils
.shell_split(prefs
.history_browser(context
))
2930 self
.argv
= browser
+ ['--'] + list(paths
)
2935 launch_history_browser(self
.argv
)
2938 class VisualizeRevision(ContextCommand
):
2939 """Visualize a specific revision."""
2941 def __init__(self
, context
, revision
, paths
=None):
2942 super().__init
__(context
)
2943 self
.revision
= revision
2947 context
= self
.context
2948 argv
= utils
.shell_split(prefs
.history_browser(context
))
2950 argv
.append(self
.revision
)
2953 argv
.extend(self
.paths
)
2954 launch_history_browser(argv
)
2957 class SubmoduleAdd(ConfirmAction
):
2958 """Add specified submodules"""
2960 def __init__(self
, context
, url
, path
, branch
, depth
, reference
):
2961 super().__init
__(context
)
2964 self
.branch
= branch
2966 self
.reference
= reference
2969 title
= N_('Add Submodule...')
2970 question
= N_('Add this submodule?')
2971 info
= N_('The submodule will be added using\n' '"%s"' % self
.command())
2972 ok_txt
= N_('Add Submodule')
2973 return Interaction
.confirm(title
, question
, info
, ok_txt
, icon
=icons
.ok())
2976 context
= self
.context
2977 args
= self
.get_args()
2978 return context
.git
.submodule('add', *args
)
2981 self
.model
.update_file_status()
2982 self
.model
.update_submodules_list()
2984 def error_message(self
):
2985 return N_('Error updating submodule %s' % self
.path
)
2988 cmd
= ['git', 'submodule', 'add']
2989 cmd
.extend(self
.get_args())
2990 return core
.list2cmdline(cmd
)
2995 args
.extend(['--branch', self
.branch
])
2997 args
.extend(['--reference', self
.reference
])
2999 args
.extend(['--depth', '%d' % self
.depth
])
3000 args
.extend(['--', self
.url
])
3002 args
.append(self
.path
)
3006 class SubmoduleUpdate(ConfirmAction
):
3007 """Update specified submodule"""
3009 def __init__(self
, context
, path
):
3010 super().__init
__(context
)
3014 title
= N_('Update Submodule...')
3015 question
= N_('Update this submodule?')
3016 info
= N_('The submodule will be updated using\n' '"%s"' % self
.command())
3017 ok_txt
= N_('Update Submodule')
3018 return Interaction
.confirm(
3019 title
, question
, info
, ok_txt
, default
=False, icon
=icons
.pull()
3023 context
= self
.context
3024 args
= self
.get_args()
3025 return context
.git
.submodule(*args
)
3028 self
.model
.update_file_status()
3030 def error_message(self
):
3031 return N_('Error updating submodule %s' % self
.path
)
3034 cmd
= ['git', 'submodule']
3035 cmd
.extend(self
.get_args())
3036 return core
.list2cmdline(cmd
)
3040 if version
.check_git(self
.context
, 'submodule-update-recursive'):
3041 cmd
.append('--recursive')
3042 cmd
.extend(['--', self
.path
])
3046 class SubmodulesUpdate(ConfirmAction
):
3047 """Update all submodules"""
3050 title
= N_('Update submodules...')
3051 question
= N_('Update all submodules?')
3052 info
= N_('All submodules will be updated using\n' '"%s"' % self
.command())
3053 ok_txt
= N_('Update Submodules')
3054 return Interaction
.confirm(
3055 title
, question
, info
, ok_txt
, default
=False, icon
=icons
.pull()
3059 context
= self
.context
3060 args
= self
.get_args()
3061 return context
.git
.submodule(*args
)
3064 self
.model
.update_file_status()
3066 def error_message(self
):
3067 return N_('Error updating submodules')
3070 cmd
= ['git', 'submodule']
3071 cmd
.extend(self
.get_args())
3072 return core
.list2cmdline(cmd
)
3076 if version
.check_git(self
.context
, 'submodule-update-recursive'):
3077 cmd
.append('--recursive')
3081 def launch_history_browser(argv
):
3082 """Launch the configured history browser"""
3085 except OSError as e
:
3086 _
, details
= utils
.format_exception(e
)
3087 title
= N_('Error Launching History Browser')
3088 msg
= N_('Cannot exec "%s": please configure a history browser') % ' '.join(
3091 Interaction
.critical(title
, message
=msg
, details
=details
)
3094 def run(cls
, *args
, **opts
):
3096 Returns a callback that runs a command
3098 If the caller of run() provides args or opts then those are
3099 used instead of the ones provided by the invoker of the callback.
3103 def runner(*local_args
, **local_opts
):
3104 """Closure return by run() which runs the command"""
3106 return do(cls
, *args
, **opts
)
3107 return do(cls
, *local_args
, **local_opts
)
3112 def do(cls
, *args
, **opts
):
3113 """Run a command in-place"""
3115 cmd
= cls(*args
, **opts
)
3117 except Exception as e
: # pylint: disable=broad-except
3118 msg
, details
= utils
.format_exception(e
)
3119 if hasattr(cls
, '__name__'):
3120 msg
= f
'{cls.__name__} exception:\n{msg}'
3121 Interaction
.critical(N_('Error'), message
=msg
, details
=details
)