2 from __future__
import absolute_import
, division
, print_function
, unicode_literals
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 EMPTY_TREE_OID
25 from .git
import MISSING_BLOB_OID
27 from .interaction
import Interaction
28 from .models
import main
29 from .models
import prefs
32 class UsageError(Exception):
33 """Exception class for usage errors."""
35 def __init__(self
, title
, message
):
36 Exception.__init
__(self
, message
)
41 class EditModel(ContextCommand
):
42 """Commands that mutate the main model diff data"""
46 def __init__(self
, context
):
47 """Common edit operations on the main model"""
48 super(EditModel
, self
).__init
__(context
)
50 self
.old_diff_text
= self
.model
.diff_text
51 self
.old_filename
= self
.model
.filename
52 self
.old_mode
= self
.model
.mode
53 self
.old_diff_type
= self
.model
.diff_type
54 self
.old_file_type
= self
.model
.file_type
56 self
.new_diff_text
= self
.old_diff_text
57 self
.new_filename
= self
.old_filename
58 self
.new_mode
= self
.old_mode
59 self
.new_diff_type
= self
.old_diff_type
60 self
.new_file_type
= self
.old_file_type
63 """Perform the operation."""
64 self
.model
.filename
= self
.new_filename
65 self
.model
.set_mode(self
.new_mode
)
66 self
.model
.set_diff_text(self
.new_diff_text
)
67 self
.model
.set_diff_type(self
.new_diff_type
)
68 self
.model
.set_file_type(self
.new_file_type
)
71 """Undo the operation."""
72 self
.model
.filename
= self
.old_filename
73 self
.model
.set_mode(self
.old_mode
)
74 self
.model
.set_diff_text(self
.old_diff_text
)
75 self
.model
.set_diff_type(self
.old_diff_type
)
76 self
.model
.set_file_type(self
.old_file_type
)
79 class ConfirmAction(ContextCommand
):
80 """Confirm an action before running it"""
82 # pylint: disable=no-self-use
84 """Return True when the command is ok to run"""
87 # pylint: disable=no-self-use
89 """Prompt for confirmation"""
92 # pylint: disable=no-self-use
94 """Run the command and return (status, out, err)"""
97 # pylint: disable=no-self-use
99 """Callback run on success"""
102 # pylint: disable=no-self-use
104 """Command name, for error messages"""
107 # pylint: disable=no-self-use
108 def error_message(self
):
109 """Command error message"""
113 """Prompt for confirmation before running a command"""
116 ok
= self
.ok_to_run() and self
.confirm()
118 status
, out
, err
= self
.action()
121 title
= self
.error_message()
123 Interaction
.command(title
, cmd
, status
, out
, err
)
125 return ok
, status
, out
, err
128 class AbortApplyPatch(ConfirmAction
):
129 """Reset an in-progress "git am" patch application"""
132 title
= N_('Abort Applying Patch...')
133 question
= N_('Aborting applying the current patch?')
135 'Aborting a patch can cause uncommitted changes to be lost.\n'
136 'Recovering uncommitted changes is not possible.'
138 ok_txt
= N_('Abort Applying Patch')
139 return Interaction
.confirm(
140 title
, question
, info
, ok_txt
, default
=False, icon
=icons
.undo()
144 status
, out
, err
= gitcmds
.abort_apply_patch(self
.context
)
145 self
.model
.update_file_merge_status()
146 return status
, out
, err
149 self
.model
.set_commitmsg('')
151 def error_message(self
):
155 return 'git am --abort'
158 class AbortCherryPick(ConfirmAction
):
159 """Reset an in-progress cherry-pick"""
162 title
= N_('Abort Cherry-Pick...')
163 question
= N_('Aborting the current cherry-pick?')
165 'Aborting a cherry-pick can cause uncommitted changes to be lost.\n'
166 'Recovering uncommitted changes is not possible.'
168 ok_txt
= N_('Abort Cherry-Pick')
169 return Interaction
.confirm(
170 title
, question
, info
, ok_txt
, default
=False, icon
=icons
.undo()
174 status
, out
, err
= gitcmds
.abort_cherry_pick(self
.context
)
175 self
.model
.update_file_merge_status()
176 return status
, out
, err
179 self
.model
.set_commitmsg('')
181 def error_message(self
):
185 return 'git cherry-pick --abort'
188 class AbortMerge(ConfirmAction
):
189 """Reset an in-progress merge back to HEAD"""
192 title
= N_('Abort Merge...')
193 question
= N_('Aborting the current merge?')
195 'Aborting the current merge will cause '
196 '*ALL* uncommitted changes to be lost.\n'
197 'Recovering uncommitted changes is not possible.'
199 ok_txt
= N_('Abort Merge')
200 return Interaction
.confirm(
201 title
, question
, info
, ok_txt
, default
=False, icon
=icons
.undo()
205 status
, out
, err
= gitcmds
.abort_merge(self
.context
)
206 self
.model
.update_file_merge_status()
207 return status
, out
, err
210 self
.model
.set_commitmsg('')
212 def error_message(self
):
219 class AmendMode(EditModel
):
220 """Try to amend a commit."""
229 def __init__(self
, context
, amend
=True):
230 super(AmendMode
, self
).__init
__(context
)
232 self
.amending
= amend
233 self
.old_commitmsg
= self
.model
.commitmsg
234 self
.old_mode
= self
.model
.mode
237 self
.new_mode
= self
.model
.mode_amend
238 self
.new_commitmsg
= gitcmds
.prev_commitmsg(context
)
239 AmendMode
.LAST_MESSAGE
= self
.model
.commitmsg
241 # else, amend unchecked, regular commit
242 self
.new_mode
= self
.model
.mode_none
243 self
.new_diff_text
= ''
244 self
.new_commitmsg
= self
.model
.commitmsg
245 # If we're going back into new-commit-mode then search the
246 # undo stack for a previous amend-commit-mode and grab the
247 # commit message at that point in time.
248 if AmendMode
.LAST_MESSAGE
is not None:
249 self
.new_commitmsg
= AmendMode
.LAST_MESSAGE
250 AmendMode
.LAST_MESSAGE
= None
253 """Leave/enter amend mode."""
254 # Attempt to enter amend mode. Do not allow this when merging.
256 if self
.model
.is_merging
:
258 self
.model
.set_mode(self
.old_mode
)
259 Interaction
.information(
262 'You are in the middle of a merge.\n'
263 'Cannot amend while merging.'
268 super(AmendMode
, self
).do()
269 self
.model
.set_commitmsg(self
.new_commitmsg
)
270 self
.model
.update_file_status()
275 self
.model
.set_commitmsg(self
.old_commitmsg
)
276 super(AmendMode
, self
).undo()
277 self
.model
.update_file_status()
280 class AnnexAdd(ContextCommand
):
281 """Add to Git Annex"""
283 def __init__(self
, context
):
284 super(AnnexAdd
, self
).__init
__(context
)
285 self
.filename
= self
.selection
.filename()
288 status
, out
, err
= self
.git
.annex('add', self
.filename
)
289 Interaction
.command(N_('Error'), 'git annex add', status
, out
, err
)
290 self
.model
.update_status()
293 class AnnexInit(ContextCommand
):
294 """Initialize Git Annex"""
297 status
, out
, err
= self
.git
.annex('init')
298 Interaction
.command(N_('Error'), 'git annex init', status
, out
, err
)
299 self
.model
.cfg
.reset()
300 self
.model
.emit_updated()
303 class LFSTrack(ContextCommand
):
304 """Add a file to git lfs"""
306 def __init__(self
, context
):
307 super(LFSTrack
, self
).__init
__(context
)
308 self
.filename
= self
.selection
.filename()
309 self
.stage_cmd
= Stage(context
, [self
.filename
])
312 status
, out
, err
= self
.git
.lfs('track', self
.filename
)
313 Interaction
.command(N_('Error'), 'git lfs track', status
, out
, err
)
318 class LFSInstall(ContextCommand
):
319 """Initialize git lfs"""
322 status
, out
, err
= self
.git
.lfs('install')
323 Interaction
.command(N_('Error'), 'git lfs install', status
, out
, err
)
324 self
.model
.update_config(reset
=True, emit
=True)
327 class ApplyPatch(ContextCommand
):
328 """Apply the specfied patch to the worktree or index"""
337 super(ApplyPatch
, self
).__init
__(context
)
339 self
.encoding
= encoding
340 self
.apply_to_worktree
= apply_to_worktree
343 context
= self
.context
345 tmp_file
= utils
.tmp_filename('apply', suffix
='.patch')
347 core
.write(tmp_file
, self
.patch
.as_text(), encoding
=self
.encoding
)
348 if self
.apply_to_worktree
:
349 status
, out
, err
= gitcmds
.apply_diff_to_worktree(context
, tmp_file
)
351 status
, out
, err
= gitcmds
.apply_diff(context
, tmp_file
)
353 core
.unlink(tmp_file
)
355 Interaction
.log_status(status
, out
, err
)
356 self
.model
.update_file_status(update_index
=True)
359 class ApplyPatches(ContextCommand
):
360 """Apply patches using the "git am" command"""
362 def __init__(self
, context
, patches
):
363 super(ApplyPatches
, self
).__init
__(context
)
364 self
.patches
= patches
367 status
, output
, err
= self
.git
.am('-3', *self
.patches
)
368 out
= '# git am -3 %s\n\n%s' % (core
.list2cmdline(self
.patches
), output
)
369 Interaction
.command(N_('Patch failed to apply'), 'git am -3', status
, out
, err
)
371 self
.model
.update_file_status()
373 patch_basenames
= [os
.path
.basename(p
) for p
in self
.patches
]
374 if len(patch_basenames
) > 25:
375 patch_basenames
= patch_basenames
[:25]
376 patch_basenames
.append('...')
378 basenames
= '\n'.join(patch_basenames
)
380 Interaction
.information(
381 N_('Patch(es) Applied'),
382 (N_('%d patch(es) applied.') + '\n\n%s')
383 % (len(self
.patches
), basenames
),
387 class ApplyPatchesContinue(ContextCommand
):
388 """Run "git am --continue" to continue on the next patch in a "git am" session"""
391 status
, out
, err
= self
.git
.am('--continue')
393 N_('Failed to commit and continue applying patches'),
399 self
.model
.update_status()
400 return status
, out
, err
403 class ApplyPatchesSkip(ContextCommand
):
404 """Run "git am --skip" to continue on the next patch in a "git am" session"""
407 status
, out
, err
= self
.git
.am(skip
=True)
409 N_('Failed to continue applying patches after skipping the current patch'),
415 self
.model
.update_status()
416 return status
, out
, err
419 class Archive(ContextCommand
):
420 """ "Export archives using the "git archive" command"""
422 def __init__(self
, context
, ref
, fmt
, prefix
, filename
):
423 super(Archive
, self
).__init
__(context
)
427 self
.filename
= filename
430 fp
= core
.xopen(self
.filename
, 'wb')
431 cmd
= ['git', 'archive', '--format=' + self
.fmt
]
432 if self
.fmt
in ('tgz', 'tar.gz'):
435 cmd
.append('--prefix=' + self
.prefix
)
437 proc
= core
.start_command(cmd
, stdout
=fp
)
438 out
, err
= proc
.communicate()
440 status
= proc
.returncode
441 Interaction
.log_status(status
, out
or '', err
or '')
444 class Checkout(EditModel
):
445 """A command object for git-checkout.
447 'argv' is handed off directly to git.
451 def __init__(self
, context
, argv
, checkout_branch
=False):
452 super(Checkout
, self
).__init
__(context
)
454 self
.checkout_branch
= checkout_branch
455 self
.new_diff_text
= ''
456 self
.new_diff_type
= main
.Types
.TEXT
457 self
.new_file_type
= main
.Types
.TEXT
460 super(Checkout
, self
).do()
461 status
, out
, err
= self
.git
.checkout(*self
.argv
)
462 if self
.checkout_branch
:
463 self
.model
.update_status()
465 self
.model
.update_file_status()
466 Interaction
.command(N_('Error'), 'git checkout', status
, out
, err
)
469 class BlamePaths(ContextCommand
):
470 """Blame view for paths."""
474 return N_('Blame...')
476 def __init__(self
, context
, paths
=None):
477 super(BlamePaths
, self
).__init
__(context
)
479 paths
= context
.selection
.union()
480 viewer
= utils
.shell_split(prefs
.blame_viewer(context
))
481 self
.argv
= viewer
+ list(paths
)
487 _
, details
= utils
.format_exception(e
)
488 title
= N_('Error Launching Blame Viewer')
489 msg
= N_('Cannot exec "%s": please configure a blame viewer') % ' '.join(
492 Interaction
.critical(title
, message
=msg
, details
=details
)
495 class CheckoutBranch(Checkout
):
496 """Checkout a branch."""
498 def __init__(self
, context
, branch
):
500 super(CheckoutBranch
, self
).__init
__(context
, args
, checkout_branch
=True)
503 class CherryPick(ContextCommand
):
504 """Cherry pick commits into the current branch."""
506 def __init__(self
, context
, commits
):
507 super(CherryPick
, self
).__init
__(context
)
508 self
.commits
= commits
511 status
, out
, err
= gitcmds
.cherry_pick(self
.context
, self
.commits
)
512 self
.model
.update_file_merge_status()
513 title
= N_('Cherry-pick failed')
514 Interaction
.command(title
, 'git cherry-pick', status
, out
, err
)
517 class Revert(ContextCommand
):
518 """Cherry pick commits into the current branch."""
520 def __init__(self
, context
, oid
):
521 super(Revert
, self
).__init
__(context
)
525 status
, output
, err
= self
.git
.revert(self
.oid
, no_edit
=True)
526 self
.model
.update_file_status()
527 title
= N_('Revert failed')
528 out
= '# git revert %s\n\n' % self
.oid
529 Interaction
.command(title
, 'git revert', status
, out
, err
)
532 class ResetMode(EditModel
):
533 """Reset the mode and clear the model's diff text."""
535 def __init__(self
, context
):
536 super(ResetMode
, self
).__init
__(context
)
537 self
.new_mode
= self
.model
.mode_none
538 self
.new_diff_text
= ''
539 self
.new_diff_type
= main
.Types
.TEXT
540 self
.new_file_type
= main
.Types
.TEXT
541 self
.new_filename
= ''
544 super(ResetMode
, self
).do()
545 self
.model
.update_file_status()
548 class ResetCommand(ConfirmAction
):
549 """Reset state using the "git reset" command"""
551 def __init__(self
, context
, ref
):
552 super(ResetCommand
, self
).__init
__(context
)
561 def error_message(self
):
565 self
.model
.update_file_status()
568 raise NotImplementedError('confirm() must be overridden')
571 raise NotImplementedError('reset() must be overridden')
574 class ResetMixed(ResetCommand
):
577 tooltip
= N_('The branch will be reset using "git reset --mixed %s"')
581 title
= N_('Reset Branch and Stage (Mixed)')
582 question
= N_('Point the current branch head to a new commit?')
583 info
= self
.tooltip(self
.ref
)
584 ok_text
= N_('Reset Branch')
585 return Interaction
.confirm(title
, question
, info
, ok_text
)
588 return self
.git
.reset(self
.ref
, '--', mixed
=True)
591 class ResetKeep(ResetCommand
):
594 tooltip
= N_('The repository will be reset using "git reset --keep %s"')
598 title
= N_('Restore Worktree and Reset All (Keep Unstaged Changes)')
599 question
= N_('Restore worktree, reset, and preserve unstaged edits?')
600 info
= self
.tooltip(self
.ref
)
601 ok_text
= N_('Reset and Restore')
602 return Interaction
.confirm(title
, question
, info
, ok_text
)
605 return self
.git
.reset(self
.ref
, '--', keep
=True)
608 class ResetMerge(ResetCommand
):
611 tooltip
= N_('The repository will be reset using "git reset --merge %s"')
615 title
= N_('Restore Worktree and Reset All (Merge)')
616 question
= N_('Reset Worktree and Reset All?')
617 info
= self
.tooltip(self
.ref
)
618 ok_text
= N_('Reset and Restore')
619 return Interaction
.confirm(title
, question
, info
, ok_text
)
622 return self
.git
.reset(self
.ref
, '--', merge
=True)
625 class ResetSoft(ResetCommand
):
628 tooltip
= N_('The branch will be reset using "git reset --soft %s"')
632 title
= N_('Reset Branch (Soft)')
633 question
= N_('Reset branch?')
634 info
= self
.tooltip(self
.ref
)
635 ok_text
= N_('Reset Branch')
636 return Interaction
.confirm(title
, question
, info
, ok_text
)
639 return self
.git
.reset(self
.ref
, '--', soft
=True)
642 class ResetHard(ResetCommand
):
645 tooltip
= N_('The repository will be reset using "git reset --hard %s"')
649 title
= N_('Restore Worktree and Reset All (Hard)')
650 question
= N_('Restore Worktree and Reset All?')
651 info
= self
.tooltip(self
.ref
)
652 ok_text
= N_('Reset and Restore')
653 return Interaction
.confirm(title
, question
, info
, ok_text
)
656 return self
.git
.reset(self
.ref
, '--', hard
=True)
659 class RestoreWorktree(ConfirmAction
):
660 """Reset the worktree using the "git read-tree" command"""
665 'The worktree will be restored using "git read-tree --reset -u %s"'
669 def __init__(self
, context
, ref
):
670 super(RestoreWorktree
, self
).__init
__(context
)
674 return self
.git
.read_tree(self
.ref
, reset
=True, u
=True)
677 return 'git read-tree --reset -u %s' % self
.ref
679 def error_message(self
):
683 self
.model
.update_file_status()
686 title
= N_('Restore Worktree')
687 question
= N_('Restore Worktree to %s?') % self
.ref
688 info
= self
.tooltip(self
.ref
)
689 ok_text
= N_('Restore Worktree')
690 return Interaction
.confirm(title
, question
, info
, ok_text
)
693 class UndoLastCommit(ResetCommand
):
694 """Undo the last commit"""
696 # NOTE: this is the similar to ResetSoft() with an additional check for
697 # published commits and different messages.
698 def __init__(self
, context
):
699 super(UndoLastCommit
, self
).__init
__(context
, 'HEAD^')
702 check_published
= prefs
.check_published_commits(self
.context
)
703 if check_published
and self
.model
.is_commit_published():
704 return Interaction
.confirm(
705 N_('Rewrite Published Commit?'),
707 'This commit has already been published.\n'
708 'This operation will rewrite published history.\n'
709 'You probably don\'t want to do this.'
711 N_('Undo the published commit?'),
712 N_('Undo Last Commit'),
717 title
= N_('Undo Last Commit')
718 question
= N_('Undo last commit?')
719 info
= N_('The branch will be reset using "git reset --soft %s"')
720 ok_text
= N_('Undo Last Commit')
721 info_text
= info
% self
.ref
722 return Interaction
.confirm(title
, question
, info_text
, ok_text
)
725 return self
.git
.reset('HEAD^', '--', soft
=True)
728 class Commit(ResetMode
):
729 """Attempt to create a new commit."""
731 def __init__(self
, context
, amend
, msg
, sign
, no_verify
=False):
732 super(Commit
, self
).__init
__(context
)
736 self
.no_verify
= no_verify
737 self
.old_commitmsg
= self
.model
.commitmsg
738 self
.new_commitmsg
= ''
741 # Create the commit message file
742 context
= self
.context
743 comment_char
= prefs
.comment_char(context
)
744 msg
= self
.strip_comments(self
.msg
, comment_char
=comment_char
)
745 tmp_file
= utils
.tmp_filename('commit-message')
747 core
.write(tmp_file
, msg
)
749 status
, out
, err
= self
.git
.commit(
754 no_verify
=self
.no_verify
,
757 core
.unlink(tmp_file
)
759 super(Commit
, self
).do()
760 if context
.cfg
.get(prefs
.AUTOTEMPLATE
):
761 template_loader
= LoadCommitMessageFromTemplate(context
)
764 self
.model
.set_commitmsg(self
.new_commitmsg
)
766 title
= N_('Commit failed')
767 Interaction
.command(title
, 'git commit', status
, out
, err
)
769 return status
, out
, err
772 def strip_comments(msg
, comment_char
='#'):
775 line
for line
in msg
.split('\n') if not line
.startswith(comment_char
)
777 msg
= '\n'.join(message_lines
)
778 if not msg
.endswith('\n'):
784 class CycleReferenceSort(ContextCommand
):
785 """Choose the next reference sort type"""
788 self
.model
.cycle_ref_sort()
791 class Ignore(ContextCommand
):
792 """Add files to an exclusion file"""
794 def __init__(self
, context
, filenames
, local
=False):
795 super(Ignore
, self
).__init
__(context
)
796 self
.filenames
= list(filenames
)
800 if not self
.filenames
:
802 new_additions
= '\n'.join(self
.filenames
) + '\n'
803 for_status
= new_additions
805 filename
= os
.path
.join('.git', 'info', 'exclude')
807 filename
= '.gitignore'
808 if core
.exists(filename
):
809 current_list
= core
.read(filename
)
810 new_additions
= current_list
.rstrip() + '\n' + new_additions
811 core
.write(filename
, new_additions
)
812 Interaction
.log_status(0, 'Added to %s:\n%s' % (filename
, for_status
), '')
813 self
.model
.update_file_status()
816 def file_summary(files
):
817 txt
= core
.list2cmdline(files
)
819 txt
= txt
[:768].rstrip() + '...'
820 wrap
= textwrap
.TextWrapper()
821 return '\n'.join(wrap
.wrap(txt
))
824 class RemoteCommand(ConfirmAction
):
825 def __init__(self
, context
, remote
):
826 super(RemoteCommand
, self
).__init
__(context
)
831 self
.model
.update_remotes()
834 class RemoteAdd(RemoteCommand
):
835 def __init__(self
, context
, remote
, url
):
836 super(RemoteAdd
, self
).__init
__(context
, remote
)
840 return self
.git
.remote('add', self
.remote
, self
.url
)
842 def error_message(self
):
843 return N_('Error creating remote "%s"') % self
.remote
846 return 'git remote add "%s" "%s"' % (self
.remote
, self
.url
)
849 class RemoteRemove(RemoteCommand
):
851 title
= N_('Delete Remote')
852 question
= N_('Delete remote?')
853 info
= N_('Delete remote "%s"') % self
.remote
854 ok_text
= N_('Delete')
855 return Interaction
.confirm(title
, question
, info
, ok_text
)
858 return self
.git
.remote('rm', self
.remote
)
860 def error_message(self
):
861 return N_('Error deleting remote "%s"') % self
.remote
864 return 'git remote rm "%s"' % self
.remote
867 class RemoteRename(RemoteCommand
):
868 def __init__(self
, context
, remote
, new_name
):
869 super(RemoteRename
, self
).__init
__(context
, remote
)
870 self
.new_name
= new_name
873 title
= N_('Rename Remote')
874 text
= N_('Rename remote "%(current)s" to "%(new)s"?') % dict(
875 current
=self
.remote
, new
=self
.new_name
879 return Interaction
.confirm(title
, text
, info_text
, ok_text
)
882 return self
.git
.remote('rename', self
.remote
, self
.new_name
)
884 def error_message(self
):
885 return N_('Error renaming "%(name)s" to "%(new_name)s"') % dict(
886 name
=self
.remote
, new_name
=self
.new_name
890 return 'git remote rename "%s" "%s"' % (self
.remote
, self
.new_name
)
893 class RemoteSetURL(RemoteCommand
):
894 def __init__(self
, context
, remote
, url
):
895 super(RemoteSetURL
, self
).__init
__(context
, remote
)
899 return self
.git
.remote('set-url', self
.remote
, self
.url
)
901 def error_message(self
):
902 return N_('Unable to set URL for "%(name)s" to "%(url)s"') % dict(
903 name
=self
.remote
, url
=self
.url
907 return 'git remote set-url "%s" "%s"' % (self
.remote
, self
.url
)
910 class RemoteEdit(ContextCommand
):
911 """Combine RemoteRename and RemoteSetURL"""
913 def __init__(self
, context
, old_name
, remote
, url
):
914 super(RemoteEdit
, self
).__init
__(context
)
915 self
.rename
= RemoteRename(context
, old_name
, remote
)
916 self
.set_url
= RemoteSetURL(context
, remote
, url
)
919 result
= self
.rename
.do()
923 result
= self
.set_url
.do()
925 return name_ok
, url_ok
928 class RemoveFromSettings(ConfirmAction
):
929 def __init__(self
, context
, repo
, entry
, icon
=None):
930 super(RemoveFromSettings
, self
).__init
__(context
)
931 self
.context
= context
937 self
.context
.settings
.save()
940 class RemoveBookmark(RemoveFromSettings
):
943 title
= msg
= N_('Delete Bookmark?')
944 info
= N_('%s will be removed from your bookmarks.') % entry
945 ok_text
= N_('Delete Bookmark')
946 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
949 self
.context
.settings
.remove_bookmark(self
.repo
, self
.entry
)
953 class RemoveRecent(RemoveFromSettings
):
956 title
= msg
= N_('Remove %s from the recent list?') % repo
957 info
= N_('%s will be removed from your recent repositories.') % repo
958 ok_text
= N_('Remove')
959 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
962 self
.context
.settings
.remove_recent(self
.repo
)
966 class RemoveFiles(ContextCommand
):
969 def __init__(self
, context
, remover
, filenames
):
970 super(RemoveFiles
, self
).__init
__(context
)
973 self
.remover
= remover
974 self
.filenames
= filenames
975 # We could git-hash-object stuff and provide undo-ability
979 files
= self
.filenames
985 remove
= self
.remover
986 for filename
in files
:
992 bad_filenames
.append(filename
)
995 Interaction
.information(
996 N_('Error'), N_('Deleting "%s" failed') % file_summary(bad_filenames
)
1000 self
.model
.update_file_status()
1003 class Delete(RemoveFiles
):
1006 def __init__(self
, context
, filenames
):
1007 super(Delete
, self
).__init
__(context
, os
.remove
, filenames
)
1010 files
= self
.filenames
1014 title
= N_('Delete Files?')
1015 msg
= N_('The following files will be deleted:') + '\n\n'
1016 msg
+= file_summary(files
)
1017 info_txt
= N_('Delete %d file(s)?') % len(files
)
1018 ok_txt
= N_('Delete Files')
1020 if Interaction
.confirm(
1021 title
, msg
, info_txt
, ok_txt
, default
=True, icon
=icons
.remove()
1023 super(Delete
, self
).do()
1026 class MoveToTrash(RemoveFiles
):
1027 """Move files to the trash using send2trash"""
1029 AVAILABLE
= send2trash
is not None
1031 def __init__(self
, context
, filenames
):
1032 super(MoveToTrash
, self
).__init
__(context
, send2trash
, filenames
)
1035 class DeleteBranch(ConfirmAction
):
1036 """Delete a git branch."""
1038 def __init__(self
, context
, branch
):
1039 super(DeleteBranch
, self
).__init
__(context
)
1040 self
.branch
= branch
1043 title
= N_('Delete Branch')
1044 question
= N_('Delete branch "%s"?') % self
.branch
1045 info
= N_('The branch will be no longer available.')
1046 ok_txt
= N_('Delete Branch')
1047 return Interaction
.confirm(
1048 title
, question
, info
, ok_txt
, default
=True, icon
=icons
.discard()
1052 return self
.model
.delete_branch(self
.branch
)
1054 def error_message(self
):
1055 return N_('Error deleting branch "%s"' % self
.branch
)
1058 command
= 'git branch -D %s'
1059 return command
% self
.branch
1062 class Rename(ContextCommand
):
1063 """Rename a set of paths."""
1065 def __init__(self
, context
, paths
):
1066 super(Rename
, self
).__init
__(context
)
1070 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
1071 Interaction
.log(msg
)
1073 for path
in self
.paths
:
1074 ok
= self
.rename(path
)
1078 self
.model
.update_status()
1080 def rename(self
, path
):
1082 title
= N_('Rename "%s"') % path
1084 if os
.path
.isdir(path
):
1085 base_path
= os
.path
.dirname(path
)
1088 new_path
= Interaction
.save_as(base_path
, title
)
1092 status
, out
, err
= git
.mv(path
, new_path
, force
=True, verbose
=True)
1093 Interaction
.command(N_('Error'), 'git mv', status
, out
, err
)
1097 class RenameBranch(ContextCommand
):
1098 """Rename a git branch."""
1100 def __init__(self
, context
, branch
, new_branch
):
1101 super(RenameBranch
, self
).__init
__(context
)
1102 self
.branch
= branch
1103 self
.new_branch
= new_branch
1106 branch
= self
.branch
1107 new_branch
= self
.new_branch
1108 status
, out
, err
= self
.model
.rename_branch(branch
, new_branch
)
1109 Interaction
.log_status(status
, out
, err
)
1112 class DeleteRemoteBranch(DeleteBranch
):
1113 """Delete a remote git branch."""
1115 def __init__(self
, context
, remote
, branch
):
1116 super(DeleteRemoteBranch
, self
).__init
__(context
, branch
)
1117 self
.remote
= remote
1120 return self
.git
.push(self
.remote
, self
.branch
, delete
=True)
1123 self
.model
.update_status()
1124 Interaction
.information(
1125 N_('Remote Branch Deleted'),
1126 N_('"%(branch)s" has been deleted from "%(remote)s".')
1127 % dict(branch
=self
.branch
, remote
=self
.remote
),
1130 def error_message(self
):
1131 return N_('Error Deleting Remote Branch')
1134 command
= 'git push --delete %s %s'
1135 return command
% (self
.remote
, self
.branch
)
1138 def get_mode(context
, filename
, staged
, modified
, unmerged
, untracked
):
1139 model
= context
.model
1141 mode
= model
.mode_index
1142 elif modified
or unmerged
:
1143 mode
= model
.mode_worktree
1145 if gitcmds
.is_binary(context
, filename
):
1146 mode
= model
.mode_untracked
1148 mode
= model
.mode_untracked_diff
1154 class DiffAgainstCommitMode(ContextCommand
):
1155 """Diff against arbitrary commits"""
1157 def __init__(self
, context
, oid
):
1158 super(DiffAgainstCommitMode
, self
).__init
__(context
)
1162 self
.model
.set_mode(self
.model
.mode_diff
, head
=self
.oid
)
1163 self
.model
.update_file_status()
1166 class DiffText(EditModel
):
1167 """Set the diff type to text"""
1169 def __init__(self
, context
):
1170 super(DiffText
, self
).__init
__(context
)
1171 self
.new_file_type
= main
.Types
.TEXT
1172 self
.new_diff_type
= main
.Types
.TEXT
1175 class ToggleDiffType(ContextCommand
):
1176 """Toggle the diff type between image and text"""
1178 def __init__(self
, context
):
1179 super(ToggleDiffType
, self
).__init
__(context
)
1180 if self
.model
.diff_type
== main
.Types
.IMAGE
:
1181 self
.new_diff_type
= main
.Types
.TEXT
1182 self
.new_value
= False
1184 self
.new_diff_type
= main
.Types
.IMAGE
1185 self
.new_value
= True
1188 diff_type
= self
.new_diff_type
1189 value
= self
.new_value
1191 self
.model
.set_diff_type(diff_type
)
1193 filename
= self
.model
.filename
1194 _
, ext
= os
.path
.splitext(filename
)
1195 if ext
.startswith('.'):
1196 cfg
= 'cola.imagediff' + ext
1197 self
.cfg
.set_repo(cfg
, value
)
1200 class DiffImage(EditModel
):
1202 self
, context
, filename
, deleted
, staged
, modified
, unmerged
, untracked
1204 super(DiffImage
, self
).__init
__(context
)
1206 self
.new_filename
= filename
1207 self
.new_diff_type
= self
.get_diff_type(filename
)
1208 self
.new_file_type
= main
.Types
.IMAGE
1209 self
.new_mode
= get_mode(
1210 context
, filename
, staged
, modified
, unmerged
, untracked
1212 self
.staged
= staged
1213 self
.modified
= modified
1214 self
.unmerged
= unmerged
1215 self
.untracked
= untracked
1216 self
.deleted
= deleted
1217 self
.annex
= self
.cfg
.is_annex()
1219 def get_diff_type(self
, filename
):
1220 """Query the diff type to use based on cola.imagediff.<extension>"""
1221 _
, ext
= os
.path
.splitext(filename
)
1222 if ext
.startswith('.'):
1223 # Check eg. "cola.imagediff.svg" to see if we should imagediff.
1224 cfg
= 'cola.imagediff' + ext
1225 if self
.cfg
.get(cfg
, True):
1226 result
= main
.Types
.IMAGE
1228 result
= main
.Types
.TEXT
1230 result
= main
.Types
.IMAGE
1234 filename
= self
.new_filename
1237 images
= self
.staged_images()
1239 images
= self
.modified_images()
1241 images
= self
.unmerged_images()
1242 elif self
.untracked
:
1243 images
= [(filename
, False)]
1247 self
.model
.set_images(images
)
1248 super(DiffImage
, self
).do()
1250 def staged_images(self
):
1251 context
= self
.context
1253 head
= self
.model
.head
1254 filename
= self
.new_filename
1258 index
= git
.diff_index(head
, '--', filename
, cached
=True)[STDOUT
]
1261 # :100644 100644 fabadb8... 4866510... M describe.c
1262 parts
= index
.split(' ')
1267 if old_oid
!= MISSING_BLOB_OID
:
1268 # First, check if we can get a pre-image from git-annex
1271 annex_image
= gitcmds
.annex_path(context
, head
, filename
)
1273 images
.append((annex_image
, False)) # git annex HEAD
1275 image
= gitcmds
.write_blob_path(context
, head
, old_oid
, filename
)
1277 images
.append((image
, True))
1279 if new_oid
!= MISSING_BLOB_OID
:
1280 found_in_annex
= False
1281 if annex
and core
.islink(filename
):
1282 status
, out
, _
= git
.annex('status', '--', filename
)
1284 details
= out
.split(' ')
1285 if details
and details
[0] == 'A': # newly added file
1286 images
.append((filename
, False))
1287 found_in_annex
= True
1289 if not found_in_annex
:
1290 image
= gitcmds
.write_blob(context
, new_oid
, filename
)
1292 images
.append((image
, True))
1296 def unmerged_images(self
):
1297 context
= self
.context
1299 head
= self
.model
.head
1300 filename
= self
.new_filename
1303 candidate_merge_heads
= ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1306 for merge_head
in candidate_merge_heads
1307 if core
.exists(git
.git_path(merge_head
))
1310 if annex
: # Attempt to find files in git-annex
1312 for merge_head
in merge_heads
:
1313 image
= gitcmds
.annex_path(context
, merge_head
, filename
)
1315 annex_images
.append((image
, False))
1317 annex_images
.append((filename
, False))
1320 # DIFF FORMAT FOR MERGES
1321 # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1322 # can take -c or --cc option to generate diff output also
1323 # for merge commits. The output differs from the format
1324 # described above in the following way:
1326 # 1. there is a colon for each parent
1327 # 2. there are more "src" modes and "src" sha1
1328 # 3. status is concatenated status characters for each parent
1329 # 4. no optional "score" number
1330 # 5. single path, only for "dst"
1332 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1335 index
= git
.diff_index(head
, '--', filename
, cached
=True, cc
=True)[STDOUT
]
1337 parts
= index
.split(' ')
1339 first_mode
= parts
[0]
1340 num_parents
= first_mode
.count(':')
1341 # colon for each parent, but for the index, the "parents"
1342 # are really entries in stages 1,2,3 (head, base, remote)
1343 # remote, base, head
1344 for i
in range(num_parents
):
1345 offset
= num_parents
+ i
+ 1
1348 merge_head
= merge_heads
[i
]
1351 if oid
!= MISSING_BLOB_OID
:
1352 image
= gitcmds
.write_blob_path(
1353 context
, merge_head
, oid
, filename
1356 images
.append((image
, True))
1358 images
.append((filename
, False))
1361 def modified_images(self
):
1362 context
= self
.context
1364 head
= self
.model
.head
1365 filename
= self
.new_filename
1370 if annex
: # Check for a pre-image from git-annex
1371 annex_image
= gitcmds
.annex_path(context
, head
, filename
)
1373 images
.append((annex_image
, False)) # git annex HEAD
1375 worktree
= git
.diff_files('--', filename
)[STDOUT
]
1376 parts
= worktree
.split(' ')
1379 if oid
!= MISSING_BLOB_OID
:
1380 image
= gitcmds
.write_blob_path(context
, head
, oid
, filename
)
1382 images
.append((image
, True)) # HEAD
1384 images
.append((filename
, False)) # worktree
1388 class Diff(EditModel
):
1389 """Perform a diff and set the model's current text."""
1391 def __init__(self
, context
, filename
, cached
=False, deleted
=False):
1392 super(Diff
, self
).__init
__(context
)
1394 if cached
and gitcmds
.is_valid_ref(context
, self
.model
.head
):
1395 opts
['ref'] = self
.model
.head
1396 self
.new_filename
= filename
1397 self
.new_mode
= self
.model
.mode_worktree
1398 self
.new_diff_text
= gitcmds
.diff_helper(
1399 self
.context
, filename
=filename
, cached
=cached
, deleted
=deleted
, **opts
1403 class Diffstat(EditModel
):
1404 """Perform a diffstat and set the model's diff text."""
1406 def __init__(self
, context
):
1407 super(Diffstat
, self
).__init
__(context
)
1409 diff_context
= cfg
.get('diff.context', 3)
1410 diff
= self
.git
.diff(
1412 unified
=diff_context
,
1418 self
.new_diff_text
= diff
1419 self
.new_diff_type
= main
.Types
.TEXT
1420 self
.new_file_type
= main
.Types
.TEXT
1421 self
.new_mode
= self
.model
.mode_diffstat
1424 class DiffStaged(Diff
):
1425 """Perform a staged diff on a file."""
1427 def __init__(self
, context
, filename
, deleted
=None):
1428 super(DiffStaged
, self
).__init
__(
1429 context
, filename
, cached
=True, deleted
=deleted
1431 self
.new_mode
= self
.model
.mode_index
1434 class DiffStagedSummary(EditModel
):
1435 def __init__(self
, context
):
1436 super(DiffStagedSummary
, self
).__init
__(context
)
1437 diff
= self
.git
.diff(
1442 patch_with_stat
=True,
1445 self
.new_diff_text
= diff
1446 self
.new_diff_type
= main
.Types
.TEXT
1447 self
.new_file_type
= main
.Types
.TEXT
1448 self
.new_mode
= self
.model
.mode_index
1451 class Difftool(ContextCommand
):
1452 """Run git-difftool limited by path."""
1454 def __init__(self
, context
, staged
, filenames
):
1455 super(Difftool
, self
).__init
__(context
)
1456 self
.staged
= staged
1457 self
.filenames
= filenames
1460 difftool_launch_with_head(
1461 self
.context
, self
.filenames
, self
.staged
, self
.model
.head
1465 class Edit(ContextCommand
):
1466 """Edit a file using the configured gui.editor."""
1470 return N_('Launch Editor')
1472 def __init__(self
, context
, filenames
, line_number
=None, background_editor
=False):
1473 super(Edit
, self
).__init
__(context
)
1474 self
.filenames
= filenames
1475 self
.line_number
= line_number
1476 self
.background_editor
= background_editor
1479 context
= self
.context
1480 if not self
.filenames
:
1482 filename
= self
.filenames
[0]
1483 if not core
.exists(filename
):
1485 if self
.background_editor
:
1486 editor
= prefs
.background_editor(context
)
1488 editor
= prefs
.editor(context
)
1491 if self
.line_number
is None:
1492 opts
= self
.filenames
1494 # Single-file w/ line-numbers (likely from grep)
1496 '*vim*': [filename
, '+%s' % self
.line_number
],
1497 '*emacs*': ['+%s' % self
.line_number
, filename
],
1498 '*textpad*': ['%s(%s,0)' % (filename
, self
.line_number
)],
1499 '*notepad++*': ['-n%s' % self
.line_number
, filename
],
1500 '*subl*': ['%s:%s' % (filename
, self
.line_number
)],
1503 opts
= self
.filenames
1504 for pattern
, opt
in editor_opts
.items():
1505 if fnmatch(editor
, pattern
):
1510 core
.fork(utils
.shell_split(editor
) + opts
)
1511 except (OSError, ValueError) as e
:
1512 message
= N_('Cannot exec "%s": please configure your editor') % editor
1513 _
, details
= utils
.format_exception(e
)
1514 Interaction
.critical(N_('Error Editing File'), message
, details
)
1517 class FormatPatch(ContextCommand
):
1518 """Output a patch series given all revisions and a selected subset."""
1520 def __init__(self
, context
, to_export
, revs
, output
='patches'):
1521 super(FormatPatch
, self
).__init
__(context
)
1522 self
.to_export
= list(to_export
)
1523 self
.revs
= list(revs
)
1524 self
.output
= output
1527 context
= self
.context
1528 status
, out
, err
= gitcmds
.format_patchsets(
1529 context
, self
.to_export
, self
.revs
, self
.output
1531 Interaction
.log_status(status
, out
, err
)
1534 class LaunchDifftool(ContextCommand
):
1537 return N_('Launch Diff Tool')
1540 s
= self
.selection
.selection()
1543 if utils
.is_win32():
1544 core
.fork(['git', 'mergetool', '--no-prompt', '--'] + paths
)
1547 cmd
= cfg
.terminal()
1548 argv
= utils
.shell_split(cmd
)
1550 terminal
= os
.path
.basename(argv
[0])
1551 shellquote_terms
= set(['xfce4-terminal'])
1552 shellquote_default
= terminal
in shellquote_terms
1554 mergetool
= ['git', 'mergetool', '--no-prompt', '--']
1555 mergetool
.extend(paths
)
1556 needs_shellquote
= cfg
.get(
1557 'cola.terminalshellquote', shellquote_default
1560 if needs_shellquote
:
1561 argv
.append(core
.list2cmdline(mergetool
))
1563 argv
.extend(mergetool
)
1567 difftool_run(self
.context
)
1570 class LaunchTerminal(ContextCommand
):
1573 return N_('Launch Terminal')
1576 def is_available(context
):
1577 return context
.cfg
.terminal() is not None
1579 def __init__(self
, context
, path
):
1580 super(LaunchTerminal
, self
).__init
__(context
)
1584 cmd
= self
.context
.cfg
.terminal()
1587 if utils
.is_win32():
1588 argv
= ['start', '', cmd
, '--login']
1591 argv
= utils
.shell_split(cmd
)
1593 shells
= ('zsh', 'fish', 'bash', 'sh')
1594 for basename
in shells
:
1595 executable
= core
.find_executable(basename
)
1597 command
= executable
1599 argv
.append(os
.getenv('SHELL', command
))
1602 core
.fork(argv
, cwd
=self
.path
, shell
=shell
)
1605 class LaunchEditor(Edit
):
1608 return N_('Launch Editor')
1610 def __init__(self
, context
):
1611 s
= context
.selection
.selection()
1612 filenames
= s
.staged
+ s
.unmerged
+ s
.modified
+ s
.untracked
1613 super(LaunchEditor
, self
).__init
__(context
, filenames
, background_editor
=True)
1616 class LaunchEditorAtLine(LaunchEditor
):
1617 """Launch an editor at the specified line"""
1619 def __init__(self
, context
):
1620 super(LaunchEditorAtLine
, self
).__init
__(context
)
1621 self
.line_number
= context
.selection
.line_number
1624 class LoadCommitMessageFromFile(ContextCommand
):
1625 """Loads a commit message from a path."""
1629 def __init__(self
, context
, path
):
1630 super(LoadCommitMessageFromFile
, self
).__init
__(context
)
1632 self
.old_commitmsg
= self
.model
.commitmsg
1633 self
.old_directory
= self
.model
.directory
1636 path
= os
.path
.expanduser(self
.path
)
1637 if not path
or not core
.isfile(path
):
1639 N_('Error: Cannot find commit template'),
1640 N_('%s: No such file or directory.') % path
,
1642 self
.model
.set_directory(os
.path
.dirname(path
))
1643 self
.model
.set_commitmsg(core
.read(path
))
1646 self
.model
.set_commitmsg(self
.old_commitmsg
)
1647 self
.model
.set_directory(self
.old_directory
)
1650 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile
):
1651 """Loads the commit message template specified by commit.template."""
1653 def __init__(self
, context
):
1655 template
= cfg
.get('commit.template')
1656 super(LoadCommitMessageFromTemplate
, self
).__init
__(context
, template
)
1659 if self
.path
is None:
1661 N_('Error: Unconfigured commit template'),
1663 'A commit template has not been configured.\n'
1664 'Use "git config" to define "commit.template"\n'
1665 'so that it points to a commit template.'
1668 return LoadCommitMessageFromFile
.do(self
)
1671 class LoadCommitMessageFromOID(ContextCommand
):
1672 """Load a previous commit message"""
1676 def __init__(self
, context
, oid
, prefix
=''):
1677 super(LoadCommitMessageFromOID
, self
).__init
__(context
)
1679 self
.old_commitmsg
= self
.model
.commitmsg
1680 self
.new_commitmsg
= prefix
+ gitcmds
.prev_commitmsg(context
, oid
)
1683 self
.model
.set_commitmsg(self
.new_commitmsg
)
1686 self
.model
.set_commitmsg(self
.old_commitmsg
)
1689 class PrepareCommitMessageHook(ContextCommand
):
1690 """Use the cola-prepare-commit-msg hook to prepare the commit message"""
1694 def __init__(self
, context
):
1695 super(PrepareCommitMessageHook
, self
).__init
__(context
)
1696 self
.old_commitmsg
= self
.model
.commitmsg
1698 def get_message(self
):
1700 title
= N_('Error running prepare-commitmsg hook')
1701 hook
= gitcmds
.prepare_commit_message_hook(self
.context
)
1703 if os
.path
.exists(hook
):
1704 filename
= self
.model
.save_commitmsg()
1705 status
, out
, err
= core
.run_command([hook
, filename
])
1708 result
= core
.read(filename
)
1710 result
= self
.old_commitmsg
1711 Interaction
.command_error(title
, hook
, status
, out
, err
)
1713 message
= N_('A hook must be provided at "%s"') % hook
1714 Interaction
.critical(title
, message
=message
)
1715 result
= self
.old_commitmsg
1720 msg
= self
.get_message()
1721 self
.model
.set_commitmsg(msg
)
1724 self
.model
.set_commitmsg(self
.old_commitmsg
)
1727 class LoadFixupMessage(LoadCommitMessageFromOID
):
1728 """Load a fixup message"""
1730 def __init__(self
, context
, oid
):
1731 super(LoadFixupMessage
, self
).__init
__(context
, oid
, prefix
='fixup! ')
1732 if self
.new_commitmsg
:
1733 self
.new_commitmsg
= self
.new_commitmsg
.splitlines()[0]
1736 class Merge(ContextCommand
):
1739 def __init__(self
, context
, revision
, no_commit
, squash
, no_ff
, sign
):
1740 super(Merge
, self
).__init
__(context
)
1741 self
.revision
= revision
1743 self
.no_commit
= no_commit
1744 self
.squash
= squash
1748 squash
= self
.squash
1749 revision
= self
.revision
1751 no_commit
= self
.no_commit
1754 status
, out
, err
= self
.git
.merge(
1755 revision
, gpg_sign
=sign
, no_ff
=no_ff
, no_commit
=no_commit
, squash
=squash
1757 self
.model
.update_status()
1758 title
= N_('Merge failed. Conflict resolution is required.')
1759 Interaction
.command(title
, 'git merge', status
, out
, err
)
1761 return status
, out
, err
1764 class OpenDefaultApp(ContextCommand
):
1765 """Open a file using the OS default."""
1769 return N_('Open Using Default Application')
1771 def __init__(self
, context
, filenames
):
1772 super(OpenDefaultApp
, self
).__init
__(context
)
1773 self
.filenames
= filenames
1776 if not self
.filenames
:
1778 utils
.launch_default_app(self
.filenames
)
1781 class OpenDir(OpenDefaultApp
):
1782 """Open directories using the OS default."""
1786 return N_('Open Directory')
1789 def _dirnames(self
):
1790 return self
.filenames
1793 dirnames
= self
._dirnames
1796 # An empty dirname defaults to CWD.
1797 dirs
= [(dirname
or core
.getcwd()) for dirname
in dirnames
]
1798 utils
.launch_default_app(dirs
)
1801 class OpenParentDir(OpenDir
):
1802 """Open parent directories using the OS default."""
1806 return N_('Open Parent Directory')
1809 def _dirnames(self
):
1810 dirnames
= list(set([os
.path
.dirname(x
) for x
in self
.filenames
]))
1814 class OpenWorktree(OpenDir
):
1815 """Open worktree directory using the OS default."""
1819 return N_('Open Worktree')
1821 # The _unused parameter is needed by worktree_dir_action() -> common.cmd_action().
1822 def __init__(self
, context
, _unused
=None):
1823 dirnames
= [context
.git
.worktree()]
1824 super(OpenWorktree
, self
).__init
__(context
, dirnames
)
1827 class OpenNewRepo(ContextCommand
):
1828 """Launches git-cola on a repo."""
1830 def __init__(self
, context
, repo_path
):
1831 super(OpenNewRepo
, self
).__init
__(context
)
1832 self
.repo_path
= repo_path
1835 self
.model
.set_directory(self
.repo_path
)
1836 core
.fork([sys
.executable
, sys
.argv
[0], '--repo', self
.repo_path
])
1839 class OpenRepo(EditModel
):
1840 def __init__(self
, context
, repo_path
):
1841 super(OpenRepo
, self
).__init
__(context
)
1842 self
.repo_path
= repo_path
1843 self
.new_mode
= self
.model
.mode_none
1844 self
.new_diff_text
= ''
1845 self
.new_diff_type
= main
.Types
.TEXT
1846 self
.new_file_type
= main
.Types
.TEXT
1847 self
.new_commitmsg
= ''
1848 self
.new_filename
= ''
1851 old_repo
= self
.git
.getcwd()
1852 if self
.model
.set_worktree(self
.repo_path
):
1853 self
.fsmonitor
.stop()
1854 self
.fsmonitor
.start()
1855 self
.model
.update_status(reset
=True)
1856 # Check if template should be loaded
1857 if self
.context
.cfg
.get(prefs
.AUTOTEMPLATE
):
1858 template_loader
= LoadCommitMessageFromTemplate(self
.context
)
1859 template_loader
.do()
1861 self
.model
.set_commitmsg(self
.new_commitmsg
)
1862 settings
= self
.context
.settings
1864 settings
.add_recent(self
.repo_path
, prefs
.maxrecent(self
.context
))
1866 super(OpenRepo
, self
).do()
1868 self
.model
.set_worktree(old_repo
)
1871 class OpenParentRepo(OpenRepo
):
1872 def __init__(self
, context
):
1874 if version
.check_git(context
, 'show-superproject-working-tree'):
1875 status
, out
, _
= context
.git
.rev_parse(show_superproject_working_tree
=True)
1879 path
= os
.path
.dirname(core
.getcwd())
1880 super(OpenParentRepo
, self
).__init
__(context
, path
)
1883 class Clone(ContextCommand
):
1884 """Clones a repository and optionally spawns a new cola session."""
1887 self
, context
, url
, new_directory
, submodules
=False, shallow
=False, spawn
=True
1889 super(Clone
, self
).__init
__(context
)
1891 self
.new_directory
= new_directory
1892 self
.submodules
= submodules
1893 self
.shallow
= shallow
1903 recurse_submodules
= self
.submodules
1904 shallow_submodules
= self
.submodules
and self
.shallow
1906 status
, out
, err
= self
.git
.clone(
1909 recurse_submodules
=recurse_submodules
,
1910 shallow_submodules
=shallow_submodules
,
1914 self
.status
= status
1917 if status
== 0 and self
.spawn
:
1918 executable
= sys
.executable
1919 core
.fork([executable
, sys
.argv
[0], '--repo', self
.new_directory
])
1923 class NewBareRepo(ContextCommand
):
1924 """Create a new shared bare repository"""
1926 def __init__(self
, context
, path
):
1927 super(NewBareRepo
, self
).__init
__(context
)
1932 status
, out
, err
= self
.git
.init(path
, bare
=True, shared
=True)
1933 Interaction
.command(
1934 N_('Error'), 'git init --bare --shared "%s"' % path
, status
, out
, err
1939 def unix_path(path
, is_win32
=utils
.is_win32
):
1940 """Git for Windows requires unix paths, so force them here"""
1942 path
= path
.replace('\\', '/')
1945 if second
== ':': # sanity check, this better be a Windows-style path
1946 path
= '/' + first
+ path
[2:]
1951 def sequence_editor():
1952 """Set GIT_SEQUENCE_EDITOR for running git-cola-sequence-editor"""
1953 xbase
= unix_path(resources
.command('git-cola-sequence-editor'))
1954 if utils
.is_win32():
1955 editor
= core
.list2cmdline([unix_path(sys
.executable
), xbase
])
1957 editor
= core
.list2cmdline([xbase
])
1961 class SequenceEditorEnvironment(object):
1962 """Set environment variables to enable git-cola-sequence-editor"""
1964 def __init__(self
, context
, **kwargs
):
1966 'GIT_EDITOR': prefs
.editor(context
),
1967 'GIT_SEQUENCE_EDITOR': sequence_editor(),
1968 'GIT_COLA_SEQ_EDITOR_CANCEL_ACTION': 'save',
1970 self
.env
.update(kwargs
)
1972 def __enter__(self
):
1973 for var
, value
in self
.env
.items():
1974 compat
.setenv(var
, value
)
1977 def __exit__(self
, exc_type
, exc_val
, exc_tb
):
1978 for var
in self
.env
:
1979 compat
.unsetenv(var
)
1982 class Rebase(ContextCommand
):
1983 def __init__(self
, context
, upstream
=None, branch
=None, **kwargs
):
1984 """Start an interactive rebase session
1986 :param upstream: upstream branch
1987 :param branch: optional branch to checkout
1988 :param kwargs: forwarded directly to `git.rebase()`
1991 super(Rebase
, self
).__init
__(context
)
1993 self
.upstream
= upstream
1994 self
.branch
= branch
1995 self
.kwargs
= kwargs
1997 def prepare_arguments(self
, upstream
):
2001 # Rebase actions must be the only option specified
2002 for action
in ('continue', 'abort', 'skip', 'edit_todo'):
2003 if self
.kwargs
.get(action
, False):
2004 kwargs
[action
] = self
.kwargs
[action
]
2007 kwargs
['interactive'] = True
2008 kwargs
['autosquash'] = self
.kwargs
.get('autosquash', True)
2009 kwargs
.update(self
.kwargs
)
2011 # Prompt to determine whether or not to use "git rebase --update-refs".
2012 has_update_refs
= version
.check_git(self
.context
, 'rebase-update-refs')
2013 if has_update_refs
and not kwargs
.get('update_refs', False):
2014 title
= N_('Update stacked branches when rebasing?')
2016 '"git rebase --update-refs" automatically force-updates any\n'
2017 'branches that point to commits that are being rebased.\n\n'
2018 'Any branches that are checked out in a worktree are not updated.\n\n'
2019 'Using this feature is helpful for "stacked" branch workflows.'
2021 info
= N_('Update stacked branches when rebasing?')
2022 ok_text
= N_('Update stacked branches')
2023 cancel_text
= N_('Do not update stacked branches')
2024 update_refs
= Interaction
.confirm(
2030 cancel_text
=cancel_text
,
2033 kwargs
['update_refs'] = True
2036 args
.append(upstream
)
2038 args
.append(self
.branch
)
2043 (status
, out
, err
) = (1, '', '')
2044 context
= self
.context
2048 if not cfg
.get('rebase.autostash', False):
2049 if model
.staged
or model
.unmerged
or model
.modified
:
2050 Interaction
.information(
2051 N_('Unable to rebase'),
2052 N_('You cannot rebase with uncommitted changes.'),
2054 return status
, out
, err
2056 upstream
= self
.upstream
or Interaction
.choose_ref(
2058 N_('Select New Upstream'),
2059 N_('Interactive Rebase'),
2060 default
='@{upstream}',
2063 return status
, out
, err
2065 self
.model
.is_rebasing
= True
2066 self
.model
.emit_updated()
2068 args
, kwargs
= self
.prepare_arguments(upstream
)
2069 upstream_title
= upstream
or '@{upstream}'
2070 with
SequenceEditorEnvironment(
2072 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Rebase onto %s') % upstream_title
,
2073 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Rebase'),
2075 # TODO this blocks the user interface window for the duration
2076 # of git-cola-sequence-editor. We would need to implement
2077 # signals for QProcess and continue running the main thread.
2078 # Alternatively, we can hide the main window while rebasing.
2079 # That doesn't require as much effort.
2080 status
, out
, err
= self
.git
.rebase(
2081 *args
, _no_win32_startupinfo
=True, **kwargs
2083 self
.model
.update_status()
2084 if err
.strip() != 'Nothing to do':
2085 title
= N_('Rebase stopped')
2086 Interaction
.command(title
, 'git rebase', status
, out
, err
)
2087 return status
, out
, err
2090 class RebaseEditTodo(ContextCommand
):
2092 (status
, out
, err
) = (1, '', '')
2093 with
SequenceEditorEnvironment(
2095 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Edit Rebase'),
2096 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Save'),
2098 status
, out
, err
= self
.git
.rebase(edit_todo
=True)
2099 Interaction
.log_status(status
, out
, err
)
2100 self
.model
.update_status()
2101 return status
, out
, err
2104 class RebaseContinue(ContextCommand
):
2106 (status
, out
, err
) = (1, '', '')
2107 with
SequenceEditorEnvironment(
2109 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Rebase'),
2110 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Rebase'),
2112 status
, out
, err
= self
.git
.rebase('--continue')
2113 Interaction
.log_status(status
, out
, err
)
2114 self
.model
.update_status()
2115 return status
, out
, err
2118 class RebaseSkip(ContextCommand
):
2120 (status
, out
, err
) = (1, '', '')
2121 with
SequenceEditorEnvironment(
2123 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Rebase'),
2124 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Rebase'),
2126 status
, out
, err
= self
.git
.rebase(skip
=True)
2127 Interaction
.log_status(status
, out
, err
)
2128 self
.model
.update_status()
2129 return status
, out
, err
2132 class RebaseAbort(ContextCommand
):
2134 status
, out
, err
= self
.git
.rebase(abort
=True)
2135 Interaction
.log_status(status
, out
, err
)
2136 self
.model
.update_status()
2139 class Rescan(ContextCommand
):
2140 """Rescan for changes"""
2143 self
.model
.update_status()
2146 class Refresh(ContextCommand
):
2147 """Update refs, refresh the index, and update config"""
2151 return N_('Refresh')
2154 self
.model
.update_status(update_index
=True)
2156 self
.fsmonitor
.refresh()
2159 class RefreshConfig(ContextCommand
):
2160 """Refresh the git config cache"""
2166 class RevertEditsCommand(ConfirmAction
):
2167 def __init__(self
, context
):
2168 super(RevertEditsCommand
, self
).__init
__(context
)
2169 self
.icon
= icons
.undo()
2171 def ok_to_run(self
):
2172 return self
.model
.is_undoable()
2174 # pylint: disable=no-self-use
2175 def checkout_from_head(self
):
2178 def checkout_args(self
):
2180 s
= self
.selection
.selection()
2181 if self
.checkout_from_head():
2182 args
.append(self
.model
.head
)
2194 checkout_args
= self
.checkout_args()
2195 return self
.git
.checkout(*checkout_args
)
2198 self
.model
.set_diff_type(main
.Types
.TEXT
)
2199 self
.model
.update_file_status()
2202 class RevertUnstagedEdits(RevertEditsCommand
):
2205 return N_('Revert Unstaged Edits...')
2207 def checkout_from_head(self
):
2208 # Being in amend mode should not affect the behavior of this command.
2209 # The only sensible thing to do is to checkout from the index.
2213 title
= N_('Revert Unstaged Changes?')
2215 'This operation removes unstaged edits from selected files.\n'
2216 'These changes cannot be recovered.'
2218 info
= N_('Revert the unstaged changes?')
2219 ok_text
= N_('Revert Unstaged Changes')
2220 return Interaction
.confirm(
2221 title
, text
, info
, ok_text
, default
=True, icon
=self
.icon
2225 class RevertUncommittedEdits(RevertEditsCommand
):
2228 return N_('Revert Uncommitted Edits...')
2230 def checkout_from_head(self
):
2234 """Prompt for reverting changes"""
2235 title
= N_('Revert Uncommitted Changes?')
2237 'This operation removes uncommitted edits from selected files.\n'
2238 'These changes cannot be recovered.'
2240 info
= N_('Revert the uncommitted changes?')
2241 ok_text
= N_('Revert Uncommitted Changes')
2242 return Interaction
.confirm(
2243 title
, text
, info
, ok_text
, default
=True, icon
=self
.icon
2247 class RunConfigAction(ContextCommand
):
2248 """Run a user-configured action, typically from the "Tools" menu"""
2250 def __init__(self
, context
, action_name
):
2251 super(RunConfigAction
, self
).__init
__(context
)
2252 self
.action_name
= action_name
2255 """Run the user-configured action"""
2256 for env
in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
2258 compat
.unsetenv(env
)
2263 context
= self
.context
2265 opts
= cfg
.get_guitool_opts(self
.action_name
)
2266 cmd
= opts
.get('cmd')
2267 if 'title' not in opts
:
2270 if 'prompt' not in opts
or opts
.get('prompt') is True:
2271 prompt
= N_('Run "%s"?') % cmd
2272 opts
['prompt'] = prompt
2274 if opts
.get('needsfile'):
2275 filename
= self
.selection
.filename()
2277 Interaction
.information(
2278 N_('Please select a file'),
2279 N_('"%s" requires a selected file.') % cmd
,
2282 dirname
= utils
.dirname(filename
, current_dir
='.')
2283 compat
.setenv('FILENAME', filename
)
2284 compat
.setenv('DIRNAME', dirname
)
2286 if opts
.get('revprompt') or opts
.get('argprompt'):
2288 ok
= Interaction
.confirm_config_action(context
, cmd
, opts
)
2291 rev
= opts
.get('revision')
2292 args
= opts
.get('args')
2293 if opts
.get('revprompt') and not rev
:
2294 title
= N_('Invalid Revision')
2295 msg
= N_('The revision expression cannot be empty.')
2296 Interaction
.critical(title
, msg
)
2300 elif opts
.get('confirm'):
2301 title
= os
.path
.expandvars(opts
.get('title'))
2302 prompt
= os
.path
.expandvars(opts
.get('prompt'))
2303 if not Interaction
.question(title
, prompt
):
2306 compat
.setenv('REVISION', rev
)
2308 compat
.setenv('ARGS', args
)
2309 title
= os
.path
.expandvars(cmd
)
2310 Interaction
.log(N_('Running command: %s') % title
)
2311 cmd
= ['sh', '-c', cmd
]
2313 if opts
.get('background'):
2315 status
, out
, err
= (0, '', '')
2316 elif opts
.get('noconsole'):
2317 status
, out
, err
= core
.run_command(cmd
)
2319 status
, out
, err
= Interaction
.run_command(title
, cmd
)
2321 if not opts
.get('background') and not opts
.get('norescan'):
2322 self
.model
.update_status()
2325 Interaction
.command(title
, cmd
, status
, out
, err
)
2330 class SetDefaultRepo(ContextCommand
):
2331 """Set the default repository"""
2333 def __init__(self
, context
, repo
):
2334 super(SetDefaultRepo
, self
).__init
__(context
)
2338 self
.cfg
.set_user('cola.defaultrepo', self
.repo
)
2341 class SetDiffText(EditModel
):
2342 """Set the diff text"""
2346 def __init__(self
, context
, text
):
2347 super(SetDiffText
, self
).__init
__(context
)
2348 self
.new_diff_text
= text
2349 self
.new_diff_type
= main
.Types
.TEXT
2350 self
.new_file_type
= main
.Types
.TEXT
2353 class SetUpstreamBranch(ContextCommand
):
2354 """Set the upstream branch"""
2356 def __init__(self
, context
, branch
, remote
, remote_branch
):
2357 super(SetUpstreamBranch
, self
).__init
__(context
)
2358 self
.branch
= branch
2359 self
.remote
= remote
2360 self
.remote_branch
= remote_branch
2364 remote
= self
.remote
2365 branch
= self
.branch
2366 remote_branch
= self
.remote_branch
2367 cfg
.set_repo('branch.%s.remote' % branch
, remote
)
2368 cfg
.set_repo('branch.%s.merge' % branch
, 'refs/heads/' + remote_branch
)
2371 def format_hex(data
):
2372 """Translate binary data into a hex dump"""
2373 hexdigits
= '0123456789ABCDEF'
2376 byte_offset_to_int
= compat
.byte_offset_to_int_converter()
2377 while offset
< len(data
):
2378 result
+= '%04u |' % offset
2380 for i
in range(0, 16):
2381 if i
> 0 and i
% 4 == 0:
2383 if offset
< len(data
):
2384 v
= byte_offset_to_int(data
[offset
])
2385 result
+= ' ' + hexdigits
[v
>> 4] + hexdigits
[v
& 0xF]
2386 textpart
+= chr(v
) if 32 <= v
< 127 else '.'
2391 result
+= ' | ' + textpart
+ ' |\n'
2396 class ShowUntracked(EditModel
):
2397 """Show an untracked file."""
2399 def __init__(self
, context
, filename
):
2400 super(ShowUntracked
, self
).__init
__(context
)
2401 self
.new_filename
= filename
2402 if gitcmds
.is_binary(context
, filename
):
2403 self
.new_mode
= self
.model
.mode_untracked
2404 self
.new_diff_text
= self
.read(filename
)
2406 self
.new_mode
= self
.model
.mode_untracked_diff
2407 self
.new_diff_text
= gitcmds
.diff_helper(
2408 self
.context
, filename
=filename
, cached
=False, untracked
=True
2410 self
.new_diff_type
= main
.Types
.TEXT
2411 self
.new_file_type
= main
.Types
.TEXT
2413 def read(self
, filename
):
2414 """Read file contents"""
2416 size
= cfg
.get('cola.readsize', 2048)
2418 result
= core
.read(filename
, size
=size
, encoding
='bytes')
2419 except (IOError, OSError):
2422 truncated
= len(result
) == size
2424 encoding
= cfg
.file_encoding(filename
) or core
.ENCODING
2426 text_result
= core
.decode_maybe(result
, encoding
)
2427 except UnicodeError:
2428 text_result
= format_hex(result
)
2431 text_result
+= '...'
2435 class SignOff(ContextCommand
):
2436 """Append a signoff to the commit message"""
2442 return N_('Sign Off')
2444 def __init__(self
, context
):
2445 super(SignOff
, self
).__init
__(context
)
2446 self
.old_commitmsg
= self
.model
.commitmsg
2449 """Add a signoff to the commit message"""
2450 signoff
= self
.signoff()
2451 if signoff
in self
.model
.commitmsg
:
2453 msg
= self
.model
.commitmsg
.rstrip()
2454 self
.model
.set_commitmsg(msg
+ '\n' + signoff
)
2457 """Restore the commit message"""
2458 self
.model
.set_commitmsg(self
.old_commitmsg
)
2461 """Generate the signoff string"""
2463 import pwd
# pylint: disable=all
2465 user
= pwd
.getpwuid(os
.getuid()).pw_name
2467 user
= os
.getenv('USER', N_('unknown'))
2470 name
= cfg
.get('user.name', user
)
2471 email
= cfg
.get('user.email', '%s@%s' % (user
, core
.node()))
2472 return '\nSigned-off-by: %s <%s>' % (name
, email
)
2475 def check_conflicts(context
, unmerged
):
2476 """Check paths for conflicts
2478 Conflicting files can be filtered out one-by-one.
2481 if prefs
.check_conflicts(context
):
2482 unmerged
= [path
for path
in unmerged
if is_conflict_free(path
)]
2486 def is_conflict_free(path
):
2487 """Return True if `path` contains no conflict markers"""
2488 rgx
= re
.compile(r
'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2490 with core
.xopen(path
, 'rb') as f
:
2492 line
= core
.decode(line
, errors
='ignore')
2494 return should_stage_conflicts(path
)
2496 # We can't read this file ~ we may be staging a removal
2501 def should_stage_conflicts(path
):
2502 """Inform the user that a file contains merge conflicts
2504 Return `True` if we should stage the path nonetheless.
2507 title
= msg
= N_('Stage conflicts?')
2510 '%s appears to contain merge conflicts.\n\n'
2511 'You should probably skip this file.\n'
2516 ok_text
= N_('Stage conflicts')
2517 cancel_text
= N_('Skip')
2518 return Interaction
.confirm(
2519 title
, msg
, info
, ok_text
, default
=False, cancel_text
=cancel_text
2523 class Stage(ContextCommand
):
2524 """Stage a set of paths."""
2530 def __init__(self
, context
, paths
):
2531 super(Stage
, self
).__init
__(context
)
2535 msg
= N_('Staging: %s') % (', '.join(self
.paths
))
2536 Interaction
.log(msg
)
2537 return self
.stage_paths()
2539 def stage_paths(self
):
2540 """Stages add/removals to git."""
2541 context
= self
.context
2544 if self
.model
.cfg
.get('cola.safemode', False):
2546 return self
.stage_all()
2554 for path
in set(paths
):
2555 if core
.exists(path
) or core
.islink(path
):
2556 if path
.endswith('/'):
2557 path
= path
.rstrip('/')
2562 self
.model
.emit_about_to_update()
2564 # `git add -u` doesn't work on untracked files
2566 status
, out
, err
= gitcmds
.add(context
, add
)
2567 Interaction
.command(N_('Error'), 'git add', status
, out
, err
)
2569 # If a path doesn't exist then that means it should be removed
2570 # from the index. We use `git add -u` for that.
2572 status
, out
, err
= gitcmds
.add(context
, remove
, u
=True)
2573 Interaction
.command(N_('Error'), 'git add -u', status
, out
, err
)
2575 self
.model
.update_files(emit
=True)
2576 return status
, out
, err
2578 def stage_all(self
):
2579 """Stage all files"""
2580 status
, out
, err
= self
.git
.add(v
=True, u
=True)
2581 Interaction
.command(N_('Error'), 'git add -u', status
, out
, err
)
2582 self
.model
.update_file_status()
2583 return (status
, out
, err
)
2586 class StageCarefully(Stage
):
2587 """Only stage when the path list is non-empty
2589 We use "git add -u -- <pathspec>" to stage, and it stages everything by
2590 default when no pathspec is specified, so this class ensures that paths
2591 are specified before calling git.
2593 When no paths are specified, the command does nothing.
2597 def __init__(self
, context
):
2598 super(StageCarefully
, self
).__init
__(context
, None)
2601 # pylint: disable=no-self-use
2602 def init_paths(self
):
2603 """Initialize path data"""
2606 def ok_to_run(self
):
2607 """Prevent catch-all "git add -u" from adding unmerged files"""
2608 return self
.paths
or not self
.model
.unmerged
2611 """Stage files when ok_to_run() return True"""
2612 if self
.ok_to_run():
2613 return super(StageCarefully
, self
).do()
2617 class StageModified(StageCarefully
):
2618 """Stage all modified files."""
2622 return N_('Stage Modified')
2624 def init_paths(self
):
2625 self
.paths
= self
.model
.modified
2628 class StageUnmerged(StageCarefully
):
2629 """Stage unmerged files."""
2633 return N_('Stage Unmerged')
2635 def init_paths(self
):
2636 self
.paths
= check_conflicts(self
.context
, self
.model
.unmerged
)
2639 class StageUntracked(StageCarefully
):
2640 """Stage all untracked files."""
2644 return N_('Stage Untracked')
2646 def init_paths(self
):
2647 self
.paths
= self
.model
.untracked
2650 class StageModifiedAndUntracked(StageCarefully
):
2651 """Stage all untracked files."""
2655 return N_('Stage Modified and Untracked')
2657 def init_paths(self
):
2658 self
.paths
= self
.model
.modified
+ self
.model
.untracked
2661 class StageOrUnstageAll(ContextCommand
):
2662 """If the selection is staged, unstage it, otherwise stage"""
2666 return N_('Stage / Unstage All')
2669 if self
.model
.staged
:
2670 do(Unstage
, self
.context
, self
.model
.staged
)
2672 if self
.cfg
.get('cola.safemode', False):
2673 unstaged
= self
.model
.modified
2675 unstaged
= self
.model
.modified
+ self
.model
.untracked
2676 do(Stage
, self
.context
, unstaged
)
2679 class StageOrUnstage(ContextCommand
):
2680 """If the selection is staged, unstage it, otherwise stage"""
2684 return N_('Stage / Unstage')
2687 s
= self
.selection
.selection()
2689 do(Unstage
, self
.context
, s
.staged
)
2692 unmerged
= check_conflicts(self
.context
, s
.unmerged
)
2694 unstaged
.extend(unmerged
)
2696 unstaged
.extend(s
.modified
)
2698 unstaged
.extend(s
.untracked
)
2700 do(Stage
, self
.context
, unstaged
)
2703 class Tag(ContextCommand
):
2704 """Create a tag object."""
2706 def __init__(self
, context
, name
, revision
, sign
=False, message
=''):
2707 super(Tag
, self
).__init
__(context
)
2709 self
._message
= message
2710 self
._revision
= revision
2716 revision
= self
._revision
2717 tag_name
= self
._name
2718 tag_message
= self
._message
2721 Interaction
.critical(
2722 N_('Missing Revision'), N_('Please specify a revision to tag.')
2727 Interaction
.critical(
2728 N_('Missing Name'), N_('Please specify a name for the new tag.')
2732 title
= N_('Missing Tag Message')
2733 message
= N_('Tag-signing was requested but the tag message is empty.')
2735 'An unsigned, lightweight tag will be created instead.\n'
2736 'Create an unsigned tag?'
2738 ok_text
= N_('Create Unsigned Tag')
2740 if sign
and not tag_message
:
2741 # We require a message in order to sign the tag, so if they
2742 # choose to create an unsigned tag we have to clear the sign flag.
2743 if not Interaction
.confirm(
2744 title
, message
, info
, ok_text
, default
=False, icon
=icons
.save()
2753 tmp_file
= utils
.tmp_filename('tag-message')
2754 opts
['file'] = tmp_file
2755 core
.write(tmp_file
, tag_message
)
2760 opts
['annotate'] = True
2761 status
, out
, err
= git
.tag(tag_name
, revision
, **opts
)
2764 core
.unlink(tmp_file
)
2766 title
= N_('Error: could not create tag "%s"') % tag_name
2767 Interaction
.command(title
, 'git tag', status
, out
, err
)
2771 self
.model
.update_status()
2772 Interaction
.information(
2774 N_('Created a new tag named "%s"') % tag_name
,
2775 details
=tag_message
or None,
2781 class Unstage(ContextCommand
):
2782 """Unstage a set of paths."""
2786 return N_('Unstage')
2788 def __init__(self
, context
, paths
):
2789 super(Unstage
, self
).__init
__(context
)
2794 context
= self
.context
2795 head
= self
.model
.head
2798 msg
= N_('Unstaging: %s') % (', '.join(paths
))
2799 Interaction
.log(msg
)
2801 return unstage_all(context
)
2802 status
, out
, err
= gitcmds
.unstage_paths(context
, paths
, head
=head
)
2803 Interaction
.command(N_('Error'), 'git reset', status
, out
, err
)
2804 self
.model
.update_file_status()
2805 return (status
, out
, err
)
2808 class UnstageAll(ContextCommand
):
2809 """Unstage all files; resets the index."""
2812 return unstage_all(self
.context
)
2815 def unstage_all(context
):
2816 """Unstage all files, even while amending"""
2817 model
= context
.model
2820 status
, out
, err
= git
.reset(head
, '--', '.')
2821 Interaction
.command(N_('Error'), 'git reset', status
, out
, err
)
2822 model
.update_file_status()
2823 return (status
, out
, err
)
2826 class StageSelected(ContextCommand
):
2827 """Stage selected files, or all files if no selection exists."""
2830 context
= self
.context
2831 paths
= self
.selection
.unstaged
2833 do(Stage
, context
, paths
)
2834 elif self
.cfg
.get('cola.safemode', False):
2835 do(StageModified
, context
)
2838 class UnstageSelected(Unstage
):
2839 """Unstage selected files."""
2841 def __init__(self
, context
):
2842 staged
= context
.selection
.staged
2843 super(UnstageSelected
, self
).__init
__(context
, staged
)
2846 class Untrack(ContextCommand
):
2847 """Unstage a set of paths."""
2849 def __init__(self
, context
, paths
):
2850 super(Untrack
, self
).__init
__(context
)
2854 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
2855 Interaction
.log(msg
)
2856 status
, out
, err
= self
.model
.untrack_paths(self
.paths
)
2857 Interaction
.log_status(status
, out
, err
)
2860 class UntrackedSummary(EditModel
):
2861 """List possible .gitignore rules as the diff text."""
2863 def __init__(self
, context
):
2864 super(UntrackedSummary
, self
).__init
__(context
)
2865 untracked
= self
.model
.untracked
2866 suffix
= 's' if untracked
else ''
2868 io
.write('# %s untracked file%s\n' % (len(untracked
), suffix
))
2870 io
.write('# possible .gitignore rule%s:\n' % suffix
)
2872 io
.write('/' + u
+ '\n')
2873 self
.new_diff_text
= io
.getvalue()
2874 self
.new_diff_type
= main
.Types
.TEXT
2875 self
.new_file_type
= main
.Types
.TEXT
2876 self
.new_mode
= self
.model
.mode_display
2879 class VisualizeAll(ContextCommand
):
2880 """Visualize all branches."""
2883 context
= self
.context
2884 browser
= utils
.shell_split(prefs
.history_browser(context
))
2885 launch_history_browser(browser
+ ['--all'])
2888 class VisualizeCurrent(ContextCommand
):
2889 """Visualize all branches."""
2892 context
= self
.context
2893 browser
= utils
.shell_split(prefs
.history_browser(context
))
2894 launch_history_browser(browser
+ [self
.model
.currentbranch
] + ['--'])
2897 class VisualizePaths(ContextCommand
):
2898 """Path-limited visualization."""
2900 def __init__(self
, context
, paths
):
2901 super(VisualizePaths
, self
).__init
__(context
)
2902 context
= self
.context
2903 browser
= utils
.shell_split(prefs
.history_browser(context
))
2905 self
.argv
= browser
+ ['--'] + list(paths
)
2910 launch_history_browser(self
.argv
)
2913 class VisualizeRevision(ContextCommand
):
2914 """Visualize a specific revision."""
2916 def __init__(self
, context
, revision
, paths
=None):
2917 super(VisualizeRevision
, self
).__init
__(context
)
2918 self
.revision
= revision
2922 context
= self
.context
2923 argv
= utils
.shell_split(prefs
.history_browser(context
))
2925 argv
.append(self
.revision
)
2928 argv
.extend(self
.paths
)
2929 launch_history_browser(argv
)
2932 class SubmoduleAdd(ConfirmAction
):
2933 """Add specified submodules"""
2935 def __init__(self
, context
, url
, path
, branch
, depth
, reference
):
2936 super(SubmoduleAdd
, self
).__init
__(context
)
2939 self
.branch
= branch
2941 self
.reference
= reference
2944 title
= N_('Add Submodule...')
2945 question
= N_('Add this submodule?')
2946 info
= N_('The submodule will be added using\n' '"%s"' % self
.command())
2947 ok_txt
= N_('Add Submodule')
2948 return Interaction
.confirm(title
, question
, info
, ok_txt
, icon
=icons
.ok())
2951 context
= self
.context
2952 args
= self
.get_args()
2953 return context
.git
.submodule('add', *args
)
2956 self
.model
.update_file_status()
2957 self
.model
.update_submodules_list()
2959 def error_message(self
):
2960 return N_('Error updating submodule %s' % self
.path
)
2963 cmd
= ['git', 'submodule', 'add']
2964 cmd
.extend(self
.get_args())
2965 return core
.list2cmdline(cmd
)
2970 args
.extend(['--branch', self
.branch
])
2972 args
.extend(['--reference', self
.reference
])
2974 args
.extend(['--depth', '%d' % self
.depth
])
2975 args
.extend(['--', self
.url
])
2977 args
.append(self
.path
)
2981 class SubmoduleUpdate(ConfirmAction
):
2982 """Update specified submodule"""
2984 def __init__(self
, context
, path
):
2985 super(SubmoduleUpdate
, self
).__init
__(context
)
2989 title
= N_('Update Submodule...')
2990 question
= N_('Update this submodule?')
2991 info
= N_('The submodule will be updated using\n' '"%s"' % self
.command())
2992 ok_txt
= N_('Update Submodule')
2993 return Interaction
.confirm(
2994 title
, question
, info
, ok_txt
, default
=False, icon
=icons
.pull()
2998 context
= self
.context
2999 args
= self
.get_args()
3000 return context
.git
.submodule(*args
)
3003 self
.model
.update_file_status()
3005 def error_message(self
):
3006 return N_('Error updating submodule %s' % self
.path
)
3009 cmd
= ['git', 'submodule']
3010 cmd
.extend(self
.get_args())
3011 return core
.list2cmdline(cmd
)
3015 if version
.check_git(self
.context
, 'submodule-update-recursive'):
3016 cmd
.append('--recursive')
3017 cmd
.extend(['--', self
.path
])
3021 class SubmodulesUpdate(ConfirmAction
):
3022 """Update all submodules"""
3025 title
= N_('Update submodules...')
3026 question
= N_('Update all submodules?')
3027 info
= N_('All submodules will be updated using\n' '"%s"' % self
.command())
3028 ok_txt
= N_('Update Submodules')
3029 return Interaction
.confirm(
3030 title
, question
, info
, ok_txt
, default
=False, icon
=icons
.pull()
3034 context
= self
.context
3035 args
= self
.get_args()
3036 return context
.git
.submodule(*args
)
3039 self
.model
.update_file_status()
3041 def error_message(self
):
3042 return N_('Error updating submodules')
3045 cmd
= ['git', 'submodule']
3046 cmd
.extend(self
.get_args())
3047 return core
.list2cmdline(cmd
)
3051 if version
.check_git(self
.context
, 'submodule-update-recursive'):
3052 cmd
.append('--recursive')
3056 def launch_history_browser(argv
):
3057 """Launch the configured history browser"""
3060 except OSError as e
:
3061 _
, details
= utils
.format_exception(e
)
3062 title
= N_('Error Launching History Browser')
3063 msg
= N_('Cannot exec "%s": please configure a history browser') % ' '.join(
3066 Interaction
.critical(title
, message
=msg
, details
=details
)
3069 def run(cls
, *args
, **opts
):
3071 Returns a callback that runs a command
3073 If the caller of run() provides args or opts then those are
3074 used instead of the ones provided by the invoker of the callback.
3078 def runner(*local_args
, **local_opts
):
3079 """Closure return by run() which runs the command"""
3081 do(cls
, *args
, **opts
)
3083 do(cls
, *local_args
, **local_opts
)
3088 def do(cls
, *args
, **opts
):
3089 """Run a command in-place"""
3091 cmd
= cls(*args
, **opts
)
3093 except Exception as e
: # pylint: disable=broad-except
3094 msg
, details
= utils
.format_exception(e
)
3095 if hasattr(cls
, '__name__'):
3096 msg
= '%s exception:\n%s' % (cls
.__name
__, msg
)
3097 Interaction
.critical(N_('Error'), message
=msg
, details
=details
)
3101 def difftool_run(context
):
3102 """Start a default difftool session"""
3103 selection
= context
.selection
3104 files
= selection
.group()
3107 s
= selection
.selection()
3108 head
= context
.model
.head
3109 difftool_launch_with_head(context
, files
, bool(s
.staged
), head
)
3112 def difftool_launch_with_head(context
, filenames
, staged
, head
):
3113 """Launch difftool against the provided head"""
3118 difftool_launch(context
, left
=left
, staged
=staged
, paths
=filenames
)
3121 def difftool_launch(
3128 left_take_magic
=False,
3129 left_take_parent
=False,
3131 """Launches 'git difftool' with given parameters
3133 :param left: first argument to difftool
3134 :param right: second argument to difftool_args
3135 :param paths: paths to diff
3136 :param staged: activate `git difftool --staged`
3137 :param dir_diff: activate `git difftool --dir-diff`
3138 :param left_take_magic: whether to append the magic ^! diff expression
3139 :param left_take_parent: whether to append the first-parent ~ for diffing
3143 difftool_args
= ['git', 'difftool', '--no-prompt']
3145 difftool_args
.append('--cached')
3147 difftool_args
.append('--dir-diff')
3150 if left_take_parent
or left_take_magic
:
3151 suffix
= '^!' if left_take_magic
else '~'
3152 # Check root commit (no parents and thus cannot execute '~')
3154 status
, out
, err
= git
.rev_list(left
, parents
=True, n
=1)
3155 Interaction
.log_status(status
, out
, err
)
3157 raise OSError('git rev-list command failed')
3159 if len(out
.split()) >= 2:
3160 # Commit has a parent, so we can take its child as requested
3163 # No parent, assume it's the root commit, so we have to diff
3164 # against the empty tree.
3165 left
= EMPTY_TREE_OID
3166 if not right
and left_take_magic
:
3168 difftool_args
.append(left
)
3171 difftool_args
.append(right
)
3174 difftool_args
.append('--')
3175 difftool_args
.extend(paths
)
3177 runtask
= context
.runtask
3179 Interaction
.async_command(N_('Difftool'), difftool_args
, runtask
)
3181 core
.fork(difftool_args
)