2 from __future__
import division
, absolute_import
, 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 .diffparse
import DiffParser
24 from .git
import STDOUT
25 from .git
import EMPTY_TREE_OID
26 from .git
import MISSING_BLOB_OID
28 from .interaction
import Interaction
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"""
45 def __init__(self
, context
):
46 """Common edit operations on the main model"""
47 super(EditModel
, self
).__init
__(context
)
49 self
.old_diff_text
= self
.model
.diff_text
50 self
.old_filename
= self
.model
.filename
51 self
.old_mode
= self
.model
.mode
52 self
.old_diff_type
= self
.model
.diff_type
54 self
.new_diff_text
= self
.old_diff_text
55 self
.new_filename
= self
.old_filename
56 self
.new_mode
= self
.old_mode
57 self
.new_diff_type
= self
.old_diff_type
60 """Perform the operation."""
61 self
.model
.set_filename(self
.new_filename
)
62 self
.model
.set_mode(self
.new_mode
)
63 self
.model
.set_diff_text(self
.new_diff_text
)
64 self
.model
.set_diff_type(self
.new_diff_type
)
67 """Undo the operation."""
68 self
.model
.set_filename(self
.old_filename
)
69 self
.model
.set_mode(self
.old_mode
)
70 self
.model
.set_diff_text(self
.old_diff_text
)
71 self
.model
.set_diff_type(self
.old_diff_type
)
74 class ConfirmAction(ContextCommand
):
75 """Confirm an action before running it"""
77 # pylint: disable=no-self-use
79 """Return True when the command is ok to run"""
82 # pylint: disable=no-self-use
84 """Prompt for confirmation"""
87 # pylint: disable=no-self-use
89 """Run the command and return (status, out, err)"""
92 # pylint: disable=no-self-use
94 """Callback run on success"""
97 # pylint: disable=no-self-use
99 """Command name, for error messages"""
102 # pylint: disable=no-self-use
103 def error_message(self
):
104 """Command error message"""
108 """Prompt for confirmation before running a command"""
111 ok
= self
.ok_to_run() and self
.confirm()
113 status
, out
, err
= self
.action()
116 title
= self
.error_message()
118 Interaction
.command(title
, cmd
, status
, out
, err
)
120 return ok
, status
, out
, err
123 class AbortMerge(ConfirmAction
):
124 """Reset an in-progress merge back to HEAD"""
127 title
= N_('Abort Merge...')
128 question
= N_('Aborting the current merge?')
129 info
= N_('Aborting the current merge will cause '
130 '*ALL* uncommitted changes to be lost.\n'
131 'Recovering uncommitted changes is not possible.')
132 ok_txt
= N_('Abort Merge')
133 return Interaction
.confirm(title
, question
, info
, ok_txt
,
134 default
=False, icon
=icons
.undo())
137 status
, out
, err
= gitcmds
.abort_merge(self
.context
)
138 self
.model
.update_file_status()
139 return status
, out
, err
142 self
.model
.set_commitmsg('')
144 def error_message(self
):
151 class AmendMode(EditModel
):
152 """Try to amend a commit."""
160 def __init__(self
, context
, amend
=True):
161 super(AmendMode
, self
).__init
__(context
)
163 self
.amending
= amend
164 self
.old_commitmsg
= self
.model
.commitmsg
165 self
.old_mode
= self
.model
.mode
168 self
.new_mode
= self
.model
.mode_amend
169 self
.new_commitmsg
= gitcmds
.prev_commitmsg(context
)
170 AmendMode
.LAST_MESSAGE
= self
.model
.commitmsg
172 # else, amend unchecked, regular commit
173 self
.new_mode
= self
.model
.mode_none
174 self
.new_diff_text
= ''
175 self
.new_commitmsg
= self
.model
.commitmsg
176 # If we're going back into new-commit-mode then search the
177 # undo stack for a previous amend-commit-mode and grab the
178 # commit message at that point in time.
179 if AmendMode
.LAST_MESSAGE
is not None:
180 self
.new_commitmsg
= AmendMode
.LAST_MESSAGE
181 AmendMode
.LAST_MESSAGE
= None
184 """Leave/enter amend mode."""
185 # Attempt to enter amend mode. Do not allow this when merging.
187 if self
.model
.is_merging
:
189 self
.model
.set_mode(self
.old_mode
)
190 Interaction
.information(
192 N_('You are in the middle of a merge.\n'
193 'Cannot amend while merging.'))
196 super(AmendMode
, self
).do()
197 self
.model
.set_commitmsg(self
.new_commitmsg
)
198 self
.model
.update_file_status()
203 self
.model
.set_commitmsg(self
.old_commitmsg
)
204 super(AmendMode
, self
).undo()
205 self
.model
.update_file_status()
208 class AnnexAdd(ContextCommand
):
209 """Add to Git Annex"""
211 def __init__(self
, context
):
212 super(AnnexAdd
, self
).__init
__(context
)
213 self
.filename
= self
.selection
.filename()
216 status
, out
, err
= self
.git
.annex('add', self
.filename
)
217 Interaction
.command(N_('Error'), 'git annex add', status
, out
, err
)
218 self
.model
.update_status()
221 class AnnexInit(ContextCommand
):
222 """Initialize Git Annex"""
225 status
, out
, err
= self
.git
.annex('init')
226 Interaction
.command(N_('Error'), 'git annex init', status
, out
, err
)
227 self
.model
.cfg
.reset()
228 self
.model
.emit_updated()
231 class LFSTrack(ContextCommand
):
232 """Add a file to git lfs"""
234 def __init__(self
, context
):
235 super(LFSTrack
, self
).__init
__(context
)
236 self
.filename
= self
.selection
.filename()
237 self
.stage_cmd
= Stage(context
, [self
.filename
])
240 status
, out
, err
= self
.git
.lfs('track', self
.filename
)
242 N_('Error'), 'git lfs track', status
, out
, err
)
247 class LFSInstall(ContextCommand
):
248 """Initialize git lfs"""
251 status
, out
, err
= self
.git
.lfs('install')
253 N_('Error'), 'git lfs install', status
, out
, err
)
254 self
.model
.update_config(reset
=True, emit
=True)
257 class ApplyDiffSelection(ContextCommand
):
258 """Apply the selected diff to the worktree or index"""
260 def __init__(self
, context
, first_line_idx
, last_line_idx
, has_selection
,
261 reverse
, apply_to_worktree
):
262 super(ApplyDiffSelection
, self
).__init
__(context
)
263 self
.first_line_idx
= first_line_idx
264 self
.last_line_idx
= last_line_idx
265 self
.has_selection
= has_selection
266 self
.reverse
= reverse
267 self
.apply_to_worktree
= apply_to_worktree
270 context
= self
.context
271 cfg
= self
.context
.cfg
272 diff_text
= self
.model
.diff_text
274 parser
= DiffParser(self
.model
.filename
, diff_text
)
275 if self
.has_selection
:
276 patch
= parser
.generate_patch(
277 self
.first_line_idx
, self
.last_line_idx
, reverse
=self
.reverse
)
279 patch
= parser
.generate_hunk_patch(
280 self
.first_line_idx
, reverse
=self
.reverse
)
284 if isinstance(diff_text
, core
.UStr
):
285 # original encoding must prevail
286 encoding
= diff_text
.encoding
288 encoding
= cfg
.file_encoding(self
.model
.filename
)
290 tmp_file
= utils
.tmp_filename('patch')
292 core
.write(tmp_file
, patch
, encoding
=encoding
)
293 if self
.apply_to_worktree
:
294 status
, out
, err
= gitcmds
.apply_diff_to_worktree(
297 status
, out
, err
= gitcmds
.apply_diff(context
, tmp_file
)
299 core
.unlink(tmp_file
)
301 Interaction
.log_status(status
, out
, err
)
302 self
.model
.update_file_status(update_index
=True)
305 class ApplyPatches(ContextCommand
):
306 """Apply patches using the "git am" command"""
308 def __init__(self
, context
, patches
):
309 super(ApplyPatches
, self
).__init
__(context
)
310 self
.patches
= patches
313 status
, out
, err
= self
.git
.am('-3', *self
.patches
)
314 Interaction
.log_status(status
, out
, err
)
317 self
.model
.update_file_status()
319 patch_basenames
= [os
.path
.basename(p
) for p
in self
.patches
]
320 if len(patch_basenames
) > 25:
321 patch_basenames
= patch_basenames
[:25]
322 patch_basenames
.append('...')
324 basenames
= '\n'.join(patch_basenames
)
325 Interaction
.information(
326 N_('Patch(es) Applied'),
327 (N_('%d patch(es) applied.') + '\n\n%s')
328 % (len(self
.patches
), basenames
))
331 class Archive(ContextCommand
):
332 """"Export archives using the "git archive" command"""
334 def __init__(self
, context
, ref
, fmt
, prefix
, filename
):
335 super(Archive
, self
).__init
__(context
)
339 self
.filename
= filename
342 fp
= core
.xopen(self
.filename
, 'wb')
343 cmd
= ['git', 'archive', '--format='+self
.fmt
]
344 if self
.fmt
in ('tgz', 'tar.gz'):
347 cmd
.append('--prefix=' + self
.prefix
)
349 proc
= core
.start_command(cmd
, stdout
=fp
)
350 out
, err
= proc
.communicate()
352 status
= proc
.returncode
353 Interaction
.log_status(status
, out
or '', err
or '')
356 class Checkout(EditModel
):
357 """A command object for git-checkout.
359 'argv' is handed off directly to git.
362 def __init__(self
, context
, argv
, checkout_branch
=False):
363 super(Checkout
, self
).__init
__(context
)
365 self
.checkout_branch
= checkout_branch
366 self
.new_diff_text
= ''
367 self
.new_diff_type
= 'text'
370 super(Checkout
, self
).do()
371 status
, out
, err
= self
.git
.checkout(*self
.argv
)
372 if self
.checkout_branch
:
373 self
.model
.update_status()
375 self
.model
.update_file_status()
376 Interaction
.command(N_('Error'), 'git checkout', status
, out
, err
)
379 class BlamePaths(ContextCommand
):
380 """Blame view for paths."""
384 return N_('Blame...')
386 def __init__(self
, context
, paths
=None):
387 super(BlamePaths
, self
).__init
__(context
)
389 paths
= context
.selection
.union()
390 viewer
= utils
.shell_split(prefs
.blame_viewer(context
))
391 self
.argv
= viewer
+ list(paths
)
397 _
, details
= utils
.format_exception(e
)
398 title
= N_('Error Launching Blame Viewer')
399 msg
= (N_('Cannot exec "%s": please configure a blame viewer')
400 % ' '.join(self
.argv
))
401 Interaction
.critical(title
, message
=msg
, details
=details
)
404 class CheckoutBranch(Checkout
):
405 """Checkout a branch."""
407 def __init__(self
, context
, branch
):
409 super(CheckoutBranch
, self
).__init
__(
410 context
, args
, checkout_branch
=True)
413 class CherryPick(ContextCommand
):
414 """Cherry pick commits into the current branch."""
416 def __init__(self
, context
, commits
):
417 super(CherryPick
, self
).__init
__(context
)
418 self
.commits
= commits
421 self
.model
.cherry_pick_list(self
.commits
)
422 self
.model
.update_file_status()
425 class Revert(ContextCommand
):
426 """Cherry pick commits into the current branch."""
428 def __init__(self
, context
, oid
):
429 super(Revert
, self
).__init
__(context
)
433 self
.git
.revert(self
.oid
)
434 self
.model
.update_file_status()
437 class ResetMode(EditModel
):
438 """Reset the mode and clear the model's diff text."""
440 def __init__(self
, context
):
441 super(ResetMode
, self
).__init
__(context
)
442 self
.new_mode
= self
.model
.mode_none
443 self
.new_diff_text
= ''
444 self
.new_diff_type
= 'text'
445 self
.new_filename
= ''
448 super(ResetMode
, self
).do()
449 self
.model
.update_file_status()
452 class ResetCommand(ConfirmAction
):
453 """Reset state using the "git reset" command"""
455 def __init__(self
, context
, ref
):
456 super(ResetCommand
, self
).__init
__(context
)
465 def error_message(self
):
469 self
.model
.update_file_status()
472 raise NotImplementedError('confirm() must be overridden')
475 raise NotImplementedError('reset() must be overridden')
478 class ResetBranchHead(ResetCommand
):
481 title
= N_('Reset Branch')
482 question
= N_('Point the current branch head to a new commit?')
483 info
= N_('The branch will be reset using "git reset --mixed %s"')
484 ok_text
= N_('Reset Branch')
485 info
= info
% self
.ref
486 return Interaction
.confirm(title
, question
, info
, ok_text
)
489 return self
.git
.reset(self
.ref
, '--', mixed
=True)
492 class ResetWorktree(ResetCommand
):
495 title
= N_('Reset Worktree')
496 question
= N_('Reset worktree?')
497 info
= N_('The worktree will be reset using "git reset --keep %s"')
498 ok_text
= N_('Reset Worktree')
499 info
= info
% self
.ref
500 return Interaction
.confirm(title
, question
, info
, ok_text
)
503 return self
.git
.reset(self
.ref
, '--', keep
=True)
506 class ResetMerge(ResetCommand
):
509 title
= N_('Reset Merge')
510 question
= N_('Reset merge?')
511 info
= N_('The branch will be reset using "git reset --merge %s"')
512 ok_text
= N_('Reset Merge')
513 info
= info
% self
.ref
514 return Interaction
.confirm(title
, question
, info
, ok_text
)
517 return self
.git
.reset(self
.ref
, '--', merge
=True)
520 class ResetSoft(ResetCommand
):
523 title
= N_('Reset Soft')
524 question
= N_('Reset soft?')
525 info
= N_('The branch will be reset using "git reset --soft %s"')
526 ok_text
= N_('Reset Soft')
527 info
= info
% self
.ref
528 return Interaction
.confirm(title
, question
, info
, ok_text
)
531 return self
.git
.reset(self
.ref
, '--', soft
=True)
534 class ResetHard(ResetCommand
):
537 title
= N_('Reset Hard')
538 question
= N_('Reset hard?')
539 info
= N_('The branch will be reset using "git reset --hard %s"')
540 ok_text
= N_('Reset Hard')
541 info
= info
% self
.ref
542 return Interaction
.confirm(title
, question
, info
, ok_text
)
545 return self
.git
.reset(self
.ref
, '--', hard
=True)
548 class Commit(ResetMode
):
549 """Attempt to create a new commit."""
551 def __init__(self
, context
, amend
, msg
, sign
, no_verify
=False):
552 super(Commit
, self
).__init
__(context
)
556 self
.no_verify
= no_verify
557 self
.old_commitmsg
= self
.model
.commitmsg
558 self
.new_commitmsg
= ''
561 # Create the commit message file
562 context
= self
.context
563 comment_char
= prefs
.comment_char(context
)
564 msg
= self
.strip_comments(self
.msg
, comment_char
=comment_char
)
565 tmp_file
= utils
.tmp_filename('commit-message')
567 core
.write(tmp_file
, msg
)
569 status
, out
, err
= self
.git
.commit(
570 F
=tmp_file
, v
=True, gpg_sign
=self
.sign
,
571 amend
=self
.amend
, no_verify
=self
.no_verify
)
573 core
.unlink(tmp_file
)
575 super(Commit
, self
).do()
576 self
.model
.set_commitmsg(self
.new_commitmsg
)
578 title
= N_('Commit failed')
579 Interaction
.command(title
, 'git commit', status
, out
, err
)
581 return status
, out
, err
584 def strip_comments(msg
, comment_char
='#'):
586 message_lines
= [line
for line
in msg
.split('\n')
587 if not line
.startswith(comment_char
)]
588 msg
= '\n'.join(message_lines
)
589 if not msg
.endswith('\n'):
595 class CycleReferenceSort(ContextCommand
):
596 """Choose the next reference sort type"""
598 self
.model
.cycle_ref_sort()
601 class Ignore(ContextCommand
):
602 """Add files to .gitignore"""
604 def __init__(self
, context
, filenames
):
605 super(Ignore
, self
).__init
__(context
)
606 self
.filenames
= list(filenames
)
609 if not self
.filenames
:
611 new_additions
= '\n'.join(self
.filenames
) + '\n'
612 for_status
= new_additions
613 if core
.exists('.gitignore'):
614 current_list
= core
.read('.gitignore')
615 new_additions
= current_list
.rstrip() + '\n' + new_additions
616 core
.write('.gitignore', new_additions
)
617 Interaction
.log_status(0, 'Added to .gitignore:\n%s' % for_status
, '')
618 self
.model
.update_file_status()
621 def file_summary(files
):
622 txt
= core
.list2cmdline(files
)
624 txt
= txt
[:768].rstrip() + '...'
625 wrap
= textwrap
.TextWrapper()
626 return '\n'.join(wrap
.wrap(txt
))
629 class RemoteCommand(ConfirmAction
):
631 def __init__(self
, context
, remote
):
632 super(RemoteCommand
, self
).__init
__(context
)
637 self
.model
.update_remotes()
640 class RemoteAdd(RemoteCommand
):
642 def __init__(self
, context
, remote
, url
):
643 super(RemoteAdd
, self
).__init
__(context
, remote
)
647 return self
.git
.remote('add', self
.remote
, self
.url
)
649 def error_message(self
):
650 return N_('Error creating remote "%s"') % self
.remote
653 return 'git remote add "%s" "%s"' % (self
.remote
, self
.url
)
656 class RemoteRemove(RemoteCommand
):
659 title
= N_('Delete Remote')
660 question
= N_('Delete remote?')
661 info
= N_('Delete remote "%s"') % self
.remote
662 ok_text
= N_('Delete')
663 return Interaction
.confirm(title
, question
, info
, ok_text
)
666 return self
.git
.remote('rm', self
.remote
)
668 def error_message(self
):
669 return N_('Error deleting remote "%s"') % self
.remote
672 return 'git remote rm "%s"' % self
.remote
675 class RemoteRename(RemoteCommand
):
677 def __init__(self
, context
, remote
, new_name
):
678 super(RemoteRename
, self
).__init
__(context
, remote
)
679 self
.new_name
= new_name
682 title
= N_('Rename Remote')
683 text
= (N_('Rename remote "%(current)s" to "%(new)s"?') %
684 dict(current
=self
.remote
, new
=self
.new_name
))
687 return Interaction
.confirm(title
, text
, info_text
, ok_text
)
690 return self
.git
.remote('rename', self
.remote
, self
.new_name
)
692 def error_message(self
):
693 return (N_('Error renaming "%(name)s" to "%(new_name)s"')
694 % dict(name
=self
.remote
, new_name
=self
.new_name
))
697 return 'git remote rename "%s" "%s"' % (self
.remote
, self
.new_name
)
700 class RemoteSetURL(RemoteCommand
):
702 def __init__(self
, context
, remote
, url
):
703 super(RemoteSetURL
, self
).__init
__(context
, remote
)
707 return self
.git
.remote('set-url', self
.remote
, self
.url
)
709 def error_message(self
):
710 return (N_('Unable to set URL for "%(name)s" to "%(url)s"')
711 % dict(name
=self
.remote
, url
=self
.url
))
714 return 'git remote set-url "%s" "%s"' % (self
.remote
, self
.url
)
717 class RemoteEdit(ContextCommand
):
718 """Combine RemoteRename and RemoteSetURL"""
720 def __init__(self
, context
, old_name
, remote
, url
):
721 super(RemoteEdit
, self
).__init
__(context
)
722 self
.rename
= RemoteRename(context
, old_name
, remote
)
723 self
.set_url
= RemoteSetURL(context
, remote
, url
)
726 result
= self
.rename
.do()
730 result
= self
.set_url
.do()
732 return name_ok
, url_ok
735 class RemoveFromSettings(ConfirmAction
):
737 def __init__(self
, context
, settings
, repo
, entry
, icon
=None):
738 super(RemoveFromSettings
, self
).__init
__(context
)
739 self
.settings
= settings
748 class RemoveBookmark(RemoveFromSettings
):
752 title
= msg
= N_('Delete Bookmark?')
753 info
= N_('%s will be removed from your bookmarks.') % entry
754 ok_text
= N_('Delete Bookmark')
755 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
758 self
.settings
.remove_bookmark(self
.repo
, self
.entry
)
762 class RemoveRecent(RemoveFromSettings
):
766 title
= msg
= N_('Remove %s from the recent list?') % repo
767 info
= N_('%s will be removed from your recent repositories.') % repo
768 ok_text
= N_('Remove')
769 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
772 self
.settings
.remove_recent(self
.repo
)
776 class RemoveFiles(ContextCommand
):
779 def __init__(self
, context
, remover
, filenames
):
780 super(RemoveFiles
, self
).__init
__(context
)
783 self
.remover
= remover
784 self
.filenames
= filenames
785 # We could git-hash-object stuff and provide undo-ability
789 files
= self
.filenames
795 remove
= self
.remover
796 for filename
in files
:
802 bad_filenames
.append(filename
)
805 Interaction
.information(
807 N_('Deleting "%s" failed') % file_summary(bad_filenames
))
810 self
.model
.update_file_status()
813 class Delete(RemoveFiles
):
816 def __init__(self
, context
, filenames
):
817 super(Delete
, self
).__init
__(context
, os
.remove
, filenames
)
820 files
= self
.filenames
824 title
= N_('Delete Files?')
825 msg
= N_('The following files will be deleted:') + '\n\n'
826 msg
+= file_summary(files
)
827 info_txt
= N_('Delete %d file(s)?') % len(files
)
828 ok_txt
= N_('Delete Files')
830 if Interaction
.confirm(title
, msg
, info_txt
, ok_txt
,
831 default
=True, icon
=icons
.remove()):
832 super(Delete
, self
).do()
835 class MoveToTrash(RemoveFiles
):
836 """Move files to the trash using send2trash"""
838 AVAILABLE
= send2trash
is not None
840 def __init__(self
, context
, filenames
):
841 super(MoveToTrash
, self
).__init
__(context
, send2trash
, filenames
)
844 class DeleteBranch(ContextCommand
):
845 """Delete a git branch."""
847 def __init__(self
, context
, branch
):
848 super(DeleteBranch
, self
).__init
__(context
)
852 status
, out
, err
= self
.model
.delete_branch(self
.branch
)
853 Interaction
.log_status(status
, out
, err
)
856 class Rename(ContextCommand
):
857 """Rename a set of paths."""
859 def __init__(self
, context
, paths
):
860 super(Rename
, self
).__init
__(context
)
864 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
867 for path
in self
.paths
:
868 ok
= self
.rename(path
)
872 self
.model
.update_status()
874 def rename(self
, path
):
876 title
= N_('Rename "%s"') % path
878 if os
.path
.isdir(path
):
879 base_path
= os
.path
.dirname(path
)
882 new_path
= Interaction
.save_as(base_path
, title
)
886 status
, out
, err
= git
.mv(path
, new_path
, force
=True, verbose
=True)
887 Interaction
.command(N_('Error'), 'git mv', status
, out
, err
)
891 class RenameBranch(ContextCommand
):
892 """Rename a git branch."""
894 def __init__(self
, context
, branch
, new_branch
):
895 super(RenameBranch
, self
).__init
__(context
)
897 self
.new_branch
= new_branch
901 new_branch
= self
.new_branch
902 status
, out
, err
= self
.model
.rename_branch(branch
, new_branch
)
903 Interaction
.log_status(status
, out
, err
)
906 class DeleteRemoteBranch(ContextCommand
):
907 """Delete a remote git branch."""
909 def __init__(self
, context
, remote
, branch
):
910 super(DeleteRemoteBranch
, self
).__init
__(context
)
915 status
, out
, err
= self
.git
.push(self
.remote
, self
.branch
, delete
=True)
916 self
.model
.update_status()
918 title
= N_('Error Deleting Remote Branch')
919 Interaction
.command(title
, 'git push', status
, out
, err
)
921 Interaction
.information(
922 N_('Remote Branch Deleted'),
923 N_('"%(branch)s" has been deleted from "%(remote)s".')
924 % dict(branch
=self
.branch
, remote
=self
.remote
))
927 def get_mode(model
, staged
, modified
, unmerged
, untracked
):
929 mode
= model
.mode_index
930 elif modified
or unmerged
:
931 mode
= model
.mode_worktree
933 mode
= model
.mode_untracked
939 class DiffImage(EditModel
):
941 def __init__(self
, context
, filename
,
942 deleted
, staged
, modified
, unmerged
, untracked
):
943 super(DiffImage
, self
).__init
__(context
)
945 self
.new_filename
= filename
946 self
.new_diff_text
= ''
947 self
.new_diff_type
= 'image'
948 self
.new_mode
= get_mode(
949 self
.model
, staged
, modified
, unmerged
, untracked
)
951 self
.modified
= modified
952 self
.unmerged
= unmerged
953 self
.untracked
= untracked
954 self
.deleted
= deleted
955 self
.annex
= self
.cfg
.is_annex()
958 filename
= self
.new_filename
961 images
= self
.staged_images()
963 images
= self
.modified_images()
965 images
= self
.unmerged_images()
967 images
= [(filename
, False)]
971 self
.model
.set_images(images
)
972 super(DiffImage
, self
).do()
974 def staged_images(self
):
975 context
= self
.context
977 head
= self
.model
.head
978 filename
= self
.new_filename
982 index
= git
.diff_index(head
, '--', filename
, cached
=True)[STDOUT
]
985 # :100644 100644 fabadb8... 4866510... M describe.c
986 parts
= index
.split(' ')
991 if old_oid
!= MISSING_BLOB_OID
:
992 # First, check if we can get a pre-image from git-annex
995 annex_image
= gitcmds
.annex_path(context
, head
, filename
)
997 images
.append((annex_image
, False)) # git annex HEAD
999 image
= gitcmds
.write_blob_path(
1000 context
, head
, old_oid
, filename
)
1002 images
.append((image
, True))
1004 if new_oid
!= MISSING_BLOB_OID
:
1005 found_in_annex
= False
1006 if annex
and core
.islink(filename
):
1007 status
, out
, _
= git
.annex('status', '--', filename
)
1009 details
= out
.split(' ')
1010 if details
and details
[0] == 'A': # newly added file
1011 images
.append((filename
, False))
1012 found_in_annex
= True
1014 if not found_in_annex
:
1015 image
= gitcmds
.write_blob(context
, new_oid
, filename
)
1017 images
.append((image
, True))
1021 def unmerged_images(self
):
1022 context
= self
.context
1024 head
= self
.model
.head
1025 filename
= self
.new_filename
1028 candidate_merge_heads
= ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1030 merge_head
for merge_head
in candidate_merge_heads
1031 if core
.exists(git
.git_path(merge_head
))]
1033 if annex
: # Attempt to find files in git-annex
1035 for merge_head
in merge_heads
:
1036 image
= gitcmds
.annex_path(context
, merge_head
, filename
)
1038 annex_images
.append((image
, False))
1040 annex_images
.append((filename
, False))
1043 # DIFF FORMAT FOR MERGES
1044 # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1045 # can take -c or --cc option to generate diff output also
1046 # for merge commits. The output differs from the format
1047 # described above in the following way:
1049 # 1. there is a colon for each parent
1050 # 2. there are more "src" modes and "src" sha1
1051 # 3. status is concatenated status characters for each parent
1052 # 4. no optional "score" number
1053 # 5. single path, only for "dst"
1055 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1058 index
= git
.diff_index(head
, '--', filename
,
1059 cached
=True, cc
=True)[STDOUT
]
1061 parts
= index
.split(' ')
1063 first_mode
= parts
[0]
1064 num_parents
= first_mode
.count(':')
1065 # colon for each parent, but for the index, the "parents"
1066 # are really entries in stages 1,2,3 (head, base, remote)
1067 # remote, base, head
1068 for i
in range(num_parents
):
1069 offset
= num_parents
+ i
+ 1
1072 merge_head
= merge_heads
[i
]
1075 if oid
!= MISSING_BLOB_OID
:
1076 image
= gitcmds
.write_blob_path(
1077 context
, merge_head
, oid
, filename
)
1079 images
.append((image
, True))
1081 images
.append((filename
, False))
1084 def modified_images(self
):
1085 context
= self
.context
1087 head
= self
.model
.head
1088 filename
= self
.new_filename
1093 if annex
: # Check for a pre-image from git-annex
1094 annex_image
= gitcmds
.annex_path(context
, head
, filename
)
1096 images
.append((annex_image
, False)) # git annex HEAD
1098 worktree
= git
.diff_files('--', filename
)[STDOUT
]
1099 parts
= worktree
.split(' ')
1102 if oid
!= MISSING_BLOB_OID
:
1103 image
= gitcmds
.write_blob_path(
1104 context
, head
, oid
, filename
)
1106 images
.append((image
, True)) # HEAD
1108 images
.append((filename
, False)) # worktree
1112 class Diff(EditModel
):
1113 """Perform a diff and set the model's current text."""
1115 def __init__(self
, context
, filename
, cached
=False, deleted
=False):
1116 super(Diff
, self
).__init
__(context
)
1119 opts
['ref'] = self
.model
.head
1120 self
.new_filename
= filename
1121 self
.new_mode
= self
.model
.mode_worktree
1122 self
.new_diff_text
= gitcmds
.diff_helper(
1123 self
.context
, filename
=filename
, cached
=cached
,
1124 deleted
=deleted
, **opts
)
1125 self
.new_diff_type
= 'text'
1128 class Diffstat(EditModel
):
1129 """Perform a diffstat and set the model's diff text."""
1131 def __init__(self
, context
):
1132 super(Diffstat
, self
).__init
__(context
)
1134 diff_context
= cfg
.get('diff.context', 3)
1135 diff
= self
.git
.diff(
1136 self
.model
.head
, unified
=diff_context
, no_ext_diff
=True,
1137 no_color
=True, M
=True, stat
=True)[STDOUT
]
1138 self
.new_diff_text
= diff
1139 self
.new_diff_type
= 'text'
1140 self
.new_mode
= self
.model
.mode_diffstat
1143 class DiffStaged(Diff
):
1144 """Perform a staged diff on a file."""
1146 def __init__(self
, context
, filename
, deleted
=None):
1147 super(DiffStaged
, self
).__init
__(
1148 context
, filename
, cached
=True, deleted
=deleted
)
1149 self
.new_mode
= self
.model
.mode_index
1152 class DiffStagedSummary(EditModel
):
1154 def __init__(self
, context
):
1155 super(DiffStagedSummary
, self
).__init
__(context
)
1156 diff
= self
.git
.diff(
1157 self
.model
.head
, cached
=True, no_color
=True,
1158 no_ext_diff
=True, patch_with_stat
=True, M
=True)[STDOUT
]
1159 self
.new_diff_text
= diff
1160 self
.new_diff_type
= 'text'
1161 self
.new_mode
= self
.model
.mode_index
1164 class Difftool(ContextCommand
):
1165 """Run git-difftool limited by path."""
1167 def __init__(self
, context
, staged
, filenames
):
1168 super(Difftool
, self
).__init
__(context
)
1169 self
.staged
= staged
1170 self
.filenames
= filenames
1173 difftool_launch_with_head(
1174 self
.context
, self
.filenames
, self
.staged
, self
.model
.head
)
1177 class Edit(ContextCommand
):
1178 """Edit a file using the configured gui.editor."""
1182 return N_('Launch Editor')
1184 def __init__(self
, context
, filenames
,
1185 line_number
=None, background_editor
=False):
1186 super(Edit
, self
).__init
__(context
)
1187 self
.filenames
= filenames
1188 self
.line_number
= line_number
1189 self
.background_editor
= background_editor
1192 context
= self
.context
1193 if not self
.filenames
:
1195 filename
= self
.filenames
[0]
1196 if not core
.exists(filename
):
1198 if self
.background_editor
:
1199 editor
= prefs
.background_editor(context
)
1201 editor
= prefs
.editor(context
)
1204 if self
.line_number
is None:
1205 opts
= self
.filenames
1207 # Single-file w/ line-numbers (likely from grep)
1209 '*vim*': [filename
, '+%s' % self
.line_number
],
1210 '*emacs*': ['+%s' % self
.line_number
, filename
],
1211 '*textpad*': ['%s(%s,0)' % (filename
, self
.line_number
)],
1212 '*notepad++*': ['-n%s' % self
.line_number
, filename
],
1213 '*subl*': ['%s:%s' % (filename
, self
.line_number
)],
1216 opts
= self
.filenames
1217 for pattern
, opt
in editor_opts
.items():
1218 if fnmatch(editor
, pattern
):
1223 core
.fork(utils
.shell_split(editor
) + opts
)
1224 except (OSError, ValueError) as e
:
1225 message
= (N_('Cannot exec "%s": please configure your editor')
1227 _
, details
= utils
.format_exception(e
)
1228 Interaction
.critical(N_('Error Editing File'), message
, details
)
1231 class FormatPatch(ContextCommand
):
1232 """Output a patch series given all revisions and a selected subset."""
1234 def __init__(self
, context
, to_export
, revs
, output
='patches'):
1235 super(FormatPatch
, self
).__init
__(context
)
1236 self
.to_export
= list(to_export
)
1237 self
.revs
= list(revs
)
1238 self
.output
= output
1241 context
= self
.context
1242 status
, out
, err
= gitcmds
.format_patchsets(
1243 context
, self
.to_export
, self
.revs
, self
.output
)
1244 Interaction
.log_status(status
, out
, err
)
1247 class LaunchDifftool(ContextCommand
):
1251 return N_('Launch Diff Tool')
1254 s
= self
.selection
.selection()
1257 if utils
.is_win32():
1258 core
.fork(['git', 'mergetool', '--no-prompt', '--'] + paths
)
1261 cmd
= cfg
.terminal()
1262 argv
= utils
.shell_split(cmd
)
1264 terminal
= os
.path
.basename(argv
[0])
1265 shellquote_terms
= set(['xfce4-terminal'])
1266 shellquote_default
= terminal
in shellquote_terms
1268 mergetool
= ['git', 'mergetool', '--no-prompt', '--']
1269 mergetool
.extend(paths
)
1270 needs_shellquote
= cfg
.get(
1271 'cola.terminalshellquote', shellquote_default
)
1273 if needs_shellquote
:
1274 argv
.append(core
.list2cmdline(mergetool
))
1276 argv
.extend(mergetool
)
1280 difftool_run(self
.context
)
1283 class LaunchTerminal(ContextCommand
):
1287 return N_('Launch Terminal')
1290 def is_available(context
):
1291 return context
.cfg
.terminal() is not None
1293 def __init__(self
, context
, path
):
1294 super(LaunchTerminal
, self
).__init
__(context
)
1298 cmd
= self
.context
.cfg
.terminal()
1301 if utils
.is_win32():
1302 argv
= ['start', '', cmd
, '--login']
1305 argv
= utils
.shell_split(cmd
)
1306 argv
.append(os
.getenv('SHELL', '/bin/sh'))
1308 core
.fork(argv
, cwd
=self
.path
, shell
=shell
)
1311 class LaunchEditor(Edit
):
1315 return N_('Launch Editor')
1317 def __init__(self
, context
):
1318 s
= context
.selection
.selection()
1319 filenames
= s
.staged
+ s
.unmerged
+ s
.modified
+ s
.untracked
1320 super(LaunchEditor
, self
).__init
__(
1321 context
, filenames
, background_editor
=True)
1324 class LaunchEditorAtLine(LaunchEditor
):
1325 """Launch an editor at the specified line"""
1327 def __init__(self
, context
):
1328 super(LaunchEditorAtLine
, self
).__init
__(context
)
1329 self
.line_number
= context
.selection
.line_number
1332 class LoadCommitMessageFromFile(ContextCommand
):
1333 """Loads a commit message from a path."""
1336 def __init__(self
, context
, path
):
1337 super(LoadCommitMessageFromFile
, self
).__init
__(context
)
1339 self
.old_commitmsg
= self
.model
.commitmsg
1340 self
.old_directory
= self
.model
.directory
1343 path
= os
.path
.expanduser(self
.path
)
1344 if not path
or not core
.isfile(path
):
1345 raise UsageError(N_('Error: Cannot find commit template'),
1346 N_('%s: No such file or directory.') % path
)
1347 self
.model
.set_directory(os
.path
.dirname(path
))
1348 self
.model
.set_commitmsg(core
.read(path
))
1351 self
.model
.set_commitmsg(self
.old_commitmsg
)
1352 self
.model
.set_directory(self
.old_directory
)
1355 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile
):
1356 """Loads the commit message template specified by commit.template."""
1358 def __init__(self
, context
):
1360 template
= cfg
.get('commit.template')
1361 super(LoadCommitMessageFromTemplate
, self
).__init
__(context
, template
)
1364 if self
.path
is None:
1366 N_('Error: Unconfigured commit template'),
1367 N_('A commit template has not been configured.\n'
1368 'Use "git config" to define "commit.template"\n'
1369 'so that it points to a commit template.'))
1370 return LoadCommitMessageFromFile
.do(self
)
1373 class LoadCommitMessageFromOID(ContextCommand
):
1374 """Load a previous commit message"""
1377 def __init__(self
, context
, oid
, prefix
=''):
1378 super(LoadCommitMessageFromOID
, self
).__init
__(context
)
1380 self
.old_commitmsg
= self
.model
.commitmsg
1381 self
.new_commitmsg
= prefix
+ gitcmds
.prev_commitmsg(context
, oid
)
1384 self
.model
.set_commitmsg(self
.new_commitmsg
)
1387 self
.model
.set_commitmsg(self
.old_commitmsg
)
1390 class PrepareCommitMessageHook(ContextCommand
):
1391 """Use the cola-prepare-commit-msg hook to prepare the commit message
1395 def __init__(self
, context
):
1396 super(PrepareCommitMessageHook
, self
).__init
__(context
)
1397 self
.old_commitmsg
= self
.model
.commitmsg
1399 def get_message(self
):
1401 title
= N_('Error running prepare-commitmsg hook')
1402 hook
= gitcmds
.prepare_commit_message_hook(self
.context
)
1404 if os
.path
.exists(hook
):
1405 filename
= self
.model
.save_commitmsg()
1406 status
, out
, err
= core
.run_command([hook
, filename
])
1409 result
= core
.read(filename
)
1411 result
= self
.old_commitmsg
1412 Interaction
.command_error(title
, hook
, status
, out
, err
)
1414 message
= N_('A hook must be provided at "%s"') % hook
1415 Interaction
.critical(title
, message
=message
)
1416 result
= self
.old_commitmsg
1421 msg
= self
.get_message()
1422 self
.model
.set_commitmsg(msg
)
1425 self
.model
.set_commitmsg(self
.old_commitmsg
)
1428 class LoadFixupMessage(LoadCommitMessageFromOID
):
1429 """Load a fixup message"""
1431 def __init__(self
, context
, oid
):
1432 super(LoadFixupMessage
, self
).__init
__(context
, oid
, prefix
='fixup! ')
1433 if self
.new_commitmsg
:
1434 self
.new_commitmsg
= self
.new_commitmsg
.splitlines()[0]
1437 class Merge(ContextCommand
):
1440 def __init__(self
, context
, revision
, no_commit
, squash
, no_ff
, sign
):
1441 super(Merge
, self
).__init
__(context
)
1442 self
.revision
= revision
1444 self
.no_commit
= no_commit
1445 self
.squash
= squash
1449 squash
= self
.squash
1450 revision
= self
.revision
1452 no_commit
= self
.no_commit
1455 status
, out
, err
= self
.git
.merge(
1456 revision
, gpg_sign
=sign
, no_ff
=no_ff
,
1457 no_commit
=no_commit
, squash
=squash
)
1458 self
.model
.update_status()
1459 title
= N_('Merge failed. Conflict resolution is required.')
1460 Interaction
.command(title
, 'git merge', status
, out
, err
)
1462 return status
, out
, err
1465 class OpenDefaultApp(ContextCommand
):
1466 """Open a file using the OS default."""
1470 return N_('Open Using Default Application')
1472 def __init__(self
, context
, filenames
):
1473 super(OpenDefaultApp
, self
).__init
__(context
)
1474 if utils
.is_darwin():
1477 launcher
= 'xdg-open'
1478 self
.launcher
= launcher
1479 self
.filenames
= filenames
1482 if not self
.filenames
:
1484 core
.fork([self
.launcher
] + self
.filenames
)
1487 class OpenParentDir(OpenDefaultApp
):
1488 """Open parent directories using the OS default."""
1492 return N_('Open Parent Directory')
1494 def __init__(self
, context
, filenames
):
1495 OpenDefaultApp
.__init
__(self
, context
, filenames
)
1498 if not self
.filenames
:
1500 dirnames
= list(set([os
.path
.dirname(x
) for x
in self
.filenames
]))
1501 # os.path.dirname() can return an empty string so we fallback to
1502 # the current directory
1503 dirs
= [(dirname
or core
.getcwd()) for dirname
in dirnames
]
1504 core
.fork([self
.launcher
] + dirs
)
1507 class OpenNewRepo(ContextCommand
):
1508 """Launches git-cola on a repo."""
1510 def __init__(self
, context
, repo_path
):
1511 super(OpenNewRepo
, self
).__init
__(context
)
1512 self
.repo_path
= repo_path
1515 self
.model
.set_directory(self
.repo_path
)
1516 core
.fork([sys
.executable
, sys
.argv
[0], '--repo', self
.repo_path
])
1519 class OpenRepo(EditModel
):
1521 def __init__(self
, context
, repo_path
):
1522 super(OpenRepo
, self
).__init
__(context
)
1523 self
.repo_path
= repo_path
1524 self
.new_mode
= self
.model
.mode_none
1525 self
.new_diff_text
= ''
1526 self
.new_diff_type
= 'text'
1527 self
.new_commitmsg
= ''
1528 self
.new_filename
= ''
1531 old_repo
= self
.git
.getcwd()
1532 if self
.model
.set_worktree(self
.repo_path
):
1533 self
.fsmonitor
.stop()
1534 self
.fsmonitor
.start()
1535 self
.model
.update_status()
1536 self
.model
.set_commitmsg(self
.new_commitmsg
)
1537 super(OpenRepo
, self
).do()
1539 self
.model
.set_worktree(old_repo
)
1542 class OpenParentRepo(OpenRepo
):
1544 def __init__(self
, context
):
1546 if version
.check_git(context
, 'show-superproject-working-tree'):
1547 status
, out
, _
= context
.git
.rev_parse(
1548 show_superproject_working_tree
=True)
1552 path
= os
.path
.dirname(core
.getcwd())
1553 super(OpenParentRepo
, self
).__init
__(context
, path
)
1556 class Clone(ContextCommand
):
1557 """Clones a repository and optionally spawns a new cola session."""
1559 def __init__(self
, context
, url
, new_directory
,
1560 submodules
=False, shallow
=False, spawn
=True):
1561 super(Clone
, self
).__init
__(context
)
1563 self
.new_directory
= new_directory
1564 self
.submodules
= submodules
1565 self
.shallow
= shallow
1575 recurse_submodules
= self
.submodules
1576 shallow_submodules
= self
.submodules
and self
.shallow
1578 status
, out
, err
= self
.git
.clone(
1579 self
.url
, self
.new_directory
,
1580 recurse_submodules
=recurse_submodules
,
1581 shallow_submodules
=shallow_submodules
,
1584 self
.status
= status
1587 if status
== 0 and self
.spawn
:
1588 executable
= sys
.executable
1589 core
.fork([executable
, sys
.argv
[0], '--repo', self
.new_directory
])
1593 class NewBareRepo(ContextCommand
):
1594 """Create a new shared bare repository"""
1596 def __init__(self
, context
, path
):
1597 super(NewBareRepo
, self
).__init
__(context
)
1602 status
, out
, err
= self
.git
.init(path
, bare
=True, shared
=True)
1603 Interaction
.command(
1604 N_('Error'), 'git init --bare --shared "%s"' % path
,
1609 def unix_path(path
, is_win32
=utils
.is_win32
):
1610 """Git for Windows requires unix paths, so force them here
1613 path
= path
.replace('\\', '/')
1616 if second
== ':': # sanity check, this better be a Windows-style path
1617 path
= '/' + first
+ path
[2:]
1622 def sequence_editor():
1623 """Return a GIT_SEQUENCE_EDITOR environment value that enables git-xbase"""
1624 xbase
= unix_path(resources
.share('bin', 'git-xbase'))
1625 editor
= core
.list2cmdline([unix_path(sys
.executable
), xbase
])
1629 class GitXBaseContext(object):
1631 def __init__(self
, context
, **kwargs
):
1633 'GIT_EDITOR': prefs
.editor(context
),
1634 'GIT_SEQUENCE_EDITOR': sequence_editor(),
1635 'GIT_XBASE_CANCEL_ACTION': 'save',
1637 self
.env
.update(kwargs
)
1639 def __enter__(self
):
1640 for var
, value
in self
.env
.items():
1641 compat
.setenv(var
, value
)
1644 def __exit__(self
, exc_type
, exc_val
, exc_tb
):
1645 for var
in self
.env
:
1646 compat
.unsetenv(var
)
1649 class Rebase(ContextCommand
):
1651 def __init__(self
, context
, upstream
=None, branch
=None, **kwargs
):
1652 """Start an interactive rebase session
1654 :param upstream: upstream branch
1655 :param branch: optional branch to checkout
1656 :param kwargs: forwarded directly to `git.rebase()`
1659 super(Rebase
, self
).__init
__(context
)
1661 self
.upstream
= upstream
1662 self
.branch
= branch
1663 self
.kwargs
= kwargs
1665 def prepare_arguments(self
, upstream
):
1669 # Rebase actions must be the only option specified
1670 for action
in ('continue', 'abort', 'skip', 'edit_todo'):
1671 if self
.kwargs
.get(action
, False):
1672 kwargs
[action
] = self
.kwargs
[action
]
1675 kwargs
['interactive'] = True
1676 kwargs
['autosquash'] = self
.kwargs
.get('autosquash', True)
1677 kwargs
.update(self
.kwargs
)
1680 args
.append(upstream
)
1682 args
.append(self
.branch
)
1687 (status
, out
, err
) = (1, '', '')
1688 context
= self
.context
1692 if not cfg
.get('rebase.autostash', False):
1693 if model
.staged
or model
.unmerged
or model
.modified
:
1694 Interaction
.information(
1695 N_('Unable to rebase'),
1696 N_('You cannot rebase with uncommitted changes.'))
1697 return status
, out
, err
1699 upstream
= self
.upstream
or Interaction
.choose_ref(
1700 context
, N_('Select New Upstream'), N_('Interactive Rebase'),
1701 default
='@{upstream}')
1703 return status
, out
, err
1705 self
.model
.is_rebasing
= True
1706 self
.model
.emit_updated()
1708 args
, kwargs
= self
.prepare_arguments(upstream
)
1709 upstream_title
= upstream
or '@{upstream}'
1710 with
GitXBaseContext(
1712 GIT_XBASE_TITLE
=N_('Rebase onto %s') % upstream_title
,
1713 GIT_XBASE_ACTION
=N_('Rebase')
1715 # TODO this blocks the user interface window for the duration
1716 # of git-xbase's invocation. We would need to implement
1717 # signals for QProcess and continue running the main thread.
1718 # alternatively we could hide the main window while rebasing.
1719 # that doesn't require as much effort.
1720 status
, out
, err
= self
.git
.rebase(
1721 *args
, _no_win32_startupinfo
=True, **kwargs
)
1722 self
.model
.update_status()
1723 if err
.strip() != 'Nothing to do':
1724 title
= N_('Rebase stopped')
1725 Interaction
.command(title
, 'git rebase', status
, out
, err
)
1726 return status
, out
, err
1729 class RebaseEditTodo(ContextCommand
):
1732 (status
, out
, err
) = (1, '', '')
1733 with
GitXBaseContext(
1735 GIT_XBASE_TITLE
=N_('Edit Rebase'),
1736 GIT_XBASE_ACTION
=N_('Save')
1738 status
, out
, err
= self
.git
.rebase(edit_todo
=True)
1739 Interaction
.log_status(status
, out
, err
)
1740 self
.model
.update_status()
1741 return status
, out
, err
1744 class RebaseContinue(ContextCommand
):
1747 (status
, out
, err
) = (1, '', '')
1748 with
GitXBaseContext(
1750 GIT_XBASE_TITLE
=N_('Rebase'),
1751 GIT_XBASE_ACTION
=N_('Rebase')
1753 status
, out
, err
= self
.git
.rebase('--continue')
1754 Interaction
.log_status(status
, out
, err
)
1755 self
.model
.update_status()
1756 return status
, out
, err
1759 class RebaseSkip(ContextCommand
):
1762 (status
, out
, err
) = (1, '', '')
1763 with
GitXBaseContext(
1765 GIT_XBASE_TITLE
=N_('Rebase'),
1766 GIT_XBASE_ACTION
=N_('Rebase')
1768 status
, out
, err
= self
.git
.rebase(skip
=True)
1769 Interaction
.log_status(status
, out
, err
)
1770 self
.model
.update_status()
1771 return status
, out
, err
1774 class RebaseAbort(ContextCommand
):
1777 status
, out
, err
= self
.git
.rebase(abort
=True)
1778 Interaction
.log_status(status
, out
, err
)
1779 self
.model
.update_status()
1782 class Rescan(ContextCommand
):
1783 """Rescan for changes"""
1786 self
.model
.update_status()
1789 class Refresh(ContextCommand
):
1790 """Update refs, refresh the index, and update config"""
1794 return N_('Refresh')
1797 self
.model
.update_status(update_index
=True)
1799 self
.fsmonitor
.refresh()
1802 class RefreshConfig(ContextCommand
):
1803 """Refresh the git config cache"""
1809 class RevertEditsCommand(ConfirmAction
):
1811 def __init__(self
, context
):
1812 super(RevertEditsCommand
, self
).__init
__(context
)
1813 self
.icon
= icons
.undo()
1815 def ok_to_run(self
):
1816 return self
.model
.undoable()
1818 # pylint: disable=no-self-use
1819 def checkout_from_head(self
):
1822 def checkout_args(self
):
1824 s
= self
.selection
.selection()
1825 if self
.checkout_from_head():
1826 args
.append(self
.model
.head
)
1838 checkout_args
= self
.checkout_args()
1839 return self
.git
.checkout(*checkout_args
)
1842 self
.model
.update_file_status()
1845 class RevertUnstagedEdits(RevertEditsCommand
):
1849 return N_('Revert Unstaged Edits...')
1851 def checkout_from_head(self
):
1852 # Being in amend mode should not affect the behavior of this command.
1853 # The only sensible thing to do is to checkout from the index.
1857 title
= N_('Revert Unstaged Changes?')
1859 'This operation removes unstaged edits from selected files.\n'
1860 'These changes cannot be recovered.')
1861 info
= N_('Revert the unstaged changes?')
1862 ok_text
= N_('Revert Unstaged Changes')
1863 return Interaction
.confirm(title
, text
, info
, ok_text
,
1864 default
=True, icon
=self
.icon
)
1867 class RevertUncommittedEdits(RevertEditsCommand
):
1871 return N_('Revert Uncommitted Edits...')
1873 def checkout_from_head(self
):
1877 """Prompt for reverting changes"""
1878 title
= N_('Revert Uncommitted Changes?')
1880 'This operation removes uncommitted edits from selected files.\n'
1881 'These changes cannot be recovered.')
1882 info
= N_('Revert the uncommitted changes?')
1883 ok_text
= N_('Revert Uncommitted Changes')
1884 return Interaction
.confirm(title
, text
, info
, ok_text
,
1885 default
=True, icon
=self
.icon
)
1888 class RunConfigAction(ContextCommand
):
1889 """Run a user-configured action, typically from the "Tools" menu"""
1891 def __init__(self
, context
, action_name
):
1892 super(RunConfigAction
, self
).__init
__(context
)
1893 self
.action_name
= action_name
1896 """Run the user-configured action"""
1897 for env
in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
1899 compat
.unsetenv(env
)
1904 context
= self
.context
1906 opts
= cfg
.get_guitool_opts(self
.action_name
)
1907 cmd
= opts
.get('cmd')
1908 if 'title' not in opts
:
1911 if 'prompt' not in opts
or opts
.get('prompt') is True:
1912 prompt
= N_('Run "%s"?') % cmd
1913 opts
['prompt'] = prompt
1915 if opts
.get('needsfile'):
1916 filename
= self
.selection
.filename()
1918 Interaction
.information(
1919 N_('Please select a file'),
1920 N_('"%s" requires a selected file.') % cmd
)
1922 dirname
= utils
.dirname(filename
, current_dir
='.')
1923 compat
.setenv('FILENAME', filename
)
1924 compat
.setenv('DIRNAME', dirname
)
1926 if opts
.get('revprompt') or opts
.get('argprompt'):
1928 ok
= Interaction
.confirm_config_action(context
, cmd
, opts
)
1931 rev
= opts
.get('revision')
1932 args
= opts
.get('args')
1933 if opts
.get('revprompt') and not rev
:
1934 title
= N_('Invalid Revision')
1935 msg
= N_('The revision expression cannot be empty.')
1936 Interaction
.critical(title
, msg
)
1940 elif opts
.get('confirm'):
1941 title
= os
.path
.expandvars(opts
.get('title'))
1942 prompt
= os
.path
.expandvars(opts
.get('prompt'))
1943 if not Interaction
.question(title
, prompt
):
1946 compat
.setenv('REVISION', rev
)
1948 compat
.setenv('ARGS', args
)
1949 title
= os
.path
.expandvars(cmd
)
1950 Interaction
.log(N_('Running command: %s') % title
)
1951 cmd
= ['sh', '-c', cmd
]
1953 if opts
.get('background'):
1955 status
, out
, err
= (0, '', '')
1956 elif opts
.get('noconsole'):
1957 status
, out
, err
= core
.run_command(cmd
)
1959 status
, out
, err
= Interaction
.run_command(title
, cmd
)
1961 if not opts
.get('background') and not opts
.get('norescan'):
1962 self
.model
.update_status()
1965 Interaction
.command(title
, cmd
, status
, out
, err
)
1970 class SetDefaultRepo(ContextCommand
):
1971 """Set the default repository"""
1973 def __init__(self
, context
, repo
):
1974 super(SetDefaultRepo
, self
).__init
__(context
)
1978 self
.cfg
.set_user('cola.defaultrepo', self
.repo
)
1981 class SetDiffText(EditModel
):
1982 """Set the diff text"""
1985 def __init__(self
, context
, text
):
1986 super(SetDiffText
, self
).__init
__(context
)
1987 self
.new_diff_text
= text
1988 self
.new_diff_type
= 'text'
1991 class SetUpstreamBranch(ContextCommand
):
1992 """Set the upstream branch"""
1994 def __init__(self
, context
, branch
, remote
, remote_branch
):
1995 super(SetUpstreamBranch
, self
).__init
__(context
)
1996 self
.branch
= branch
1997 self
.remote
= remote
1998 self
.remote_branch
= remote_branch
2002 remote
= self
.remote
2003 branch
= self
.branch
2004 remote_branch
= self
.remote_branch
2005 cfg
.set_repo('branch.%s.remote' % branch
, remote
)
2006 cfg
.set_repo('branch.%s.merge' % branch
, 'refs/heads/' + remote_branch
)
2009 class ShowUntracked(EditModel
):
2010 """Show an untracked file."""
2012 def __init__(self
, context
, filename
):
2013 super(ShowUntracked
, self
).__init
__(context
)
2014 self
.new_filename
= filename
2015 self
.new_mode
= self
.model
.mode_untracked
2016 self
.new_diff_text
= self
.read(filename
)
2017 self
.new_diff_type
= 'text'
2019 def read(self
, filename
):
2020 """Read file contents"""
2022 size
= cfg
.get('cola.readsize', 2048)
2024 result
= core
.read(filename
, size
=size
,
2025 encoding
=core
.ENCODING
, errors
='ignore')
2026 except (IOError, OSError):
2029 if len(result
) == size
:
2034 class SignOff(ContextCommand
):
2035 """Append a signoff to the commit message"""
2040 return N_('Sign Off')
2042 def __init__(self
, context
):
2043 super(SignOff
, self
).__init
__(context
)
2044 self
.old_commitmsg
= self
.model
.commitmsg
2047 """Add a signoff to the commit message"""
2048 signoff
= self
.signoff()
2049 if signoff
in self
.model
.commitmsg
:
2051 msg
= self
.model
.commitmsg
.rstrip()
2052 self
.model
.set_commitmsg(msg
+ '\n' + signoff
)
2055 """Restore the commit message"""
2056 self
.model
.set_commitmsg(self
.old_commitmsg
)
2059 """Generate the signoff string"""
2062 user
= pwd
.getpwuid(os
.getuid()).pw_name
2064 user
= os
.getenv('USER', N_('unknown'))
2067 name
= cfg
.get('user.name', user
)
2068 email
= cfg
.get('user.email', '%s@%s' % (user
, core
.node()))
2069 return '\nSigned-off-by: %s <%s>' % (name
, email
)
2072 def check_conflicts(context
, unmerged
):
2073 """Check paths for conflicts
2075 Conflicting files can be filtered out one-by-one.
2078 if prefs
.check_conflicts(context
):
2079 unmerged
= [path
for path
in unmerged
if is_conflict_free(path
)]
2083 def is_conflict_free(path
):
2084 """Return True if `path` contains no conflict markers
2086 rgx
= re
.compile(r
'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2088 with core
.xopen(path
, 'r') as f
:
2090 line
= core
.decode(line
, errors
='ignore')
2092 return should_stage_conflicts(path
)
2094 # We can't read this file ~ we may be staging a removal
2099 def should_stage_conflicts(path
):
2100 """Inform the user that a file contains merge conflicts
2102 Return `True` if we should stage the path nonetheless.
2105 title
= msg
= N_('Stage conflicts?')
2106 info
= N_('%s appears to contain merge conflicts.\n\n'
2107 'You should probably skip this file.\n'
2108 'Stage it anyways?') % path
2109 ok_text
= N_('Stage conflicts')
2110 cancel_text
= N_('Skip')
2111 return Interaction
.confirm(title
, msg
, info
, ok_text
,
2112 default
=False, cancel_text
=cancel_text
)
2115 class Stage(ContextCommand
):
2116 """Stage a set of paths."""
2122 def __init__(self
, context
, paths
):
2123 super(Stage
, self
).__init
__(context
)
2127 msg
= N_('Staging: %s') % (', '.join(self
.paths
))
2128 Interaction
.log(msg
)
2129 return self
.stage_paths()
2131 def stage_paths(self
):
2132 """Stages add/removals to git."""
2133 context
= self
.context
2136 if self
.model
.cfg
.get('cola.safemode', False):
2138 return self
.stage_all()
2143 for path
in set(paths
):
2144 if core
.exists(path
) or core
.islink(path
):
2145 if path
.endswith('/'):
2146 path
= path
.rstrip('/')
2151 self
.model
.emit_about_to_update()
2153 # `git add -u` doesn't work on untracked files
2155 status
, out
, err
= gitcmds
.add(context
, add
)
2156 Interaction
.command(N_('Error'), 'git add', status
, out
, err
)
2158 # If a path doesn't exist then that means it should be removed
2159 # from the index. We use `git add -u` for that.
2161 status
, out
, err
= gitcmds
.add(context
, remove
, u
=True)
2162 Interaction
.command(N_('Error'), 'git add -u', status
, out
, err
)
2164 self
.model
.update_files(emit
=True)
2165 return status
, out
, err
2167 def stage_all(self
):
2168 """Stage all files"""
2169 status
, out
, err
= self
.git
.add(v
=True, u
=True)
2170 Interaction
.command(N_('Error'), 'git add -u', status
, out
, err
)
2171 self
.model
.update_file_status()
2172 return (status
, out
, err
)
2175 class StageCarefully(Stage
):
2176 """Only stage when the path list is non-empty
2178 We use "git add -u -- <pathspec>" to stage, and it stages everything by
2179 default when no pathspec is specified, so this class ensures that paths
2180 are specified before calling git.
2182 When no paths are specified, the command does nothing.
2185 def __init__(self
, context
):
2186 super(StageCarefully
, self
).__init
__(context
, None)
2189 # pylint: disable=no-self-use
2190 def init_paths(self
):
2191 """Initialize path data"""
2194 def ok_to_run(self
):
2195 """Prevent catch-all "git add -u" from adding unmerged files"""
2196 return self
.paths
or not self
.model
.unmerged
2199 """Stage files when ok_to_run() return True"""
2200 if self
.ok_to_run():
2201 return super(StageCarefully
, self
).do()
2205 class StageModified(StageCarefully
):
2206 """Stage all modified files."""
2210 return N_('Stage Modified')
2212 def init_paths(self
):
2213 self
.paths
= self
.model
.modified
2216 class StageUnmerged(StageCarefully
):
2217 """Stage unmerged files."""
2221 return N_('Stage Unmerged')
2223 def init_paths(self
):
2224 self
.paths
= check_conflicts(self
.context
, self
.model
.unmerged
)
2227 class StageUntracked(StageCarefully
):
2228 """Stage all untracked files."""
2232 return N_('Stage Untracked')
2234 def init_paths(self
):
2235 self
.paths
= self
.model
.untracked
2238 class StageOrUnstage(ContextCommand
):
2239 """If the selection is staged, unstage it, otherwise stage"""
2243 return N_('Stage / Unstage')
2246 s
= self
.selection
.selection()
2248 do(Unstage
, self
.context
, s
.staged
)
2251 unmerged
= check_conflicts(self
.context
, s
.unmerged
)
2253 unstaged
.extend(unmerged
)
2255 unstaged
.extend(s
.modified
)
2257 unstaged
.extend(s
.untracked
)
2259 do(Stage
, self
.context
, unstaged
)
2262 class Tag(ContextCommand
):
2263 """Create a tag object."""
2265 def __init__(self
, context
, name
, revision
, sign
=False, message
=''):
2266 super(Tag
, self
).__init
__(context
)
2268 self
._message
= message
2269 self
._revision
= revision
2275 revision
= self
._revision
2276 tag_name
= self
._name
2277 tag_message
= self
._message
2280 Interaction
.critical(
2281 N_('Missing Revision'),
2282 N_('Please specify a revision to tag.'))
2286 Interaction
.critical(
2288 N_('Please specify a name for the new tag.'))
2291 title
= N_('Missing Tag Message')
2292 message
= N_('Tag-signing was requested but the tag message is empty.')
2293 info
= N_('An unsigned, lightweight tag will be created instead.\n'
2294 'Create an unsigned tag?')
2295 ok_text
= N_('Create Unsigned Tag')
2297 if sign
and not tag_message
:
2298 # We require a message in order to sign the tag, so if they
2299 # choose to create an unsigned tag we have to clear the sign flag.
2300 if not Interaction
.confirm(title
, message
, info
, ok_text
,
2301 default
=False, icon
=icons
.save()):
2309 tmp_file
= utils
.tmp_filename('tag-message')
2310 opts
['file'] = tmp_file
2311 core
.write(tmp_file
, tag_message
)
2316 opts
['annotate'] = True
2317 status
, out
, err
= git
.tag(tag_name
, revision
, **opts
)
2320 core
.unlink(tmp_file
)
2322 title
= N_('Error: could not create tag "%s"') % tag_name
2323 Interaction
.command(title
, 'git tag', status
, out
, err
)
2327 self
.model
.update_status()
2328 Interaction
.information(
2330 N_('Created a new tag named "%s"') % tag_name
,
2331 details
=tag_message
or None)
2336 class Unstage(ContextCommand
):
2337 """Unstage a set of paths."""
2341 return N_('Unstage')
2343 def __init__(self
, context
, paths
):
2344 super(Unstage
, self
).__init
__(context
)
2349 context
= self
.context
2350 head
= self
.model
.head
2353 msg
= N_('Unstaging: %s') % (', '.join(paths
))
2354 Interaction
.log(msg
)
2356 return unstage_all(context
)
2357 status
, out
, err
= gitcmds
.unstage_paths(context
, paths
, head
=head
)
2358 Interaction
.command(N_('Error'), 'git reset', status
, out
, err
)
2359 self
.model
.update_file_status()
2360 return (status
, out
, err
)
2363 class UnstageAll(ContextCommand
):
2364 """Unstage all files; resets the index."""
2367 return unstage_all(self
.context
)
2370 def unstage_all(context
):
2371 """Unstage all files, even while amending"""
2372 model
= context
.model
2375 status
, out
, err
= git
.reset(head
, '--', '.')
2376 Interaction
.command(N_('Error'), 'git reset', status
, out
, err
)
2377 model
.update_file_status()
2378 return (status
, out
, err
)
2381 class StageSelected(ContextCommand
):
2382 """Stage selected files, or all files if no selection exists."""
2385 context
= self
.context
2386 paths
= self
.selection
.unstaged
2388 do(Stage
, context
, paths
)
2389 elif self
.cfg
.get('cola.safemode', False):
2390 do(StageModified
, context
)
2393 class UnstageSelected(Unstage
):
2394 """Unstage selected files."""
2396 def __init__(self
, context
):
2397 staged
= self
.selection
.staged
2398 super(UnstageSelected
, self
).__init
__(context
, staged
)
2401 class Untrack(ContextCommand
):
2402 """Unstage a set of paths."""
2404 def __init__(self
, context
, paths
):
2405 super(Untrack
, self
).__init
__(context
)
2409 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
2410 Interaction
.log(msg
)
2411 status
, out
, err
= self
.model
.untrack_paths(self
.paths
)
2412 Interaction
.log_status(status
, out
, err
)
2415 class UntrackedSummary(EditModel
):
2416 """List possible .gitignore rules as the diff text."""
2418 def __init__(self
, context
):
2419 super(UntrackedSummary
, self
).__init
__(context
)
2420 untracked
= self
.model
.untracked
2421 suffix
= 's' if untracked
else ''
2423 io
.write('# %s untracked file%s\n' % (len(untracked
), suffix
))
2425 io
.write('# possible .gitignore rule%s:\n' % suffix
)
2427 io
.write('/'+u
+'\n')
2428 self
.new_diff_text
= io
.getvalue()
2429 self
.new_diff_type
= 'text'
2430 self
.new_mode
= self
.model
.mode_untracked
2433 class VisualizeAll(ContextCommand
):
2434 """Visualize all branches."""
2437 context
= self
.context
2438 browser
= utils
.shell_split(prefs
.history_browser(context
))
2439 launch_history_browser(browser
+ ['--all'])
2442 class VisualizeCurrent(ContextCommand
):
2443 """Visualize all branches."""
2446 context
= self
.context
2447 browser
= utils
.shell_split(prefs
.history_browser(context
))
2448 launch_history_browser(browser
+ [self
.model
.currentbranch
] + ['--'])
2451 class VisualizePaths(ContextCommand
):
2452 """Path-limited visualization."""
2454 def __init__(self
, context
, paths
):
2455 super(VisualizePaths
, self
).__init
__(context
)
2456 context
= self
.context
2457 browser
= utils
.shell_split(prefs
.history_browser(context
))
2459 self
.argv
= browser
+ ['--'] + list(paths
)
2464 launch_history_browser(self
.argv
)
2467 class VisualizeRevision(ContextCommand
):
2468 """Visualize a specific revision."""
2470 def __init__(self
, context
, revision
, paths
=None):
2471 super(VisualizeRevision
, self
).__init
__(context
)
2472 self
.revision
= revision
2476 context
= self
.context
2477 argv
= utils
.shell_split(prefs
.history_browser(context
))
2479 argv
.append(self
.revision
)
2482 argv
.extend(self
.paths
)
2483 launch_history_browser(argv
)
2486 class SubmoduleUpdate(ConfirmAction
):
2487 """Update specified submodule"""
2489 def __init__(self
, context
, path
):
2490 super(SubmoduleUpdate
, self
).__init
__(context
)
2494 title
= N_('Update Submodule...')
2495 question
= N_('Update this submodule?')
2496 info
= N_('The submodule will be updated using\n'
2497 '"%s"' % self
.command())
2498 ok_txt
= N_('Update Submodule')
2499 return Interaction
.confirm(title
, question
, info
, ok_txt
,
2500 default
=False, icon
=icons
.pull())
2503 context
= self
.context
2504 return context
.git
.submodule('update', '--', self
.path
)
2507 self
.model
.update_file_status()
2509 def error_message(self
):
2510 return N_('Error updating submodule %s' % self
.path
)
2513 command
= 'git submodule update -- %s'
2514 return command
% self
.path
2517 class SubmodulesUpdate(ConfirmAction
):
2518 """Update all submodules"""
2521 title
= N_('Update submodules...')
2522 question
= N_('Update all submodules?')
2523 info
= N_('All submodules will be updated using\n'
2524 '"%s"' % self
.command())
2525 ok_txt
= N_('Update Submodules')
2526 return Interaction
.confirm(title
, question
, info
, ok_txt
,
2527 default
=False, icon
=icons
.pull())
2530 context
= self
.context
2531 return context
.git
.submodule('update')
2534 self
.model
.update_file_status()
2536 def error_message(self
):
2537 return N_('Error updating submodules')
2540 return 'git submodule update'
2543 def launch_history_browser(argv
):
2544 """Launch the configured history browser"""
2547 except OSError as e
:
2548 _
, details
= utils
.format_exception(e
)
2549 title
= N_('Error Launching History Browser')
2550 msg
= (N_('Cannot exec "%s": please configure a history browser') %
2552 Interaction
.critical(title
, message
=msg
, details
=details
)
2555 def run(cls
, *args
, **opts
):
2557 Returns a callback that runs a command
2559 If the caller of run() provides args or opts then those are
2560 used instead of the ones provided by the invoker of the callback.
2563 def runner(*local_args
, **local_opts
):
2564 """Closure return by run() which runs the command"""
2566 do(cls
, *args
, **opts
)
2568 do(cls
, *local_args
, **local_opts
)
2573 def do(cls
, *args
, **opts
):
2574 """Run a command in-place"""
2576 cmd
= cls(*args
, **opts
)
2578 except Exception as e
: # pylint: disable=broad-except
2579 msg
, details
= utils
.format_exception(e
)
2580 if hasattr(cls
, '__name__'):
2581 msg
= ('%s exception:\n%s' % (cls
.__name
__, msg
))
2582 Interaction
.critical(N_('Error'), message
=msg
, details
=details
)
2586 def difftool_run(context
):
2587 """Start a default difftool session"""
2588 selection
= context
.selection
2589 files
= selection
.group()
2592 s
= selection
.selection()
2593 head
= context
.model
.head
2594 difftool_launch_with_head(context
, files
, bool(s
.staged
), head
)
2597 def difftool_launch_with_head(context
, filenames
, staged
, head
):
2598 """Launch difftool against the provided head"""
2603 difftool_launch(context
, left
=left
, staged
=staged
, paths
=filenames
)
2606 def difftool_launch(context
, left
=None, right
=None, paths
=None,
2607 staged
=False, dir_diff
=False,
2608 left_take_magic
=False, left_take_parent
=False):
2609 """Launches 'git difftool' with given parameters
2611 :param left: first argument to difftool
2612 :param right: second argument to difftool_args
2613 :param paths: paths to diff
2614 :param staged: activate `git difftool --staged`
2615 :param dir_diff: activate `git difftool --dir-diff`
2616 :param left_take_magic: whether to append the magic ^! diff expression
2617 :param left_take_parent: whether to append the first-parent ~ for diffing
2621 difftool_args
= ['git', 'difftool', '--no-prompt']
2623 difftool_args
.append('--cached')
2625 difftool_args
.append('--dir-diff')
2628 if left_take_parent
or left_take_magic
:
2629 suffix
= '^!' if left_take_magic
else '~'
2630 # Check root commit (no parents and thus cannot execute '~')
2632 status
, out
, err
= git
.rev_list(left
, parents
=True, n
=1)
2633 Interaction
.log_status(status
, out
, err
)
2635 raise OSError('git rev-list command failed')
2637 if len(out
.split()) >= 2:
2638 # Commit has a parent, so we can take its child as requested
2641 # No parent, assume it's the root commit, so we have to diff
2642 # against the empty tree.
2643 left
= EMPTY_TREE_OID
2644 if not right
and left_take_magic
:
2646 difftool_args
.append(left
)
2649 difftool_args
.append(right
)
2652 difftool_args
.append('--')
2653 difftool_args
.extend(paths
)
2655 runtask
= context
.runtask
2657 Interaction
.async_command(N_('Difftool'), difftool_args
, runtask
)
2659 core
.fork(difftool_args
)