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
):
1771 title
= N_('Error running prepare-commitmsg hook')
1772 hook
= gitcmds
.prepare_commit_message_hook(self
.context
)
1774 if os
.path
.exists(hook
):
1775 filename
= self
.model
.save_commitmsg()
1776 status
, out
, err
= core
.run_command([hook
, filename
])
1779 result
= core
.read(filename
)
1781 result
= self
.old_commitmsg
1782 Interaction
.command_error(title
, hook
, status
, out
, err
)
1784 message
= N_('A hook must be provided at "%s"') % hook
1785 Interaction
.critical(title
, message
=message
)
1786 result
= self
.old_commitmsg
1791 msg
= self
.get_message()
1792 self
.model
.set_commitmsg(msg
)
1795 self
.model
.set_commitmsg(self
.old_commitmsg
)
1798 class LoadFixupMessage(LoadCommitMessageFromOID
):
1799 """Load a fixup message"""
1801 def __init__(self
, context
, oid
):
1802 super(LoadFixupMessage
, self
).__init
__(context
, oid
, prefix
='fixup! ')
1803 if self
.new_commitmsg
:
1804 self
.new_commitmsg
= self
.new_commitmsg
.splitlines()[0]
1807 class Merge(ContextCommand
):
1810 def __init__(self
, context
, revision
, no_commit
, squash
, no_ff
, sign
):
1811 super(Merge
, self
).__init
__(context
)
1812 self
.revision
= revision
1814 self
.no_commit
= no_commit
1815 self
.squash
= squash
1819 squash
= self
.squash
1820 revision
= self
.revision
1822 no_commit
= self
.no_commit
1825 status
, out
, err
= self
.git
.merge(
1826 revision
, gpg_sign
=sign
, no_ff
=no_ff
, no_commit
=no_commit
, squash
=squash
1828 self
.model
.update_status()
1829 title
= N_('Merge failed. Conflict resolution is required.')
1830 Interaction
.command(title
, 'git merge', status
, out
, err
)
1832 return status
, out
, err
1835 class OpenDefaultApp(ContextCommand
):
1836 """Open a file using the OS default."""
1840 return N_('Open Using Default Application')
1842 def __init__(self
, context
, filenames
):
1843 super(OpenDefaultApp
, self
).__init
__(context
)
1844 self
.filenames
= filenames
1847 if not self
.filenames
:
1849 utils
.launch_default_app(self
.filenames
)
1852 class OpenDir(OpenDefaultApp
):
1853 """Open directories using the OS default."""
1857 return N_('Open Directory')
1860 def _dirnames(self
):
1861 return self
.filenames
1864 dirnames
= self
._dirnames
1867 # An empty dirname defaults to CWD.
1868 dirs
= [(dirname
or core
.getcwd()) for dirname
in dirnames
]
1869 utils
.launch_default_app(dirs
)
1872 class OpenParentDir(OpenDir
):
1873 """Open parent directories using the OS default."""
1877 return N_('Open Parent Directory')
1880 def _dirnames(self
):
1881 dirnames
= list(set([os
.path
.dirname(x
) for x
in self
.filenames
]))
1885 class OpenWorktree(OpenDir
):
1886 """Open worktree directory using the OS default."""
1890 return N_('Open Worktree')
1892 # The _unused parameter is needed by worktree_dir_action() -> common.cmd_action().
1893 def __init__(self
, context
, _unused
=None):
1894 dirnames
= [context
.git
.worktree()]
1895 super(OpenWorktree
, self
).__init
__(context
, dirnames
)
1898 class OpenNewRepo(ContextCommand
):
1899 """Launches git-cola on a repo."""
1901 def __init__(self
, context
, repo_path
):
1902 super(OpenNewRepo
, self
).__init
__(context
)
1903 self
.repo_path
= repo_path
1906 self
.model
.set_directory(self
.repo_path
)
1907 core
.fork([sys
.executable
, sys
.argv
[0], '--repo', self
.repo_path
])
1910 class OpenRepo(EditModel
):
1911 def __init__(self
, context
, repo_path
):
1912 super(OpenRepo
, self
).__init
__(context
)
1913 self
.repo_path
= repo_path
1914 self
.new_mode
= self
.model
.mode_none
1915 self
.new_diff_text
= ''
1916 self
.new_diff_type
= main
.Types
.TEXT
1917 self
.new_file_type
= main
.Types
.TEXT
1918 self
.new_commitmsg
= ''
1919 self
.new_filename
= ''
1922 old_repo
= self
.git
.getcwd()
1923 if self
.model
.set_worktree(self
.repo_path
):
1924 self
.fsmonitor
.stop()
1925 self
.fsmonitor
.start()
1926 self
.model
.update_status(reset
=True)
1927 # Check if template should be loaded
1928 if self
.context
.cfg
.get(prefs
.AUTOTEMPLATE
):
1929 template_loader
= LoadCommitMessageFromTemplate(self
.context
)
1930 template_loader
.do()
1932 self
.model
.set_commitmsg(self
.new_commitmsg
)
1933 settings
= self
.context
.settings
1935 settings
.add_recent(self
.repo_path
, prefs
.maxrecent(self
.context
))
1937 super(OpenRepo
, self
).do()
1939 self
.model
.set_worktree(old_repo
)
1942 class OpenParentRepo(OpenRepo
):
1943 def __init__(self
, context
):
1945 if version
.check_git(context
, 'show-superproject-working-tree'):
1946 status
, out
, _
= context
.git
.rev_parse(show_superproject_working_tree
=True)
1950 path
= os
.path
.dirname(core
.getcwd())
1951 super(OpenParentRepo
, self
).__init
__(context
, path
)
1954 class Clone(ContextCommand
):
1955 """Clones a repository and optionally spawns a new cola session."""
1958 self
, context
, url
, new_directory
, submodules
=False, shallow
=False, spawn
=True
1960 super(Clone
, self
).__init
__(context
)
1962 self
.new_directory
= new_directory
1963 self
.submodules
= submodules
1964 self
.shallow
= shallow
1974 recurse_submodules
= self
.submodules
1975 shallow_submodules
= self
.submodules
and self
.shallow
1977 status
, out
, err
= self
.git
.clone(
1980 recurse_submodules
=recurse_submodules
,
1981 shallow_submodules
=shallow_submodules
,
1985 self
.status
= status
1988 if status
== 0 and self
.spawn
:
1989 executable
= sys
.executable
1990 core
.fork([executable
, sys
.argv
[0], '--repo', self
.new_directory
])
1994 class NewBareRepo(ContextCommand
):
1995 """Create a new shared bare repository"""
1997 def __init__(self
, context
, path
):
1998 super(NewBareRepo
, self
).__init
__(context
)
2003 status
, out
, err
= self
.git
.init(path
, bare
=True, shared
=True)
2004 Interaction
.command(
2005 N_('Error'), 'git init --bare --shared "%s"' % path
, status
, out
, err
2010 def unix_path(path
, is_win32
=utils
.is_win32
):
2011 """Git for Windows requires unix paths, so force them here"""
2013 path
= path
.replace('\\', '/')
2016 if second
== ':': # sanity check, this better be a Windows-style path
2017 path
= '/' + first
+ path
[2:]
2022 def sequence_editor():
2023 """Set GIT_SEQUENCE_EDITOR for running git-cola-sequence-editor"""
2024 xbase
= unix_path(resources
.command('git-cola-sequence-editor'))
2025 if utils
.is_win32():
2026 editor
= core
.list2cmdline([unix_path(sys
.executable
), xbase
])
2028 editor
= core
.list2cmdline([xbase
])
2032 class SequenceEditorEnvironment(object):
2033 """Set environment variables to enable git-cola-sequence-editor"""
2035 def __init__(self
, context
, **kwargs
):
2037 'GIT_EDITOR': prefs
.editor(context
),
2038 'GIT_SEQUENCE_EDITOR': sequence_editor(),
2039 'GIT_COLA_SEQ_EDITOR_CANCEL_ACTION': 'save',
2041 self
.env
.update(kwargs
)
2043 def __enter__(self
):
2044 for var
, value
in self
.env
.items():
2045 compat
.setenv(var
, value
)
2048 def __exit__(self
, exc_type
, exc_val
, exc_tb
):
2049 for var
in self
.env
:
2050 compat
.unsetenv(var
)
2053 class Rebase(ContextCommand
):
2054 def __init__(self
, context
, upstream
=None, branch
=None, **kwargs
):
2055 """Start an interactive rebase session
2057 :param upstream: upstream branch
2058 :param branch: optional branch to checkout
2059 :param kwargs: forwarded directly to `git.rebase()`
2062 super(Rebase
, self
).__init
__(context
)
2064 self
.upstream
= upstream
2065 self
.branch
= branch
2066 self
.kwargs
= kwargs
2068 def prepare_arguments(self
, upstream
):
2072 # Rebase actions must be the only option specified
2073 for action
in ('continue', 'abort', 'skip', 'edit_todo'):
2074 if self
.kwargs
.get(action
, False):
2075 kwargs
[action
] = self
.kwargs
[action
]
2078 kwargs
['interactive'] = True
2079 kwargs
['autosquash'] = self
.kwargs
.get('autosquash', True)
2080 kwargs
.update(self
.kwargs
)
2082 # Prompt to determine whether or not to use "git rebase --update-refs".
2083 has_update_refs
= version
.check_git(self
.context
, 'rebase-update-refs')
2084 if has_update_refs
and not kwargs
.get('update_refs', False):
2085 title
= N_('Update stacked branches when rebasing?')
2087 '"git rebase --update-refs" automatically force-updates any\n'
2088 'branches that point to commits that are being rebased.\n\n'
2089 'Any branches that are checked out in a worktree are not updated.\n\n'
2090 'Using this feature is helpful for "stacked" branch workflows.'
2092 info
= N_('Update stacked branches when rebasing?')
2093 ok_text
= N_('Update stacked branches')
2094 cancel_text
= N_('Do not update stacked branches')
2095 update_refs
= Interaction
.confirm(
2101 cancel_text
=cancel_text
,
2104 kwargs
['update_refs'] = True
2107 args
.append(upstream
)
2109 args
.append(self
.branch
)
2114 (status
, out
, err
) = (1, '', '')
2115 context
= self
.context
2119 if not cfg
.get('rebase.autostash', False):
2120 if model
.staged
or model
.unmerged
or model
.modified
:
2121 Interaction
.information(
2122 N_('Unable to rebase'),
2123 N_('You cannot rebase with uncommitted changes.'),
2125 return status
, out
, err
2127 upstream
= self
.upstream
or Interaction
.choose_ref(
2129 N_('Select New Upstream'),
2130 N_('Interactive Rebase'),
2131 default
='@{upstream}',
2134 return status
, out
, err
2136 self
.model
.is_rebasing
= True
2137 self
.model
.emit_updated()
2139 args
, kwargs
= self
.prepare_arguments(upstream
)
2140 upstream_title
= upstream
or '@{upstream}'
2141 with
SequenceEditorEnvironment(
2143 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Rebase onto %s') % upstream_title
,
2144 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Rebase'),
2146 # TODO this blocks the user interface window for the duration
2147 # of git-cola-sequence-editor. We would need to implement
2148 # signals for QProcess and continue running the main thread.
2149 # Alternatively, we can hide the main window while rebasing.
2150 # That doesn't require as much effort.
2151 status
, out
, err
= self
.git
.rebase(
2152 *args
, _no_win32_startupinfo
=True, **kwargs
2154 self
.model
.update_status()
2155 if err
.strip() != 'Nothing to do':
2156 title
= N_('Rebase stopped')
2157 Interaction
.command(title
, 'git rebase', status
, out
, err
)
2158 return status
, out
, err
2161 class RebaseEditTodo(ContextCommand
):
2163 (status
, out
, err
) = (1, '', '')
2164 with
SequenceEditorEnvironment(
2166 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Edit Rebase'),
2167 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Save'),
2169 status
, out
, err
= self
.git
.rebase(edit_todo
=True)
2170 Interaction
.log_status(status
, out
, err
)
2171 self
.model
.update_status()
2172 return status
, out
, err
2175 class RebaseContinue(ContextCommand
):
2177 (status
, out
, err
) = (1, '', '')
2178 with
SequenceEditorEnvironment(
2180 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Rebase'),
2181 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Rebase'),
2183 status
, out
, err
= self
.git
.rebase('--continue')
2184 Interaction
.log_status(status
, out
, err
)
2185 self
.model
.update_status()
2186 return status
, out
, err
2189 class RebaseSkip(ContextCommand
):
2191 (status
, out
, err
) = (1, '', '')
2192 with
SequenceEditorEnvironment(
2194 GIT_COLA_SEQ_EDITOR_TITLE
=N_('Rebase'),
2195 GIT_COLA_SEQ_EDITOR_ACTION
=N_('Rebase'),
2197 status
, out
, err
= self
.git
.rebase(skip
=True)
2198 Interaction
.log_status(status
, out
, err
)
2199 self
.model
.update_status()
2200 return status
, out
, err
2203 class RebaseAbort(ContextCommand
):
2205 status
, out
, err
= self
.git
.rebase(abort
=True)
2206 Interaction
.log_status(status
, out
, err
)
2207 self
.model
.update_status()
2210 class Rescan(ContextCommand
):
2211 """Rescan for changes"""
2214 self
.model
.update_status()
2217 class Refresh(ContextCommand
):
2218 """Update refs, refresh the index, and update config"""
2222 return N_('Refresh')
2225 self
.model
.update_status(update_index
=True)
2227 self
.fsmonitor
.refresh()
2230 class RefreshConfig(ContextCommand
):
2231 """Refresh the git config cache"""
2237 class RevertEditsCommand(ConfirmAction
):
2238 def __init__(self
, context
):
2239 super(RevertEditsCommand
, self
).__init
__(context
)
2240 self
.icon
= icons
.undo()
2242 def ok_to_run(self
):
2243 return self
.model
.is_undoable()
2245 def checkout_from_head(self
):
2248 def checkout_args(self
):
2250 s
= self
.selection
.selection()
2251 if self
.checkout_from_head():
2252 args
.append(self
.model
.head
)
2264 checkout_args
= self
.checkout_args()
2265 return self
.git
.checkout(*checkout_args
)
2268 self
.model
.set_diff_type(main
.Types
.TEXT
)
2269 self
.model
.update_file_status()
2272 class RevertUnstagedEdits(RevertEditsCommand
):
2275 return N_('Revert Unstaged Edits...')
2277 def checkout_from_head(self
):
2278 # Being in amend mode should not affect the behavior of this command.
2279 # The only sensible thing to do is to checkout from the index.
2283 title
= N_('Revert Unstaged Changes?')
2285 'This operation removes unstaged edits from selected files.\n'
2286 'These changes cannot be recovered.'
2288 info
= N_('Revert the unstaged changes?')
2289 ok_text
= N_('Revert Unstaged Changes')
2290 return Interaction
.confirm(
2291 title
, text
, info
, ok_text
, default
=True, icon
=self
.icon
2295 class RevertUncommittedEdits(RevertEditsCommand
):
2298 return N_('Revert Uncommitted Edits...')
2300 def checkout_from_head(self
):
2304 """Prompt for reverting changes"""
2305 title
= N_('Revert Uncommitted Changes?')
2307 'This operation removes uncommitted edits from selected files.\n'
2308 'These changes cannot be recovered.'
2310 info
= N_('Revert the uncommitted changes?')
2311 ok_text
= N_('Revert Uncommitted Changes')
2312 return Interaction
.confirm(
2313 title
, text
, info
, ok_text
, default
=True, icon
=self
.icon
2317 class RunConfigAction(ContextCommand
):
2318 """Run a user-configured action, typically from the "Tools" menu"""
2320 def __init__(self
, context
, action_name
):
2321 super(RunConfigAction
, self
).__init
__(context
)
2322 self
.action_name
= action_name
2325 """Run the user-configured action"""
2326 for env
in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
2328 compat
.unsetenv(env
)
2333 context
= self
.context
2335 opts
= cfg
.get_guitool_opts(self
.action_name
)
2336 cmd
= opts
.get('cmd')
2337 if 'title' not in opts
:
2340 if 'prompt' not in opts
or opts
.get('prompt') is True:
2341 prompt
= N_('Run "%s"?') % cmd
2342 opts
['prompt'] = prompt
2344 if opts
.get('needsfile'):
2345 filename
= self
.selection
.filename()
2347 Interaction
.information(
2348 N_('Please select a file'),
2349 N_('"%s" requires a selected file.') % cmd
,
2352 dirname
= utils
.dirname(filename
, current_dir
='.')
2353 compat
.setenv('FILENAME', filename
)
2354 compat
.setenv('DIRNAME', dirname
)
2356 if opts
.get('revprompt') or opts
.get('argprompt'):
2358 ok
= Interaction
.confirm_config_action(context
, cmd
, opts
)
2361 rev
= opts
.get('revision')
2362 args
= opts
.get('args')
2363 if opts
.get('revprompt') and not rev
:
2364 title
= N_('Invalid Revision')
2365 msg
= N_('The revision expression cannot be empty.')
2366 Interaction
.critical(title
, msg
)
2370 elif opts
.get('confirm'):
2371 title
= os
.path
.expandvars(opts
.get('title'))
2372 prompt
= os
.path
.expandvars(opts
.get('prompt'))
2373 if not Interaction
.question(title
, prompt
):
2376 compat
.setenv('REVISION', rev
)
2378 compat
.setenv('ARGS', args
)
2379 title
= os
.path
.expandvars(cmd
)
2380 Interaction
.log(N_('Running command: %s') % title
)
2381 cmd
= ['sh', '-c', cmd
]
2383 if opts
.get('background'):
2385 status
, out
, err
= (0, '', '')
2386 elif opts
.get('noconsole'):
2387 status
, out
, err
= core
.run_command(cmd
)
2389 status
, out
, err
= Interaction
.run_command(title
, cmd
)
2391 if not opts
.get('background') and not opts
.get('norescan'):
2392 self
.model
.update_status()
2395 Interaction
.command(title
, cmd
, status
, out
, err
)
2400 class SetDefaultRepo(ContextCommand
):
2401 """Set the default repository"""
2403 def __init__(self
, context
, repo
):
2404 super(SetDefaultRepo
, self
).__init
__(context
)
2408 self
.cfg
.set_user('cola.defaultrepo', self
.repo
)
2411 class SetDiffText(EditModel
):
2412 """Set the diff text"""
2416 def __init__(self
, context
, text
):
2417 super(SetDiffText
, self
).__init
__(context
)
2418 self
.new_diff_text
= text
2419 self
.new_diff_type
= main
.Types
.TEXT
2420 self
.new_file_type
= main
.Types
.TEXT
2423 class SetUpstreamBranch(ContextCommand
):
2424 """Set the upstream branch"""
2426 def __init__(self
, context
, branch
, remote
, remote_branch
):
2427 super(SetUpstreamBranch
, self
).__init
__(context
)
2428 self
.branch
= branch
2429 self
.remote
= remote
2430 self
.remote_branch
= remote_branch
2434 remote
= self
.remote
2435 branch
= self
.branch
2436 remote_branch
= self
.remote_branch
2437 cfg
.set_repo('branch.%s.remote' % branch
, remote
)
2438 cfg
.set_repo('branch.%s.merge' % branch
, 'refs/heads/' + remote_branch
)
2441 def format_hex(data
):
2442 """Translate binary data into a hex dump"""
2443 hexdigits
= '0123456789ABCDEF'
2446 byte_offset_to_int
= compat
.byte_offset_to_int_converter()
2447 while offset
< len(data
):
2448 result
+= '%04u |' % offset
2450 for i
in range(0, 16):
2451 if i
> 0 and i
% 4 == 0:
2453 if offset
< len(data
):
2454 v
= byte_offset_to_int(data
[offset
])
2455 result
+= ' ' + hexdigits
[v
>> 4] + hexdigits
[v
& 0xF]
2456 textpart
+= chr(v
) if 32 <= v
< 127 else '.'
2461 result
+= ' | ' + textpart
+ ' |\n'
2466 class ShowUntracked(EditModel
):
2467 """Show an untracked file."""
2469 def __init__(self
, context
, filename
):
2470 super(ShowUntracked
, self
).__init
__(context
)
2471 self
.new_filename
= filename
2472 if gitcmds
.is_binary(context
, filename
):
2473 self
.new_mode
= self
.model
.mode_untracked
2474 self
.new_diff_text
= self
.read(filename
)
2476 self
.new_mode
= self
.model
.mode_untracked_diff
2477 self
.new_diff_text
= gitcmds
.diff_helper(
2478 self
.context
, filename
=filename
, cached
=False, untracked
=True
2480 self
.new_diff_type
= main
.Types
.TEXT
2481 self
.new_file_type
= main
.Types
.TEXT
2483 def read(self
, filename
):
2484 """Read file contents"""
2486 size
= cfg
.get('cola.readsize', 2048)
2488 result
= core
.read(filename
, size
=size
, encoding
='bytes')
2489 except (IOError, OSError):
2492 truncated
= len(result
) == size
2494 encoding
= cfg
.file_encoding(filename
) or core
.ENCODING
2496 text_result
= core
.decode_maybe(result
, encoding
)
2497 except UnicodeError:
2498 text_result
= format_hex(result
)
2501 text_result
+= '...'
2505 class SignOff(ContextCommand
):
2506 """Append a signoff to the commit message"""
2512 return N_('Sign Off')
2514 def __init__(self
, context
):
2515 super(SignOff
, self
).__init
__(context
)
2516 self
.old_commitmsg
= self
.model
.commitmsg
2519 """Add a signoff to the commit message"""
2520 signoff
= self
.signoff()
2521 if signoff
in self
.model
.commitmsg
:
2523 msg
= self
.model
.commitmsg
.rstrip()
2524 self
.model
.set_commitmsg(msg
+ '\n' + signoff
)
2527 """Restore the commit message"""
2528 self
.model
.set_commitmsg(self
.old_commitmsg
)
2531 """Generate the signoff string"""
2533 import pwd
# pylint: disable=all
2535 user
= pwd
.getpwuid(os
.getuid()).pw_name
2537 user
= os
.getenv('USER', N_('unknown'))
2540 name
= cfg
.get('user.name', user
)
2541 email
= cfg
.get('user.email', '%s@%s' % (user
, core
.node()))
2542 return '\nSigned-off-by: %s <%s>' % (name
, email
)
2545 def check_conflicts(context
, unmerged
):
2546 """Check paths for conflicts
2548 Conflicting files can be filtered out one-by-one.
2551 if prefs
.check_conflicts(context
):
2552 unmerged
= [path
for path
in unmerged
if is_conflict_free(path
)]
2556 def is_conflict_free(path
):
2557 """Return True if `path` contains no conflict markers"""
2558 rgx
= re
.compile(r
'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2560 with core
.xopen(path
, 'rb') as f
:
2562 line
= core
.decode(line
, errors
='ignore')
2564 return should_stage_conflicts(path
)
2566 # We can't read this file ~ we may be staging a removal
2571 def should_stage_conflicts(path
):
2572 """Inform the user that a file contains merge conflicts
2574 Return `True` if we should stage the path nonetheless.
2577 title
= msg
= N_('Stage conflicts?')
2580 '%s appears to contain merge conflicts.\n\n'
2581 'You should probably skip this file.\n'
2586 ok_text
= N_('Stage conflicts')
2587 cancel_text
= N_('Skip')
2588 return Interaction
.confirm(
2589 title
, msg
, info
, ok_text
, default
=False, cancel_text
=cancel_text
2593 class Stage(ContextCommand
):
2594 """Stage a set of paths."""
2600 def __init__(self
, context
, paths
):
2601 super(Stage
, self
).__init
__(context
)
2605 msg
= N_('Staging: %s') % (', '.join(self
.paths
))
2606 Interaction
.log(msg
)
2607 return self
.stage_paths()
2609 def stage_paths(self
):
2610 """Stages add/removals to git."""
2611 context
= self
.context
2614 if self
.model
.cfg
.get('cola.safemode', False):
2616 return self
.stage_all()
2624 for path
in set(paths
):
2625 if core
.exists(path
) or core
.islink(path
):
2626 if path
.endswith('/'):
2627 path
= path
.rstrip('/')
2632 self
.model
.emit_about_to_update()
2634 # `git add -u` doesn't work on untracked files
2636 status
, out
, err
= gitcmds
.add(context
, add
)
2637 Interaction
.command(N_('Error'), 'git add', status
, out
, err
)
2639 # If a path doesn't exist then that means it should be removed
2640 # from the index. We use `git add -u` for that.
2642 status
, out
, err
= gitcmds
.add(context
, remove
, u
=True)
2643 Interaction
.command(N_('Error'), 'git add -u', status
, out
, err
)
2645 self
.model
.update_files(emit
=True)
2646 return status
, out
, err
2648 def stage_all(self
):
2649 """Stage all files"""
2650 status
, out
, err
= self
.git
.add(v
=True, u
=True)
2651 Interaction
.command(N_('Error'), 'git add -u', status
, out
, err
)
2652 self
.model
.update_file_status()
2653 return (status
, out
, err
)
2656 class StageCarefully(Stage
):
2657 """Only stage when the path list is non-empty
2659 We use "git add -u -- <pathspec>" to stage, and it stages everything by
2660 default when no pathspec is specified, so this class ensures that paths
2661 are specified before calling git.
2663 When no paths are specified, the command does nothing.
2667 def __init__(self
, context
):
2668 super(StageCarefully
, self
).__init
__(context
, None)
2671 def init_paths(self
):
2672 """Initialize path data"""
2675 def ok_to_run(self
):
2676 """Prevent catch-all "git add -u" from adding unmerged files"""
2677 return self
.paths
or not self
.model
.unmerged
2680 """Stage files when ok_to_run() return True"""
2681 if self
.ok_to_run():
2682 return super(StageCarefully
, self
).do()
2686 class StageModified(StageCarefully
):
2687 """Stage all modified files."""
2691 return N_('Stage Modified')
2693 def init_paths(self
):
2694 self
.paths
= self
.model
.modified
2697 class StageUnmerged(StageCarefully
):
2698 """Stage unmerged files."""
2702 return N_('Stage Unmerged')
2704 def init_paths(self
):
2705 self
.paths
= check_conflicts(self
.context
, self
.model
.unmerged
)
2708 class StageUntracked(StageCarefully
):
2709 """Stage all untracked files."""
2713 return N_('Stage Untracked')
2715 def init_paths(self
):
2716 self
.paths
= self
.model
.untracked
2719 class StageModifiedAndUntracked(StageCarefully
):
2720 """Stage all untracked files."""
2724 return N_('Stage Modified and Untracked')
2726 def init_paths(self
):
2727 self
.paths
= self
.model
.modified
+ self
.model
.untracked
2730 class StageOrUnstageAll(ContextCommand
):
2731 """If the selection is staged, unstage it, otherwise stage"""
2735 return N_('Stage / Unstage All')
2738 if self
.model
.staged
:
2739 do(Unstage
, self
.context
, self
.model
.staged
)
2741 if self
.cfg
.get('cola.safemode', False):
2742 unstaged
= self
.model
.modified
2744 unstaged
= self
.model
.modified
+ self
.model
.untracked
2745 do(Stage
, self
.context
, unstaged
)
2748 class StageOrUnstage(ContextCommand
):
2749 """If the selection is staged, unstage it, otherwise stage"""
2753 return N_('Stage / Unstage')
2756 s
= self
.selection
.selection()
2758 do(Unstage
, self
.context
, s
.staged
)
2761 unmerged
= check_conflicts(self
.context
, s
.unmerged
)
2763 unstaged
.extend(unmerged
)
2765 unstaged
.extend(s
.modified
)
2767 unstaged
.extend(s
.untracked
)
2769 do(Stage
, self
.context
, unstaged
)
2772 class Tag(ContextCommand
):
2773 """Create a tag object."""
2775 def __init__(self
, context
, name
, revision
, sign
=False, message
=''):
2776 super(Tag
, self
).__init
__(context
)
2778 self
._message
= message
2779 self
._revision
= revision
2785 revision
= self
._revision
2786 tag_name
= self
._name
2787 tag_message
= self
._message
2790 Interaction
.critical(
2791 N_('Missing Revision'), N_('Please specify a revision to tag.')
2796 Interaction
.critical(
2797 N_('Missing Name'), N_('Please specify a name for the new tag.')
2801 title
= N_('Missing Tag Message')
2802 message
= N_('Tag-signing was requested but the tag message is empty.')
2804 'An unsigned, lightweight tag will be created instead.\n'
2805 'Create an unsigned tag?'
2807 ok_text
= N_('Create Unsigned Tag')
2809 if sign
and not tag_message
:
2810 # We require a message in order to sign the tag, so if they
2811 # choose to create an unsigned tag we have to clear the sign flag.
2812 if not Interaction
.confirm(
2813 title
, message
, info
, ok_text
, default
=False, icon
=icons
.save()
2822 tmp_file
= utils
.tmp_filename('tag-message')
2823 opts
['file'] = tmp_file
2824 core
.write(tmp_file
, tag_message
)
2829 opts
['annotate'] = True
2830 status
, out
, err
= git
.tag(tag_name
, revision
, **opts
)
2833 core
.unlink(tmp_file
)
2835 title
= N_('Error: could not create tag "%s"') % tag_name
2836 Interaction
.command(title
, 'git tag', status
, out
, err
)
2840 self
.model
.update_status()
2841 Interaction
.information(
2843 N_('Created a new tag named "%s"') % tag_name
,
2844 details
=tag_message
or None,
2850 class Unstage(ContextCommand
):
2851 """Unstage a set of paths."""
2855 return N_('Unstage')
2857 def __init__(self
, context
, paths
):
2858 super(Unstage
, self
).__init
__(context
)
2863 context
= self
.context
2864 head
= self
.model
.head
2867 msg
= N_('Unstaging: %s') % (', '.join(paths
))
2868 Interaction
.log(msg
)
2870 return unstage_all(context
)
2871 status
, out
, err
= gitcmds
.unstage_paths(context
, paths
, head
=head
)
2872 Interaction
.command(N_('Error'), 'git reset', status
, out
, err
)
2873 self
.model
.update_file_status()
2874 return (status
, out
, err
)
2877 class UnstageAll(ContextCommand
):
2878 """Unstage all files; resets the index."""
2881 return unstage_all(self
.context
)
2884 def unstage_all(context
):
2885 """Unstage all files, even while amending"""
2886 model
= context
.model
2889 status
, out
, err
= git
.reset(head
, '--', '.')
2890 Interaction
.command(N_('Error'), 'git reset', status
, out
, err
)
2891 model
.update_file_status()
2892 return (status
, out
, err
)
2895 class StageSelected(ContextCommand
):
2896 """Stage selected files, or all files if no selection exists."""
2899 context
= self
.context
2900 paths
= self
.selection
.unstaged
2902 do(Stage
, context
, paths
)
2903 elif self
.cfg
.get('cola.safemode', False):
2904 do(StageModified
, context
)
2907 class UnstageSelected(Unstage
):
2908 """Unstage selected files."""
2910 def __init__(self
, context
):
2911 staged
= context
.selection
.staged
2912 super(UnstageSelected
, self
).__init
__(context
, staged
)
2915 class Untrack(ContextCommand
):
2916 """Unstage a set of paths."""
2918 def __init__(self
, context
, paths
):
2919 super(Untrack
, self
).__init
__(context
)
2923 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
2924 Interaction
.log(msg
)
2925 status
, out
, err
= self
.model
.untrack_paths(self
.paths
)
2926 Interaction
.log_status(status
, out
, err
)
2929 class UnmergedSummary(EditModel
):
2930 """List unmerged files in the diff text."""
2932 def __init__(self
, context
):
2933 super(UnmergedSummary
, self
).__init
__(context
)
2934 unmerged
= self
.model
.unmerged
2936 io
.write('# %s unmerged file(s)\n' % len(unmerged
))
2938 io
.write('\n'.join(unmerged
) + '\n')
2939 self
.new_diff_text
= io
.getvalue()
2940 self
.new_diff_type
= main
.Types
.TEXT
2941 self
.new_file_type
= main
.Types
.TEXT
2942 self
.new_mode
= self
.model
.mode_display
2945 class UntrackedSummary(EditModel
):
2946 """List possible .gitignore rules as the diff text."""
2948 def __init__(self
, context
):
2949 super(UntrackedSummary
, self
).__init
__(context
)
2950 untracked
= self
.model
.untracked
2952 io
.write('# %s untracked file(s)\n' % len(untracked
))
2954 io
.write('# Add these lines to ".gitignore" to ignore these files:\n')
2955 io
.write('\n'.join('/' + filename
for filename
in untracked
) + '\n')
2956 self
.new_diff_text
= io
.getvalue()
2957 self
.new_diff_type
= main
.Types
.TEXT
2958 self
.new_file_type
= main
.Types
.TEXT
2959 self
.new_mode
= self
.model
.mode_display
2962 class VisualizeAll(ContextCommand
):
2963 """Visualize all branches."""
2966 context
= self
.context
2967 browser
= utils
.shell_split(prefs
.history_browser(context
))
2968 launch_history_browser(browser
+ ['--all'])
2971 class VisualizeCurrent(ContextCommand
):
2972 """Visualize all branches."""
2975 context
= self
.context
2976 browser
= utils
.shell_split(prefs
.history_browser(context
))
2977 launch_history_browser(browser
+ [self
.model
.currentbranch
] + ['--'])
2980 class VisualizePaths(ContextCommand
):
2981 """Path-limited visualization."""
2983 def __init__(self
, context
, paths
):
2984 super(VisualizePaths
, self
).__init
__(context
)
2985 context
= self
.context
2986 browser
= utils
.shell_split(prefs
.history_browser(context
))
2988 self
.argv
= browser
+ ['--'] + list(paths
)
2993 launch_history_browser(self
.argv
)
2996 class VisualizeRevision(ContextCommand
):
2997 """Visualize a specific revision."""
2999 def __init__(self
, context
, revision
, paths
=None):
3000 super(VisualizeRevision
, self
).__init
__(context
)
3001 self
.revision
= revision
3005 context
= self
.context
3006 argv
= utils
.shell_split(prefs
.history_browser(context
))
3008 argv
.append(self
.revision
)
3011 argv
.extend(self
.paths
)
3012 launch_history_browser(argv
)
3015 class SubmoduleAdd(ConfirmAction
):
3016 """Add specified submodules"""
3018 def __init__(self
, context
, url
, path
, branch
, depth
, reference
):
3019 super(SubmoduleAdd
, self
).__init
__(context
)
3022 self
.branch
= branch
3024 self
.reference
= reference
3027 title
= N_('Add Submodule...')
3028 question
= N_('Add this submodule?')
3029 info
= N_('The submodule will be added using\n' '"%s"' % self
.command())
3030 ok_txt
= N_('Add Submodule')
3031 return Interaction
.confirm(title
, question
, info
, ok_txt
, icon
=icons
.ok())
3034 context
= self
.context
3035 args
= self
.get_args()
3036 return context
.git
.submodule('add', *args
)
3039 self
.model
.update_file_status()
3040 self
.model
.update_submodules_list()
3042 def error_message(self
):
3043 return N_('Error updating submodule %s' % self
.path
)
3046 cmd
= ['git', 'submodule', 'add']
3047 cmd
.extend(self
.get_args())
3048 return core
.list2cmdline(cmd
)
3053 args
.extend(['--branch', self
.branch
])
3055 args
.extend(['--reference', self
.reference
])
3057 args
.extend(['--depth', '%d' % self
.depth
])
3058 args
.extend(['--', self
.url
])
3060 args
.append(self
.path
)
3064 class SubmoduleUpdate(ConfirmAction
):
3065 """Update specified submodule"""
3067 def __init__(self
, context
, path
):
3068 super(SubmoduleUpdate
, self
).__init
__(context
)
3072 title
= N_('Update Submodule...')
3073 question
= N_('Update this submodule?')
3074 info
= N_('The submodule will be updated using\n' '"%s"' % self
.command())
3075 ok_txt
= N_('Update Submodule')
3076 return Interaction
.confirm(
3077 title
, question
, info
, ok_txt
, default
=False, icon
=icons
.pull()
3081 context
= self
.context
3082 args
= self
.get_args()
3083 return context
.git
.submodule(*args
)
3086 self
.model
.update_file_status()
3088 def error_message(self
):
3089 return N_('Error updating submodule %s' % self
.path
)
3092 cmd
= ['git', 'submodule']
3093 cmd
.extend(self
.get_args())
3094 return core
.list2cmdline(cmd
)
3098 if version
.check_git(self
.context
, 'submodule-update-recursive'):
3099 cmd
.append('--recursive')
3100 cmd
.extend(['--', self
.path
])
3104 class SubmodulesUpdate(ConfirmAction
):
3105 """Update all submodules"""
3108 title
= N_('Update submodules...')
3109 question
= N_('Update all submodules?')
3110 info
= N_('All submodules will be updated using\n' '"%s"' % self
.command())
3111 ok_txt
= N_('Update Submodules')
3112 return Interaction
.confirm(
3113 title
, question
, info
, ok_txt
, default
=False, icon
=icons
.pull()
3117 context
= self
.context
3118 args
= self
.get_args()
3119 return context
.git
.submodule(*args
)
3122 self
.model
.update_file_status()
3124 def error_message(self
):
3125 return N_('Error updating submodules')
3128 cmd
= ['git', 'submodule']
3129 cmd
.extend(self
.get_args())
3130 return core
.list2cmdline(cmd
)
3134 if version
.check_git(self
.context
, 'submodule-update-recursive'):
3135 cmd
.append('--recursive')
3139 def launch_history_browser(argv
):
3140 """Launch the configured history browser"""
3143 except OSError as e
:
3144 _
, details
= utils
.format_exception(e
)
3145 title
= N_('Error Launching History Browser')
3146 msg
= N_('Cannot exec "%s": please configure a history browser') % ' '.join(
3149 Interaction
.critical(title
, message
=msg
, details
=details
)
3152 def run(cls
, *args
, **opts
):
3154 Returns a callback that runs a command
3156 If the caller of run() provides args or opts then those are
3157 used instead of the ones provided by the invoker of the callback.
3161 def runner(*local_args
, **local_opts
):
3162 """Closure return by run() which runs the command"""
3164 do(cls
, *args
, **opts
)
3166 do(cls
, *local_args
, **local_opts
)
3171 def do(cls
, *args
, **opts
):
3172 """Run a command in-place"""
3174 cmd
= cls(*args
, **opts
)
3176 except Exception as e
: # pylint: disable=broad-except
3177 msg
, details
= utils
.format_exception(e
)
3178 if hasattr(cls
, '__name__'):
3179 msg
= '%s exception:\n%s' % (cls
.__name
__, msg
)
3180 Interaction
.critical(N_('Error'), message
=msg
, details
=details
)
3184 def difftool_run(context
):
3185 """Start a default difftool session"""
3186 selection
= context
.selection
3187 files
= selection
.group()
3190 s
= selection
.selection()
3191 head
= context
.model
.head
3192 difftool_launch_with_head(context
, files
, bool(s
.staged
), head
)
3195 def difftool_launch_with_head(context
, filenames
, staged
, head
):
3196 """Launch difftool against the provided head"""
3201 difftool_launch(context
, left
=left
, staged
=staged
, paths
=filenames
)
3204 def difftool_launch(
3211 left_take_magic
=False,
3212 left_take_parent
=False,
3214 """Launches 'git difftool' with given parameters
3216 :param left: first argument to difftool
3217 :param right: second argument to difftool_args
3218 :param paths: paths to diff
3219 :param staged: activate `git difftool --staged`
3220 :param dir_diff: activate `git difftool --dir-diff`
3221 :param left_take_magic: whether to append the magic ^! diff expression
3222 :param left_take_parent: whether to append the first-parent ~ for diffing
3226 difftool_args
= ['git', 'difftool', '--no-prompt']
3228 difftool_args
.append('--cached')
3230 difftool_args
.append('--dir-diff')
3233 if left_take_parent
or left_take_magic
:
3234 suffix
= '^!' if left_take_magic
else '~'
3235 # Check root commit (no parents and thus cannot execute '~')
3237 status
, out
, err
= git
.rev_list(left
, parents
=True, n
=1, _readonly
=True)
3238 Interaction
.log_status(status
, out
, err
)
3240 raise OSError('git rev-list command failed')
3242 if len(out
.split()) >= 2:
3243 # Commit has a parent, so we can take its child as requested
3246 # No parent, assume it's the root commit, so we have to diff
3247 # against the empty tree.
3248 left
= EMPTY_TREE_OID
3249 if not right
and left_take_magic
:
3251 difftool_args
.append(left
)
3254 difftool_args
.append(right
)
3257 difftool_args
.append('--')
3258 difftool_args
.extend(paths
)
3260 runtask
= context
.runtask
3262 Interaction
.async_command(N_('Difftool'), difftool_args
, runtask
)
3264 core
.fork(difftool_args
)