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"""
83 """Return True when the command is ok to run"""
87 """Prompt for confirmation"""
91 """Run the command and return (status, out, err)"""
95 """Callback run on success"""
99 """Command name, for error messages"""
102 def error_message(self
):
103 """Command error message"""
107 """Prompt for confirmation before running a command"""
110 ok
= self
.ok_to_run() and self
.confirm()
112 status
, out
, err
= self
.action()
115 title
= self
.error_message()
117 Interaction
.command(title
, cmd
, status
, out
, err
)
119 return ok
, status
, out
, err
122 class AbortApplyPatch(ConfirmAction
):
123 """Reset an in-progress "git am" patch application"""
126 title
= N_('Abort Applying Patch...')
127 question
= N_('Aborting applying the current patch?')
129 'Aborting a patch can cause uncommitted changes to be lost.\n'
130 'Recovering uncommitted changes is not possible.'
132 ok_txt
= N_('Abort Applying Patch')
133 return Interaction
.confirm(
134 title
, question
, info
, ok_txt
, default
=False, icon
=icons
.undo()
138 status
, out
, err
= gitcmds
.abort_apply_patch(self
.context
)
139 self
.model
.update_file_merge_status()
140 return status
, out
, err
143 self
.model
.set_commitmsg('')
145 def error_message(self
):
149 return 'git am --abort'
152 class AbortCherryPick(ConfirmAction
):
153 """Reset an in-progress cherry-pick"""
156 title
= N_('Abort Cherry-Pick...')
157 question
= N_('Aborting the current cherry-pick?')
159 'Aborting a cherry-pick can cause uncommitted changes to be lost.\n'
160 'Recovering uncommitted changes is not possible.'
162 ok_txt
= N_('Abort Cherry-Pick')
163 return Interaction
.confirm(
164 title
, question
, info
, ok_txt
, default
=False, icon
=icons
.undo()
168 status
, out
, err
= gitcmds
.abort_cherry_pick(self
.context
)
169 self
.model
.update_file_merge_status()
170 return status
, out
, err
173 self
.model
.set_commitmsg('')
175 def error_message(self
):
179 return 'git cherry-pick --abort'
182 class AbortMerge(ConfirmAction
):
183 """Reset an in-progress merge back to HEAD"""
186 title
= N_('Abort Merge...')
187 question
= N_('Aborting the current merge?')
189 'Aborting the current merge will cause '
190 '*ALL* uncommitted changes to be lost.\n'
191 'Recovering uncommitted changes is not possible.'
193 ok_txt
= N_('Abort Merge')
194 return Interaction
.confirm(
195 title
, question
, info
, ok_txt
, default
=False, icon
=icons
.undo()
199 status
, out
, err
= gitcmds
.abort_merge(self
.context
)
200 self
.model
.update_file_merge_status()
201 return status
, out
, err
204 self
.model
.set_commitmsg('')
206 def error_message(self
):
213 class AmendMode(EditModel
):
214 """Try to amend a commit."""
223 def __init__(self
, context
, amend
=True):
224 super(AmendMode
, self
).__init
__(context
)
226 self
.amending
= amend
227 self
.old_commitmsg
= self
.model
.commitmsg
228 self
.old_mode
= self
.model
.mode
231 self
.new_mode
= self
.model
.mode_amend
232 self
.new_commitmsg
= gitcmds
.prev_commitmsg(context
)
233 AmendMode
.LAST_MESSAGE
= self
.model
.commitmsg
235 # else, amend unchecked, regular commit
236 self
.new_mode
= self
.model
.mode_none
237 self
.new_diff_text
= ''
238 self
.new_commitmsg
= self
.model
.commitmsg
239 # If we're going back into new-commit-mode then search the
240 # undo stack for a previous amend-commit-mode and grab the
241 # commit message at that point in time.
242 if AmendMode
.LAST_MESSAGE
is not None:
243 self
.new_commitmsg
= AmendMode
.LAST_MESSAGE
244 AmendMode
.LAST_MESSAGE
= None
247 """Leave/enter amend mode."""
248 # Attempt to enter amend mode. Do not allow this when merging.
250 if self
.model
.is_merging
:
252 self
.model
.set_mode(self
.old_mode
)
253 Interaction
.information(
256 'You are in the middle of a merge.\n'
257 'Cannot amend while merging.'
262 super(AmendMode
, self
).do()
263 self
.model
.set_commitmsg(self
.new_commitmsg
)
264 self
.model
.update_file_status()
269 self
.model
.set_commitmsg(self
.old_commitmsg
)
270 super(AmendMode
, self
).undo()
271 self
.model
.update_file_status()
274 class AnnexAdd(ContextCommand
):
275 """Add to Git Annex"""
277 def __init__(self
, context
):
278 super(AnnexAdd
, self
).__init
__(context
)
279 self
.filename
= self
.selection
.filename()
282 status
, out
, err
= self
.git
.annex('add', self
.filename
)
283 Interaction
.command(N_('Error'), 'git annex add', status
, out
, err
)
284 self
.model
.update_status()
287 class AnnexInit(ContextCommand
):
288 """Initialize Git Annex"""
291 status
, out
, err
= self
.git
.annex('init')
292 Interaction
.command(N_('Error'), 'git annex init', status
, out
, err
)
293 self
.model
.cfg
.reset()
294 self
.model
.emit_updated()
297 class LFSTrack(ContextCommand
):
298 """Add a file to git lfs"""
300 def __init__(self
, context
):
301 super(LFSTrack
, self
).__init
__(context
)
302 self
.filename
= self
.selection
.filename()
303 self
.stage_cmd
= Stage(context
, [self
.filename
])
306 status
, out
, err
= self
.git
.lfs('track', self
.filename
)
307 Interaction
.command(N_('Error'), 'git lfs track', status
, out
, err
)
312 class LFSInstall(ContextCommand
):
313 """Initialize git lfs"""
316 status
, out
, err
= self
.git
.lfs('install')
317 Interaction
.command(N_('Error'), 'git lfs install', status
, out
, err
)
318 self
.model
.update_config(reset
=True, emit
=True)
321 class ApplyPatch(ContextCommand
):
322 """Apply the specfied patch to the worktree or index"""
331 super(ApplyPatch
, self
).__init
__(context
)
333 self
.encoding
= encoding
334 self
.apply_to_worktree
= apply_to_worktree
337 context
= self
.context
339 tmp_file
= utils
.tmp_filename('apply', suffix
='.patch')
341 core
.write(tmp_file
, self
.patch
.as_text(), encoding
=self
.encoding
)
342 if self
.apply_to_worktree
:
343 status
, out
, err
= gitcmds
.apply_diff_to_worktree(context
, tmp_file
)
345 status
, out
, err
= gitcmds
.apply_diff(context
, tmp_file
)
347 core
.unlink(tmp_file
)
349 Interaction
.log_status(status
, out
, err
)
350 self
.model
.update_file_status(update_index
=True)
353 class ApplyPatches(ContextCommand
):
354 """Apply patches using the "git am" command"""
356 def __init__(self
, context
, patches
):
357 super(ApplyPatches
, self
).__init
__(context
)
358 self
.patches
= patches
361 status
, output
, err
= self
.git
.am('-3', *self
.patches
)
362 out
= '# git am -3 %s\n\n%s' % (core
.list2cmdline(self
.patches
), output
)
363 Interaction
.command(N_('Patch failed to apply'), 'git am -3', status
, out
, err
)
365 self
.model
.update_file_status()
367 patch_basenames
= [os
.path
.basename(p
) for p
in self
.patches
]
368 if len(patch_basenames
) > 25:
369 patch_basenames
= patch_basenames
[:25]
370 patch_basenames
.append('...')
372 basenames
= '\n'.join(patch_basenames
)
374 Interaction
.information(
375 N_('Patch(es) Applied'),
376 (N_('%d patch(es) applied.') + '\n\n%s')
377 % (len(self
.patches
), basenames
),
381 class ApplyPatchesContinue(ContextCommand
):
382 """Run "git am --continue" to continue on the next patch in a "git am" session"""
385 status
, out
, err
= self
.git
.am('--continue')
387 N_('Failed to commit and continue applying patches'),
393 self
.model
.update_status()
394 return status
, out
, err
397 class ApplyPatchesSkip(ContextCommand
):
398 """Run "git am --skip" to continue on the next patch in a "git am" session"""
401 status
, out
, err
= self
.git
.am(skip
=True)
403 N_('Failed to continue applying patches after skipping the current patch'),
409 self
.model
.update_status()
410 return status
, out
, err
413 class Archive(ContextCommand
):
414 """ "Export archives using the "git archive" command"""
416 def __init__(self
, context
, ref
, fmt
, prefix
, filename
):
417 super(Archive
, self
).__init
__(context
)
421 self
.filename
= filename
424 fp
= core
.xopen(self
.filename
, 'wb')
425 cmd
= ['git', 'archive', '--format=' + self
.fmt
]
426 if self
.fmt
in ('tgz', 'tar.gz'):
429 cmd
.append('--prefix=' + self
.prefix
)
431 proc
= core
.start_command(cmd
, stdout
=fp
)
432 out
, err
= proc
.communicate()
434 status
= proc
.returncode
435 Interaction
.log_status(status
, out
or '', err
or '')
438 class Checkout(EditModel
):
439 """A command object for git-checkout.
441 'argv' is handed off directly to git.
445 def __init__(self
, context
, argv
, checkout_branch
=False):
446 super(Checkout
, self
).__init
__(context
)
448 self
.checkout_branch
= checkout_branch
449 self
.new_diff_text
= ''
450 self
.new_diff_type
= main
.Types
.TEXT
451 self
.new_file_type
= main
.Types
.TEXT
454 super(Checkout
, self
).do()
455 status
, out
, err
= self
.git
.checkout(*self
.argv
)
456 if self
.checkout_branch
:
457 self
.model
.update_status()
459 self
.model
.update_file_status()
460 Interaction
.command(N_('Error'), 'git checkout', status
, out
, err
)
461 return status
, out
, err
464 class CheckoutTheirs(ConfirmAction
):
465 """Checkout "their" version of a file when performing a merge"""
469 return N_('Checkout files from their branch (MERGE_HEAD)')
473 question
= N_('Checkout files from their branch?')
475 'This operation will replace the selected unmerged files with content '
476 'from the branch being merged using "git checkout --theirs".\n'
477 '*ALL* uncommitted changes will be lost.\n'
478 'Recovering uncommitted changes is not possible.'
480 ok_txt
= N_('Checkout Files')
481 return Interaction
.confirm(
482 title
, question
, info
, ok_txt
, default
=True, icon
=icons
.merge()
486 selection
= self
.selection
.selection()
487 paths
= selection
.unmerged
491 argv
= ['--theirs', '--'] + paths
492 cmd
= Checkout(self
.context
, argv
)
495 def error_message(self
):
499 return 'git checkout --theirs'
502 class CheckoutOurs(ConfirmAction
):
503 """Checkout "our" version of a file when performing a merge"""
507 return N_('Checkout files from our branch (HEAD)')
511 question
= N_('Checkout files from our branch?')
513 'This operation will replace the selected unmerged files with content '
514 'from your current branch using "git checkout --ours".\n'
515 '*ALL* uncommitted changes will be lost.\n'
516 'Recovering uncommitted changes is not possible.'
518 ok_txt
= N_('Checkout Files')
519 return Interaction
.confirm(
520 title
, question
, info
, ok_txt
, default
=True, icon
=icons
.merge()
524 selection
= self
.selection
.selection()
525 paths
= selection
.unmerged
529 argv
= ['--ours', '--'] + paths
530 cmd
= Checkout(self
.context
, argv
)
533 def error_message(self
):
537 return 'git checkout --ours'
540 class BlamePaths(ContextCommand
):
541 """Blame view for paths."""
545 return N_('Blame...')
547 def __init__(self
, context
, paths
=None):
548 super(BlamePaths
, self
).__init
__(context
)
550 paths
= context
.selection
.union()
551 viewer
= utils
.shell_split(prefs
.blame_viewer(context
))
552 self
.argv
= viewer
+ list(paths
)
558 _
, details
= utils
.format_exception(e
)
559 title
= N_('Error Launching Blame Viewer')
560 msg
= N_('Cannot exec "%s": please configure a blame viewer') % ' '.join(
563 Interaction
.critical(title
, message
=msg
, details
=details
)
566 class CheckoutBranch(Checkout
):
567 """Checkout a branch."""
569 def __init__(self
, context
, branch
):
571 super(CheckoutBranch
, self
).__init
__(context
, args
, checkout_branch
=True)
574 class CherryPick(ContextCommand
):
575 """Cherry pick commits into the current branch."""
577 def __init__(self
, context
, commits
):
578 super(CherryPick
, self
).__init
__(context
)
579 self
.commits
= commits
582 status
, out
, err
= gitcmds
.cherry_pick(self
.context
, self
.commits
)
583 self
.model
.update_file_merge_status()
584 title
= N_('Cherry-pick failed')
585 Interaction
.command(title
, 'git cherry-pick', status
, out
, err
)
588 class Revert(ContextCommand
):
589 """Cherry pick commits into the current branch."""
591 def __init__(self
, context
, oid
):
592 super(Revert
, self
).__init
__(context
)
596 status
, output
, err
= self
.git
.revert(self
.oid
, no_edit
=True)
597 self
.model
.update_file_status()
598 title
= N_('Revert failed')
599 out
= '# git revert %s\n\n' % self
.oid
600 Interaction
.command(title
, 'git revert', status
, out
, err
)
603 class ResetMode(EditModel
):
604 """Reset the mode and clear the model's diff text."""
606 def __init__(self
, context
):
607 super(ResetMode
, self
).__init
__(context
)
608 self
.new_mode
= self
.model
.mode_none
609 self
.new_diff_text
= ''
610 self
.new_diff_type
= main
.Types
.TEXT
611 self
.new_file_type
= main
.Types
.TEXT
612 self
.new_filename
= ''
615 super(ResetMode
, self
).do()
616 self
.model
.update_file_status()
619 class ResetCommand(ConfirmAction
):
620 """Reset state using the "git reset" command"""
622 def __init__(self
, context
, ref
):
623 super(ResetCommand
, self
).__init
__(context
)
632 def error_message(self
):
636 self
.model
.update_file_status()
639 raise NotImplementedError('confirm() must be overridden')
642 raise NotImplementedError('reset() must be overridden')
645 class ResetMixed(ResetCommand
):
648 tooltip
= N_('The branch will be reset using "git reset --mixed %s"')
652 title
= N_('Reset Branch and Stage (Mixed)')
653 question
= N_('Point the current branch head to a new commit?')
654 info
= self
.tooltip(self
.ref
)
655 ok_text
= N_('Reset Branch')
656 return Interaction
.confirm(title
, question
, info
, ok_text
)
659 return self
.git
.reset(self
.ref
, '--', mixed
=True)
662 class ResetKeep(ResetCommand
):
665 tooltip
= N_('The repository will be reset using "git reset --keep %s"')
669 title
= N_('Restore Worktree and Reset All (Keep Unstaged Changes)')
670 question
= N_('Restore worktree, reset, and preserve unstaged edits?')
671 info
= self
.tooltip(self
.ref
)
672 ok_text
= N_('Reset and Restore')
673 return Interaction
.confirm(title
, question
, info
, ok_text
)
676 return self
.git
.reset(self
.ref
, '--', keep
=True)
679 class ResetMerge(ResetCommand
):
682 tooltip
= N_('The repository will be reset using "git reset --merge %s"')
686 title
= N_('Restore Worktree and Reset All (Merge)')
687 question
= N_('Reset Worktree and Reset All?')
688 info
= self
.tooltip(self
.ref
)
689 ok_text
= N_('Reset and Restore')
690 return Interaction
.confirm(title
, question
, info
, ok_text
)
693 return self
.git
.reset(self
.ref
, '--', merge
=True)
696 class ResetSoft(ResetCommand
):
699 tooltip
= N_('The branch will be reset using "git reset --soft %s"')
703 title
= N_('Reset Branch (Soft)')
704 question
= N_('Reset branch?')
705 info
= self
.tooltip(self
.ref
)
706 ok_text
= N_('Reset Branch')
707 return Interaction
.confirm(title
, question
, info
, ok_text
)
710 return self
.git
.reset(self
.ref
, '--', soft
=True)
713 class ResetHard(ResetCommand
):
716 tooltip
= N_('The repository will be reset using "git reset --hard %s"')
720 title
= N_('Restore Worktree and Reset All (Hard)')
721 question
= N_('Restore Worktree and Reset All?')
722 info
= self
.tooltip(self
.ref
)
723 ok_text
= N_('Reset and Restore')
724 return Interaction
.confirm(title
, question
, info
, ok_text
)
727 return self
.git
.reset(self
.ref
, '--', hard
=True)
730 class RestoreWorktree(ConfirmAction
):
731 """Reset the worktree using the "git read-tree" command"""
736 'The worktree will be restored using "git read-tree --reset -u %s"'
740 def __init__(self
, context
, ref
):
741 super(RestoreWorktree
, self
).__init
__(context
)
745 return self
.git
.read_tree(self
.ref
, reset
=True, u
=True)
748 return 'git read-tree --reset -u %s' % self
.ref
750 def error_message(self
):
754 self
.model
.update_file_status()
757 title
= N_('Restore Worktree')
758 question
= N_('Restore Worktree to %s?') % self
.ref
759 info
= self
.tooltip(self
.ref
)
760 ok_text
= N_('Restore Worktree')
761 return Interaction
.confirm(title
, question
, info
, ok_text
)
764 class UndoLastCommit(ResetCommand
):
765 """Undo the last commit"""
767 # NOTE: this is the similar to ResetSoft() with an additional check for
768 # published commits and different messages.
769 def __init__(self
, context
):
770 super(UndoLastCommit
, self
).__init
__(context
, 'HEAD^')
773 check_published
= prefs
.check_published_commits(self
.context
)
774 if check_published
and self
.model
.is_commit_published():
775 return Interaction
.confirm(
776 N_('Rewrite Published Commit?'),
778 'This commit has already been published.\n'
779 'This operation will rewrite published history.\n'
780 'You probably don\'t want to do this.'
782 N_('Undo the published commit?'),
783 N_('Undo Last Commit'),
788 title
= N_('Undo Last Commit')
789 question
= N_('Undo last commit?')
790 info
= N_('The branch will be reset using "git reset --soft %s"')
791 ok_text
= N_('Undo Last Commit')
792 info_text
= info
% self
.ref
793 return Interaction
.confirm(title
, question
, info_text
, ok_text
)
796 return self
.git
.reset('HEAD^', '--', soft
=True)
799 class Commit(ResetMode
):
800 """Attempt to create a new commit."""
802 def __init__(self
, context
, amend
, msg
, sign
, no_verify
=False):
803 super(Commit
, self
).__init
__(context
)
807 self
.no_verify
= no_verify
808 self
.old_commitmsg
= self
.model
.commitmsg
809 self
.new_commitmsg
= ''
812 # Create the commit message file
813 context
= self
.context
814 comment_char
= prefs
.comment_char(context
)
815 msg
= self
.strip_comments(self
.msg
, comment_char
=comment_char
)
816 tmp_file
= utils
.tmp_filename('commit-message')
818 core
.write(tmp_file
, msg
)
820 status
, out
, err
= self
.git
.commit(
825 no_verify
=self
.no_verify
,
828 core
.unlink(tmp_file
)
830 super(Commit
, self
).do()
831 if context
.cfg
.get(prefs
.AUTOTEMPLATE
):
832 template_loader
= LoadCommitMessageFromTemplate(context
)
835 self
.model
.set_commitmsg(self
.new_commitmsg
)
837 title
= N_('Commit failed')
838 Interaction
.command(title
, 'git commit', status
, out
, err
)
840 return status
, out
, err
843 def strip_comments(msg
, comment_char
='#'):
846 line
for line
in msg
.split('\n') if not line
.startswith(comment_char
)
848 msg
= '\n'.join(message_lines
)
849 if not msg
.endswith('\n'):
855 class CycleReferenceSort(ContextCommand
):
856 """Choose the next reference sort type"""
859 self
.model
.cycle_ref_sort()
862 class Ignore(ContextCommand
):
863 """Add files to an exclusion file"""
865 def __init__(self
, context
, filenames
, local
=False):
866 super(Ignore
, self
).__init
__(context
)
867 self
.filenames
= list(filenames
)
871 if not self
.filenames
:
873 new_additions
= '\n'.join(self
.filenames
) + '\n'
874 for_status
= new_additions
876 filename
= os
.path
.join('.git', 'info', 'exclude')
878 filename
= '.gitignore'
879 if core
.exists(filename
):
880 current_list
= core
.read(filename
)
881 new_additions
= current_list
.rstrip() + '\n' + new_additions
882 core
.write(filename
, new_additions
)
883 Interaction
.log_status(0, 'Added to %s:\n%s' % (filename
, for_status
), '')
884 self
.model
.update_file_status()
887 def file_summary(files
):
888 txt
= core
.list2cmdline(files
)
890 txt
= txt
[:768].rstrip() + '...'
891 wrap
= textwrap
.TextWrapper()
892 return '\n'.join(wrap
.wrap(txt
))
895 class RemoteCommand(ConfirmAction
):
896 def __init__(self
, context
, remote
):
897 super(RemoteCommand
, self
).__init
__(context
)
902 self
.model
.update_remotes()
905 class RemoteAdd(RemoteCommand
):
906 def __init__(self
, context
, remote
, url
):
907 super(RemoteAdd
, self
).__init
__(context
, remote
)
911 return self
.git
.remote('add', self
.remote
, self
.url
)
913 def error_message(self
):
914 return N_('Error creating remote "%s"') % self
.remote
917 return 'git remote add "%s" "%s"' % (self
.remote
, self
.url
)
920 class RemoteRemove(RemoteCommand
):
922 title
= N_('Delete Remote')
923 question
= N_('Delete remote?')
924 info
= N_('Delete remote "%s"') % self
.remote
925 ok_text
= N_('Delete')
926 return Interaction
.confirm(title
, question
, info
, ok_text
)
929 return self
.git
.remote('rm', self
.remote
)
931 def error_message(self
):
932 return N_('Error deleting remote "%s"') % self
.remote
935 return 'git remote rm "%s"' % self
.remote
938 class RemoteRename(RemoteCommand
):
939 def __init__(self
, context
, remote
, new_name
):
940 super(RemoteRename
, self
).__init
__(context
, remote
)
941 self
.new_name
= new_name
944 title
= N_('Rename Remote')
945 text
= N_('Rename remote "%(current)s" to "%(new)s"?') % dict(
946 current
=self
.remote
, new
=self
.new_name
950 return Interaction
.confirm(title
, text
, info_text
, ok_text
)
953 return self
.git
.remote('rename', self
.remote
, self
.new_name
)
955 def error_message(self
):
956 return N_('Error renaming "%(name)s" to "%(new_name)s"') % dict(
957 name
=self
.remote
, new_name
=self
.new_name
961 return 'git remote rename "%s" "%s"' % (self
.remote
, self
.new_name
)
964 class RemoteSetURL(RemoteCommand
):
965 def __init__(self
, context
, remote
, url
):
966 super(RemoteSetURL
, self
).__init
__(context
, remote
)
970 return self
.git
.remote('set-url', self
.remote
, self
.url
)
972 def error_message(self
):
973 return N_('Unable to set URL for "%(name)s" to "%(url)s"') % dict(
974 name
=self
.remote
, url
=self
.url
978 return 'git remote set-url "%s" "%s"' % (self
.remote
, self
.url
)
981 class RemoteEdit(ContextCommand
):
982 """Combine RemoteRename and RemoteSetURL"""
984 def __init__(self
, context
, old_name
, remote
, url
):
985 super(RemoteEdit
, self
).__init
__(context
)
986 self
.rename
= RemoteRename(context
, old_name
, remote
)
987 self
.set_url
= RemoteSetURL(context
, remote
, url
)
990 result
= self
.rename
.do()
994 result
= self
.set_url
.do()
996 return name_ok
, url_ok
999 class RemoveFromSettings(ConfirmAction
):
1000 def __init__(self
, context
, repo
, entry
, icon
=None):
1001 super(RemoveFromSettings
, self
).__init
__(context
)
1002 self
.context
= context
1008 self
.context
.settings
.save()
1011 class RemoveBookmark(RemoveFromSettings
):
1014 title
= msg
= N_('Delete Bookmark?')
1015 info
= N_('%s will be removed from your bookmarks.') % entry
1016 ok_text
= N_('Delete Bookmark')
1017 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
1020 self
.context
.settings
.remove_bookmark(self
.repo
, self
.entry
)
1024 class RemoveRecent(RemoveFromSettings
):
1027 title
= msg
= N_('Remove %s from the recent list?') % repo
1028 info
= N_('%s will be removed from your recent repositories.') % repo
1029 ok_text
= N_('Remove')
1030 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
1033 self
.context
.settings
.remove_recent(self
.repo
)
1037 class RemoveFiles(ContextCommand
):
1040 def __init__(self
, context
, remover
, filenames
):
1041 super(RemoveFiles
, self
).__init
__(context
)
1044 self
.remover
= remover
1045 self
.filenames
= filenames
1046 # We could git-hash-object stuff and provide undo-ability
1047 # as an option. Heh.
1050 files
= self
.filenames
1056 remove
= self
.remover
1057 for filename
in files
:
1063 bad_filenames
.append(filename
)
1066 Interaction
.information(
1067 N_('Error'), N_('Deleting "%s" failed') % file_summary(bad_filenames
)
1071 self
.model
.update_file_status()
1074 class Delete(RemoveFiles
):
1077 def __init__(self
, context
, filenames
):
1078 super(Delete
, self
).__init
__(context
, os
.remove
, filenames
)
1081 files
= self
.filenames
1085 title
= N_('Delete Files?')
1086 msg
= N_('The following files will be deleted:') + '\n\n'
1087 msg
+= file_summary(files
)
1088 info_txt
= N_('Delete %d file(s)?') % len(files
)
1089 ok_txt
= N_('Delete Files')
1091 if Interaction
.confirm(
1092 title
, msg
, info_txt
, ok_txt
, default
=True, icon
=icons
.remove()
1094 super(Delete
, self
).do()
1097 class MoveToTrash(RemoveFiles
):
1098 """Move files to the trash using send2trash"""
1100 AVAILABLE
= send2trash
is not None
1102 def __init__(self
, context
, filenames
):
1103 super(MoveToTrash
, self
).__init
__(context
, send2trash
, filenames
)
1106 class DeleteBranch(ConfirmAction
):
1107 """Delete a git branch."""
1109 def __init__(self
, context
, branch
):
1110 super(DeleteBranch
, self
).__init
__(context
)
1111 self
.branch
= branch
1114 title
= N_('Delete Branch')
1115 question
= N_('Delete branch "%s"?') % self
.branch
1116 info
= N_('The branch will be no longer available.')
1117 ok_txt
= N_('Delete Branch')
1118 return Interaction
.confirm(
1119 title
, question
, info
, ok_txt
, default
=True, icon
=icons
.discard()
1123 return self
.model
.delete_branch(self
.branch
)
1125 def error_message(self
):
1126 return N_('Error deleting branch "%s"' % self
.branch
)
1129 command
= 'git branch -D %s'
1130 return command
% self
.branch
1133 class Rename(ContextCommand
):
1134 """Rename a set of paths."""
1136 def __init__(self
, context
, paths
):
1137 super(Rename
, self
).__init
__(context
)
1141 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
1142 Interaction
.log(msg
)
1144 for path
in self
.paths
:
1145 ok
= self
.rename(path
)
1149 self
.model
.update_status()
1151 def rename(self
, path
):
1153 title
= N_('Rename "%s"') % path
1155 if os
.path
.isdir(path
):
1156 base_path
= os
.path
.dirname(path
)
1159 new_path
= Interaction
.save_as(base_path
, title
)
1163 status
, out
, err
= git
.mv(path
, new_path
, force
=True, verbose
=True)
1164 Interaction
.command(N_('Error'), 'git mv', status
, out
, err
)
1168 class RenameBranch(ContextCommand
):
1169 """Rename a git branch."""
1171 def __init__(self
, context
, branch
, new_branch
):
1172 super(RenameBranch
, self
).__init
__(context
)
1173 self
.branch
= branch
1174 self
.new_branch
= new_branch
1177 branch
= self
.branch
1178 new_branch
= self
.new_branch
1179 status
, out
, err
= self
.model
.rename_branch(branch
, new_branch
)
1180 Interaction
.log_status(status
, out
, err
)
1183 class DeleteRemoteBranch(DeleteBranch
):
1184 """Delete a remote git branch."""
1186 def __init__(self
, context
, remote
, branch
):
1187 super(DeleteRemoteBranch
, self
).__init
__(context
, branch
)
1188 self
.remote
= remote
1191 return self
.git
.push(self
.remote
, self
.branch
, delete
=True)
1194 self
.model
.update_status()
1195 Interaction
.information(
1196 N_('Remote Branch Deleted'),
1197 N_('"%(branch)s" has been deleted from "%(remote)s".')
1198 % dict(branch
=self
.branch
, remote
=self
.remote
),
1201 def error_message(self
):
1202 return N_('Error Deleting Remote Branch')
1205 command
= 'git push --delete %s %s'
1206 return command
% (self
.remote
, self
.branch
)
1209 def get_mode(context
, filename
, staged
, modified
, unmerged
, untracked
):
1210 model
= context
.model
1212 mode
= model
.mode_index
1213 elif modified
or unmerged
:
1214 mode
= model
.mode_worktree
1216 if gitcmds
.is_binary(context
, filename
):
1217 mode
= model
.mode_untracked
1219 mode
= model
.mode_untracked_diff
1225 class DiffAgainstCommitMode(ContextCommand
):
1226 """Diff against arbitrary commits"""
1228 def __init__(self
, context
, oid
):
1229 super(DiffAgainstCommitMode
, self
).__init
__(context
)
1233 self
.model
.set_mode(self
.model
.mode_diff
, head
=self
.oid
)
1234 self
.model
.update_file_status()
1237 class DiffText(EditModel
):
1238 """Set the diff type to text"""
1240 def __init__(self
, context
):
1241 super(DiffText
, self
).__init
__(context
)
1242 self
.new_file_type
= main
.Types
.TEXT
1243 self
.new_diff_type
= main
.Types
.TEXT
1246 class ToggleDiffType(ContextCommand
):
1247 """Toggle the diff type between image and text"""
1249 def __init__(self
, context
):
1250 super(ToggleDiffType
, self
).__init
__(context
)
1251 if self
.model
.diff_type
== main
.Types
.IMAGE
:
1252 self
.new_diff_type
= main
.Types
.TEXT
1253 self
.new_value
= False
1255 self
.new_diff_type
= main
.Types
.IMAGE
1256 self
.new_value
= True
1259 diff_type
= self
.new_diff_type
1260 value
= self
.new_value
1262 self
.model
.set_diff_type(diff_type
)
1264 filename
= self
.model
.filename
1265 _
, ext
= os
.path
.splitext(filename
)
1266 if ext
.startswith('.'):
1267 cfg
= 'cola.imagediff' + ext
1268 self
.cfg
.set_repo(cfg
, value
)
1271 class DiffImage(EditModel
):
1273 self
, context
, filename
, deleted
, staged
, modified
, unmerged
, untracked
1275 super(DiffImage
, self
).__init
__(context
)
1277 self
.new_filename
= filename
1278 self
.new_diff_type
= self
.get_diff_type(filename
)
1279 self
.new_file_type
= main
.Types
.IMAGE
1280 self
.new_mode
= get_mode(
1281 context
, filename
, staged
, modified
, unmerged
, untracked
1283 self
.staged
= staged
1284 self
.modified
= modified
1285 self
.unmerged
= unmerged
1286 self
.untracked
= untracked
1287 self
.deleted
= deleted
1288 self
.annex
= self
.cfg
.is_annex()
1290 def get_diff_type(self
, filename
):
1291 """Query the diff type to use based on cola.imagediff.<extension>"""
1292 _
, ext
= os
.path
.splitext(filename
)
1293 if ext
.startswith('.'):
1294 # Check eg. "cola.imagediff.svg" to see if we should imagediff.
1295 cfg
= 'cola.imagediff' + ext
1296 if self
.cfg
.get(cfg
, True):
1297 result
= main
.Types
.IMAGE
1299 result
= main
.Types
.TEXT
1301 result
= main
.Types
.IMAGE
1305 filename
= self
.new_filename
1308 images
= self
.staged_images()
1310 images
= self
.modified_images()
1312 images
= self
.unmerged_images()
1313 elif self
.untracked
:
1314 images
= [(filename
, False)]
1318 self
.model
.set_images(images
)
1319 super(DiffImage
, self
).do()
1321 def staged_images(self
):
1322 context
= self
.context
1324 head
= self
.model
.head
1325 filename
= self
.new_filename
1329 index
= git
.diff_index(head
, '--', filename
, cached
=True)[STDOUT
]
1332 # :100644 100644 fabadb8... 4866510... M describe.c
1333 parts
= index
.split(' ')
1338 if old_oid
!= MISSING_BLOB_OID
:
1339 # First, check if we can get a pre-image from git-annex
1342 annex_image
= gitcmds
.annex_path(context
, head
, filename
)
1344 images
.append((annex_image
, False)) # git annex HEAD
1346 image
= gitcmds
.write_blob_path(context
, head
, old_oid
, filename
)
1348 images
.append((image
, True))
1350 if new_oid
!= MISSING_BLOB_OID
:
1351 found_in_annex
= False
1352 if annex
and core
.islink(filename
):
1353 status
, out
, _
= git
.annex('status', '--', filename
)
1355 details
= out
.split(' ')
1356 if details
and details
[0] == 'A': # newly added file
1357 images
.append((filename
, False))
1358 found_in_annex
= True
1360 if not found_in_annex
:
1361 image
= gitcmds
.write_blob(context
, new_oid
, filename
)
1363 images
.append((image
, True))
1367 def unmerged_images(self
):
1368 context
= self
.context
1370 head
= self
.model
.head
1371 filename
= self
.new_filename
1374 candidate_merge_heads
= ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1377 for merge_head
in candidate_merge_heads
1378 if core
.exists(git
.git_path(merge_head
))
1381 if annex
: # Attempt to find files in git-annex
1383 for merge_head
in merge_heads
:
1384 image
= gitcmds
.annex_path(context
, merge_head
, filename
)
1386 annex_images
.append((image
, False))
1388 annex_images
.append((filename
, False))
1391 # DIFF FORMAT FOR MERGES
1392 # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1393 # can take -c or --cc option to generate diff output also
1394 # for merge commits. The output differs from the format
1395 # described above in the following way:
1397 # 1. there is a colon for each parent
1398 # 2. there are more "src" modes and "src" sha1
1399 # 3. status is concatenated status characters for each parent
1400 # 4. no optional "score" number
1401 # 5. single path, only for "dst"
1403 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1406 index
= git
.diff_index(head
, '--', filename
, cached
=True, cc
=True)[STDOUT
]
1408 parts
= index
.split(' ')
1410 first_mode
= parts
[0]
1411 num_parents
= first_mode
.count(':')
1412 # colon for each parent, but for the index, the "parents"
1413 # are really entries in stages 1,2,3 (head, base, remote)
1414 # remote, base, head
1415 for i
in range(num_parents
):
1416 offset
= num_parents
+ i
+ 1
1419 merge_head
= merge_heads
[i
]
1422 if oid
!= MISSING_BLOB_OID
:
1423 image
= gitcmds
.write_blob_path(
1424 context
, merge_head
, oid
, filename
1427 images
.append((image
, True))
1429 images
.append((filename
, False))
1432 def modified_images(self
):
1433 context
= self
.context
1435 head
= self
.model
.head
1436 filename
= self
.new_filename
1441 if annex
: # Check for a pre-image from git-annex
1442 annex_image
= gitcmds
.annex_path(context
, head
, filename
)
1444 images
.append((annex_image
, False)) # git annex HEAD
1446 worktree
= git
.diff_files('--', filename
)[STDOUT
]
1447 parts
= worktree
.split(' ')
1450 if oid
!= MISSING_BLOB_OID
:
1451 image
= gitcmds
.write_blob_path(context
, head
, oid
, filename
)
1453 images
.append((image
, True)) # HEAD
1455 images
.append((filename
, False)) # worktree
1459 class Diff(EditModel
):
1460 """Perform a diff and set the model's current text."""
1462 def __init__(self
, context
, filename
, cached
=False, deleted
=False):
1463 super(Diff
, self
).__init
__(context
)
1465 if cached
and gitcmds
.is_valid_ref(context
, self
.model
.head
):
1466 opts
['ref'] = self
.model
.head
1467 self
.new_filename
= filename
1468 self
.new_mode
= self
.model
.mode_worktree
1469 self
.new_diff_text
= gitcmds
.diff_helper(
1470 self
.context
, filename
=filename
, cached
=cached
, deleted
=deleted
, **opts
1474 class Diffstat(EditModel
):
1475 """Perform a diffstat and set the model's diff text."""
1477 def __init__(self
, context
):
1478 super(Diffstat
, self
).__init
__(context
)
1480 diff_context
= cfg
.get('diff.context', 3)
1481 diff
= self
.git
.diff(
1483 unified
=diff_context
,
1489 self
.new_diff_text
= diff
1490 self
.new_diff_type
= main
.Types
.TEXT
1491 self
.new_file_type
= main
.Types
.TEXT
1492 self
.new_mode
= self
.model
.mode_diffstat
1495 class DiffStaged(Diff
):
1496 """Perform a staged diff on a file."""
1498 def __init__(self
, context
, filename
, deleted
=None):
1499 super(DiffStaged
, self
).__init
__(
1500 context
, filename
, cached
=True, deleted
=deleted
1502 self
.new_mode
= self
.model
.mode_index
1505 class DiffStagedSummary(EditModel
):
1506 def __init__(self
, context
):
1507 super(DiffStagedSummary
, self
).__init
__(context
)
1508 diff
= self
.git
.diff(
1513 patch_with_stat
=True,
1516 self
.new_diff_text
= diff
1517 self
.new_diff_type
= main
.Types
.TEXT
1518 self
.new_file_type
= main
.Types
.TEXT
1519 self
.new_mode
= self
.model
.mode_index
1522 class Difftool(ContextCommand
):
1523 """Run git-difftool limited by path."""
1525 def __init__(self
, context
, staged
, filenames
):
1526 super(Difftool
, self
).__init
__(context
)
1527 self
.staged
= staged
1528 self
.filenames
= filenames
1531 difftool_launch_with_head(
1532 self
.context
, self
.filenames
, self
.staged
, self
.model
.head
1536 class Edit(ContextCommand
):
1537 """Edit a file using the configured gui.editor."""
1541 return N_('Launch Editor')
1543 def __init__(self
, context
, filenames
, line_number
=None, background_editor
=False):
1544 super(Edit
, self
).__init
__(context
)
1545 self
.filenames
= filenames
1546 self
.line_number
= line_number
1547 self
.background_editor
= background_editor
1550 context
= self
.context
1551 if not self
.filenames
:
1553 filename
= self
.filenames
[0]
1554 if not core
.exists(filename
):
1556 if self
.background_editor
:
1557 editor
= prefs
.background_editor(context
)
1559 editor
= prefs
.editor(context
)
1562 if self
.line_number
is None:
1563 opts
= self
.filenames
1565 # Single-file w/ line-numbers (likely from grep)
1567 '*vim*': [filename
, '+%s' % self
.line_number
],
1568 '*emacs*': ['+%s' % self
.line_number
, filename
],
1569 '*textpad*': ['%s(%s,0)' % (filename
, self
.line_number
)],
1570 '*notepad++*': ['-n%s' % self
.line_number
, filename
],
1571 '*subl*': ['%s:%s' % (filename
, self
.line_number
)],
1574 opts
= self
.filenames
1575 for pattern
, opt
in editor_opts
.items():
1576 if fnmatch(editor
, pattern
):
1581 core
.fork(utils
.shell_split(editor
) + opts
)
1582 except (OSError, ValueError) as e
:
1583 message
= N_('Cannot exec "%s": please configure your editor') % editor
1584 _
, details
= utils
.format_exception(e
)
1585 Interaction
.critical(N_('Error Editing File'), message
, details
)
1588 class FormatPatch(ContextCommand
):
1589 """Output a patch series given all revisions and a selected subset."""
1591 def __init__(self
, context
, to_export
, revs
, output
='patches'):
1592 super(FormatPatch
, self
).__init
__(context
)
1593 self
.to_export
= list(to_export
)
1594 self
.revs
= list(revs
)
1595 self
.output
= output
1598 context
= self
.context
1599 status
, out
, err
= gitcmds
.format_patchsets(
1600 context
, self
.to_export
, self
.revs
, self
.output
1602 Interaction
.log_status(status
, out
, err
)
1605 class LaunchDifftool(ContextCommand
):
1608 return N_('Launch Diff Tool')
1611 s
= self
.selection
.selection()
1614 if utils
.is_win32():
1615 core
.fork(['git', 'mergetool', '--no-prompt', '--'] + paths
)
1618 cmd
= cfg
.terminal()
1619 argv
= utils
.shell_split(cmd
)
1621 terminal
= os
.path
.basename(argv
[0])
1622 shellquote_terms
= set(['xfce4-terminal'])
1623 shellquote_default
= terminal
in shellquote_terms
1625 mergetool
= ['git', 'mergetool', '--no-prompt', '--']
1626 mergetool
.extend(paths
)
1627 needs_shellquote
= cfg
.get(
1628 'cola.terminalshellquote', shellquote_default
1631 if needs_shellquote
:
1632 argv
.append(core
.list2cmdline(mergetool
))
1634 argv
.extend(mergetool
)
1638 difftool_run(self
.context
)
1641 class LaunchTerminal(ContextCommand
):
1644 return N_('Launch Terminal')
1647 def is_available(context
):
1648 return context
.cfg
.terminal() is not None
1650 def __init__(self
, context
, path
):
1651 super(LaunchTerminal
, self
).__init
__(context
)
1655 cmd
= self
.context
.cfg
.terminal()
1658 if utils
.is_win32():
1659 argv
= ['start', '', cmd
, '--login']
1662 argv
= utils
.shell_split(cmd
)
1664 shells
= ('zsh', 'fish', 'bash', 'sh')
1665 for basename
in shells
:
1666 executable
= core
.find_executable(basename
)
1668 command
= executable
1670 argv
.append(os
.getenv('SHELL', command
))
1673 core
.fork(argv
, cwd
=self
.path
, shell
=shell
)
1676 class LaunchEditor(Edit
):
1679 return N_('Launch Editor')
1681 def __init__(self
, context
):
1682 s
= context
.selection
.selection()
1683 filenames
= s
.staged
+ s
.unmerged
+ s
.modified
+ s
.untracked
1684 super(LaunchEditor
, self
).__init
__(context
, filenames
, background_editor
=True)
1687 class LaunchEditorAtLine(LaunchEditor
):
1688 """Launch an editor at the specified line"""
1690 def __init__(self
, context
):
1691 super(LaunchEditorAtLine
, self
).__init
__(context
)
1692 self
.line_number
= context
.selection
.line_number
1695 class LoadCommitMessageFromFile(ContextCommand
):
1696 """Loads a commit message from a path."""
1700 def __init__(self
, context
, path
):
1701 super(LoadCommitMessageFromFile
, self
).__init
__(context
)
1703 self
.old_commitmsg
= self
.model
.commitmsg
1704 self
.old_directory
= self
.model
.directory
1707 path
= os
.path
.expanduser(self
.path
)
1708 if not path
or not core
.isfile(path
):
1710 N_('Error: Cannot find commit template'),
1711 N_('%s: No such file or directory.') % path
,
1713 self
.model
.set_directory(os
.path
.dirname(path
))
1714 self
.model
.set_commitmsg(core
.read(path
))
1717 self
.model
.set_commitmsg(self
.old_commitmsg
)
1718 self
.model
.set_directory(self
.old_directory
)
1721 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile
):
1722 """Loads the commit message template specified by commit.template."""
1724 def __init__(self
, context
):
1726 template
= cfg
.get('commit.template')
1727 super(LoadCommitMessageFromTemplate
, self
).__init
__(context
, template
)
1730 if self
.path
is None:
1732 N_('Error: Unconfigured commit template'),
1734 'A commit template has not been configured.\n'
1735 'Use "git config" to define "commit.template"\n'
1736 'so that it points to a commit template.'
1739 return LoadCommitMessageFromFile
.do(self
)
1742 class LoadCommitMessageFromOID(ContextCommand
):
1743 """Load a previous commit message"""
1747 def __init__(self
, context
, oid
, prefix
=''):
1748 super(LoadCommitMessageFromOID
, self
).__init
__(context
)
1750 self
.old_commitmsg
= self
.model
.commitmsg
1751 self
.new_commitmsg
= prefix
+ gitcmds
.prev_commitmsg(context
, oid
)
1754 self
.model
.set_commitmsg(self
.new_commitmsg
)
1757 self
.model
.set_commitmsg(self
.old_commitmsg
)
1760 class PrepareCommitMessageHook(ContextCommand
):
1761 """Use the cola-prepare-commit-msg hook to prepare the commit message"""
1765 def __init__(self
, context
):
1766 super(PrepareCommitMessageHook
, self
).__init
__(context
)
1767 self
.old_commitmsg
= self
.model
.commitmsg
1769 def get_message(self
):
1770 title
= N_('Error running prepare-commitmsg hook')
1771 hook
= gitcmds
.prepare_commit_message_hook(self
.context
)
1773 if os
.path
.exists(hook
):
1774 filename
= self
.model
.save_commitmsg()
1775 status
, out
, err
= core
.run_command([hook
, filename
])
1778 result
= core
.read(filename
)
1780 result
= self
.old_commitmsg
1781 Interaction
.command_error(title
, hook
, status
, out
, err
)
1783 message
= N_('A hook must be provided at "%s"') % hook
1784 Interaction
.critical(title
, message
=message
)
1785 result
= self
.old_commitmsg
1790 msg
= self
.get_message()
1791 self
.model
.set_commitmsg(msg
)
1794 self
.model
.set_commitmsg(self
.old_commitmsg
)
1797 class LoadFixupMessage(LoadCommitMessageFromOID
):
1798 """Load a fixup message"""
1800 def __init__(self
, context
, oid
):
1801 super(LoadFixupMessage
, self
).__init
__(context
, oid
, prefix
='fixup! ')
1802 if self
.new_commitmsg
:
1803 self
.new_commitmsg
= self
.new_commitmsg
.splitlines()[0]
1806 class Merge(ContextCommand
):
1809 def __init__(self
, context
, revision
, no_commit
, squash
, no_ff
, sign
):
1810 super(Merge
, self
).__init
__(context
)
1811 self
.revision
= revision
1813 self
.no_commit
= no_commit
1814 self
.squash
= squash
1818 squash
= self
.squash
1819 revision
= self
.revision
1821 no_commit
= self
.no_commit
1824 status
, out
, err
= self
.git
.merge(
1825 revision
, gpg_sign
=sign
, no_ff
=no_ff
, no_commit
=no_commit
, squash
=squash
1827 self
.model
.update_status()
1828 title
= N_('Merge failed. Conflict resolution is required.')
1829 Interaction
.command(title
, 'git merge', status
, out
, err
)
1831 return status
, out
, err
1834 class OpenDefaultApp(ContextCommand
):
1835 """Open a file using the OS default."""
1839 return N_('Open Using Default Application')
1841 def __init__(self
, context
, filenames
):
1842 super(OpenDefaultApp
, self
).__init
__(context
)
1843 self
.filenames
= filenames
1846 if not self
.filenames
:
1848 utils
.launch_default_app(self
.filenames
)
1851 class OpenDir(OpenDefaultApp
):
1852 """Open directories using the OS default."""
1856 return N_('Open Directory')
1859 def _dirnames(self
):
1860 return self
.filenames
1863 dirnames
= self
._dirnames
1866 # An empty dirname defaults to CWD.
1867 dirs
= [(dirname
or core
.getcwd()) for dirname
in dirnames
]
1868 utils
.launch_default_app(dirs
)
1871 class OpenParentDir(OpenDir
):
1872 """Open parent directories using the OS default."""
1876 return N_('Open Parent Directory')
1879 def _dirnames(self
):
1880 dirnames
= list(set([os
.path
.dirname(x
) for x
in self
.filenames
]))
1884 class OpenWorktree(OpenDir
):
1885 """Open worktree directory using the OS default."""
1889 return N_('Open Worktree')
1891 # The _unused parameter is needed by worktree_dir_action() -> common.cmd_action().
1892 def __init__(self
, context
, _unused
=None):
1893 dirnames
= [context
.git
.worktree()]
1894 super(OpenWorktree
, self
).__init
__(context
, dirnames
)
1897 class OpenNewRepo(ContextCommand
):
1898 """Launches git-cola on a repo."""
1900 def __init__(self
, context
, repo_path
):
1901 super(OpenNewRepo
, self
).__init
__(context
)
1902 self
.repo_path
= repo_path
1905 self
.model
.set_directory(self
.repo_path
)
1906 core
.fork([sys
.executable
, sys
.argv
[0], '--repo', self
.repo_path
])
1909 class OpenRepo(EditModel
):
1910 def __init__(self
, context
, repo_path
):
1911 super(OpenRepo
, self
).__init
__(context
)
1912 self
.repo_path
= repo_path
1913 self
.new_mode
= self
.model
.mode_none
1914 self
.new_diff_text
= ''
1915 self
.new_diff_type
= main
.Types
.TEXT
1916 self
.new_file_type
= main
.Types
.TEXT
1917 self
.new_commitmsg
= ''
1918 self
.new_filename
= ''
1921 old_repo
= self
.git
.getcwd()
1922 if self
.model
.set_worktree(self
.repo_path
):
1923 self
.fsmonitor
.stop()
1924 self
.fsmonitor
.start()
1925 self
.model
.update_status(reset
=True)
1926 # Check if template should be loaded
1927 if self
.context
.cfg
.get(prefs
.AUTOTEMPLATE
):
1928 template_loader
= LoadCommitMessageFromTemplate(self
.context
)
1929 template_loader
.do()
1931 self
.model
.set_commitmsg(self
.new_commitmsg
)
1932 settings
= self
.context
.settings
1934 settings
.add_recent(self
.repo_path
, prefs
.maxrecent(self
.context
))
1936 super(OpenRepo
, self
).do()
1938 self
.model
.set_worktree(old_repo
)
1941 class OpenParentRepo(OpenRepo
):
1942 def __init__(self
, context
):
1944 if version
.check_git(context
, 'show-superproject-working-tree'):
1945 status
, out
, _
= context
.git
.rev_parse(show_superproject_working_tree
=True)
1949 path
= os
.path
.dirname(core
.getcwd())
1950 super(OpenParentRepo
, self
).__init
__(context
, path
)
1953 class Clone(ContextCommand
):
1954 """Clones a repository and optionally spawns a new cola session."""
1957 self
, context
, url
, new_directory
, submodules
=False, shallow
=False, spawn
=True
1959 super(Clone
, self
).__init
__(context
)
1961 self
.new_directory
= new_directory
1962 self
.submodules
= submodules
1963 self
.shallow
= shallow
1973 recurse_submodules
= self
.submodules
1974 shallow_submodules
= self
.submodules
and self
.shallow
1976 status
, out
, err
= self
.git
.clone(
1979 recurse_submodules
=recurse_submodules
,
1980 shallow_submodules
=shallow_submodules
,
1984 self
.status
= status
1987 if status
== 0 and self
.spawn
:
1988 executable
= sys
.executable
1989 core
.fork([executable
, sys
.argv
[0], '--repo', self
.new_directory
])
1993 class NewBareRepo(ContextCommand
):
1994 """Create a new shared bare repository"""
1996 def __init__(self
, context
, path
):
1997 super(NewBareRepo
, self
).__init
__(context
)
2002 status
, out
, err
= self
.git
.init(path
, bare
=True, shared
=True)
2003 Interaction
.command(
2004 N_('Error'), 'git init --bare --shared "%s"' % path
, status
, out
, err
2009 def unix_path(path
, is_win32
=utils
.is_win32
):
2010 """Git for Windows requires unix paths, so force them here"""
2012 path
= path
.replace('\\', '/')
2015 if second
== ':': # sanity check, this better be a Windows-style path
2016 path
= '/' + first
+ path
[2:]
2021 def sequence_editor():
2022 """Set GIT_SEQUENCE_EDITOR for running git-cola-sequence-editor"""
2023 xbase
= unix_path(resources
.command('git-cola-sequence-editor'))
2024 if utils
.is_win32():
2025 editor
= core
.list2cmdline([unix_path(sys
.executable
), xbase
])
2027 editor
= core
.list2cmdline([xbase
])
2031 class SequenceEditorEnvironment(object):
2032 """Set environment variables to enable git-cola-sequence-editor"""
2034 def __init__(self
, context
, **kwargs
):
2036 'GIT_EDITOR': prefs
.editor(context
),
2037 'GIT_SEQUENCE_EDITOR': sequence_editor(),
2038 'GIT_COLA_SEQ_EDITOR_CANCEL_ACTION': 'save',
2040 self
.env
.update(kwargs
)
2042 def __enter__(self
):
2043 for var
, value
in self
.env
.items():
2044 compat
.setenv(var
, value
)
2047 def __exit__(self
, exc_type
, exc_val
, exc_tb
):
2048 for var
in self
.env
:
2049 compat
.unsetenv(var
)
2052 class Rebase(ContextCommand
):
2053 def __init__(self
, context
, upstream
=None, branch
=None, **kwargs
):
2054 """Start an interactive rebase session
2056 :param upstream: upstream branch
2057 :param branch: optional branch to checkout
2058 :param kwargs: forwarded directly to `git.rebase()`
2061 super(Rebase
, self
).__init
__(context
)
2063 self
.upstream
= upstream
2064 self
.branch
= branch
2065 self
.kwargs
= kwargs
2067 def prepare_arguments(self
, upstream
):
2071 # Rebase actions must be the only option specified
2072 for action
in ('continue', 'abort', 'skip', 'edit_todo'):
2073 if self
.kwargs
.get(action
, False):
2074 kwargs
[action
] = self
.kwargs
[action
]
2077 kwargs
['interactive'] = True
2078 kwargs
['autosquash'] = self
.kwargs
.get('autosquash', True)
2079 kwargs
.update(self
.kwargs
)
2081 # Prompt to determine whether or not to use "git rebase --update-refs".
2082 has_update_refs
= version
.check_git(self
.context
, 'rebase-update-refs')
2083 if has_update_refs
and not kwargs
.get('update_refs', False):
2084 title
= N_('Update stacked branches when rebasing?')
2086 '"git rebase --update-refs" automatically force-updates any\n'
2087 'branches that point to commits that are being rebased.\n\n'
2088 'Any branches that are checked out in a worktree are not updated.\n\n'
2089 'Using this feature is helpful for "stacked" branch workflows.'
2091 info
= N_('Update stacked branches when rebasing?')
2092 ok_text
= N_('Update stacked branches')
2093 cancel_text
= N_('Do not update stacked branches')
2094 update_refs
= Interaction
.confirm(
2100 cancel_text
=cancel_text
,
2103 kwargs
['update_refs'] = True
2106 args
.append(upstream
)
2108 args
.append(self
.branch
)
2113 (status
, out
, err
) = (1, '', '')
2114 context
= self
.context
2118 if not cfg
.get('rebase.autostash', False):
2119 if model
.staged
or model
.unmerged
or model
.modified
:
2120 Interaction
.information(
2121 N_('Unable to rebase'),
2122 N_('You cannot rebase with uncommitted changes.'),
2124 return status
, out
, err
2126 upstream
= self
.upstream
or Interaction
.choose_ref(
2128 N_('Select New Upstream'),
2129 N_('Interactive Rebase'),
2130 default
='@{upstream}',
2133 return status
, out
, err
2135 self
.model
.is_rebasing
= True
2136 self
.model
.emit_updated()
2138 args
, kwargs
= self
.prepare_arguments(upstream
)
2139 upstream_title
= upstream
or '@{upstream}'
2140 with
SequenceEditorEnvironment(
2142 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Rebase onto %s') % upstream_title
,
2143 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Rebase'),
2145 # TODO this blocks the user interface window for the duration
2146 # of git-cola-sequence-editor. We would need to implement
2147 # signals for QProcess and continue running the main thread.
2148 # Alternatively, we can hide the main window while rebasing.
2149 # That doesn't require as much effort.
2150 status
, out
, err
= self
.git
.rebase(
2151 *args
, _no_win32_startupinfo
=True, **kwargs
2153 self
.model
.update_status()
2154 if err
.strip() != 'Nothing to do':
2155 title
= N_('Rebase stopped')
2156 Interaction
.command(title
, 'git rebase', status
, out
, err
)
2157 return status
, out
, err
2160 class RebaseEditTodo(ContextCommand
):
2162 (status
, out
, err
) = (1, '', '')
2163 with
SequenceEditorEnvironment(
2165 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Edit Rebase'),
2166 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Save'),
2168 status
, out
, err
= self
.git
.rebase(edit_todo
=True)
2169 Interaction
.log_status(status
, out
, err
)
2170 self
.model
.update_status()
2171 return status
, out
, err
2174 class RebaseContinue(ContextCommand
):
2176 (status
, out
, err
) = (1, '', '')
2177 with
SequenceEditorEnvironment(
2179 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Rebase'),
2180 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Rebase'),
2182 status
, out
, err
= self
.git
.rebase('--continue')
2183 Interaction
.log_status(status
, out
, err
)
2184 self
.model
.update_status()
2185 return status
, out
, err
2188 class RebaseSkip(ContextCommand
):
2190 (status
, out
, err
) = (1, '', '')
2191 with
SequenceEditorEnvironment(
2193 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Rebase'),
2194 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Rebase'),
2196 status
, out
, err
= self
.git
.rebase(skip
=True)
2197 Interaction
.log_status(status
, out
, err
)
2198 self
.model
.update_status()
2199 return status
, out
, err
2202 class RebaseAbort(ContextCommand
):
2204 status
, out
, err
= self
.git
.rebase(abort
=True)
2205 Interaction
.log_status(status
, out
, err
)
2206 self
.model
.update_status()
2209 class Rescan(ContextCommand
):
2210 """Rescan for changes"""
2213 self
.model
.update_status()
2216 class Refresh(ContextCommand
):
2217 """Update refs, refresh the index, and update config"""
2221 return N_('Refresh')
2224 self
.model
.update_status(update_index
=True)
2226 self
.fsmonitor
.refresh()
2229 class RefreshConfig(ContextCommand
):
2230 """Refresh the git config cache"""
2236 class RevertEditsCommand(ConfirmAction
):
2237 def __init__(self
, context
):
2238 super(RevertEditsCommand
, self
).__init
__(context
)
2239 self
.icon
= icons
.undo()
2241 def ok_to_run(self
):
2242 return self
.model
.is_undoable()
2244 def checkout_from_head(self
):
2247 def checkout_args(self
):
2249 s
= self
.selection
.selection()
2250 if self
.checkout_from_head():
2251 args
.append(self
.model
.head
)
2263 checkout_args
= self
.checkout_args()
2264 return self
.git
.checkout(*checkout_args
)
2267 self
.model
.set_diff_type(main
.Types
.TEXT
)
2268 self
.model
.update_file_status()
2271 class RevertUnstagedEdits(RevertEditsCommand
):
2274 return N_('Revert Unstaged Edits...')
2276 def checkout_from_head(self
):
2277 # Being in amend mode should not affect the behavior of this command.
2278 # The only sensible thing to do is to checkout from the index.
2282 title
= N_('Revert Unstaged Changes?')
2284 'This operation removes unstaged edits from selected files.\n'
2285 'These changes cannot be recovered.'
2287 info
= N_('Revert the unstaged changes?')
2288 ok_text
= N_('Revert Unstaged Changes')
2289 return Interaction
.confirm(
2290 title
, text
, info
, ok_text
, default
=True, icon
=self
.icon
2294 class RevertUncommittedEdits(RevertEditsCommand
):
2297 return N_('Revert Uncommitted Edits...')
2299 def checkout_from_head(self
):
2303 """Prompt for reverting changes"""
2304 title
= N_('Revert Uncommitted Changes?')
2306 'This operation removes uncommitted edits from selected files.\n'
2307 'These changes cannot be recovered.'
2309 info
= N_('Revert the uncommitted changes?')
2310 ok_text
= N_('Revert Uncommitted Changes')
2311 return Interaction
.confirm(
2312 title
, text
, info
, ok_text
, default
=True, icon
=self
.icon
2316 class RunConfigAction(ContextCommand
):
2317 """Run a user-configured action, typically from the "Tools" menu"""
2319 def __init__(self
, context
, action_name
):
2320 super(RunConfigAction
, self
).__init
__(context
)
2321 self
.action_name
= action_name
2324 """Run the user-configured action"""
2325 for env
in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
2327 compat
.unsetenv(env
)
2332 context
= self
.context
2334 opts
= cfg
.get_guitool_opts(self
.action_name
)
2335 cmd
= opts
.get('cmd')
2336 if 'title' not in opts
:
2339 if 'prompt' not in opts
or opts
.get('prompt') is True:
2340 prompt
= N_('Run "%s"?') % cmd
2341 opts
['prompt'] = prompt
2343 if opts
.get('needsfile'):
2344 filename
= self
.selection
.filename()
2346 Interaction
.information(
2347 N_('Please select a file'),
2348 N_('"%s" requires a selected file.') % cmd
,
2351 dirname
= utils
.dirname(filename
, current_dir
='.')
2352 compat
.setenv('FILENAME', filename
)
2353 compat
.setenv('DIRNAME', dirname
)
2355 if opts
.get('revprompt') or opts
.get('argprompt'):
2357 ok
= Interaction
.confirm_config_action(context
, cmd
, opts
)
2360 rev
= opts
.get('revision')
2361 args
= opts
.get('args')
2362 if opts
.get('revprompt') and not rev
:
2363 title
= N_('Invalid Revision')
2364 msg
= N_('The revision expression cannot be empty.')
2365 Interaction
.critical(title
, msg
)
2369 elif opts
.get('confirm'):
2370 title
= os
.path
.expandvars(opts
.get('title'))
2371 prompt
= os
.path
.expandvars(opts
.get('prompt'))
2372 if not Interaction
.question(title
, prompt
):
2375 compat
.setenv('REVISION', rev
)
2377 compat
.setenv('ARGS', args
)
2378 title
= os
.path
.expandvars(cmd
)
2379 Interaction
.log(N_('Running command: %s') % title
)
2380 cmd
= ['sh', '-c', cmd
]
2382 if opts
.get('background'):
2384 status
, out
, err
= (0, '', '')
2385 elif opts
.get('noconsole'):
2386 status
, out
, err
= core
.run_command(cmd
)
2388 status
, out
, err
= Interaction
.run_command(title
, cmd
)
2390 if not opts
.get('background') and not opts
.get('norescan'):
2391 self
.model
.update_status()
2394 Interaction
.command(title
, cmd
, status
, out
, err
)
2399 class SetDefaultRepo(ContextCommand
):
2400 """Set the default repository"""
2402 def __init__(self
, context
, repo
):
2403 super(SetDefaultRepo
, self
).__init
__(context
)
2407 self
.cfg
.set_user('cola.defaultrepo', self
.repo
)
2410 class SetDiffText(EditModel
):
2411 """Set the diff text"""
2415 def __init__(self
, context
, text
):
2416 super(SetDiffText
, self
).__init
__(context
)
2417 self
.new_diff_text
= text
2418 self
.new_diff_type
= main
.Types
.TEXT
2419 self
.new_file_type
= main
.Types
.TEXT
2422 class SetUpstreamBranch(ContextCommand
):
2423 """Set the upstream branch"""
2425 def __init__(self
, context
, branch
, remote
, remote_branch
):
2426 super(SetUpstreamBranch
, self
).__init
__(context
)
2427 self
.branch
= branch
2428 self
.remote
= remote
2429 self
.remote_branch
= remote_branch
2433 remote
= self
.remote
2434 branch
= self
.branch
2435 remote_branch
= self
.remote_branch
2436 cfg
.set_repo('branch.%s.remote' % branch
, remote
)
2437 cfg
.set_repo('branch.%s.merge' % branch
, 'refs/heads/' + remote_branch
)
2440 def format_hex(data
):
2441 """Translate binary data into a hex dump"""
2442 hexdigits
= '0123456789ABCDEF'
2445 byte_offset_to_int
= compat
.byte_offset_to_int_converter()
2446 while offset
< len(data
):
2447 result
+= '%04u |' % offset
2449 for i
in range(0, 16):
2450 if i
> 0 and i
% 4 == 0:
2452 if offset
< len(data
):
2453 v
= byte_offset_to_int(data
[offset
])
2454 result
+= ' ' + hexdigits
[v
>> 4] + hexdigits
[v
& 0xF]
2455 textpart
+= chr(v
) if 32 <= v
< 127 else '.'
2460 result
+= ' | ' + textpart
+ ' |\n'
2465 class ShowUntracked(EditModel
):
2466 """Show an untracked file."""
2468 def __init__(self
, context
, filename
):
2469 super(ShowUntracked
, self
).__init
__(context
)
2470 self
.new_filename
= filename
2471 if gitcmds
.is_binary(context
, filename
):
2472 self
.new_mode
= self
.model
.mode_untracked
2473 self
.new_diff_text
= self
.read(filename
)
2475 self
.new_mode
= self
.model
.mode_untracked_diff
2476 self
.new_diff_text
= gitcmds
.diff_helper(
2477 self
.context
, filename
=filename
, cached
=False, untracked
=True
2479 self
.new_diff_type
= main
.Types
.TEXT
2480 self
.new_file_type
= main
.Types
.TEXT
2482 def read(self
, filename
):
2483 """Read file contents"""
2485 size
= cfg
.get('cola.readsize', 2048)
2487 result
= core
.read(filename
, size
=size
, encoding
='bytes')
2488 except (IOError, OSError):
2491 truncated
= len(result
) == size
2493 encoding
= cfg
.file_encoding(filename
) or core
.ENCODING
2495 text_result
= core
.decode_maybe(result
, encoding
)
2496 except UnicodeError:
2497 text_result
= format_hex(result
)
2500 text_result
+= '...'
2504 class SignOff(ContextCommand
):
2505 """Append a signoff to the commit message"""
2511 return N_('Sign Off')
2513 def __init__(self
, context
):
2514 super(SignOff
, self
).__init
__(context
)
2515 self
.old_commitmsg
= self
.model
.commitmsg
2518 """Add a signoff to the commit message"""
2519 signoff
= self
.signoff()
2520 if signoff
in self
.model
.commitmsg
:
2522 msg
= self
.model
.commitmsg
.rstrip()
2523 self
.model
.set_commitmsg(msg
+ '\n' + signoff
)
2526 """Restore the commit message"""
2527 self
.model
.set_commitmsg(self
.old_commitmsg
)
2530 """Generate the signoff string"""
2532 import pwd
# pylint: disable=all
2534 user
= pwd
.getpwuid(os
.getuid()).pw_name
2536 user
= os
.getenv('USER', N_('unknown'))
2539 name
= cfg
.get('user.name', user
)
2540 email
= cfg
.get('user.email', '%s@%s' % (user
, core
.node()))
2541 return '\nSigned-off-by: %s <%s>' % (name
, email
)
2544 def check_conflicts(context
, unmerged
):
2545 """Check paths for conflicts
2547 Conflicting files can be filtered out one-by-one.
2550 if prefs
.check_conflicts(context
):
2551 unmerged
= [path
for path
in unmerged
if is_conflict_free(path
)]
2555 def is_conflict_free(path
):
2556 """Return True if `path` contains no conflict markers"""
2557 rgx
= re
.compile(r
'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2559 with core
.xopen(path
, 'rb') as f
:
2561 line
= core
.decode(line
, errors
='ignore')
2563 return should_stage_conflicts(path
)
2565 # We can't read this file ~ we may be staging a removal
2570 def should_stage_conflicts(path
):
2571 """Inform the user that a file contains merge conflicts
2573 Return `True` if we should stage the path nonetheless.
2576 title
= msg
= N_('Stage conflicts?')
2579 '%s appears to contain merge conflicts.\n\n'
2580 'You should probably skip this file.\n'
2585 ok_text
= N_('Stage conflicts')
2586 cancel_text
= N_('Skip')
2587 return Interaction
.confirm(
2588 title
, msg
, info
, ok_text
, default
=False, cancel_text
=cancel_text
2592 class Stage(ContextCommand
):
2593 """Stage a set of paths."""
2599 def __init__(self
, context
, paths
):
2600 super(Stage
, self
).__init
__(context
)
2604 msg
= N_('Staging: %s') % (', '.join(self
.paths
))
2605 Interaction
.log(msg
)
2606 return self
.stage_paths()
2608 def stage_paths(self
):
2609 """Stages add/removals to git."""
2610 context
= self
.context
2613 if self
.model
.cfg
.get('cola.safemode', False):
2615 return self
.stage_all()
2623 for path
in set(paths
):
2624 if core
.exists(path
) or core
.islink(path
):
2625 if path
.endswith('/'):
2626 path
= path
.rstrip('/')
2631 self
.model
.emit_about_to_update()
2633 # `git add -u` doesn't work on untracked files
2635 status
, out
, err
= gitcmds
.add(context
, add
)
2636 Interaction
.command(N_('Error'), 'git add', status
, out
, err
)
2638 # If a path doesn't exist then that means it should be removed
2639 # from the index. We use `git add -u` for that.
2641 status
, out
, err
= gitcmds
.add(context
, remove
, u
=True)
2642 Interaction
.command(N_('Error'), 'git add -u', status
, out
, err
)
2644 self
.model
.update_files(emit
=True)
2645 return status
, out
, err
2647 def stage_all(self
):
2648 """Stage all files"""
2649 status
, out
, err
= self
.git
.add(v
=True, u
=True)
2650 Interaction
.command(N_('Error'), 'git add -u', status
, out
, err
)
2651 self
.model
.update_file_status()
2652 return (status
, out
, err
)
2655 class StageCarefully(Stage
):
2656 """Only stage when the path list is non-empty
2658 We use "git add -u -- <pathspec>" to stage, and it stages everything by
2659 default when no pathspec is specified, so this class ensures that paths
2660 are specified before calling git.
2662 When no paths are specified, the command does nothing.
2666 def __init__(self
, context
):
2667 super(StageCarefully
, self
).__init
__(context
, None)
2670 def init_paths(self
):
2671 """Initialize path data"""
2674 def ok_to_run(self
):
2675 """Prevent catch-all "git add -u" from adding unmerged files"""
2676 return self
.paths
or not self
.model
.unmerged
2679 """Stage files when ok_to_run() return True"""
2680 if self
.ok_to_run():
2681 return super(StageCarefully
, self
).do()
2685 class StageModified(StageCarefully
):
2686 """Stage all modified files."""
2690 return N_('Stage Modified')
2692 def init_paths(self
):
2693 self
.paths
= self
.model
.modified
2696 class StageUnmerged(StageCarefully
):
2697 """Stage unmerged files."""
2701 return N_('Stage Unmerged')
2703 def init_paths(self
):
2704 self
.paths
= check_conflicts(self
.context
, self
.model
.unmerged
)
2707 class StageUntracked(StageCarefully
):
2708 """Stage all untracked files."""
2712 return N_('Stage Untracked')
2714 def init_paths(self
):
2715 self
.paths
= self
.model
.untracked
2718 class StageModifiedAndUntracked(StageCarefully
):
2719 """Stage all untracked files."""
2723 return N_('Stage Modified and Untracked')
2725 def init_paths(self
):
2726 self
.paths
= self
.model
.modified
+ self
.model
.untracked
2729 class StageOrUnstageAll(ContextCommand
):
2730 """If the selection is staged, unstage it, otherwise stage"""
2734 return N_('Stage / Unstage All')
2737 if self
.model
.staged
:
2738 do(Unstage
, self
.context
, self
.model
.staged
)
2740 if self
.cfg
.get('cola.safemode', False):
2741 unstaged
= self
.model
.modified
2743 unstaged
= self
.model
.modified
+ self
.model
.untracked
2744 do(Stage
, self
.context
, unstaged
)
2747 class StageOrUnstage(ContextCommand
):
2748 """If the selection is staged, unstage it, otherwise stage"""
2752 return N_('Stage / Unstage')
2755 s
= self
.selection
.selection()
2757 do(Unstage
, self
.context
, s
.staged
)
2760 unmerged
= check_conflicts(self
.context
, s
.unmerged
)
2762 unstaged
.extend(unmerged
)
2764 unstaged
.extend(s
.modified
)
2766 unstaged
.extend(s
.untracked
)
2768 do(Stage
, self
.context
, unstaged
)
2771 class Tag(ContextCommand
):
2772 """Create a tag object."""
2774 def __init__(self
, context
, name
, revision
, sign
=False, message
=''):
2775 super(Tag
, self
).__init
__(context
)
2777 self
._message
= message
2778 self
._revision
= revision
2784 revision
= self
._revision
2785 tag_name
= self
._name
2786 tag_message
= self
._message
2789 Interaction
.critical(
2790 N_('Missing Revision'), N_('Please specify a revision to tag.')
2795 Interaction
.critical(
2796 N_('Missing Name'), N_('Please specify a name for the new tag.')
2800 title
= N_('Missing Tag Message')
2801 message
= N_('Tag-signing was requested but the tag message is empty.')
2803 'An unsigned, lightweight tag will be created instead.\n'
2804 'Create an unsigned tag?'
2806 ok_text
= N_('Create Unsigned Tag')
2808 if sign
and not tag_message
:
2809 # We require a message in order to sign the tag, so if they
2810 # choose to create an unsigned tag we have to clear the sign flag.
2811 if not Interaction
.confirm(
2812 title
, message
, info
, ok_text
, default
=False, icon
=icons
.save()
2821 tmp_file
= utils
.tmp_filename('tag-message')
2822 opts
['file'] = tmp_file
2823 core
.write(tmp_file
, tag_message
)
2828 opts
['annotate'] = True
2829 status
, out
, err
= git
.tag(tag_name
, revision
, **opts
)
2832 core
.unlink(tmp_file
)
2834 title
= N_('Error: could not create tag "%s"') % tag_name
2835 Interaction
.command(title
, 'git tag', status
, out
, err
)
2839 self
.model
.update_status()
2840 Interaction
.information(
2842 N_('Created a new tag named "%s"') % tag_name
,
2843 details
=tag_message
or None,
2849 class Unstage(ContextCommand
):
2850 """Unstage a set of paths."""
2854 return N_('Unstage')
2856 def __init__(self
, context
, paths
):
2857 super(Unstage
, self
).__init
__(context
)
2862 context
= self
.context
2863 head
= self
.model
.head
2866 msg
= N_('Unstaging: %s') % (', '.join(paths
))
2867 Interaction
.log(msg
)
2869 return unstage_all(context
)
2870 status
, out
, err
= gitcmds
.unstage_paths(context
, paths
, head
=head
)
2871 Interaction
.command(N_('Error'), 'git reset', status
, out
, err
)
2872 self
.model
.update_file_status()
2873 return (status
, out
, err
)
2876 class UnstageAll(ContextCommand
):
2877 """Unstage all files; resets the index."""
2880 return unstage_all(self
.context
)
2883 def unstage_all(context
):
2884 """Unstage all files, even while amending"""
2885 model
= context
.model
2888 status
, out
, err
= git
.reset(head
, '--', '.')
2889 Interaction
.command(N_('Error'), 'git reset', status
, out
, err
)
2890 model
.update_file_status()
2891 return (status
, out
, err
)
2894 class StageSelected(ContextCommand
):
2895 """Stage selected files, or all files if no selection exists."""
2898 context
= self
.context
2899 paths
= self
.selection
.unstaged
2901 do(Stage
, context
, paths
)
2902 elif self
.cfg
.get('cola.safemode', False):
2903 do(StageModified
, context
)
2906 class UnstageSelected(Unstage
):
2907 """Unstage selected files."""
2909 def __init__(self
, context
):
2910 staged
= context
.selection
.staged
2911 super(UnstageSelected
, self
).__init
__(context
, staged
)
2914 class Untrack(ContextCommand
):
2915 """Unstage a set of paths."""
2917 def __init__(self
, context
, paths
):
2918 super(Untrack
, self
).__init
__(context
)
2922 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
2923 Interaction
.log(msg
)
2924 status
, out
, err
= self
.model
.untrack_paths(self
.paths
)
2925 Interaction
.log_status(status
, out
, err
)
2928 class UnmergedSummary(EditModel
):
2929 """List unmerged files in the diff text."""
2931 def __init__(self
, context
):
2932 super(UnmergedSummary
, self
).__init
__(context
)
2933 unmerged
= self
.model
.unmerged
2935 io
.write('# %s unmerged file(s)\n' % len(unmerged
))
2937 io
.write('\n'.join(unmerged
) + '\n')
2938 self
.new_diff_text
= io
.getvalue()
2939 self
.new_diff_type
= main
.Types
.TEXT
2940 self
.new_file_type
= main
.Types
.TEXT
2941 self
.new_mode
= self
.model
.mode_display
2944 class UntrackedSummary(EditModel
):
2945 """List possible .gitignore rules as the diff text."""
2947 def __init__(self
, context
):
2948 super(UntrackedSummary
, self
).__init
__(context
)
2949 untracked
= self
.model
.untracked
2951 io
.write('# %s untracked file(s)\n' % len(untracked
))
2953 io
.write('# Add these lines to ".gitignore" to ignore these files:\n')
2954 io
.write('\n'.join('/' + filename
for filename
in untracked
) + '\n')
2955 self
.new_diff_text
= io
.getvalue()
2956 self
.new_diff_type
= main
.Types
.TEXT
2957 self
.new_file_type
= main
.Types
.TEXT
2958 self
.new_mode
= self
.model
.mode_display
2961 class VisualizeAll(ContextCommand
):
2962 """Visualize all branches."""
2965 context
= self
.context
2966 browser
= utils
.shell_split(prefs
.history_browser(context
))
2967 launch_history_browser(browser
+ ['--all'])
2970 class VisualizeCurrent(ContextCommand
):
2971 """Visualize all branches."""
2974 context
= self
.context
2975 browser
= utils
.shell_split(prefs
.history_browser(context
))
2976 launch_history_browser(browser
+ [self
.model
.currentbranch
] + ['--'])
2979 class VisualizePaths(ContextCommand
):
2980 """Path-limited visualization."""
2982 def __init__(self
, context
, paths
):
2983 super(VisualizePaths
, self
).__init
__(context
)
2984 context
= self
.context
2985 browser
= utils
.shell_split(prefs
.history_browser(context
))
2987 self
.argv
= browser
+ ['--'] + list(paths
)
2992 launch_history_browser(self
.argv
)
2995 class VisualizeRevision(ContextCommand
):
2996 """Visualize a specific revision."""
2998 def __init__(self
, context
, revision
, paths
=None):
2999 super(VisualizeRevision
, self
).__init
__(context
)
3000 self
.revision
= revision
3004 context
= self
.context
3005 argv
= utils
.shell_split(prefs
.history_browser(context
))
3007 argv
.append(self
.revision
)
3010 argv
.extend(self
.paths
)
3011 launch_history_browser(argv
)
3014 class SubmoduleAdd(ConfirmAction
):
3015 """Add specified submodules"""
3017 def __init__(self
, context
, url
, path
, branch
, depth
, reference
):
3018 super(SubmoduleAdd
, self
).__init
__(context
)
3021 self
.branch
= branch
3023 self
.reference
= reference
3026 title
= N_('Add Submodule...')
3027 question
= N_('Add this submodule?')
3028 info
= N_('The submodule will be added using\n' '"%s"' % self
.command())
3029 ok_txt
= N_('Add Submodule')
3030 return Interaction
.confirm(title
, question
, info
, ok_txt
, icon
=icons
.ok())
3033 context
= self
.context
3034 args
= self
.get_args()
3035 return context
.git
.submodule('add', *args
)
3038 self
.model
.update_file_status()
3039 self
.model
.update_submodules_list()
3041 def error_message(self
):
3042 return N_('Error updating submodule %s' % self
.path
)
3045 cmd
= ['git', 'submodule', 'add']
3046 cmd
.extend(self
.get_args())
3047 return core
.list2cmdline(cmd
)
3052 args
.extend(['--branch', self
.branch
])
3054 args
.extend(['--reference', self
.reference
])
3056 args
.extend(['--depth', '%d' % self
.depth
])
3057 args
.extend(['--', self
.url
])
3059 args
.append(self
.path
)
3063 class SubmoduleUpdate(ConfirmAction
):
3064 """Update specified submodule"""
3066 def __init__(self
, context
, path
):
3067 super(SubmoduleUpdate
, self
).__init
__(context
)
3071 title
= N_('Update Submodule...')
3072 question
= N_('Update this submodule?')
3073 info
= N_('The submodule will be updated using\n' '"%s"' % self
.command())
3074 ok_txt
= N_('Update Submodule')
3075 return Interaction
.confirm(
3076 title
, question
, info
, ok_txt
, default
=False, icon
=icons
.pull()
3080 context
= self
.context
3081 args
= self
.get_args()
3082 return context
.git
.submodule(*args
)
3085 self
.model
.update_file_status()
3087 def error_message(self
):
3088 return N_('Error updating submodule %s' % self
.path
)
3091 cmd
= ['git', 'submodule']
3092 cmd
.extend(self
.get_args())
3093 return core
.list2cmdline(cmd
)
3097 if version
.check_git(self
.context
, 'submodule-update-recursive'):
3098 cmd
.append('--recursive')
3099 cmd
.extend(['--', self
.path
])
3103 class SubmodulesUpdate(ConfirmAction
):
3104 """Update all submodules"""
3107 title
= N_('Update submodules...')
3108 question
= N_('Update all submodules?')
3109 info
= N_('All submodules will be updated using\n' '"%s"' % self
.command())
3110 ok_txt
= N_('Update Submodules')
3111 return Interaction
.confirm(
3112 title
, question
, info
, ok_txt
, default
=False, icon
=icons
.pull()
3116 context
= self
.context
3117 args
= self
.get_args()
3118 return context
.git
.submodule(*args
)
3121 self
.model
.update_file_status()
3123 def error_message(self
):
3124 return N_('Error updating submodules')
3127 cmd
= ['git', 'submodule']
3128 cmd
.extend(self
.get_args())
3129 return core
.list2cmdline(cmd
)
3133 if version
.check_git(self
.context
, 'submodule-update-recursive'):
3134 cmd
.append('--recursive')
3138 def launch_history_browser(argv
):
3139 """Launch the configured history browser"""
3142 except OSError as e
:
3143 _
, details
= utils
.format_exception(e
)
3144 title
= N_('Error Launching History Browser')
3145 msg
= N_('Cannot exec "%s": please configure a history browser') % ' '.join(
3148 Interaction
.critical(title
, message
=msg
, details
=details
)
3151 def run(cls
, *args
, **opts
):
3153 Returns a callback that runs a command
3155 If the caller of run() provides args or opts then those are
3156 used instead of the ones provided by the invoker of the callback.
3160 def runner(*local_args
, **local_opts
):
3161 """Closure return by run() which runs the command"""
3163 do(cls
, *args
, **opts
)
3165 do(cls
, *local_args
, **local_opts
)
3170 def do(cls
, *args
, **opts
):
3171 """Run a command in-place"""
3173 cmd
= cls(*args
, **opts
)
3175 except Exception as e
: # pylint: disable=broad-except
3176 msg
, details
= utils
.format_exception(e
)
3177 if hasattr(cls
, '__name__'):
3178 msg
= '%s exception:\n%s' % (cls
.__name
__, msg
)
3179 Interaction
.critical(N_('Error'), message
=msg
, details
=details
)
3183 def difftool_run(context
):
3184 """Start a default difftool session"""
3185 selection
= context
.selection
3186 files
= selection
.group()
3189 s
= selection
.selection()
3190 head
= context
.model
.head
3191 difftool_launch_with_head(context
, files
, bool(s
.staged
), head
)
3194 def difftool_launch_with_head(context
, filenames
, staged
, head
):
3195 """Launch difftool against the provided head"""
3200 difftool_launch(context
, left
=left
, staged
=staged
, paths
=filenames
)
3203 def difftool_launch(
3210 left_take_magic
=False,
3211 left_take_parent
=False,
3213 """Launches 'git difftool' with given parameters
3215 :param left: first argument to difftool
3216 :param right: second argument to difftool_args
3217 :param paths: paths to diff
3218 :param staged: activate `git difftool --staged`
3219 :param dir_diff: activate `git difftool --dir-diff`
3220 :param left_take_magic: whether to append the magic ^! diff expression
3221 :param left_take_parent: whether to append the first-parent ~ for diffing
3225 difftool_args
= ['git', 'difftool', '--no-prompt']
3227 difftool_args
.append('--cached')
3229 difftool_args
.append('--dir-diff')
3232 if left_take_parent
or left_take_magic
:
3233 suffix
= '^!' if left_take_magic
else '~'
3234 # Check root commit (no parents and thus cannot execute '~')
3236 status
, out
, err
= git
.rev_list(left
, parents
=True, n
=1, _readonly
=True)
3237 Interaction
.log_status(status
, out
, err
)
3239 raise OSError('git rev-list command failed')
3241 if len(out
.split()) >= 2:
3242 # Commit has a parent, so we can take its child as requested
3245 # No parent, assume it's the root commit, so we have to diff
3246 # against the empty tree.
3247 left
= EMPTY_TREE_OID
3248 if not right
and left_take_magic
:
3250 difftool_args
.append(left
)
3253 difftool_args
.append(right
)
3256 difftool_args
.append('--')
3257 difftool_args
.extend(paths
)
3259 runtask
= context
.runtask
3261 Interaction
.async_command(N_('Difftool'), difftool_args
, runtask
)
3263 core
.fork(difftool_args
)