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
)
467 return status
, out
, err
470 class CheckoutTheirs(ConfirmAction
):
471 """Checkout "their" version of a file when performing a merge"""
475 return N_('Checkout files from "their" branch (MERGE_HEAD)')
479 question
= N_('Checkout files from their MERGE_HEAD using "git checkout --theirs"?')
481 'This operation will replace the selected unmerged files with content '
482 'from the branch being merged.\n'
483 '*ALL* uncommitted changes will be lost.\n'
484 'Recovering uncommitted changes is not possible.'
486 ok_txt
= N_('Checkout Files')
487 return Interaction
.confirm(
488 title
, question
, info
, ok_txt
, default
=True, icon
=icons
.merge()
492 selection
= self
.selection
.selection()
493 paths
= selection
.unmerged
497 argv
= ['--theirs', '--'] + paths
498 cmd
= Checkout(self
.context
, argv
)
501 def error_message(self
):
505 return 'git checkout --theirs'
508 class CheckoutOurs(ConfirmAction
):
509 """Checkout "our" version of a file when performing a merge"""
513 return N_('Checkout files from "our" branch (HEAD)')
517 question
= N_('Checkout files from MERGE_HEAD?')
519 'This will replace the selected unmerged files with content '
520 'from the branch being merged into.\n'
521 '*ALL* uncommitted changes will be lost.\n'
522 'Recovering uncommitted changes is not possible.'
524 ok_txt
= N_('Checkout Files')
525 return Interaction
.confirm(
526 title
, question
, info
, ok_txt
, default
=True, icon
=icons
.merge()
530 selection
= self
.selection
.selection()
531 paths
= selection
.unmerged
535 argv
= ['--ours', '--'] + paths
536 cmd
= Checkout(self
.context
, argv
)
539 def error_message(self
):
543 return 'git checkout --ours'
546 class BlamePaths(ContextCommand
):
547 """Blame view for paths."""
551 return N_('Blame...')
553 def __init__(self
, context
, paths
=None):
554 super(BlamePaths
, self
).__init
__(context
)
556 paths
= context
.selection
.union()
557 viewer
= utils
.shell_split(prefs
.blame_viewer(context
))
558 self
.argv
= viewer
+ list(paths
)
564 _
, details
= utils
.format_exception(e
)
565 title
= N_('Error Launching Blame Viewer')
566 msg
= N_('Cannot exec "%s": please configure a blame viewer') % ' '.join(
569 Interaction
.critical(title
, message
=msg
, details
=details
)
572 class CheckoutBranch(Checkout
):
573 """Checkout a branch."""
575 def __init__(self
, context
, branch
):
577 super(CheckoutBranch
, self
).__init
__(context
, args
, checkout_branch
=True)
580 class CherryPick(ContextCommand
):
581 """Cherry pick commits into the current branch."""
583 def __init__(self
, context
, commits
):
584 super(CherryPick
, self
).__init
__(context
)
585 self
.commits
= commits
588 status
, out
, err
= gitcmds
.cherry_pick(self
.context
, self
.commits
)
589 self
.model
.update_file_merge_status()
590 title
= N_('Cherry-pick failed')
591 Interaction
.command(title
, 'git cherry-pick', status
, out
, err
)
594 class Revert(ContextCommand
):
595 """Cherry pick commits into the current branch."""
597 def __init__(self
, context
, oid
):
598 super(Revert
, self
).__init
__(context
)
602 status
, output
, err
= self
.git
.revert(self
.oid
, no_edit
=True)
603 self
.model
.update_file_status()
604 title
= N_('Revert failed')
605 out
= '# git revert %s\n\n' % self
.oid
606 Interaction
.command(title
, 'git revert', status
, out
, err
)
609 class ResetMode(EditModel
):
610 """Reset the mode and clear the model's diff text."""
612 def __init__(self
, context
):
613 super(ResetMode
, self
).__init
__(context
)
614 self
.new_mode
= self
.model
.mode_none
615 self
.new_diff_text
= ''
616 self
.new_diff_type
= main
.Types
.TEXT
617 self
.new_file_type
= main
.Types
.TEXT
618 self
.new_filename
= ''
621 super(ResetMode
, self
).do()
622 self
.model
.update_file_status()
625 class ResetCommand(ConfirmAction
):
626 """Reset state using the "git reset" command"""
628 def __init__(self
, context
, ref
):
629 super(ResetCommand
, self
).__init
__(context
)
638 def error_message(self
):
642 self
.model
.update_file_status()
645 raise NotImplementedError('confirm() must be overridden')
648 raise NotImplementedError('reset() must be overridden')
651 class ResetMixed(ResetCommand
):
654 tooltip
= N_('The branch will be reset using "git reset --mixed %s"')
658 title
= N_('Reset Branch and Stage (Mixed)')
659 question
= N_('Point the current branch head to a new commit?')
660 info
= self
.tooltip(self
.ref
)
661 ok_text
= N_('Reset Branch')
662 return Interaction
.confirm(title
, question
, info
, ok_text
)
665 return self
.git
.reset(self
.ref
, '--', mixed
=True)
668 class ResetKeep(ResetCommand
):
671 tooltip
= N_('The repository will be reset using "git reset --keep %s"')
675 title
= N_('Restore Worktree and Reset All (Keep Unstaged Changes)')
676 question
= N_('Restore worktree, reset, and preserve unstaged edits?')
677 info
= self
.tooltip(self
.ref
)
678 ok_text
= N_('Reset and Restore')
679 return Interaction
.confirm(title
, question
, info
, ok_text
)
682 return self
.git
.reset(self
.ref
, '--', keep
=True)
685 class ResetMerge(ResetCommand
):
688 tooltip
= N_('The repository will be reset using "git reset --merge %s"')
692 title
= N_('Restore Worktree and Reset All (Merge)')
693 question
= N_('Reset Worktree and Reset All?')
694 info
= self
.tooltip(self
.ref
)
695 ok_text
= N_('Reset and Restore')
696 return Interaction
.confirm(title
, question
, info
, ok_text
)
699 return self
.git
.reset(self
.ref
, '--', merge
=True)
702 class ResetSoft(ResetCommand
):
705 tooltip
= N_('The branch will be reset using "git reset --soft %s"')
709 title
= N_('Reset Branch (Soft)')
710 question
= N_('Reset branch?')
711 info
= self
.tooltip(self
.ref
)
712 ok_text
= N_('Reset Branch')
713 return Interaction
.confirm(title
, question
, info
, ok_text
)
716 return self
.git
.reset(self
.ref
, '--', soft
=True)
719 class ResetHard(ResetCommand
):
722 tooltip
= N_('The repository will be reset using "git reset --hard %s"')
726 title
= N_('Restore Worktree and Reset All (Hard)')
727 question
= N_('Restore Worktree and Reset All?')
728 info
= self
.tooltip(self
.ref
)
729 ok_text
= N_('Reset and Restore')
730 return Interaction
.confirm(title
, question
, info
, ok_text
)
733 return self
.git
.reset(self
.ref
, '--', hard
=True)
736 class RestoreWorktree(ConfirmAction
):
737 """Reset the worktree using the "git read-tree" command"""
742 'The worktree will be restored using "git read-tree --reset -u %s"'
746 def __init__(self
, context
, ref
):
747 super(RestoreWorktree
, self
).__init
__(context
)
751 return self
.git
.read_tree(self
.ref
, reset
=True, u
=True)
754 return 'git read-tree --reset -u %s' % self
.ref
756 def error_message(self
):
760 self
.model
.update_file_status()
763 title
= N_('Restore Worktree')
764 question
= N_('Restore Worktree to %s?') % self
.ref
765 info
= self
.tooltip(self
.ref
)
766 ok_text
= N_('Restore Worktree')
767 return Interaction
.confirm(title
, question
, info
, ok_text
)
770 class UndoLastCommit(ResetCommand
):
771 """Undo the last commit"""
773 # NOTE: this is the similar to ResetSoft() with an additional check for
774 # published commits and different messages.
775 def __init__(self
, context
):
776 super(UndoLastCommit
, self
).__init
__(context
, 'HEAD^')
779 check_published
= prefs
.check_published_commits(self
.context
)
780 if check_published
and self
.model
.is_commit_published():
781 return Interaction
.confirm(
782 N_('Rewrite Published Commit?'),
784 'This commit has already been published.\n'
785 'This operation will rewrite published history.\n'
786 'You probably don\'t want to do this.'
788 N_('Undo the published commit?'),
789 N_('Undo Last Commit'),
794 title
= N_('Undo Last Commit')
795 question
= N_('Undo last commit?')
796 info
= N_('The branch will be reset using "git reset --soft %s"')
797 ok_text
= N_('Undo Last Commit')
798 info_text
= info
% self
.ref
799 return Interaction
.confirm(title
, question
, info_text
, ok_text
)
802 return self
.git
.reset('HEAD^', '--', soft
=True)
805 class Commit(ResetMode
):
806 """Attempt to create a new commit."""
808 def __init__(self
, context
, amend
, msg
, sign
, no_verify
=False):
809 super(Commit
, self
).__init
__(context
)
813 self
.no_verify
= no_verify
814 self
.old_commitmsg
= self
.model
.commitmsg
815 self
.new_commitmsg
= ''
818 # Create the commit message file
819 context
= self
.context
820 comment_char
= prefs
.comment_char(context
)
821 msg
= self
.strip_comments(self
.msg
, comment_char
=comment_char
)
822 tmp_file
= utils
.tmp_filename('commit-message')
824 core
.write(tmp_file
, msg
)
826 status
, out
, err
= self
.git
.commit(
831 no_verify
=self
.no_verify
,
834 core
.unlink(tmp_file
)
836 super(Commit
, self
).do()
837 if context
.cfg
.get(prefs
.AUTOTEMPLATE
):
838 template_loader
= LoadCommitMessageFromTemplate(context
)
841 self
.model
.set_commitmsg(self
.new_commitmsg
)
843 title
= N_('Commit failed')
844 Interaction
.command(title
, 'git commit', status
, out
, err
)
846 return status
, out
, err
849 def strip_comments(msg
, comment_char
='#'):
852 line
for line
in msg
.split('\n') if not line
.startswith(comment_char
)
854 msg
= '\n'.join(message_lines
)
855 if not msg
.endswith('\n'):
861 class CycleReferenceSort(ContextCommand
):
862 """Choose the next reference sort type"""
865 self
.model
.cycle_ref_sort()
868 class Ignore(ContextCommand
):
869 """Add files to an exclusion file"""
871 def __init__(self
, context
, filenames
, local
=False):
872 super(Ignore
, self
).__init
__(context
)
873 self
.filenames
= list(filenames
)
877 if not self
.filenames
:
879 new_additions
= '\n'.join(self
.filenames
) + '\n'
880 for_status
= new_additions
882 filename
= os
.path
.join('.git', 'info', 'exclude')
884 filename
= '.gitignore'
885 if core
.exists(filename
):
886 current_list
= core
.read(filename
)
887 new_additions
= current_list
.rstrip() + '\n' + new_additions
888 core
.write(filename
, new_additions
)
889 Interaction
.log_status(0, 'Added to %s:\n%s' % (filename
, for_status
), '')
890 self
.model
.update_file_status()
893 def file_summary(files
):
894 txt
= core
.list2cmdline(files
)
896 txt
= txt
[:768].rstrip() + '...'
897 wrap
= textwrap
.TextWrapper()
898 return '\n'.join(wrap
.wrap(txt
))
901 class RemoteCommand(ConfirmAction
):
902 def __init__(self
, context
, remote
):
903 super(RemoteCommand
, self
).__init
__(context
)
908 self
.model
.update_remotes()
911 class RemoteAdd(RemoteCommand
):
912 def __init__(self
, context
, remote
, url
):
913 super(RemoteAdd
, self
).__init
__(context
, remote
)
917 return self
.git
.remote('add', self
.remote
, self
.url
)
919 def error_message(self
):
920 return N_('Error creating remote "%s"') % self
.remote
923 return 'git remote add "%s" "%s"' % (self
.remote
, self
.url
)
926 class RemoteRemove(RemoteCommand
):
928 title
= N_('Delete Remote')
929 question
= N_('Delete remote?')
930 info
= N_('Delete remote "%s"') % self
.remote
931 ok_text
= N_('Delete')
932 return Interaction
.confirm(title
, question
, info
, ok_text
)
935 return self
.git
.remote('rm', self
.remote
)
937 def error_message(self
):
938 return N_('Error deleting remote "%s"') % self
.remote
941 return 'git remote rm "%s"' % self
.remote
944 class RemoteRename(RemoteCommand
):
945 def __init__(self
, context
, remote
, new_name
):
946 super(RemoteRename
, self
).__init
__(context
, remote
)
947 self
.new_name
= new_name
950 title
= N_('Rename Remote')
951 text
= N_('Rename remote "%(current)s" to "%(new)s"?') % dict(
952 current
=self
.remote
, new
=self
.new_name
956 return Interaction
.confirm(title
, text
, info_text
, ok_text
)
959 return self
.git
.remote('rename', self
.remote
, self
.new_name
)
961 def error_message(self
):
962 return N_('Error renaming "%(name)s" to "%(new_name)s"') % dict(
963 name
=self
.remote
, new_name
=self
.new_name
967 return 'git remote rename "%s" "%s"' % (self
.remote
, self
.new_name
)
970 class RemoteSetURL(RemoteCommand
):
971 def __init__(self
, context
, remote
, url
):
972 super(RemoteSetURL
, self
).__init
__(context
, remote
)
976 return self
.git
.remote('set-url', self
.remote
, self
.url
)
978 def error_message(self
):
979 return N_('Unable to set URL for "%(name)s" to "%(url)s"') % dict(
980 name
=self
.remote
, url
=self
.url
984 return 'git remote set-url "%s" "%s"' % (self
.remote
, self
.url
)
987 class RemoteEdit(ContextCommand
):
988 """Combine RemoteRename and RemoteSetURL"""
990 def __init__(self
, context
, old_name
, remote
, url
):
991 super(RemoteEdit
, self
).__init
__(context
)
992 self
.rename
= RemoteRename(context
, old_name
, remote
)
993 self
.set_url
= RemoteSetURL(context
, remote
, url
)
996 result
= self
.rename
.do()
1000 result
= self
.set_url
.do()
1002 return name_ok
, url_ok
1005 class RemoveFromSettings(ConfirmAction
):
1006 def __init__(self
, context
, repo
, entry
, icon
=None):
1007 super(RemoveFromSettings
, self
).__init
__(context
)
1008 self
.context
= context
1014 self
.context
.settings
.save()
1017 class RemoveBookmark(RemoveFromSettings
):
1020 title
= msg
= N_('Delete Bookmark?')
1021 info
= N_('%s will be removed from your bookmarks.') % entry
1022 ok_text
= N_('Delete Bookmark')
1023 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
1026 self
.context
.settings
.remove_bookmark(self
.repo
, self
.entry
)
1030 class RemoveRecent(RemoveFromSettings
):
1033 title
= msg
= N_('Remove %s from the recent list?') % repo
1034 info
= N_('%s will be removed from your recent repositories.') % repo
1035 ok_text
= N_('Remove')
1036 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
1039 self
.context
.settings
.remove_recent(self
.repo
)
1043 class RemoveFiles(ContextCommand
):
1046 def __init__(self
, context
, remover
, filenames
):
1047 super(RemoveFiles
, self
).__init
__(context
)
1050 self
.remover
= remover
1051 self
.filenames
= filenames
1052 # We could git-hash-object stuff and provide undo-ability
1053 # as an option. Heh.
1056 files
= self
.filenames
1062 remove
= self
.remover
1063 for filename
in files
:
1069 bad_filenames
.append(filename
)
1072 Interaction
.information(
1073 N_('Error'), N_('Deleting "%s" failed') % file_summary(bad_filenames
)
1077 self
.model
.update_file_status()
1080 class Delete(RemoveFiles
):
1083 def __init__(self
, context
, filenames
):
1084 super(Delete
, self
).__init
__(context
, os
.remove
, filenames
)
1087 files
= self
.filenames
1091 title
= N_('Delete Files?')
1092 msg
= N_('The following files will be deleted:') + '\n\n'
1093 msg
+= file_summary(files
)
1094 info_txt
= N_('Delete %d file(s)?') % len(files
)
1095 ok_txt
= N_('Delete Files')
1097 if Interaction
.confirm(
1098 title
, msg
, info_txt
, ok_txt
, default
=True, icon
=icons
.remove()
1100 super(Delete
, self
).do()
1103 class MoveToTrash(RemoveFiles
):
1104 """Move files to the trash using send2trash"""
1106 AVAILABLE
= send2trash
is not None
1108 def __init__(self
, context
, filenames
):
1109 super(MoveToTrash
, self
).__init
__(context
, send2trash
, filenames
)
1112 class DeleteBranch(ConfirmAction
):
1113 """Delete a git branch."""
1115 def __init__(self
, context
, branch
):
1116 super(DeleteBranch
, self
).__init
__(context
)
1117 self
.branch
= branch
1120 title
= N_('Delete Branch')
1121 question
= N_('Delete branch "%s"?') % self
.branch
1122 info
= N_('The branch will be no longer available.')
1123 ok_txt
= N_('Delete Branch')
1124 return Interaction
.confirm(
1125 title
, question
, info
, ok_txt
, default
=True, icon
=icons
.discard()
1129 return self
.model
.delete_branch(self
.branch
)
1131 def error_message(self
):
1132 return N_('Error deleting branch "%s"' % self
.branch
)
1135 command
= 'git branch -D %s'
1136 return command
% self
.branch
1139 class Rename(ContextCommand
):
1140 """Rename a set of paths."""
1142 def __init__(self
, context
, paths
):
1143 super(Rename
, self
).__init
__(context
)
1147 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
1148 Interaction
.log(msg
)
1150 for path
in self
.paths
:
1151 ok
= self
.rename(path
)
1155 self
.model
.update_status()
1157 def rename(self
, path
):
1159 title
= N_('Rename "%s"') % path
1161 if os
.path
.isdir(path
):
1162 base_path
= os
.path
.dirname(path
)
1165 new_path
= Interaction
.save_as(base_path
, title
)
1169 status
, out
, err
= git
.mv(path
, new_path
, force
=True, verbose
=True)
1170 Interaction
.command(N_('Error'), 'git mv', status
, out
, err
)
1174 class RenameBranch(ContextCommand
):
1175 """Rename a git branch."""
1177 def __init__(self
, context
, branch
, new_branch
):
1178 super(RenameBranch
, self
).__init
__(context
)
1179 self
.branch
= branch
1180 self
.new_branch
= new_branch
1183 branch
= self
.branch
1184 new_branch
= self
.new_branch
1185 status
, out
, err
= self
.model
.rename_branch(branch
, new_branch
)
1186 Interaction
.log_status(status
, out
, err
)
1189 class DeleteRemoteBranch(DeleteBranch
):
1190 """Delete a remote git branch."""
1192 def __init__(self
, context
, remote
, branch
):
1193 super(DeleteRemoteBranch
, self
).__init
__(context
, branch
)
1194 self
.remote
= remote
1197 return self
.git
.push(self
.remote
, self
.branch
, delete
=True)
1200 self
.model
.update_status()
1201 Interaction
.information(
1202 N_('Remote Branch Deleted'),
1203 N_('"%(branch)s" has been deleted from "%(remote)s".')
1204 % dict(branch
=self
.branch
, remote
=self
.remote
),
1207 def error_message(self
):
1208 return N_('Error Deleting Remote Branch')
1211 command
= 'git push --delete %s %s'
1212 return command
% (self
.remote
, self
.branch
)
1215 def get_mode(context
, filename
, staged
, modified
, unmerged
, untracked
):
1216 model
= context
.model
1218 mode
= model
.mode_index
1219 elif modified
or unmerged
:
1220 mode
= model
.mode_worktree
1222 if gitcmds
.is_binary(context
, filename
):
1223 mode
= model
.mode_untracked
1225 mode
= model
.mode_untracked_diff
1231 class DiffAgainstCommitMode(ContextCommand
):
1232 """Diff against arbitrary commits"""
1234 def __init__(self
, context
, oid
):
1235 super(DiffAgainstCommitMode
, self
).__init
__(context
)
1239 self
.model
.set_mode(self
.model
.mode_diff
, head
=self
.oid
)
1240 self
.model
.update_file_status()
1243 class DiffText(EditModel
):
1244 """Set the diff type to text"""
1246 def __init__(self
, context
):
1247 super(DiffText
, self
).__init
__(context
)
1248 self
.new_file_type
= main
.Types
.TEXT
1249 self
.new_diff_type
= main
.Types
.TEXT
1252 class ToggleDiffType(ContextCommand
):
1253 """Toggle the diff type between image and text"""
1255 def __init__(self
, context
):
1256 super(ToggleDiffType
, self
).__init
__(context
)
1257 if self
.model
.diff_type
== main
.Types
.IMAGE
:
1258 self
.new_diff_type
= main
.Types
.TEXT
1259 self
.new_value
= False
1261 self
.new_diff_type
= main
.Types
.IMAGE
1262 self
.new_value
= True
1265 diff_type
= self
.new_diff_type
1266 value
= self
.new_value
1268 self
.model
.set_diff_type(diff_type
)
1270 filename
= self
.model
.filename
1271 _
, ext
= os
.path
.splitext(filename
)
1272 if ext
.startswith('.'):
1273 cfg
= 'cola.imagediff' + ext
1274 self
.cfg
.set_repo(cfg
, value
)
1277 class DiffImage(EditModel
):
1279 self
, context
, filename
, deleted
, staged
, modified
, unmerged
, untracked
1281 super(DiffImage
, self
).__init
__(context
)
1283 self
.new_filename
= filename
1284 self
.new_diff_type
= self
.get_diff_type(filename
)
1285 self
.new_file_type
= main
.Types
.IMAGE
1286 self
.new_mode
= get_mode(
1287 context
, filename
, staged
, modified
, unmerged
, untracked
1289 self
.staged
= staged
1290 self
.modified
= modified
1291 self
.unmerged
= unmerged
1292 self
.untracked
= untracked
1293 self
.deleted
= deleted
1294 self
.annex
= self
.cfg
.is_annex()
1296 def get_diff_type(self
, filename
):
1297 """Query the diff type to use based on cola.imagediff.<extension>"""
1298 _
, ext
= os
.path
.splitext(filename
)
1299 if ext
.startswith('.'):
1300 # Check eg. "cola.imagediff.svg" to see if we should imagediff.
1301 cfg
= 'cola.imagediff' + ext
1302 if self
.cfg
.get(cfg
, True):
1303 result
= main
.Types
.IMAGE
1305 result
= main
.Types
.TEXT
1307 result
= main
.Types
.IMAGE
1311 filename
= self
.new_filename
1314 images
= self
.staged_images()
1316 images
= self
.modified_images()
1318 images
= self
.unmerged_images()
1319 elif self
.untracked
:
1320 images
= [(filename
, False)]
1324 self
.model
.set_images(images
)
1325 super(DiffImage
, self
).do()
1327 def staged_images(self
):
1328 context
= self
.context
1330 head
= self
.model
.head
1331 filename
= self
.new_filename
1335 index
= git
.diff_index(head
, '--', filename
, cached
=True)[STDOUT
]
1338 # :100644 100644 fabadb8... 4866510... M describe.c
1339 parts
= index
.split(' ')
1344 if old_oid
!= MISSING_BLOB_OID
:
1345 # First, check if we can get a pre-image from git-annex
1348 annex_image
= gitcmds
.annex_path(context
, head
, filename
)
1350 images
.append((annex_image
, False)) # git annex HEAD
1352 image
= gitcmds
.write_blob_path(context
, head
, old_oid
, filename
)
1354 images
.append((image
, True))
1356 if new_oid
!= MISSING_BLOB_OID
:
1357 found_in_annex
= False
1358 if annex
and core
.islink(filename
):
1359 status
, out
, _
= git
.annex('status', '--', filename
)
1361 details
= out
.split(' ')
1362 if details
and details
[0] == 'A': # newly added file
1363 images
.append((filename
, False))
1364 found_in_annex
= True
1366 if not found_in_annex
:
1367 image
= gitcmds
.write_blob(context
, new_oid
, filename
)
1369 images
.append((image
, True))
1373 def unmerged_images(self
):
1374 context
= self
.context
1376 head
= self
.model
.head
1377 filename
= self
.new_filename
1380 candidate_merge_heads
= ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1383 for merge_head
in candidate_merge_heads
1384 if core
.exists(git
.git_path(merge_head
))
1387 if annex
: # Attempt to find files in git-annex
1389 for merge_head
in merge_heads
:
1390 image
= gitcmds
.annex_path(context
, merge_head
, filename
)
1392 annex_images
.append((image
, False))
1394 annex_images
.append((filename
, False))
1397 # DIFF FORMAT FOR MERGES
1398 # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1399 # can take -c or --cc option to generate diff output also
1400 # for merge commits. The output differs from the format
1401 # described above in the following way:
1403 # 1. there is a colon for each parent
1404 # 2. there are more "src" modes and "src" sha1
1405 # 3. status is concatenated status characters for each parent
1406 # 4. no optional "score" number
1407 # 5. single path, only for "dst"
1409 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1412 index
= git
.diff_index(head
, '--', filename
, cached
=True, cc
=True)[STDOUT
]
1414 parts
= index
.split(' ')
1416 first_mode
= parts
[0]
1417 num_parents
= first_mode
.count(':')
1418 # colon for each parent, but for the index, the "parents"
1419 # are really entries in stages 1,2,3 (head, base, remote)
1420 # remote, base, head
1421 for i
in range(num_parents
):
1422 offset
= num_parents
+ i
+ 1
1425 merge_head
= merge_heads
[i
]
1428 if oid
!= MISSING_BLOB_OID
:
1429 image
= gitcmds
.write_blob_path(
1430 context
, merge_head
, oid
, filename
1433 images
.append((image
, True))
1435 images
.append((filename
, False))
1438 def modified_images(self
):
1439 context
= self
.context
1441 head
= self
.model
.head
1442 filename
= self
.new_filename
1447 if annex
: # Check for a pre-image from git-annex
1448 annex_image
= gitcmds
.annex_path(context
, head
, filename
)
1450 images
.append((annex_image
, False)) # git annex HEAD
1452 worktree
= git
.diff_files('--', filename
)[STDOUT
]
1453 parts
= worktree
.split(' ')
1456 if oid
!= MISSING_BLOB_OID
:
1457 image
= gitcmds
.write_blob_path(context
, head
, oid
, filename
)
1459 images
.append((image
, True)) # HEAD
1461 images
.append((filename
, False)) # worktree
1465 class Diff(EditModel
):
1466 """Perform a diff and set the model's current text."""
1468 def __init__(self
, context
, filename
, cached
=False, deleted
=False):
1469 super(Diff
, self
).__init
__(context
)
1471 if cached
and gitcmds
.is_valid_ref(context
, self
.model
.head
):
1472 opts
['ref'] = self
.model
.head
1473 self
.new_filename
= filename
1474 self
.new_mode
= self
.model
.mode_worktree
1475 self
.new_diff_text
= gitcmds
.diff_helper(
1476 self
.context
, filename
=filename
, cached
=cached
, deleted
=deleted
, **opts
1480 class Diffstat(EditModel
):
1481 """Perform a diffstat and set the model's diff text."""
1483 def __init__(self
, context
):
1484 super(Diffstat
, self
).__init
__(context
)
1486 diff_context
= cfg
.get('diff.context', 3)
1487 diff
= self
.git
.diff(
1489 unified
=diff_context
,
1495 self
.new_diff_text
= diff
1496 self
.new_diff_type
= main
.Types
.TEXT
1497 self
.new_file_type
= main
.Types
.TEXT
1498 self
.new_mode
= self
.model
.mode_diffstat
1501 class DiffStaged(Diff
):
1502 """Perform a staged diff on a file."""
1504 def __init__(self
, context
, filename
, deleted
=None):
1505 super(DiffStaged
, self
).__init
__(
1506 context
, filename
, cached
=True, deleted
=deleted
1508 self
.new_mode
= self
.model
.mode_index
1511 class DiffStagedSummary(EditModel
):
1512 def __init__(self
, context
):
1513 super(DiffStagedSummary
, self
).__init
__(context
)
1514 diff
= self
.git
.diff(
1519 patch_with_stat
=True,
1522 self
.new_diff_text
= diff
1523 self
.new_diff_type
= main
.Types
.TEXT
1524 self
.new_file_type
= main
.Types
.TEXT
1525 self
.new_mode
= self
.model
.mode_index
1528 class Difftool(ContextCommand
):
1529 """Run git-difftool limited by path."""
1531 def __init__(self
, context
, staged
, filenames
):
1532 super(Difftool
, self
).__init
__(context
)
1533 self
.staged
= staged
1534 self
.filenames
= filenames
1537 difftool_launch_with_head(
1538 self
.context
, self
.filenames
, self
.staged
, self
.model
.head
1542 class Edit(ContextCommand
):
1543 """Edit a file using the configured gui.editor."""
1547 return N_('Launch Editor')
1549 def __init__(self
, context
, filenames
, line_number
=None, background_editor
=False):
1550 super(Edit
, self
).__init
__(context
)
1551 self
.filenames
= filenames
1552 self
.line_number
= line_number
1553 self
.background_editor
= background_editor
1556 context
= self
.context
1557 if not self
.filenames
:
1559 filename
= self
.filenames
[0]
1560 if not core
.exists(filename
):
1562 if self
.background_editor
:
1563 editor
= prefs
.background_editor(context
)
1565 editor
= prefs
.editor(context
)
1568 if self
.line_number
is None:
1569 opts
= self
.filenames
1571 # Single-file w/ line-numbers (likely from grep)
1573 '*vim*': [filename
, '+%s' % self
.line_number
],
1574 '*emacs*': ['+%s' % self
.line_number
, filename
],
1575 '*textpad*': ['%s(%s,0)' % (filename
, self
.line_number
)],
1576 '*notepad++*': ['-n%s' % self
.line_number
, filename
],
1577 '*subl*': ['%s:%s' % (filename
, self
.line_number
)],
1580 opts
= self
.filenames
1581 for pattern
, opt
in editor_opts
.items():
1582 if fnmatch(editor
, pattern
):
1587 core
.fork(utils
.shell_split(editor
) + opts
)
1588 except (OSError, ValueError) as e
:
1589 message
= N_('Cannot exec "%s": please configure your editor') % editor
1590 _
, details
= utils
.format_exception(e
)
1591 Interaction
.critical(N_('Error Editing File'), message
, details
)
1594 class FormatPatch(ContextCommand
):
1595 """Output a patch series given all revisions and a selected subset."""
1597 def __init__(self
, context
, to_export
, revs
, output
='patches'):
1598 super(FormatPatch
, self
).__init
__(context
)
1599 self
.to_export
= list(to_export
)
1600 self
.revs
= list(revs
)
1601 self
.output
= output
1604 context
= self
.context
1605 status
, out
, err
= gitcmds
.format_patchsets(
1606 context
, self
.to_export
, self
.revs
, self
.output
1608 Interaction
.log_status(status
, out
, err
)
1611 class LaunchDifftool(ContextCommand
):
1614 return N_('Launch Diff Tool')
1617 s
= self
.selection
.selection()
1620 if utils
.is_win32():
1621 core
.fork(['git', 'mergetool', '--no-prompt', '--'] + paths
)
1624 cmd
= cfg
.terminal()
1625 argv
= utils
.shell_split(cmd
)
1627 terminal
= os
.path
.basename(argv
[0])
1628 shellquote_terms
= set(['xfce4-terminal'])
1629 shellquote_default
= terminal
in shellquote_terms
1631 mergetool
= ['git', 'mergetool', '--no-prompt', '--']
1632 mergetool
.extend(paths
)
1633 needs_shellquote
= cfg
.get(
1634 'cola.terminalshellquote', shellquote_default
1637 if needs_shellquote
:
1638 argv
.append(core
.list2cmdline(mergetool
))
1640 argv
.extend(mergetool
)
1644 difftool_run(self
.context
)
1647 class LaunchTerminal(ContextCommand
):
1650 return N_('Launch Terminal')
1653 def is_available(context
):
1654 return context
.cfg
.terminal() is not None
1656 def __init__(self
, context
, path
):
1657 super(LaunchTerminal
, self
).__init
__(context
)
1661 cmd
= self
.context
.cfg
.terminal()
1664 if utils
.is_win32():
1665 argv
= ['start', '', cmd
, '--login']
1668 argv
= utils
.shell_split(cmd
)
1670 shells
= ('zsh', 'fish', 'bash', 'sh')
1671 for basename
in shells
:
1672 executable
= core
.find_executable(basename
)
1674 command
= executable
1676 argv
.append(os
.getenv('SHELL', command
))
1679 core
.fork(argv
, cwd
=self
.path
, shell
=shell
)
1682 class LaunchEditor(Edit
):
1685 return N_('Launch Editor')
1687 def __init__(self
, context
):
1688 s
= context
.selection
.selection()
1689 filenames
= s
.staged
+ s
.unmerged
+ s
.modified
+ s
.untracked
1690 super(LaunchEditor
, self
).__init
__(context
, filenames
, background_editor
=True)
1693 class LaunchEditorAtLine(LaunchEditor
):
1694 """Launch an editor at the specified line"""
1696 def __init__(self
, context
):
1697 super(LaunchEditorAtLine
, self
).__init
__(context
)
1698 self
.line_number
= context
.selection
.line_number
1701 class LoadCommitMessageFromFile(ContextCommand
):
1702 """Loads a commit message from a path."""
1706 def __init__(self
, context
, path
):
1707 super(LoadCommitMessageFromFile
, self
).__init
__(context
)
1709 self
.old_commitmsg
= self
.model
.commitmsg
1710 self
.old_directory
= self
.model
.directory
1713 path
= os
.path
.expanduser(self
.path
)
1714 if not path
or not core
.isfile(path
):
1716 N_('Error: Cannot find commit template'),
1717 N_('%s: No such file or directory.') % path
,
1719 self
.model
.set_directory(os
.path
.dirname(path
))
1720 self
.model
.set_commitmsg(core
.read(path
))
1723 self
.model
.set_commitmsg(self
.old_commitmsg
)
1724 self
.model
.set_directory(self
.old_directory
)
1727 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile
):
1728 """Loads the commit message template specified by commit.template."""
1730 def __init__(self
, context
):
1732 template
= cfg
.get('commit.template')
1733 super(LoadCommitMessageFromTemplate
, self
).__init
__(context
, template
)
1736 if self
.path
is None:
1738 N_('Error: Unconfigured commit template'),
1740 'A commit template has not been configured.\n'
1741 'Use "git config" to define "commit.template"\n'
1742 'so that it points to a commit template.'
1745 return LoadCommitMessageFromFile
.do(self
)
1748 class LoadCommitMessageFromOID(ContextCommand
):
1749 """Load a previous commit message"""
1753 def __init__(self
, context
, oid
, prefix
=''):
1754 super(LoadCommitMessageFromOID
, self
).__init
__(context
)
1756 self
.old_commitmsg
= self
.model
.commitmsg
1757 self
.new_commitmsg
= prefix
+ gitcmds
.prev_commitmsg(context
, oid
)
1760 self
.model
.set_commitmsg(self
.new_commitmsg
)
1763 self
.model
.set_commitmsg(self
.old_commitmsg
)
1766 class PrepareCommitMessageHook(ContextCommand
):
1767 """Use the cola-prepare-commit-msg hook to prepare the commit message"""
1771 def __init__(self
, context
):
1772 super(PrepareCommitMessageHook
, self
).__init
__(context
)
1773 self
.old_commitmsg
= self
.model
.commitmsg
1775 def get_message(self
):
1777 title
= N_('Error running prepare-commitmsg hook')
1778 hook
= gitcmds
.prepare_commit_message_hook(self
.context
)
1780 if os
.path
.exists(hook
):
1781 filename
= self
.model
.save_commitmsg()
1782 status
, out
, err
= core
.run_command([hook
, filename
])
1785 result
= core
.read(filename
)
1787 result
= self
.old_commitmsg
1788 Interaction
.command_error(title
, hook
, status
, out
, err
)
1790 message
= N_('A hook must be provided at "%s"') % hook
1791 Interaction
.critical(title
, message
=message
)
1792 result
= self
.old_commitmsg
1797 msg
= self
.get_message()
1798 self
.model
.set_commitmsg(msg
)
1801 self
.model
.set_commitmsg(self
.old_commitmsg
)
1804 class LoadFixupMessage(LoadCommitMessageFromOID
):
1805 """Load a fixup message"""
1807 def __init__(self
, context
, oid
):
1808 super(LoadFixupMessage
, self
).__init
__(context
, oid
, prefix
='fixup! ')
1809 if self
.new_commitmsg
:
1810 self
.new_commitmsg
= self
.new_commitmsg
.splitlines()[0]
1813 class Merge(ContextCommand
):
1816 def __init__(self
, context
, revision
, no_commit
, squash
, no_ff
, sign
):
1817 super(Merge
, self
).__init
__(context
)
1818 self
.revision
= revision
1820 self
.no_commit
= no_commit
1821 self
.squash
= squash
1825 squash
= self
.squash
1826 revision
= self
.revision
1828 no_commit
= self
.no_commit
1831 status
, out
, err
= self
.git
.merge(
1832 revision
, gpg_sign
=sign
, no_ff
=no_ff
, no_commit
=no_commit
, squash
=squash
1834 self
.model
.update_status()
1835 title
= N_('Merge failed. Conflict resolution is required.')
1836 Interaction
.command(title
, 'git merge', status
, out
, err
)
1838 return status
, out
, err
1841 class OpenDefaultApp(ContextCommand
):
1842 """Open a file using the OS default."""
1846 return N_('Open Using Default Application')
1848 def __init__(self
, context
, filenames
):
1849 super(OpenDefaultApp
, self
).__init
__(context
)
1850 self
.filenames
= filenames
1853 if not self
.filenames
:
1855 utils
.launch_default_app(self
.filenames
)
1858 class OpenDir(OpenDefaultApp
):
1859 """Open directories using the OS default."""
1863 return N_('Open Directory')
1866 def _dirnames(self
):
1867 return self
.filenames
1870 dirnames
= self
._dirnames
1873 # An empty dirname defaults to CWD.
1874 dirs
= [(dirname
or core
.getcwd()) for dirname
in dirnames
]
1875 utils
.launch_default_app(dirs
)
1878 class OpenParentDir(OpenDir
):
1879 """Open parent directories using the OS default."""
1883 return N_('Open Parent Directory')
1886 def _dirnames(self
):
1887 dirnames
= list(set([os
.path
.dirname(x
) for x
in self
.filenames
]))
1891 class OpenWorktree(OpenDir
):
1892 """Open worktree directory using the OS default."""
1896 return N_('Open Worktree')
1898 # The _unused parameter is needed by worktree_dir_action() -> common.cmd_action().
1899 def __init__(self
, context
, _unused
=None):
1900 dirnames
= [context
.git
.worktree()]
1901 super(OpenWorktree
, self
).__init
__(context
, dirnames
)
1904 class OpenNewRepo(ContextCommand
):
1905 """Launches git-cola on a repo."""
1907 def __init__(self
, context
, repo_path
):
1908 super(OpenNewRepo
, self
).__init
__(context
)
1909 self
.repo_path
= repo_path
1912 self
.model
.set_directory(self
.repo_path
)
1913 core
.fork([sys
.executable
, sys
.argv
[0], '--repo', self
.repo_path
])
1916 class OpenRepo(EditModel
):
1917 def __init__(self
, context
, repo_path
):
1918 super(OpenRepo
, self
).__init
__(context
)
1919 self
.repo_path
= repo_path
1920 self
.new_mode
= self
.model
.mode_none
1921 self
.new_diff_text
= ''
1922 self
.new_diff_type
= main
.Types
.TEXT
1923 self
.new_file_type
= main
.Types
.TEXT
1924 self
.new_commitmsg
= ''
1925 self
.new_filename
= ''
1928 old_repo
= self
.git
.getcwd()
1929 if self
.model
.set_worktree(self
.repo_path
):
1930 self
.fsmonitor
.stop()
1931 self
.fsmonitor
.start()
1932 self
.model
.update_status(reset
=True)
1933 # Check if template should be loaded
1934 if self
.context
.cfg
.get(prefs
.AUTOTEMPLATE
):
1935 template_loader
= LoadCommitMessageFromTemplate(self
.context
)
1936 template_loader
.do()
1938 self
.model
.set_commitmsg(self
.new_commitmsg
)
1939 settings
= self
.context
.settings
1941 settings
.add_recent(self
.repo_path
, prefs
.maxrecent(self
.context
))
1943 super(OpenRepo
, self
).do()
1945 self
.model
.set_worktree(old_repo
)
1948 class OpenParentRepo(OpenRepo
):
1949 def __init__(self
, context
):
1951 if version
.check_git(context
, 'show-superproject-working-tree'):
1952 status
, out
, _
= context
.git
.rev_parse(show_superproject_working_tree
=True)
1956 path
= os
.path
.dirname(core
.getcwd())
1957 super(OpenParentRepo
, self
).__init
__(context
, path
)
1960 class Clone(ContextCommand
):
1961 """Clones a repository and optionally spawns a new cola session."""
1964 self
, context
, url
, new_directory
, submodules
=False, shallow
=False, spawn
=True
1966 super(Clone
, self
).__init
__(context
)
1968 self
.new_directory
= new_directory
1969 self
.submodules
= submodules
1970 self
.shallow
= shallow
1980 recurse_submodules
= self
.submodules
1981 shallow_submodules
= self
.submodules
and self
.shallow
1983 status
, out
, err
= self
.git
.clone(
1986 recurse_submodules
=recurse_submodules
,
1987 shallow_submodules
=shallow_submodules
,
1991 self
.status
= status
1994 if status
== 0 and self
.spawn
:
1995 executable
= sys
.executable
1996 core
.fork([executable
, sys
.argv
[0], '--repo', self
.new_directory
])
2000 class NewBareRepo(ContextCommand
):
2001 """Create a new shared bare repository"""
2003 def __init__(self
, context
, path
):
2004 super(NewBareRepo
, self
).__init
__(context
)
2009 status
, out
, err
= self
.git
.init(path
, bare
=True, shared
=True)
2010 Interaction
.command(
2011 N_('Error'), 'git init --bare --shared "%s"' % path
, status
, out
, err
2016 def unix_path(path
, is_win32
=utils
.is_win32
):
2017 """Git for Windows requires unix paths, so force them here"""
2019 path
= path
.replace('\\', '/')
2022 if second
== ':': # sanity check, this better be a Windows-style path
2023 path
= '/' + first
+ path
[2:]
2028 def sequence_editor():
2029 """Set GIT_SEQUENCE_EDITOR for running git-cola-sequence-editor"""
2030 xbase
= unix_path(resources
.command('git-cola-sequence-editor'))
2031 if utils
.is_win32():
2032 editor
= core
.list2cmdline([unix_path(sys
.executable
), xbase
])
2034 editor
= core
.list2cmdline([xbase
])
2038 class SequenceEditorEnvironment(object):
2039 """Set environment variables to enable git-cola-sequence-editor"""
2041 def __init__(self
, context
, **kwargs
):
2043 'GIT_EDITOR': prefs
.editor(context
),
2044 'GIT_SEQUENCE_EDITOR': sequence_editor(),
2045 'GIT_COLA_SEQ_EDITOR_CANCEL_ACTION': 'save',
2047 self
.env
.update(kwargs
)
2049 def __enter__(self
):
2050 for var
, value
in self
.env
.items():
2051 compat
.setenv(var
, value
)
2054 def __exit__(self
, exc_type
, exc_val
, exc_tb
):
2055 for var
in self
.env
:
2056 compat
.unsetenv(var
)
2059 class Rebase(ContextCommand
):
2060 def __init__(self
, context
, upstream
=None, branch
=None, **kwargs
):
2061 """Start an interactive rebase session
2063 :param upstream: upstream branch
2064 :param branch: optional branch to checkout
2065 :param kwargs: forwarded directly to `git.rebase()`
2068 super(Rebase
, self
).__init
__(context
)
2070 self
.upstream
= upstream
2071 self
.branch
= branch
2072 self
.kwargs
= kwargs
2074 def prepare_arguments(self
, upstream
):
2078 # Rebase actions must be the only option specified
2079 for action
in ('continue', 'abort', 'skip', 'edit_todo'):
2080 if self
.kwargs
.get(action
, False):
2081 kwargs
[action
] = self
.kwargs
[action
]
2084 kwargs
['interactive'] = True
2085 kwargs
['autosquash'] = self
.kwargs
.get('autosquash', True)
2086 kwargs
.update(self
.kwargs
)
2088 # Prompt to determine whether or not to use "git rebase --update-refs".
2089 has_update_refs
= version
.check_git(self
.context
, 'rebase-update-refs')
2090 if has_update_refs
and not kwargs
.get('update_refs', False):
2091 title
= N_('Update stacked branches when rebasing?')
2093 '"git rebase --update-refs" automatically force-updates any\n'
2094 'branches that point to commits that are being rebased.\n\n'
2095 'Any branches that are checked out in a worktree are not updated.\n\n'
2096 'Using this feature is helpful for "stacked" branch workflows.'
2098 info
= N_('Update stacked branches when rebasing?')
2099 ok_text
= N_('Update stacked branches')
2100 cancel_text
= N_('Do not update stacked branches')
2101 update_refs
= Interaction
.confirm(
2107 cancel_text
=cancel_text
,
2110 kwargs
['update_refs'] = True
2113 args
.append(upstream
)
2115 args
.append(self
.branch
)
2120 (status
, out
, err
) = (1, '', '')
2121 context
= self
.context
2125 if not cfg
.get('rebase.autostash', False):
2126 if model
.staged
or model
.unmerged
or model
.modified
:
2127 Interaction
.information(
2128 N_('Unable to rebase'),
2129 N_('You cannot rebase with uncommitted changes.'),
2131 return status
, out
, err
2133 upstream
= self
.upstream
or Interaction
.choose_ref(
2135 N_('Select New Upstream'),
2136 N_('Interactive Rebase'),
2137 default
='@{upstream}',
2140 return status
, out
, err
2142 self
.model
.is_rebasing
= True
2143 self
.model
.emit_updated()
2145 args
, kwargs
= self
.prepare_arguments(upstream
)
2146 upstream_title
= upstream
or '@{upstream}'
2147 with
SequenceEditorEnvironment(
2149 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Rebase onto %s') % upstream_title
,
2150 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Rebase'),
2152 # TODO this blocks the user interface window for the duration
2153 # of git-cola-sequence-editor. We would need to implement
2154 # signals for QProcess and continue running the main thread.
2155 # Alternatively, we can hide the main window while rebasing.
2156 # That doesn't require as much effort.
2157 status
, out
, err
= self
.git
.rebase(
2158 *args
, _no_win32_startupinfo
=True, **kwargs
2160 self
.model
.update_status()
2161 if err
.strip() != 'Nothing to do':
2162 title
= N_('Rebase stopped')
2163 Interaction
.command(title
, 'git rebase', status
, out
, err
)
2164 return status
, out
, err
2167 class RebaseEditTodo(ContextCommand
):
2169 (status
, out
, err
) = (1, '', '')
2170 with
SequenceEditorEnvironment(
2172 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Edit Rebase'),
2173 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Save'),
2175 status
, out
, err
= self
.git
.rebase(edit_todo
=True)
2176 Interaction
.log_status(status
, out
, err
)
2177 self
.model
.update_status()
2178 return status
, out
, err
2181 class RebaseContinue(ContextCommand
):
2183 (status
, out
, err
) = (1, '', '')
2184 with
SequenceEditorEnvironment(
2186 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Rebase'),
2187 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Rebase'),
2189 status
, out
, err
= self
.git
.rebase('--continue')
2190 Interaction
.log_status(status
, out
, err
)
2191 self
.model
.update_status()
2192 return status
, out
, err
2195 class RebaseSkip(ContextCommand
):
2197 (status
, out
, err
) = (1, '', '')
2198 with
SequenceEditorEnvironment(
2200 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Rebase'),
2201 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Rebase'),
2203 status
, out
, err
= self
.git
.rebase(skip
=True)
2204 Interaction
.log_status(status
, out
, err
)
2205 self
.model
.update_status()
2206 return status
, out
, err
2209 class RebaseAbort(ContextCommand
):
2211 status
, out
, err
= self
.git
.rebase(abort
=True)
2212 Interaction
.log_status(status
, out
, err
)
2213 self
.model
.update_status()
2216 class Rescan(ContextCommand
):
2217 """Rescan for changes"""
2220 self
.model
.update_status()
2223 class Refresh(ContextCommand
):
2224 """Update refs, refresh the index, and update config"""
2228 return N_('Refresh')
2231 self
.model
.update_status(update_index
=True)
2233 self
.fsmonitor
.refresh()
2236 class RefreshConfig(ContextCommand
):
2237 """Refresh the git config cache"""
2243 class RevertEditsCommand(ConfirmAction
):
2244 def __init__(self
, context
):
2245 super(RevertEditsCommand
, self
).__init
__(context
)
2246 self
.icon
= icons
.undo()
2248 def ok_to_run(self
):
2249 return self
.model
.is_undoable()
2251 # pylint: disable=no-self-use
2252 def checkout_from_head(self
):
2255 def checkout_args(self
):
2257 s
= self
.selection
.selection()
2258 if self
.checkout_from_head():
2259 args
.append(self
.model
.head
)
2271 checkout_args
= self
.checkout_args()
2272 return self
.git
.checkout(*checkout_args
)
2275 self
.model
.set_diff_type(main
.Types
.TEXT
)
2276 self
.model
.update_file_status()
2279 class RevertUnstagedEdits(RevertEditsCommand
):
2282 return N_('Revert Unstaged Edits...')
2284 def checkout_from_head(self
):
2285 # Being in amend mode should not affect the behavior of this command.
2286 # The only sensible thing to do is to checkout from the index.
2290 title
= N_('Revert Unstaged Changes?')
2292 'This operation removes unstaged edits from selected files.\n'
2293 'These changes cannot be recovered.'
2295 info
= N_('Revert the unstaged changes?')
2296 ok_text
= N_('Revert Unstaged Changes')
2297 return Interaction
.confirm(
2298 title
, text
, info
, ok_text
, default
=True, icon
=self
.icon
2302 class RevertUncommittedEdits(RevertEditsCommand
):
2305 return N_('Revert Uncommitted Edits...')
2307 def checkout_from_head(self
):
2311 """Prompt for reverting changes"""
2312 title
= N_('Revert Uncommitted Changes?')
2314 'This operation removes uncommitted edits from selected files.\n'
2315 'These changes cannot be recovered.'
2317 info
= N_('Revert the uncommitted changes?')
2318 ok_text
= N_('Revert Uncommitted Changes')
2319 return Interaction
.confirm(
2320 title
, text
, info
, ok_text
, default
=True, icon
=self
.icon
2324 class RunConfigAction(ContextCommand
):
2325 """Run a user-configured action, typically from the "Tools" menu"""
2327 def __init__(self
, context
, action_name
):
2328 super(RunConfigAction
, self
).__init
__(context
)
2329 self
.action_name
= action_name
2332 """Run the user-configured action"""
2333 for env
in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
2335 compat
.unsetenv(env
)
2340 context
= self
.context
2342 opts
= cfg
.get_guitool_opts(self
.action_name
)
2343 cmd
= opts
.get('cmd')
2344 if 'title' not in opts
:
2347 if 'prompt' not in opts
or opts
.get('prompt') is True:
2348 prompt
= N_('Run "%s"?') % cmd
2349 opts
['prompt'] = prompt
2351 if opts
.get('needsfile'):
2352 filename
= self
.selection
.filename()
2354 Interaction
.information(
2355 N_('Please select a file'),
2356 N_('"%s" requires a selected file.') % cmd
,
2359 dirname
= utils
.dirname(filename
, current_dir
='.')
2360 compat
.setenv('FILENAME', filename
)
2361 compat
.setenv('DIRNAME', dirname
)
2363 if opts
.get('revprompt') or opts
.get('argprompt'):
2365 ok
= Interaction
.confirm_config_action(context
, cmd
, opts
)
2368 rev
= opts
.get('revision')
2369 args
= opts
.get('args')
2370 if opts
.get('revprompt') and not rev
:
2371 title
= N_('Invalid Revision')
2372 msg
= N_('The revision expression cannot be empty.')
2373 Interaction
.critical(title
, msg
)
2377 elif opts
.get('confirm'):
2378 title
= os
.path
.expandvars(opts
.get('title'))
2379 prompt
= os
.path
.expandvars(opts
.get('prompt'))
2380 if not Interaction
.question(title
, prompt
):
2383 compat
.setenv('REVISION', rev
)
2385 compat
.setenv('ARGS', args
)
2386 title
= os
.path
.expandvars(cmd
)
2387 Interaction
.log(N_('Running command: %s') % title
)
2388 cmd
= ['sh', '-c', cmd
]
2390 if opts
.get('background'):
2392 status
, out
, err
= (0, '', '')
2393 elif opts
.get('noconsole'):
2394 status
, out
, err
= core
.run_command(cmd
)
2396 status
, out
, err
= Interaction
.run_command(title
, cmd
)
2398 if not opts
.get('background') and not opts
.get('norescan'):
2399 self
.model
.update_status()
2402 Interaction
.command(title
, cmd
, status
, out
, err
)
2407 class SetDefaultRepo(ContextCommand
):
2408 """Set the default repository"""
2410 def __init__(self
, context
, repo
):
2411 super(SetDefaultRepo
, self
).__init
__(context
)
2415 self
.cfg
.set_user('cola.defaultrepo', self
.repo
)
2418 class SetDiffText(EditModel
):
2419 """Set the diff text"""
2423 def __init__(self
, context
, text
):
2424 super(SetDiffText
, self
).__init
__(context
)
2425 self
.new_diff_text
= text
2426 self
.new_diff_type
= main
.Types
.TEXT
2427 self
.new_file_type
= main
.Types
.TEXT
2430 class SetUpstreamBranch(ContextCommand
):
2431 """Set the upstream branch"""
2433 def __init__(self
, context
, branch
, remote
, remote_branch
):
2434 super(SetUpstreamBranch
, self
).__init
__(context
)
2435 self
.branch
= branch
2436 self
.remote
= remote
2437 self
.remote_branch
= remote_branch
2441 remote
= self
.remote
2442 branch
= self
.branch
2443 remote_branch
= self
.remote_branch
2444 cfg
.set_repo('branch.%s.remote' % branch
, remote
)
2445 cfg
.set_repo('branch.%s.merge' % branch
, 'refs/heads/' + remote_branch
)
2448 def format_hex(data
):
2449 """Translate binary data into a hex dump"""
2450 hexdigits
= '0123456789ABCDEF'
2453 byte_offset_to_int
= compat
.byte_offset_to_int_converter()
2454 while offset
< len(data
):
2455 result
+= '%04u |' % offset
2457 for i
in range(0, 16):
2458 if i
> 0 and i
% 4 == 0:
2460 if offset
< len(data
):
2461 v
= byte_offset_to_int(data
[offset
])
2462 result
+= ' ' + hexdigits
[v
>> 4] + hexdigits
[v
& 0xF]
2463 textpart
+= chr(v
) if 32 <= v
< 127 else '.'
2468 result
+= ' | ' + textpart
+ ' |\n'
2473 class ShowUntracked(EditModel
):
2474 """Show an untracked file."""
2476 def __init__(self
, context
, filename
):
2477 super(ShowUntracked
, self
).__init
__(context
)
2478 self
.new_filename
= filename
2479 if gitcmds
.is_binary(context
, filename
):
2480 self
.new_mode
= self
.model
.mode_untracked
2481 self
.new_diff_text
= self
.read(filename
)
2483 self
.new_mode
= self
.model
.mode_untracked_diff
2484 self
.new_diff_text
= gitcmds
.diff_helper(
2485 self
.context
, filename
=filename
, cached
=False, untracked
=True
2487 self
.new_diff_type
= main
.Types
.TEXT
2488 self
.new_file_type
= main
.Types
.TEXT
2490 def read(self
, filename
):
2491 """Read file contents"""
2493 size
= cfg
.get('cola.readsize', 2048)
2495 result
= core
.read(filename
, size
=size
, encoding
='bytes')
2496 except (IOError, OSError):
2499 truncated
= len(result
) == size
2501 encoding
= cfg
.file_encoding(filename
) or core
.ENCODING
2503 text_result
= core
.decode_maybe(result
, encoding
)
2504 except UnicodeError:
2505 text_result
= format_hex(result
)
2508 text_result
+= '...'
2512 class SignOff(ContextCommand
):
2513 """Append a signoff to the commit message"""
2519 return N_('Sign Off')
2521 def __init__(self
, context
):
2522 super(SignOff
, self
).__init
__(context
)
2523 self
.old_commitmsg
= self
.model
.commitmsg
2526 """Add a signoff to the commit message"""
2527 signoff
= self
.signoff()
2528 if signoff
in self
.model
.commitmsg
:
2530 msg
= self
.model
.commitmsg
.rstrip()
2531 self
.model
.set_commitmsg(msg
+ '\n' + signoff
)
2534 """Restore the commit message"""
2535 self
.model
.set_commitmsg(self
.old_commitmsg
)
2538 """Generate the signoff string"""
2540 import pwd
# pylint: disable=all
2542 user
= pwd
.getpwuid(os
.getuid()).pw_name
2544 user
= os
.getenv('USER', N_('unknown'))
2547 name
= cfg
.get('user.name', user
)
2548 email
= cfg
.get('user.email', '%s@%s' % (user
, core
.node()))
2549 return '\nSigned-off-by: %s <%s>' % (name
, email
)
2552 def check_conflicts(context
, unmerged
):
2553 """Check paths for conflicts
2555 Conflicting files can be filtered out one-by-one.
2558 if prefs
.check_conflicts(context
):
2559 unmerged
= [path
for path
in unmerged
if is_conflict_free(path
)]
2563 def is_conflict_free(path
):
2564 """Return True if `path` contains no conflict markers"""
2565 rgx
= re
.compile(r
'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2567 with core
.xopen(path
, 'rb') as f
:
2569 line
= core
.decode(line
, errors
='ignore')
2571 return should_stage_conflicts(path
)
2573 # We can't read this file ~ we may be staging a removal
2578 def should_stage_conflicts(path
):
2579 """Inform the user that a file contains merge conflicts
2581 Return `True` if we should stage the path nonetheless.
2584 title
= msg
= N_('Stage conflicts?')
2587 '%s appears to contain merge conflicts.\n\n'
2588 'You should probably skip this file.\n'
2593 ok_text
= N_('Stage conflicts')
2594 cancel_text
= N_('Skip')
2595 return Interaction
.confirm(
2596 title
, msg
, info
, ok_text
, default
=False, cancel_text
=cancel_text
2600 class Stage(ContextCommand
):
2601 """Stage a set of paths."""
2607 def __init__(self
, context
, paths
):
2608 super(Stage
, self
).__init
__(context
)
2612 msg
= N_('Staging: %s') % (', '.join(self
.paths
))
2613 Interaction
.log(msg
)
2614 return self
.stage_paths()
2616 def stage_paths(self
):
2617 """Stages add/removals to git."""
2618 context
= self
.context
2621 if self
.model
.cfg
.get('cola.safemode', False):
2623 return self
.stage_all()
2631 for path
in set(paths
):
2632 if core
.exists(path
) or core
.islink(path
):
2633 if path
.endswith('/'):
2634 path
= path
.rstrip('/')
2639 self
.model
.emit_about_to_update()
2641 # `git add -u` doesn't work on untracked files
2643 status
, out
, err
= gitcmds
.add(context
, add
)
2644 Interaction
.command(N_('Error'), 'git add', status
, out
, err
)
2646 # If a path doesn't exist then that means it should be removed
2647 # from the index. We use `git add -u` for that.
2649 status
, out
, err
= gitcmds
.add(context
, remove
, u
=True)
2650 Interaction
.command(N_('Error'), 'git add -u', status
, out
, err
)
2652 self
.model
.update_files(emit
=True)
2653 return status
, out
, err
2655 def stage_all(self
):
2656 """Stage all files"""
2657 status
, out
, err
= self
.git
.add(v
=True, u
=True)
2658 Interaction
.command(N_('Error'), 'git add -u', status
, out
, err
)
2659 self
.model
.update_file_status()
2660 return (status
, out
, err
)
2663 class StageCarefully(Stage
):
2664 """Only stage when the path list is non-empty
2666 We use "git add -u -- <pathspec>" to stage, and it stages everything by
2667 default when no pathspec is specified, so this class ensures that paths
2668 are specified before calling git.
2670 When no paths are specified, the command does nothing.
2674 def __init__(self
, context
):
2675 super(StageCarefully
, self
).__init
__(context
, None)
2678 # pylint: disable=no-self-use
2679 def init_paths(self
):
2680 """Initialize path data"""
2683 def ok_to_run(self
):
2684 """Prevent catch-all "git add -u" from adding unmerged files"""
2685 return self
.paths
or not self
.model
.unmerged
2688 """Stage files when ok_to_run() return True"""
2689 if self
.ok_to_run():
2690 return super(StageCarefully
, self
).do()
2694 class StageModified(StageCarefully
):
2695 """Stage all modified files."""
2699 return N_('Stage Modified')
2701 def init_paths(self
):
2702 self
.paths
= self
.model
.modified
2705 class StageUnmerged(StageCarefully
):
2706 """Stage unmerged files."""
2710 return N_('Stage Unmerged')
2712 def init_paths(self
):
2713 self
.paths
= check_conflicts(self
.context
, self
.model
.unmerged
)
2716 class StageUntracked(StageCarefully
):
2717 """Stage all untracked files."""
2721 return N_('Stage Untracked')
2723 def init_paths(self
):
2724 self
.paths
= self
.model
.untracked
2727 class StageModifiedAndUntracked(StageCarefully
):
2728 """Stage all untracked files."""
2732 return N_('Stage Modified and Untracked')
2734 def init_paths(self
):
2735 self
.paths
= self
.model
.modified
+ self
.model
.untracked
2738 class StageOrUnstageAll(ContextCommand
):
2739 """If the selection is staged, unstage it, otherwise stage"""
2743 return N_('Stage / Unstage All')
2746 if self
.model
.staged
:
2747 do(Unstage
, self
.context
, self
.model
.staged
)
2749 if self
.cfg
.get('cola.safemode', False):
2750 unstaged
= self
.model
.modified
2752 unstaged
= self
.model
.modified
+ self
.model
.untracked
2753 do(Stage
, self
.context
, unstaged
)
2756 class StageOrUnstage(ContextCommand
):
2757 """If the selection is staged, unstage it, otherwise stage"""
2761 return N_('Stage / Unstage')
2764 s
= self
.selection
.selection()
2766 do(Unstage
, self
.context
, s
.staged
)
2769 unmerged
= check_conflicts(self
.context
, s
.unmerged
)
2771 unstaged
.extend(unmerged
)
2773 unstaged
.extend(s
.modified
)
2775 unstaged
.extend(s
.untracked
)
2777 do(Stage
, self
.context
, unstaged
)
2780 class Tag(ContextCommand
):
2781 """Create a tag object."""
2783 def __init__(self
, context
, name
, revision
, sign
=False, message
=''):
2784 super(Tag
, self
).__init
__(context
)
2786 self
._message
= message
2787 self
._revision
= revision
2793 revision
= self
._revision
2794 tag_name
= self
._name
2795 tag_message
= self
._message
2798 Interaction
.critical(
2799 N_('Missing Revision'), N_('Please specify a revision to tag.')
2804 Interaction
.critical(
2805 N_('Missing Name'), N_('Please specify a name for the new tag.')
2809 title
= N_('Missing Tag Message')
2810 message
= N_('Tag-signing was requested but the tag message is empty.')
2812 'An unsigned, lightweight tag will be created instead.\n'
2813 'Create an unsigned tag?'
2815 ok_text
= N_('Create Unsigned Tag')
2817 if sign
and not tag_message
:
2818 # We require a message in order to sign the tag, so if they
2819 # choose to create an unsigned tag we have to clear the sign flag.
2820 if not Interaction
.confirm(
2821 title
, message
, info
, ok_text
, default
=False, icon
=icons
.save()
2830 tmp_file
= utils
.tmp_filename('tag-message')
2831 opts
['file'] = tmp_file
2832 core
.write(tmp_file
, tag_message
)
2837 opts
['annotate'] = True
2838 status
, out
, err
= git
.tag(tag_name
, revision
, **opts
)
2841 core
.unlink(tmp_file
)
2843 title
= N_('Error: could not create tag "%s"') % tag_name
2844 Interaction
.command(title
, 'git tag', status
, out
, err
)
2848 self
.model
.update_status()
2849 Interaction
.information(
2851 N_('Created a new tag named "%s"') % tag_name
,
2852 details
=tag_message
or None,
2858 class Unstage(ContextCommand
):
2859 """Unstage a set of paths."""
2863 return N_('Unstage')
2865 def __init__(self
, context
, paths
):
2866 super(Unstage
, self
).__init
__(context
)
2871 context
= self
.context
2872 head
= self
.model
.head
2875 msg
= N_('Unstaging: %s') % (', '.join(paths
))
2876 Interaction
.log(msg
)
2878 return unstage_all(context
)
2879 status
, out
, err
= gitcmds
.unstage_paths(context
, paths
, head
=head
)
2880 Interaction
.command(N_('Error'), 'git reset', status
, out
, err
)
2881 self
.model
.update_file_status()
2882 return (status
, out
, err
)
2885 class UnstageAll(ContextCommand
):
2886 """Unstage all files; resets the index."""
2889 return unstage_all(self
.context
)
2892 def unstage_all(context
):
2893 """Unstage all files, even while amending"""
2894 model
= context
.model
2897 status
, out
, err
= git
.reset(head
, '--', '.')
2898 Interaction
.command(N_('Error'), 'git reset', status
, out
, err
)
2899 model
.update_file_status()
2900 return (status
, out
, err
)
2903 class StageSelected(ContextCommand
):
2904 """Stage selected files, or all files if no selection exists."""
2907 context
= self
.context
2908 paths
= self
.selection
.unstaged
2910 do(Stage
, context
, paths
)
2911 elif self
.cfg
.get('cola.safemode', False):
2912 do(StageModified
, context
)
2915 class UnstageSelected(Unstage
):
2916 """Unstage selected files."""
2918 def __init__(self
, context
):
2919 staged
= context
.selection
.staged
2920 super(UnstageSelected
, self
).__init
__(context
, staged
)
2923 class Untrack(ContextCommand
):
2924 """Unstage a set of paths."""
2926 def __init__(self
, context
, paths
):
2927 super(Untrack
, self
).__init
__(context
)
2931 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
2932 Interaction
.log(msg
)
2933 status
, out
, err
= self
.model
.untrack_paths(self
.paths
)
2934 Interaction
.log_status(status
, out
, err
)
2937 class UntrackedSummary(EditModel
):
2938 """List possible .gitignore rules as the diff text."""
2940 def __init__(self
, context
):
2941 super(UntrackedSummary
, self
).__init
__(context
)
2942 untracked
= self
.model
.untracked
2943 suffix
= 's' if untracked
else ''
2945 io
.write('# %s untracked file%s\n' % (len(untracked
), suffix
))
2947 io
.write('# possible .gitignore rule%s:\n' % suffix
)
2949 io
.write('/' + u
+ '\n')
2950 self
.new_diff_text
= io
.getvalue()
2951 self
.new_diff_type
= main
.Types
.TEXT
2952 self
.new_file_type
= main
.Types
.TEXT
2953 self
.new_mode
= self
.model
.mode_display
2956 class VisualizeAll(ContextCommand
):
2957 """Visualize all branches."""
2960 context
= self
.context
2961 browser
= utils
.shell_split(prefs
.history_browser(context
))
2962 launch_history_browser(browser
+ ['--all'])
2965 class VisualizeCurrent(ContextCommand
):
2966 """Visualize all branches."""
2969 context
= self
.context
2970 browser
= utils
.shell_split(prefs
.history_browser(context
))
2971 launch_history_browser(browser
+ [self
.model
.currentbranch
] + ['--'])
2974 class VisualizePaths(ContextCommand
):
2975 """Path-limited visualization."""
2977 def __init__(self
, context
, paths
):
2978 super(VisualizePaths
, self
).__init
__(context
)
2979 context
= self
.context
2980 browser
= utils
.shell_split(prefs
.history_browser(context
))
2982 self
.argv
= browser
+ ['--'] + list(paths
)
2987 launch_history_browser(self
.argv
)
2990 class VisualizeRevision(ContextCommand
):
2991 """Visualize a specific revision."""
2993 def __init__(self
, context
, revision
, paths
=None):
2994 super(VisualizeRevision
, self
).__init
__(context
)
2995 self
.revision
= revision
2999 context
= self
.context
3000 argv
= utils
.shell_split(prefs
.history_browser(context
))
3002 argv
.append(self
.revision
)
3005 argv
.extend(self
.paths
)
3006 launch_history_browser(argv
)
3009 class SubmoduleAdd(ConfirmAction
):
3010 """Add specified submodules"""
3012 def __init__(self
, context
, url
, path
, branch
, depth
, reference
):
3013 super(SubmoduleAdd
, self
).__init
__(context
)
3016 self
.branch
= branch
3018 self
.reference
= reference
3021 title
= N_('Add Submodule...')
3022 question
= N_('Add this submodule?')
3023 info
= N_('The submodule will be added using\n' '"%s"' % self
.command())
3024 ok_txt
= N_('Add Submodule')
3025 return Interaction
.confirm(title
, question
, info
, ok_txt
, icon
=icons
.ok())
3028 context
= self
.context
3029 args
= self
.get_args()
3030 return context
.git
.submodule('add', *args
)
3033 self
.model
.update_file_status()
3034 self
.model
.update_submodules_list()
3036 def error_message(self
):
3037 return N_('Error updating submodule %s' % self
.path
)
3040 cmd
= ['git', 'submodule', 'add']
3041 cmd
.extend(self
.get_args())
3042 return core
.list2cmdline(cmd
)
3047 args
.extend(['--branch', self
.branch
])
3049 args
.extend(['--reference', self
.reference
])
3051 args
.extend(['--depth', '%d' % self
.depth
])
3052 args
.extend(['--', self
.url
])
3054 args
.append(self
.path
)
3058 class SubmoduleUpdate(ConfirmAction
):
3059 """Update specified submodule"""
3061 def __init__(self
, context
, path
):
3062 super(SubmoduleUpdate
, self
).__init
__(context
)
3066 title
= N_('Update Submodule...')
3067 question
= N_('Update this submodule?')
3068 info
= N_('The submodule will be updated using\n' '"%s"' % self
.command())
3069 ok_txt
= N_('Update Submodule')
3070 return Interaction
.confirm(
3071 title
, question
, info
, ok_txt
, default
=False, icon
=icons
.pull()
3075 context
= self
.context
3076 args
= self
.get_args()
3077 return context
.git
.submodule(*args
)
3080 self
.model
.update_file_status()
3082 def error_message(self
):
3083 return N_('Error updating submodule %s' % self
.path
)
3086 cmd
= ['git', 'submodule']
3087 cmd
.extend(self
.get_args())
3088 return core
.list2cmdline(cmd
)
3092 if version
.check_git(self
.context
, 'submodule-update-recursive'):
3093 cmd
.append('--recursive')
3094 cmd
.extend(['--', self
.path
])
3098 class SubmodulesUpdate(ConfirmAction
):
3099 """Update all submodules"""
3102 title
= N_('Update submodules...')
3103 question
= N_('Update all submodules?')
3104 info
= N_('All submodules will be updated using\n' '"%s"' % self
.command())
3105 ok_txt
= N_('Update Submodules')
3106 return Interaction
.confirm(
3107 title
, question
, info
, ok_txt
, default
=False, icon
=icons
.pull()
3111 context
= self
.context
3112 args
= self
.get_args()
3113 return context
.git
.submodule(*args
)
3116 self
.model
.update_file_status()
3118 def error_message(self
):
3119 return N_('Error updating submodules')
3122 cmd
= ['git', 'submodule']
3123 cmd
.extend(self
.get_args())
3124 return core
.list2cmdline(cmd
)
3128 if version
.check_git(self
.context
, 'submodule-update-recursive'):
3129 cmd
.append('--recursive')
3133 def launch_history_browser(argv
):
3134 """Launch the configured history browser"""
3137 except OSError as e
:
3138 _
, details
= utils
.format_exception(e
)
3139 title
= N_('Error Launching History Browser')
3140 msg
= N_('Cannot exec "%s": please configure a history browser') % ' '.join(
3143 Interaction
.critical(title
, message
=msg
, details
=details
)
3146 def run(cls
, *args
, **opts
):
3148 Returns a callback that runs a command
3150 If the caller of run() provides args or opts then those are
3151 used instead of the ones provided by the invoker of the callback.
3155 def runner(*local_args
, **local_opts
):
3156 """Closure return by run() which runs the command"""
3158 do(cls
, *args
, **opts
)
3160 do(cls
, *local_args
, **local_opts
)
3165 def do(cls
, *args
, **opts
):
3166 """Run a command in-place"""
3168 cmd
= cls(*args
, **opts
)
3170 except Exception as e
: # pylint: disable=broad-except
3171 msg
, details
= utils
.format_exception(e
)
3172 if hasattr(cls
, '__name__'):
3173 msg
= '%s exception:\n%s' % (cls
.__name
__, msg
)
3174 Interaction
.critical(N_('Error'), message
=msg
, details
=details
)
3178 def difftool_run(context
):
3179 """Start a default difftool session"""
3180 selection
= context
.selection
3181 files
= selection
.group()
3184 s
= selection
.selection()
3185 head
= context
.model
.head
3186 difftool_launch_with_head(context
, files
, bool(s
.staged
), head
)
3189 def difftool_launch_with_head(context
, filenames
, staged
, head
):
3190 """Launch difftool against the provided head"""
3195 difftool_launch(context
, left
=left
, staged
=staged
, paths
=filenames
)
3198 def difftool_launch(
3205 left_take_magic
=False,
3206 left_take_parent
=False,
3208 """Launches 'git difftool' with given parameters
3210 :param left: first argument to difftool
3211 :param right: second argument to difftool_args
3212 :param paths: paths to diff
3213 :param staged: activate `git difftool --staged`
3214 :param dir_diff: activate `git difftool --dir-diff`
3215 :param left_take_magic: whether to append the magic ^! diff expression
3216 :param left_take_parent: whether to append the first-parent ~ for diffing
3220 difftool_args
= ['git', 'difftool', '--no-prompt']
3222 difftool_args
.append('--cached')
3224 difftool_args
.append('--dir-diff')
3227 if left_take_parent
or left_take_magic
:
3228 suffix
= '^!' if left_take_magic
else '~'
3229 # Check root commit (no parents and thus cannot execute '~')
3231 status
, out
, err
= git
.rev_list(left
, parents
=True, n
=1)
3232 Interaction
.log_status(status
, out
, err
)
3234 raise OSError('git rev-list command failed')
3236 if len(out
.split()) >= 2:
3237 # Commit has a parent, so we can take its child as requested
3240 # No parent, assume it's the root commit, so we have to diff
3241 # against the empty tree.
3242 left
= EMPTY_TREE_OID
3243 if not right
and left_take_magic
:
3245 difftool_args
.append(left
)
3248 difftool_args
.append(right
)
3251 difftool_args
.append('--')
3252 difftool_args
.extend(paths
)
3254 runtask
= context
.runtask
3256 Interaction
.async_command(N_('Difftool'), difftool_args
, runtask
)
3258 core
.fork(difftool_args
)