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."""
382 def __init__(self
, context
, paths
):
383 super(BlamePaths
, self
).__init
__(context
)
384 viewer
= utils
.shell_split(prefs
.blame_viewer(context
))
385 self
.argv
= viewer
+ list(paths
)
391 _
, details
= utils
.format_exception(e
)
392 title
= N_('Error Launching Blame Viewer')
393 msg
= (N_('Cannot exec "%s": please configure a blame viewer')
394 % ' '.join(self
.argv
))
395 Interaction
.critical(title
, message
=msg
, details
=details
)
398 class CheckoutBranch(Checkout
):
399 """Checkout a branch."""
401 def __init__(self
, context
, branch
):
403 super(CheckoutBranch
, self
).__init
__(
404 context
, args
, checkout_branch
=True)
407 class CherryPick(ContextCommand
):
408 """Cherry pick commits into the current branch."""
410 def __init__(self
, context
, commits
):
411 super(CherryPick
, self
).__init
__(context
)
412 self
.commits
= commits
415 self
.model
.cherry_pick_list(self
.commits
)
416 self
.model
.update_file_status()
419 class Revert(ContextCommand
):
420 """Cherry pick commits into the current branch."""
422 def __init__(self
, context
, oid
):
423 super(Revert
, self
).__init
__(context
)
427 self
.git
.revert(self
.oid
)
428 self
.model
.update_file_status()
431 class ResetMode(EditModel
):
432 """Reset the mode and clear the model's diff text."""
434 def __init__(self
, context
):
435 super(ResetMode
, self
).__init
__(context
)
436 self
.new_mode
= self
.model
.mode_none
437 self
.new_diff_text
= ''
438 self
.new_diff_type
= 'text'
439 self
.new_filename
= ''
442 super(ResetMode
, self
).do()
443 self
.model
.update_file_status()
446 class ResetCommand(ConfirmAction
):
447 """Reset state using the "git reset" command"""
449 def __init__(self
, context
, ref
):
450 super(ResetCommand
, self
).__init
__(context
)
459 def error_message(self
):
463 self
.model
.update_file_status()
466 raise NotImplementedError('confirm() must be overridden')
469 raise NotImplementedError('reset() must be overridden')
472 class ResetBranchHead(ResetCommand
):
475 title
= N_('Reset Branch')
476 question
= N_('Point the current branch head to a new commit?')
477 info
= N_('The branch will be reset using "git reset --mixed %s"')
478 ok_text
= N_('Reset Branch')
479 info
= info
% self
.ref
480 return Interaction
.confirm(title
, question
, info
, ok_text
)
483 return self
.git
.reset(self
.ref
, '--', mixed
=True)
486 class ResetWorktree(ResetCommand
):
489 title
= N_('Reset Worktree')
490 question
= N_('Reset worktree?')
491 info
= N_('The worktree will be reset using "git reset --keep %s"')
492 ok_text
= N_('Reset Worktree')
493 info
= info
% self
.ref
494 return Interaction
.confirm(title
, question
, info
, ok_text
)
497 return self
.git
.reset(self
.ref
, '--', keep
=True)
500 class ResetMerge(ResetCommand
):
503 title
= N_('Reset Merge')
504 question
= N_('Reset merge?')
505 info
= N_('The branch will be reset using "git reset --merge %s"')
506 ok_text
= N_('Reset Merge')
507 info
= info
% self
.ref
508 return Interaction
.confirm(title
, question
, info
, ok_text
)
511 return self
.git
.reset(self
.ref
, '--', merge
=True)
514 class ResetSoft(ResetCommand
):
517 title
= N_('Reset Soft')
518 question
= N_('Reset soft?')
519 info
= N_('The branch will be reset using "git reset --soft %s"')
520 ok_text
= N_('Reset Soft')
521 info
= info
% self
.ref
522 return Interaction
.confirm(title
, question
, info
, ok_text
)
525 return self
.git
.reset(self
.ref
, '--', soft
=True)
528 class ResetHard(ResetCommand
):
531 title
= N_('Reset Hard')
532 question
= N_('Reset hard?')
533 info
= N_('The branch will be reset using "git reset --hard %s"')
534 ok_text
= N_('Reset Hard')
535 info
= info
% self
.ref
536 return Interaction
.confirm(title
, question
, info
, ok_text
)
539 return self
.git
.reset(self
.ref
, '--', hard
=True)
542 class Commit(ResetMode
):
543 """Attempt to create a new commit."""
545 def __init__(self
, context
, amend
, msg
, sign
, no_verify
=False):
546 super(Commit
, self
).__init
__(context
)
550 self
.no_verify
= no_verify
551 self
.old_commitmsg
= self
.model
.commitmsg
552 self
.new_commitmsg
= ''
555 # Create the commit message file
556 context
= self
.context
557 comment_char
= prefs
.comment_char(context
)
558 msg
= self
.strip_comments(self
.msg
, comment_char
=comment_char
)
559 tmp_file
= utils
.tmp_filename('commit-message')
561 core
.write(tmp_file
, msg
)
563 status
, out
, err
= self
.git
.commit(
564 F
=tmp_file
, v
=True, gpg_sign
=self
.sign
,
565 amend
=self
.amend
, no_verify
=self
.no_verify
)
567 core
.unlink(tmp_file
)
569 super(Commit
, self
).do()
570 self
.model
.set_commitmsg(self
.new_commitmsg
)
572 title
= N_('Commit failed')
573 Interaction
.command(title
, 'git commit', status
, out
, err
)
575 return status
, out
, err
578 def strip_comments(msg
, comment_char
='#'):
580 message_lines
= [line
for line
in msg
.split('\n')
581 if not line
.startswith(comment_char
)]
582 msg
= '\n'.join(message_lines
)
583 if not msg
.endswith('\n'):
589 class Ignore(ContextCommand
):
590 """Add files to .gitignore"""
592 def __init__(self
, context
, filenames
):
593 super(Ignore
, self
).__init
__(context
)
594 self
.filenames
= list(filenames
)
597 if not self
.filenames
:
599 new_additions
= '\n'.join(self
.filenames
) + '\n'
600 for_status
= new_additions
601 if core
.exists('.gitignore'):
602 current_list
= core
.read('.gitignore')
603 new_additions
= current_list
.rstrip() + '\n' + new_additions
604 core
.write('.gitignore', new_additions
)
605 Interaction
.log_status(0, 'Added to .gitignore:\n%s' % for_status
, '')
606 self
.model
.update_file_status()
609 def file_summary(files
):
610 txt
= core
.list2cmdline(files
)
612 txt
= txt
[:768].rstrip() + '...'
613 wrap
= textwrap
.TextWrapper()
614 return '\n'.join(wrap
.wrap(txt
))
617 class RemoteCommand(ConfirmAction
):
619 def __init__(self
, context
, remote
):
620 super(RemoteCommand
, self
).__init
__(context
)
625 self
.model
.update_remotes()
628 class RemoteAdd(RemoteCommand
):
630 def __init__(self
, context
, remote
, url
):
631 super(RemoteAdd
, self
).__init
__(context
, remote
)
635 return self
.git
.remote('add', self
.remote
, self
.url
)
637 def error_message(self
):
638 return N_('Error creating remote "%s"') % self
.remote
641 return 'git remote add "%s" "%s"' % (self
.remote
, self
.url
)
644 class RemoteRemove(RemoteCommand
):
647 title
= N_('Delete Remote')
648 question
= N_('Delete remote?')
649 info
= N_('Delete remote "%s"') % self
.remote
650 ok_text
= N_('Delete')
651 return Interaction
.confirm(title
, question
, info
, ok_text
)
654 return self
.git
.remote('rm', self
.remote
)
656 def error_message(self
):
657 return N_('Error deleting remote "%s"') % self
.remote
660 return 'git remote rm "%s"' % self
.remote
663 class RemoteRename(RemoteCommand
):
665 def __init__(self
, context
, remote
, new_name
):
666 super(RemoteRename
, self
).__init
__(context
, remote
)
667 self
.new_name
= new_name
670 title
= N_('Rename Remote')
671 text
= (N_('Rename remote "%(current)s" to "%(new)s"?') %
672 dict(current
=self
.remote
, new
=self
.new_name
))
675 return Interaction
.confirm(title
, text
, info_text
, ok_text
)
678 return self
.git
.remote('rename', self
.remote
, self
.new_name
)
680 def error_message(self
):
681 return (N_('Error renaming "%(name)s" to "%(new_name)s"')
682 % dict(name
=self
.remote
, new_name
=self
.new_name
))
685 return 'git remote rename "%s" "%s"' % (self
.remote
, self
.new_name
)
688 class RemoteSetURL(RemoteCommand
):
690 def __init__(self
, context
, remote
, url
):
691 super(RemoteSetURL
, self
).__init
__(context
, remote
)
695 return self
.git
.remote('set-url', self
.remote
, self
.url
)
697 def error_message(self
):
698 return (N_('Unable to set URL for "%(name)s" to "%(url)s"')
699 % dict(name
=self
.remote
, url
=self
.url
))
702 return 'git remote set-url "%s" "%s"' % (self
.remote
, self
.url
)
705 class RemoteEdit(ContextCommand
):
706 """Combine RemoteRename and RemoteSetURL"""
708 def __init__(self
, context
, old_name
, remote
, url
):
709 super(RemoteEdit
, self
).__init
__(context
)
710 self
.rename
= RemoteRename(context
, old_name
, remote
)
711 self
.set_url
= RemoteSetURL(context
, remote
, url
)
714 result
= self
.rename
.do()
718 result
= self
.set_url
.do()
720 return name_ok
, url_ok
723 class RemoveFromSettings(ConfirmAction
):
725 def __init__(self
, context
, settings
, repo
, entry
, icon
=None):
726 super(RemoveFromSettings
, self
).__init
__(context
)
727 self
.settings
= settings
736 class RemoveBookmark(RemoveFromSettings
):
740 title
= msg
= N_('Delete Bookmark?')
741 info
= N_('%s will be removed from your bookmarks.') % entry
742 ok_text
= N_('Delete Bookmark')
743 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
746 self
.settings
.remove_bookmark(self
.repo
, self
.entry
)
750 class RemoveRecent(RemoveFromSettings
):
754 title
= msg
= N_('Remove %s from the recent list?') % repo
755 info
= N_('%s will be removed from your recent repositories.') % repo
756 ok_text
= N_('Remove')
757 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
760 self
.settings
.remove_recent(self
.repo
)
764 class RemoveFiles(ContextCommand
):
767 def __init__(self
, context
, remover
, filenames
):
768 super(RemoveFiles
, self
).__init
__(context
)
771 self
.remover
= remover
772 self
.filenames
= filenames
773 # We could git-hash-object stuff and provide undo-ability
777 files
= self
.filenames
783 remove
= self
.remover
784 for filename
in files
:
790 bad_filenames
.append(filename
)
793 Interaction
.information(
795 N_('Deleting "%s" failed') % file_summary(bad_filenames
))
798 self
.model
.update_file_status()
801 class Delete(RemoveFiles
):
804 def __init__(self
, context
, filenames
):
805 super(Delete
, self
).__init
__(context
, os
.remove
, filenames
)
808 files
= self
.filenames
812 title
= N_('Delete Files?')
813 msg
= N_('The following files will be deleted:') + '\n\n'
814 msg
+= file_summary(files
)
815 info_txt
= N_('Delete %d file(s)?') % len(files
)
816 ok_txt
= N_('Delete Files')
818 if Interaction
.confirm(title
, msg
, info_txt
, ok_txt
,
819 default
=True, icon
=icons
.remove()):
820 super(Delete
, self
).do()
823 class MoveToTrash(RemoveFiles
):
824 """Move files to the trash using send2trash"""
826 AVAILABLE
= send2trash
is not None
828 def __init__(self
, context
, filenames
):
829 super(MoveToTrash
, self
).__init
__(context
, send2trash
, filenames
)
832 class DeleteBranch(ContextCommand
):
833 """Delete a git branch."""
835 def __init__(self
, context
, branch
):
836 super(DeleteBranch
, self
).__init
__(context
)
840 status
, out
, err
= self
.model
.delete_branch(self
.branch
)
841 Interaction
.log_status(status
, out
, err
)
844 class Rename(ContextCommand
):
845 """Rename a set of paths."""
847 def __init__(self
, context
, paths
):
848 super(Rename
, self
).__init
__(context
)
852 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
855 for path
in self
.paths
:
856 ok
= self
.rename(path
)
860 self
.model
.update_status()
862 def rename(self
, path
):
864 title
= N_('Rename "%s"') % path
866 if os
.path
.isdir(path
):
867 base_path
= os
.path
.dirname(path
)
870 new_path
= Interaction
.save_as(base_path
, title
)
874 status
, out
, err
= git
.mv(path
, new_path
, force
=True, verbose
=True)
875 Interaction
.command(N_('Error'), 'git mv', status
, out
, err
)
879 class RenameBranch(ContextCommand
):
880 """Rename a git branch."""
882 def __init__(self
, context
, branch
, new_branch
):
883 super(RenameBranch
, self
).__init
__(context
)
885 self
.new_branch
= new_branch
889 new_branch
= self
.new_branch
890 status
, out
, err
= self
.model
.rename_branch(branch
, new_branch
)
891 Interaction
.log_status(status
, out
, err
)
894 class DeleteRemoteBranch(ContextCommand
):
895 """Delete a remote git branch."""
897 def __init__(self
, context
, remote
, branch
):
898 super(DeleteRemoteBranch
, self
).__init
__(context
)
903 status
, out
, err
= self
.git
.push(self
.remote
, self
.branch
, delete
=True)
904 self
.model
.update_status()
906 title
= N_('Error Deleting Remote Branch')
907 Interaction
.command(title
, 'git push', status
, out
, err
)
909 Interaction
.information(
910 N_('Remote Branch Deleted'),
911 N_('"%(branch)s" has been deleted from "%(remote)s".')
912 % dict(branch
=self
.branch
, remote
=self
.remote
))
915 def get_mode(model
, staged
, modified
, unmerged
, untracked
):
917 mode
= model
.mode_index
918 elif modified
or unmerged
:
919 mode
= model
.mode_worktree
921 mode
= model
.mode_untracked
927 class DiffImage(EditModel
):
929 def __init__(self
, context
, filename
,
930 deleted
, staged
, modified
, unmerged
, untracked
):
931 super(DiffImage
, self
).__init
__(context
)
933 self
.new_filename
= filename
934 self
.new_diff_text
= ''
935 self
.new_diff_type
= 'image'
936 self
.new_mode
= get_mode(
937 self
.model
, staged
, modified
, unmerged
, untracked
)
939 self
.modified
= modified
940 self
.unmerged
= unmerged
941 self
.untracked
= untracked
942 self
.deleted
= deleted
943 self
.annex
= self
.cfg
.is_annex()
946 filename
= self
.new_filename
949 images
= self
.staged_images()
951 images
= self
.modified_images()
953 images
= self
.unmerged_images()
955 images
= [(filename
, False)]
959 self
.model
.set_images(images
)
960 super(DiffImage
, self
).do()
962 def staged_images(self
):
963 context
= self
.context
965 head
= self
.model
.head
966 filename
= self
.new_filename
970 index
= git
.diff_index(head
, '--', filename
, cached
=True)[STDOUT
]
973 # :100644 100644 fabadb8... 4866510... M describe.c
974 parts
= index
.split(' ')
979 if old_oid
!= MISSING_BLOB_OID
:
980 # First, check if we can get a pre-image from git-annex
983 annex_image
= gitcmds
.annex_path(context
, head
, filename
)
985 images
.append((annex_image
, False)) # git annex HEAD
987 image
= gitcmds
.write_blob_path(
988 context
, head
, old_oid
, filename
)
990 images
.append((image
, True))
992 if new_oid
!= MISSING_BLOB_OID
:
993 found_in_annex
= False
994 if annex
and core
.islink(filename
):
995 status
, out
, _
= git
.annex('status', '--', filename
)
997 details
= out
.split(' ')
998 if details
and details
[0] == 'A': # newly added file
999 images
.append((filename
, False))
1000 found_in_annex
= True
1002 if not found_in_annex
:
1003 image
= gitcmds
.write_blob(context
, new_oid
, filename
)
1005 images
.append((image
, True))
1009 def unmerged_images(self
):
1010 context
= self
.context
1012 head
= self
.model
.head
1013 filename
= self
.new_filename
1016 candidate_merge_heads
= ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1018 merge_head
for merge_head
in candidate_merge_heads
1019 if core
.exists(git
.git_path(merge_head
))]
1021 if annex
: # Attempt to find files in git-annex
1023 for merge_head
in merge_heads
:
1024 image
= gitcmds
.annex_path(context
, merge_head
, filename
)
1026 annex_images
.append((image
, False))
1028 annex_images
.append((filename
, False))
1031 # DIFF FORMAT FOR MERGES
1032 # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1033 # can take -c or --cc option to generate diff output also
1034 # for merge commits. The output differs from the format
1035 # described above in the following way:
1037 # 1. there is a colon for each parent
1038 # 2. there are more "src" modes and "src" sha1
1039 # 3. status is concatenated status characters for each parent
1040 # 4. no optional "score" number
1041 # 5. single path, only for "dst"
1043 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1046 index
= git
.diff_index(head
, '--', filename
,
1047 cached
=True, cc
=True)[STDOUT
]
1049 parts
= index
.split(' ')
1051 first_mode
= parts
[0]
1052 num_parents
= first_mode
.count(':')
1053 # colon for each parent, but for the index, the "parents"
1054 # are really entries in stages 1,2,3 (head, base, remote)
1055 # remote, base, head
1056 for i
in range(num_parents
):
1057 offset
= num_parents
+ i
+ 1
1060 merge_head
= merge_heads
[i
]
1063 if oid
!= MISSING_BLOB_OID
:
1064 image
= gitcmds
.write_blob_path(
1065 context
, merge_head
, oid
, filename
)
1067 images
.append((image
, True))
1069 images
.append((filename
, False))
1072 def modified_images(self
):
1073 context
= self
.context
1075 head
= self
.model
.head
1076 filename
= self
.new_filename
1081 if annex
: # Check for a pre-image from git-annex
1082 annex_image
= gitcmds
.annex_path(context
, head
, filename
)
1084 images
.append((annex_image
, False)) # git annex HEAD
1086 worktree
= git
.diff_files('--', filename
)[STDOUT
]
1087 parts
= worktree
.split(' ')
1090 if oid
!= MISSING_BLOB_OID
:
1091 image
= gitcmds
.write_blob_path(
1092 context
, head
, oid
, filename
)
1094 images
.append((image
, True)) # HEAD
1096 images
.append((filename
, False)) # worktree
1100 class Diff(EditModel
):
1101 """Perform a diff and set the model's current text."""
1103 def __init__(self
, context
, filename
, cached
=False, deleted
=False):
1104 super(Diff
, self
).__init
__(context
)
1107 opts
['ref'] = self
.model
.head
1108 self
.new_filename
= filename
1109 self
.new_mode
= self
.model
.mode_worktree
1110 self
.new_diff_text
= gitcmds
.diff_helper(
1111 self
.context
, filename
=filename
, cached
=cached
,
1112 deleted
=deleted
, **opts
)
1113 self
.new_diff_type
= 'text'
1116 class Diffstat(EditModel
):
1117 """Perform a diffstat and set the model's diff text."""
1119 def __init__(self
, context
):
1120 super(Diffstat
, self
).__init
__(context
)
1122 diff_context
= cfg
.get('diff.context', 3)
1123 diff
= self
.git
.diff(
1124 self
.model
.head
, unified
=diff_context
, no_ext_diff
=True,
1125 no_color
=True, M
=True, stat
=True)[STDOUT
]
1126 self
.new_diff_text
= diff
1127 self
.new_diff_type
= 'text'
1128 self
.new_mode
= self
.model
.mode_diffstat
1131 class DiffStaged(Diff
):
1132 """Perform a staged diff on a file."""
1134 def __init__(self
, context
, filename
, deleted
=None):
1135 super(DiffStaged
, self
).__init
__(
1136 context
, filename
, cached
=True, deleted
=deleted
)
1137 self
.new_mode
= self
.model
.mode_index
1140 class DiffStagedSummary(EditModel
):
1142 def __init__(self
, context
):
1143 super(DiffStagedSummary
, self
).__init
__(context
)
1144 diff
= self
.git
.diff(
1145 self
.model
.head
, cached
=True, no_color
=True,
1146 no_ext_diff
=True, patch_with_stat
=True, M
=True)[STDOUT
]
1147 self
.new_diff_text
= diff
1148 self
.new_diff_type
= 'text'
1149 self
.new_mode
= self
.model
.mode_index
1152 class Difftool(ContextCommand
):
1153 """Run git-difftool limited by path."""
1155 def __init__(self
, context
, staged
, filenames
):
1156 super(Difftool
, self
).__init
__(context
)
1157 self
.staged
= staged
1158 self
.filenames
= filenames
1161 difftool_launch_with_head(
1162 self
.context
, self
.filenames
, self
.staged
, self
.model
.head
)
1165 class Edit(ContextCommand
):
1166 """Edit a file using the configured gui.editor."""
1170 return N_('Launch Editor')
1172 def __init__(self
, context
, filenames
,
1173 line_number
=None, background_editor
=False):
1174 super(Edit
, self
).__init
__(context
)
1175 self
.filenames
= filenames
1176 self
.line_number
= line_number
1177 self
.background_editor
= background_editor
1180 context
= self
.context
1181 if not self
.filenames
:
1183 filename
= self
.filenames
[0]
1184 if not core
.exists(filename
):
1186 if self
.background_editor
:
1187 editor
= prefs
.background_editor(context
)
1189 editor
= prefs
.editor(context
)
1192 if self
.line_number
is None:
1193 opts
= self
.filenames
1195 # Single-file w/ line-numbers (likely from grep)
1197 '*vim*': [filename
, '+%s' % self
.line_number
],
1198 '*emacs*': ['+%s' % self
.line_number
, filename
],
1199 '*textpad*': ['%s(%s,0)' % (filename
, self
.line_number
)],
1200 '*notepad++*': ['-n%s' % self
.line_number
, filename
],
1201 '*subl*': ['%s:%s' % (filename
, self
.line_number
)],
1204 opts
= self
.filenames
1205 for pattern
, opt
in editor_opts
.items():
1206 if fnmatch(editor
, pattern
):
1211 core
.fork(utils
.shell_split(editor
) + opts
)
1212 except (OSError, ValueError) as e
:
1213 message
= (N_('Cannot exec "%s": please configure your editor')
1215 _
, details
= utils
.format_exception(e
)
1216 Interaction
.critical(N_('Error Editing File'), message
, details
)
1219 class FormatPatch(ContextCommand
):
1220 """Output a patch series given all revisions and a selected subset."""
1222 def __init__(self
, context
, to_export
, revs
, output
='patches'):
1223 super(FormatPatch
, self
).__init
__(context
)
1224 self
.to_export
= list(to_export
)
1225 self
.revs
= list(revs
)
1226 self
.output
= output
1229 context
= self
.context
1230 status
, out
, err
= gitcmds
.format_patchsets(
1231 context
, self
.to_export
, self
.revs
, self
.output
)
1232 Interaction
.log_status(status
, out
, err
)
1235 class LaunchDifftool(ContextCommand
):
1239 return N_('Launch Diff Tool')
1242 s
= self
.selection
.selection()
1245 if utils
.is_win32():
1246 core
.fork(['git', 'mergetool', '--no-prompt', '--'] + paths
)
1249 cmd
= cfg
.terminal()
1250 argv
= utils
.shell_split(cmd
)
1251 mergetool
= ['git', 'mergetool', '--no-prompt', '--']
1252 mergetool
.extend(paths
)
1253 needs_shellquote
= set(['gnome-terminal', 'xfce4-terminal'])
1254 if os
.path
.basename(argv
[0]) in needs_shellquote
:
1255 argv
.append(core
.list2cmdline(mergetool
))
1257 argv
.extend(mergetool
)
1260 difftool_run(self
.context
)
1263 class LaunchTerminal(ContextCommand
):
1267 return N_('Launch Terminal')
1270 def is_available(context
):
1271 return context
.cfg
.terminal() is not None
1273 def __init__(self
, context
, path
):
1274 super(LaunchTerminal
, self
).__init
__(context
)
1278 cmd
= self
.context
.cfg
.terminal()
1281 if utils
.is_win32():
1282 argv
= ['start', '', cmd
, '--login']
1285 argv
= utils
.shell_split(cmd
)
1286 argv
.append(os
.getenv('SHELL', '/bin/sh'))
1288 core
.fork(argv
, cwd
=self
.path
, shell
=shell
)
1291 class LaunchEditor(Edit
):
1295 return N_('Launch Editor')
1297 def __init__(self
, context
):
1298 s
= context
.selection
.selection()
1299 filenames
= s
.staged
+ s
.unmerged
+ s
.modified
+ s
.untracked
1300 super(LaunchEditor
, self
).__init
__(
1301 context
, filenames
, background_editor
=True)
1304 class LaunchEditorAtLine(LaunchEditor
):
1305 """Launch an editor at the specified line"""
1307 def __init__(self
, context
):
1308 super(LaunchEditorAtLine
, self
).__init
__(context
)
1309 self
.line_number
= context
.selection
.line_number
1312 class LoadCommitMessageFromFile(ContextCommand
):
1313 """Loads a commit message from a path."""
1316 def __init__(self
, context
, path
):
1317 super(LoadCommitMessageFromFile
, self
).__init
__(context
)
1319 self
.old_commitmsg
= self
.model
.commitmsg
1320 self
.old_directory
= self
.model
.directory
1323 path
= os
.path
.expanduser(self
.path
)
1324 if not path
or not core
.isfile(path
):
1325 raise UsageError(N_('Error: Cannot find commit template'),
1326 N_('%s: No such file or directory.') % path
)
1327 self
.model
.set_directory(os
.path
.dirname(path
))
1328 self
.model
.set_commitmsg(core
.read(path
))
1331 self
.model
.set_commitmsg(self
.old_commitmsg
)
1332 self
.model
.set_directory(self
.old_directory
)
1335 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile
):
1336 """Loads the commit message template specified by commit.template."""
1338 def __init__(self
, context
):
1340 template
= cfg
.get('commit.template')
1341 super(LoadCommitMessageFromTemplate
, self
).__init
__(context
, template
)
1344 if self
.path
is None:
1346 N_('Error: Unconfigured commit template'),
1347 N_('A commit template has not been configured.\n'
1348 'Use "git config" to define "commit.template"\n'
1349 'so that it points to a commit template.'))
1350 return LoadCommitMessageFromFile
.do(self
)
1353 class LoadCommitMessageFromOID(ContextCommand
):
1354 """Load a previous commit message"""
1357 def __init__(self
, context
, oid
, prefix
=''):
1358 super(LoadCommitMessageFromOID
, self
).__init
__(context
)
1360 self
.old_commitmsg
= self
.model
.commitmsg
1361 self
.new_commitmsg
= prefix
+ gitcmds
.prev_commitmsg(context
, oid
)
1364 self
.model
.set_commitmsg(self
.new_commitmsg
)
1367 self
.model
.set_commitmsg(self
.old_commitmsg
)
1370 class PrepareCommitMessageHook(ContextCommand
):
1371 """Use the cola-prepare-commit-msg hook to prepare the commit message
1375 def __init__(self
, context
):
1376 super(PrepareCommitMessageHook
, self
).__init
__(context
)
1377 self
.old_commitmsg
= self
.model
.commitmsg
1379 def get_message(self
):
1381 title
= N_('Error running prepare-commitmsg hook')
1382 hook
= gitcmds
.prepare_commit_message_hook(self
.context
)
1384 if os
.path
.exists(hook
):
1385 filename
= self
.model
.save_commitmsg()
1386 status
, out
, err
= core
.run_command([hook
, filename
])
1389 result
= core
.read(filename
)
1391 result
= self
.old_commitmsg
1392 Interaction
.command_error(title
, hook
, status
, out
, err
)
1394 message
= N_('A hook must be provided at "%s"') % hook
1395 Interaction
.critical(title
, message
=message
)
1396 result
= self
.old_commitmsg
1401 msg
= self
.get_message()
1402 self
.model
.set_commitmsg(msg
)
1405 self
.model
.set_commitmsg(self
.old_commitmsg
)
1408 class LoadFixupMessage(LoadCommitMessageFromOID
):
1409 """Load a fixup message"""
1411 def __init__(self
, context
, oid
):
1412 super(LoadFixupMessage
, self
).__init
__(context
, oid
, prefix
='fixup! ')
1413 if self
.new_commitmsg
:
1414 self
.new_commitmsg
= self
.new_commitmsg
.splitlines()[0]
1417 class Merge(ContextCommand
):
1420 def __init__(self
, context
, revision
, no_commit
, squash
, no_ff
, sign
):
1421 super(Merge
, self
).__init
__(context
)
1422 self
.revision
= revision
1424 self
.no_commit
= no_commit
1425 self
.squash
= squash
1429 squash
= self
.squash
1430 revision
= self
.revision
1432 no_commit
= self
.no_commit
1435 status
, out
, err
= self
.git
.merge(
1436 revision
, gpg_sign
=sign
, no_ff
=no_ff
,
1437 no_commit
=no_commit
, squash
=squash
)
1438 self
.model
.update_status()
1439 title
= N_('Merge failed. Conflict resolution is required.')
1440 Interaction
.command(title
, 'git merge', status
, out
, err
)
1442 return status
, out
, err
1445 class OpenDefaultApp(ContextCommand
):
1446 """Open a file using the OS default."""
1450 return N_('Open Using Default Application')
1452 def __init__(self
, context
, filenames
):
1453 super(OpenDefaultApp
, self
).__init
__(context
)
1454 if utils
.is_darwin():
1457 launcher
= 'xdg-open'
1458 self
.launcher
= launcher
1459 self
.filenames
= filenames
1462 if not self
.filenames
:
1464 core
.fork([self
.launcher
] + self
.filenames
)
1467 class OpenParentDir(OpenDefaultApp
):
1468 """Open parent directories using the OS default."""
1472 return N_('Open Parent Directory')
1474 def __init__(self
, context
, filenames
):
1475 OpenDefaultApp
.__init
__(self
, context
, filenames
)
1478 if not self
.filenames
:
1480 dirnames
= list(set([os
.path
.dirname(x
) for x
in self
.filenames
]))
1481 # os.path.dirname() can return an empty string so we fallback to
1482 # the current directory
1483 dirs
= [(dirname
or core
.getcwd()) for dirname
in dirnames
]
1484 core
.fork([self
.launcher
] + dirs
)
1487 class OpenNewRepo(ContextCommand
):
1488 """Launches git-cola on a repo."""
1490 def __init__(self
, context
, repo_path
):
1491 super(OpenNewRepo
, self
).__init
__(context
)
1492 self
.repo_path
= repo_path
1495 self
.model
.set_directory(self
.repo_path
)
1496 core
.fork([sys
.executable
, sys
.argv
[0], '--repo', self
.repo_path
])
1499 class OpenRepo(EditModel
):
1501 def __init__(self
, context
, repo_path
):
1502 super(OpenRepo
, self
).__init
__(context
)
1503 self
.repo_path
= repo_path
1504 self
.new_mode
= self
.model
.mode_none
1505 self
.new_diff_text
= ''
1506 self
.new_diff_type
= 'text'
1507 self
.new_commitmsg
= ''
1508 self
.new_filename
= ''
1511 old_repo
= self
.git
.getcwd()
1512 if self
.model
.set_worktree(self
.repo_path
):
1513 self
.fsmonitor
.stop()
1514 self
.fsmonitor
.start()
1515 self
.model
.update_status()
1516 self
.model
.set_commitmsg(self
.new_commitmsg
)
1517 super(OpenRepo
, self
).do()
1519 self
.model
.set_worktree(old_repo
)
1522 class OpenParentRepo(OpenRepo
):
1524 def __init__(self
, context
):
1526 if version
.check_git(context
, 'show-superproject-working-tree'):
1527 status
, out
, _
= context
.git
.rev_parse(
1528 show_superproject_working_tree
=True)
1532 path
= os
.path
.dirname(core
.getcwd())
1533 super(OpenParentRepo
, self
).__init
__(context
, path
)
1536 class Clone(ContextCommand
):
1537 """Clones a repository and optionally spawns a new cola session."""
1539 def __init__(self
, context
, url
, new_directory
,
1540 submodules
=False, shallow
=False, spawn
=True):
1541 super(Clone
, self
).__init
__(context
)
1543 self
.new_directory
= new_directory
1544 self
.submodules
= submodules
1545 self
.shallow
= shallow
1555 recurse_submodules
= self
.submodules
1556 shallow_submodules
= self
.submodules
and self
.shallow
1558 status
, out
, err
= self
.git
.clone(
1559 self
.url
, self
.new_directory
,
1560 recurse_submodules
=recurse_submodules
,
1561 shallow_submodules
=shallow_submodules
,
1564 self
.status
= status
1567 if status
== 0 and self
.spawn
:
1568 executable
= sys
.executable
1569 core
.fork([executable
, sys
.argv
[0], '--repo', self
.new_directory
])
1573 class NewBareRepo(ContextCommand
):
1574 """Create a new shared bare repository"""
1576 def __init__(self
, context
, path
):
1577 super(NewBareRepo
, self
).__init
__(context
)
1582 status
, out
, err
= self
.git
.init(path
, bare
=True, shared
=True)
1583 Interaction
.command(
1584 N_('Error'), 'git init --bare --shared "%s"' % path
,
1589 def unix_path(path
, is_win32
=utils
.is_win32
):
1590 """Git for Windows requires unix paths, so force them here
1593 path
= path
.replace('\\', '/')
1596 if second
== ':': # sanity check, this better be a Windows-style path
1597 path
= '/' + first
+ path
[2:]
1602 def sequence_editor():
1603 """Return a GIT_SEQUENCE_EDITOR environment value that enables git-xbase"""
1604 xbase
= unix_path(resources
.share('bin', 'git-xbase'))
1605 editor
= core
.list2cmdline([unix_path(sys
.executable
), xbase
])
1609 class GitXBaseContext(object):
1611 def __init__(self
, context
, **kwargs
):
1613 'GIT_EDITOR': prefs
.editor(context
),
1614 'GIT_SEQUENCE_EDITOR': sequence_editor(),
1615 'GIT_XBASE_CANCEL_ACTION': 'save',
1617 self
.env
.update(kwargs
)
1619 def __enter__(self
):
1620 for var
, value
in self
.env
.items():
1621 compat
.setenv(var
, value
)
1624 def __exit__(self
, exc_type
, exc_val
, exc_tb
):
1625 for var
in self
.env
:
1626 compat
.unsetenv(var
)
1629 class Rebase(ContextCommand
):
1631 def __init__(self
, context
, upstream
=None, branch
=None, **kwargs
):
1632 """Start an interactive rebase session
1634 :param upstream: upstream branch
1635 :param branch: optional branch to checkout
1636 :param kwargs: forwarded directly to `git.rebase()`
1639 super(Rebase
, self
).__init
__(context
)
1641 self
.upstream
= upstream
1642 self
.branch
= branch
1643 self
.kwargs
= kwargs
1645 def prepare_arguments(self
, upstream
):
1649 # Rebase actions must be the only option specified
1650 for action
in ('continue', 'abort', 'skip', 'edit_todo'):
1651 if self
.kwargs
.get(action
, False):
1652 kwargs
[action
] = self
.kwargs
[action
]
1655 kwargs
['interactive'] = True
1656 kwargs
['autosquash'] = self
.kwargs
.get('autosquash', True)
1657 kwargs
.update(self
.kwargs
)
1660 args
.append(upstream
)
1662 args
.append(self
.branch
)
1667 (status
, out
, err
) = (1, '', '')
1668 context
= self
.context
1672 if not cfg
.get('rebase.autostash', False):
1673 if model
.staged
or model
.unmerged
or model
.modified
:
1674 Interaction
.information(
1675 N_('Unable to rebase'),
1676 N_('You cannot rebase with uncommitted changes.'))
1677 return status
, out
, err
1679 upstream
= self
.upstream
or Interaction
.choose_ref(
1680 context
, N_('Select New Upstream'), N_('Interactive Rebase'),
1681 default
='@{upstream}')
1683 return status
, out
, err
1685 self
.model
.is_rebasing
= True
1686 self
.model
.emit_updated()
1688 args
, kwargs
= self
.prepare_arguments(upstream
)
1689 upstream_title
= upstream
or '@{upstream}'
1690 with
GitXBaseContext(
1692 GIT_XBASE_TITLE
=N_('Rebase onto %s') % upstream_title
,
1693 GIT_XBASE_ACTION
=N_('Rebase')
1695 # TODO this blocks the user interface window for the duration
1696 # of git-xbase's invocation. We would need to implement
1697 # signals for QProcess and continue running the main thread.
1698 # alternatively we could hide the main window while rebasing.
1699 # that doesn't require as much effort.
1700 status
, out
, err
= self
.git
.rebase(
1701 *args
, _no_win32_startupinfo
=True, **kwargs
)
1702 self
.model
.update_status()
1703 if err
.strip() != 'Nothing to do':
1704 title
= N_('Rebase stopped')
1705 Interaction
.command(title
, 'git rebase', status
, out
, err
)
1706 return status
, out
, err
1709 class RebaseEditTodo(ContextCommand
):
1712 (status
, out
, err
) = (1, '', '')
1713 with
GitXBaseContext(
1715 GIT_XBASE_TITLE
=N_('Edit Rebase'),
1716 GIT_XBASE_ACTION
=N_('Save')
1718 status
, out
, err
= self
.git
.rebase(edit_todo
=True)
1719 Interaction
.log_status(status
, out
, err
)
1720 self
.model
.update_status()
1721 return status
, out
, err
1724 class RebaseContinue(ContextCommand
):
1727 (status
, out
, err
) = (1, '', '')
1728 with
GitXBaseContext(
1730 GIT_XBASE_TITLE
=N_('Rebase'),
1731 GIT_XBASE_ACTION
=N_('Rebase')
1733 status
, out
, err
= self
.git
.rebase('--continue')
1734 Interaction
.log_status(status
, out
, err
)
1735 self
.model
.update_status()
1736 return status
, out
, err
1739 class RebaseSkip(ContextCommand
):
1742 (status
, out
, err
) = (1, '', '')
1743 with
GitXBaseContext(
1745 GIT_XBASE_TITLE
=N_('Rebase'),
1746 GIT_XBASE_ACTION
=N_('Rebase')
1748 status
, out
, err
= self
.git
.rebase(skip
=True)
1749 Interaction
.log_status(status
, out
, err
)
1750 self
.model
.update_status()
1751 return status
, out
, err
1754 class RebaseAbort(ContextCommand
):
1757 status
, out
, err
= self
.git
.rebase(abort
=True)
1758 Interaction
.log_status(status
, out
, err
)
1759 self
.model
.update_status()
1762 class Rescan(ContextCommand
):
1763 """Rescan for changes"""
1766 self
.model
.update_status()
1769 class Refresh(ContextCommand
):
1770 """Update refs, refresh the index, and update config"""
1774 return N_('Refresh')
1777 self
.model
.update_status(update_index
=True)
1779 self
.fsmonitor
.refresh()
1782 class RefreshConfig(ContextCommand
):
1783 """Refresh the git config cache"""
1789 class RevertEditsCommand(ConfirmAction
):
1791 def __init__(self
, context
):
1792 super(RevertEditsCommand
, self
).__init
__(context
)
1793 self
.icon
= icons
.undo()
1795 def ok_to_run(self
):
1796 return self
.model
.undoable()
1798 # pylint: disable=no-self-use
1799 def checkout_from_head(self
):
1802 def checkout_args(self
):
1804 s
= self
.selection
.selection()
1805 if self
.checkout_from_head():
1806 args
.append(self
.model
.head
)
1818 checkout_args
= self
.checkout_args()
1819 return self
.git
.checkout(*checkout_args
)
1822 self
.model
.update_file_status()
1825 class RevertUnstagedEdits(RevertEditsCommand
):
1829 return N_('Revert Unstaged Edits...')
1831 def checkout_from_head(self
):
1832 # If we are amending and a modified file is selected
1833 # then we should include "HEAD^" on the command-line.
1834 selected
= self
.selection
.selection()
1835 return not selected
.staged
and self
.model
.amending()
1838 title
= N_('Revert Unstaged Changes?')
1840 'This operation removes unstaged edits from selected files.\n'
1841 'These changes cannot be recovered.')
1842 info
= N_('Revert the unstaged changes?')
1843 ok_text
= N_('Revert Unstaged Changes')
1844 return Interaction
.confirm(title
, text
, info
, ok_text
,
1845 default
=True, icon
=self
.icon
)
1848 class RevertUncommittedEdits(RevertEditsCommand
):
1852 return N_('Revert Uncommitted Edits...')
1854 def checkout_from_head(self
):
1858 """Prompt for reverting changes"""
1859 title
= N_('Revert Uncommitted Changes?')
1861 'This operation removes uncommitted edits from selected files.\n'
1862 'These changes cannot be recovered.')
1863 info
= N_('Revert the uncommitted changes?')
1864 ok_text
= N_('Revert Uncommitted Changes')
1865 return Interaction
.confirm(title
, text
, info
, ok_text
,
1866 default
=True, icon
=self
.icon
)
1869 class RunConfigAction(ContextCommand
):
1870 """Run a user-configured action, typically from the "Tools" menu"""
1872 def __init__(self
, context
, action_name
):
1873 super(RunConfigAction
, self
).__init
__(context
)
1874 self
.action_name
= action_name
1877 """Run the user-configured action"""
1878 for env
in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
1880 compat
.unsetenv(env
)
1885 context
= self
.context
1887 opts
= cfg
.get_guitool_opts(self
.action_name
)
1888 cmd
= opts
.get('cmd')
1889 if 'title' not in opts
:
1892 if 'prompt' not in opts
or opts
.get('prompt') is True:
1893 prompt
= N_('Run "%s"?') % cmd
1894 opts
['prompt'] = prompt
1896 if opts
.get('needsfile'):
1897 filename
= self
.selection
.filename()
1899 Interaction
.information(
1900 N_('Please select a file'),
1901 N_('"%s" requires a selected file.') % cmd
)
1903 dirname
= utils
.dirname(filename
, current_dir
='.')
1904 compat
.setenv('FILENAME', filename
)
1905 compat
.setenv('DIRNAME', dirname
)
1907 if opts
.get('revprompt') or opts
.get('argprompt'):
1909 ok
= Interaction
.confirm_config_action(context
, cmd
, opts
)
1912 rev
= opts
.get('revision')
1913 args
= opts
.get('args')
1914 if opts
.get('revprompt') and not rev
:
1915 title
= N_('Invalid Revision')
1916 msg
= N_('The revision expression cannot be empty.')
1917 Interaction
.critical(title
, msg
)
1921 elif opts
.get('confirm'):
1922 title
= os
.path
.expandvars(opts
.get('title'))
1923 prompt
= os
.path
.expandvars(opts
.get('prompt'))
1924 if not Interaction
.question(title
, prompt
):
1927 compat
.setenv('REVISION', rev
)
1929 compat
.setenv('ARGS', args
)
1930 title
= os
.path
.expandvars(cmd
)
1931 Interaction
.log(N_('Running command: %s') % title
)
1932 cmd
= ['sh', '-c', cmd
]
1934 if opts
.get('background'):
1936 status
, out
, err
= (0, '', '')
1937 elif opts
.get('noconsole'):
1938 status
, out
, err
= core
.run_command(cmd
)
1940 status
, out
, err
= Interaction
.run_command(title
, cmd
)
1942 if not opts
.get('background') and not opts
.get('norescan'):
1943 self
.model
.update_status()
1946 Interaction
.command(title
, cmd
, status
, out
, err
)
1951 class SetDefaultRepo(ContextCommand
):
1952 """Set the default repository"""
1954 def __init__(self
, context
, repo
):
1955 super(SetDefaultRepo
, self
).__init
__(context
)
1959 self
.cfg
.set_user('cola.defaultrepo', self
.repo
)
1962 class SetDiffText(EditModel
):
1963 """Set the diff text"""
1966 def __init__(self
, context
, text
):
1967 super(SetDiffText
, self
).__init
__(context
)
1968 self
.new_diff_text
= text
1969 self
.new_diff_type
= 'text'
1972 class SetUpstreamBranch(ContextCommand
):
1973 """Set the upstream branch"""
1975 def __init__(self
, context
, branch
, remote
, remote_branch
):
1976 super(SetUpstreamBranch
, self
).__init
__(context
)
1977 self
.branch
= branch
1978 self
.remote
= remote
1979 self
.remote_branch
= remote_branch
1983 remote
= self
.remote
1984 branch
= self
.branch
1985 remote_branch
= self
.remote_branch
1986 cfg
.set_repo('branch.%s.remote' % branch
, remote
)
1987 cfg
.set_repo('branch.%s.merge' % branch
, 'refs/heads/' + remote_branch
)
1990 class ShowUntracked(EditModel
):
1991 """Show an untracked file."""
1993 def __init__(self
, context
, filename
):
1994 super(ShowUntracked
, self
).__init
__(context
)
1995 self
.new_filename
= filename
1996 self
.new_mode
= self
.model
.mode_untracked
1997 self
.new_diff_text
= self
.read(filename
)
1998 self
.new_diff_type
= 'text'
2000 def read(self
, filename
):
2001 """Read file contents"""
2003 size
= cfg
.get('cola.readsize', 2048)
2005 result
= core
.read(filename
, size
=size
,
2006 encoding
=core
.ENCODING
, errors
='ignore')
2007 except (IOError, OSError):
2010 if len(result
) == size
:
2015 class SignOff(ContextCommand
):
2016 """Append a signoff to the commit message"""
2021 return N_('Sign Off')
2023 def __init__(self
, context
):
2024 super(SignOff
, self
).__init
__(context
)
2025 self
.old_commitmsg
= self
.model
.commitmsg
2028 """Add a signoff to the commit message"""
2029 signoff
= self
.signoff()
2030 if signoff
in self
.model
.commitmsg
:
2032 msg
= self
.model
.commitmsg
.rstrip()
2033 self
.model
.set_commitmsg(msg
+ '\n' + signoff
)
2036 """Restore the commit message"""
2037 self
.model
.set_commitmsg(self
.old_commitmsg
)
2040 """Generate the signoff string"""
2043 user
= pwd
.getpwuid(os
.getuid()).pw_name
2045 user
= os
.getenv('USER', N_('unknown'))
2048 name
= cfg
.get('user.name', user
)
2049 email
= cfg
.get('user.email', '%s@%s' % (user
, core
.node()))
2050 return '\nSigned-off-by: %s <%s>' % (name
, email
)
2053 def check_conflicts(context
, unmerged
):
2054 """Check paths for conflicts
2056 Conflicting files can be filtered out one-by-one.
2059 if prefs
.check_conflicts(context
):
2060 unmerged
= [path
for path
in unmerged
if is_conflict_free(path
)]
2064 def is_conflict_free(path
):
2065 """Return True if `path` contains no conflict markers
2067 rgx
= re
.compile(r
'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2069 with core
.xopen(path
, 'r') as f
:
2071 line
= core
.decode(line
, errors
='ignore')
2073 return should_stage_conflicts(path
)
2075 # We can't read this file ~ we may be staging a removal
2080 def should_stage_conflicts(path
):
2081 """Inform the user that a file contains merge conflicts
2083 Return `True` if we should stage the path nonetheless.
2086 title
= msg
= N_('Stage conflicts?')
2087 info
= N_('%s appears to contain merge conflicts.\n\n'
2088 'You should probably skip this file.\n'
2089 'Stage it anyways?') % path
2090 ok_text
= N_('Stage conflicts')
2091 cancel_text
= N_('Skip')
2092 return Interaction
.confirm(title
, msg
, info
, ok_text
,
2093 default
=False, cancel_text
=cancel_text
)
2096 class Stage(ContextCommand
):
2097 """Stage a set of paths."""
2103 def __init__(self
, context
, paths
):
2104 super(Stage
, self
).__init
__(context
)
2108 msg
= N_('Staging: %s') % (', '.join(self
.paths
))
2109 Interaction
.log(msg
)
2110 return self
.stage_paths()
2112 def stage_paths(self
):
2113 """Stages add/removals to git."""
2114 context
= self
.context
2117 if self
.model
.cfg
.get('cola.safemode', False):
2119 return self
.stage_all()
2124 for path
in set(paths
):
2125 if core
.exists(path
) or core
.islink(path
):
2126 if path
.endswith('/'):
2127 path
= path
.rstrip('/')
2132 self
.model
.emit_about_to_update()
2134 # `git add -u` doesn't work on untracked files
2136 status
, out
, err
= gitcmds
.add(context
, add
)
2137 Interaction
.command(N_('Error'), 'git add', status
, out
, err
)
2139 # If a path doesn't exist then that means it should be removed
2140 # from the index. We use `git add -u` for that.
2142 status
, out
, err
= gitcmds
.add(context
, remove
, u
=True)
2143 Interaction
.command(N_('Error'), 'git add -u', status
, out
, err
)
2145 self
.model
.update_files(emit
=True)
2146 return status
, out
, err
2148 def stage_all(self
):
2149 """Stage all files"""
2150 status
, out
, err
= self
.git
.add(v
=True, u
=True)
2151 Interaction
.command(N_('Error'), 'git add -u', status
, out
, err
)
2152 self
.model
.update_file_status()
2153 return (status
, out
, err
)
2156 class StageCarefully(Stage
):
2157 """Only stage when the path list is non-empty
2159 We use "git add -u -- <pathspec>" to stage, and it stages everything by
2160 default when no pathspec is specified, so this class ensures that paths
2161 are specified before calling git.
2163 When no paths are specified, the command does nothing.
2166 def __init__(self
, context
):
2167 super(StageCarefully
, self
).__init
__(context
, None)
2170 # pylint: disable=no-self-use
2171 def init_paths(self
):
2172 """Initialize path data"""
2175 def ok_to_run(self
):
2176 """Prevent catch-all "git add -u" from adding unmerged files"""
2177 return self
.paths
or not self
.model
.unmerged
2180 """Stage files when ok_to_run() return True"""
2181 if self
.ok_to_run():
2182 return super(StageCarefully
, self
).do()
2186 class StageModified(StageCarefully
):
2187 """Stage all modified files."""
2191 return N_('Stage Modified')
2193 def init_paths(self
):
2194 self
.paths
= self
.model
.modified
2197 class StageUnmerged(StageCarefully
):
2198 """Stage unmerged files."""
2202 return N_('Stage Unmerged')
2204 def init_paths(self
):
2205 self
.paths
= check_conflicts(self
.context
, self
.model
.unmerged
)
2208 class StageUntracked(StageCarefully
):
2209 """Stage all untracked files."""
2213 return N_('Stage Untracked')
2215 def init_paths(self
):
2216 self
.paths
= self
.model
.untracked
2219 class StageOrUnstage(ContextCommand
):
2220 """If the selection is staged, unstage it, otherwise stage"""
2224 return N_('Stage / Unstage')
2227 s
= self
.selection
.selection()
2229 do(Unstage
, self
.context
, s
.staged
)
2232 unmerged
= check_conflicts(self
.context
, s
.unmerged
)
2234 unstaged
.extend(unmerged
)
2236 unstaged
.extend(s
.modified
)
2238 unstaged
.extend(s
.untracked
)
2240 do(Stage
, self
.context
, unstaged
)
2243 class Tag(ContextCommand
):
2244 """Create a tag object."""
2246 def __init__(self
, context
, name
, revision
, sign
=False, message
=''):
2247 super(Tag
, self
).__init
__(context
)
2249 self
._message
= message
2250 self
._revision
= revision
2256 revision
= self
._revision
2257 tag_name
= self
._name
2258 tag_message
= self
._message
2261 Interaction
.critical(
2262 N_('Missing Revision'),
2263 N_('Please specify a revision to tag.'))
2267 Interaction
.critical(
2269 N_('Please specify a name for the new tag.'))
2272 title
= N_('Missing Tag Message')
2273 message
= N_('Tag-signing was requested but the tag message is empty.')
2274 info
= N_('An unsigned, lightweight tag will be created instead.\n'
2275 'Create an unsigned tag?')
2276 ok_text
= N_('Create Unsigned Tag')
2278 if sign
and not tag_message
:
2279 # We require a message in order to sign the tag, so if they
2280 # choose to create an unsigned tag we have to clear the sign flag.
2281 if not Interaction
.confirm(title
, message
, info
, ok_text
,
2282 default
=False, icon
=icons
.save()):
2290 tmp_file
= utils
.tmp_filename('tag-message')
2291 opts
['file'] = tmp_file
2292 core
.write(tmp_file
, tag_message
)
2297 opts
['annotate'] = True
2298 status
, out
, err
= git
.tag(tag_name
, revision
, **opts
)
2301 core
.unlink(tmp_file
)
2303 title
= N_('Error: could not create tag "%s"') % tag_name
2304 Interaction
.command(title
, 'git tag', status
, out
, err
)
2308 self
.model
.update_status()
2309 Interaction
.information(
2311 N_('Created a new tag named "%s"') % tag_name
,
2312 details
=tag_message
or None)
2317 class Unstage(ContextCommand
):
2318 """Unstage a set of paths."""
2322 return N_('Unstage')
2324 def __init__(self
, context
, paths
):
2325 super(Unstage
, self
).__init
__(context
)
2330 context
= self
.context
2331 head
= self
.model
.head
2334 msg
= N_('Unstaging: %s') % (', '.join(paths
))
2335 Interaction
.log(msg
)
2337 return unstage_all(context
)
2338 status
, out
, err
= gitcmds
.unstage_paths(context
, paths
, head
=head
)
2339 Interaction
.command(N_('Error'), 'git reset', status
, out
, err
)
2340 self
.model
.update_file_status()
2341 return (status
, out
, err
)
2344 class UnstageAll(ContextCommand
):
2345 """Unstage all files; resets the index."""
2348 return unstage_all(self
.context
)
2351 def unstage_all(context
):
2352 """Unstage all files, even while amending"""
2353 model
= context
.model
2356 status
, out
, err
= git
.reset(head
, '--', '.')
2357 Interaction
.command(N_('Error'), 'git reset', status
, out
, err
)
2358 model
.update_file_status()
2359 return (status
, out
, err
)
2362 class StageSelected(ContextCommand
):
2363 """Stage selected files, or all files if no selection exists."""
2366 context
= self
.context
2367 paths
= self
.selection
.unstaged
2369 do(Stage
, context
, paths
)
2370 elif self
.cfg
.get('cola.safemode', False):
2371 do(StageModified
, context
)
2374 class UnstageSelected(Unstage
):
2375 """Unstage selected files."""
2377 def __init__(self
, context
):
2378 staged
= self
.selection
.staged
2379 super(UnstageSelected
, self
).__init
__(context
, staged
)
2382 class Untrack(ContextCommand
):
2383 """Unstage a set of paths."""
2385 def __init__(self
, context
, paths
):
2386 super(Untrack
, self
).__init
__(context
)
2390 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
2391 Interaction
.log(msg
)
2392 status
, out
, err
= self
.model
.untrack_paths(self
.paths
)
2393 Interaction
.log_status(status
, out
, err
)
2396 class UntrackedSummary(EditModel
):
2397 """List possible .gitignore rules as the diff text."""
2399 def __init__(self
, context
):
2400 super(UntrackedSummary
, self
).__init
__(context
)
2401 untracked
= self
.model
.untracked
2402 suffix
= 's' if untracked
else ''
2404 io
.write('# %s untracked file%s\n' % (len(untracked
), suffix
))
2406 io
.write('# possible .gitignore rule%s:\n' % suffix
)
2408 io
.write('/'+u
+'\n')
2409 self
.new_diff_text
= io
.getvalue()
2410 self
.new_diff_type
= 'text'
2411 self
.new_mode
= self
.model
.mode_untracked
2414 class VisualizeAll(ContextCommand
):
2415 """Visualize all branches."""
2418 context
= self
.context
2419 browser
= utils
.shell_split(prefs
.history_browser(context
))
2420 launch_history_browser(browser
+ ['--all'])
2423 class VisualizeCurrent(ContextCommand
):
2424 """Visualize all branches."""
2427 context
= self
.context
2428 browser
= utils
.shell_split(prefs
.history_browser(context
))
2429 launch_history_browser(browser
+ [self
.model
.currentbranch
] + ['--'])
2432 class VisualizePaths(ContextCommand
):
2433 """Path-limited visualization."""
2435 def __init__(self
, context
, paths
):
2436 super(VisualizePaths
, self
).__init
__(context
)
2437 context
= self
.context
2438 browser
= utils
.shell_split(prefs
.history_browser(context
))
2440 self
.argv
= browser
+ ['--'] + list(paths
)
2445 launch_history_browser(self
.argv
)
2448 class VisualizeRevision(ContextCommand
):
2449 """Visualize a specific revision."""
2451 def __init__(self
, context
, revision
, paths
=None):
2452 super(VisualizeRevision
, self
).__init
__(context
)
2453 self
.revision
= revision
2457 context
= self
.context
2458 argv
= utils
.shell_split(prefs
.history_browser(context
))
2460 argv
.append(self
.revision
)
2463 argv
.extend(self
.paths
)
2464 launch_history_browser(argv
)
2467 class SubmoduleUpdate(ConfirmAction
):
2468 """Update specified submodule"""
2470 def __init__(self
, context
, path
):
2471 super(SubmoduleUpdate
, self
).__init
__(context
)
2475 title
= N_('Update Submodule...')
2476 question
= N_('Update this submodule?')
2477 info
= N_('The submodule will be updated using\n'
2478 '"%s"' % self
.command())
2479 ok_txt
= N_('Update Submodule')
2480 return Interaction
.confirm(title
, question
, info
, ok_txt
,
2481 default
=False, icon
=icons
.pull())
2484 context
= self
.context
2485 return context
.git
.submodule('update', '--', self
.path
)
2488 self
.model
.update_file_status()
2490 def error_message(self
):
2491 return N_('Error updating submodule %s' % self
.path
)
2494 command
= 'git submodule update -- %s'
2495 return command
% self
.path
2498 class SubmodulesUpdate(ConfirmAction
):
2499 """Update all submodules"""
2502 title
= N_('Update submodules...')
2503 question
= N_('Update all submodules?')
2504 info
= N_('All submodules will be updated using\n'
2505 '"%s"' % self
.command())
2506 ok_txt
= N_('Update Submodules')
2507 return Interaction
.confirm(title
, question
, info
, ok_txt
,
2508 default
=False, icon
=icons
.pull())
2511 context
= self
.context
2512 return context
.git
.submodule('update')
2515 self
.model
.update_file_status()
2517 def error_message(self
):
2518 return N_('Error updating submodules')
2521 return 'git submodule update'
2524 def launch_history_browser(argv
):
2525 """Launch the configured history browser"""
2528 except OSError as e
:
2529 _
, details
= utils
.format_exception(e
)
2530 title
= N_('Error Launching History Browser')
2531 msg
= (N_('Cannot exec "%s": please configure a history browser') %
2533 Interaction
.critical(title
, message
=msg
, details
=details
)
2536 def run(cls
, *args
, **opts
):
2538 Returns a callback that runs a command
2540 If the caller of run() provides args or opts then those are
2541 used instead of the ones provided by the invoker of the callback.
2544 def runner(*local_args
, **local_opts
):
2545 """Closure return by run() which runs the command"""
2547 do(cls
, *args
, **opts
)
2549 do(cls
, *local_args
, **local_opts
)
2554 def do(cls
, *args
, **opts
):
2555 """Run a command in-place"""
2557 cmd
= cls(*args
, **opts
)
2559 except Exception as e
: # pylint: disable=broad-except
2560 msg
, details
= utils
.format_exception(e
)
2561 if hasattr(cls
, '__name__'):
2562 msg
= ('%s exception:\n%s' % (cls
.__name
__, msg
))
2563 Interaction
.critical(N_('Error'), message
=msg
, details
=details
)
2567 def difftool_run(context
):
2568 """Start a default difftool session"""
2569 selection
= context
.selection
2570 files
= selection
.group()
2573 s
= selection
.selection()
2574 head
= context
.model
.head
2575 difftool_launch_with_head(context
, files
, bool(s
.staged
), head
)
2578 def difftool_launch_with_head(context
, filenames
, staged
, head
):
2579 """Launch difftool against the provided head"""
2584 difftool_launch(context
, left
=left
, staged
=staged
, paths
=filenames
)
2587 def difftool_launch(context
, left
=None, right
=None, paths
=None,
2588 staged
=False, dir_diff
=False,
2589 left_take_magic
=False, left_take_parent
=False):
2590 """Launches 'git difftool' with given parameters
2592 :param left: first argument to difftool
2593 :param right: second argument to difftool_args
2594 :param paths: paths to diff
2595 :param staged: activate `git difftool --staged`
2596 :param dir_diff: activate `git difftool --dir-diff`
2597 :param left_take_magic: whether to append the magic ^! diff expression
2598 :param left_take_parent: whether to append the first-parent ~ for diffing
2602 difftool_args
= ['git', 'difftool', '--no-prompt']
2604 difftool_args
.append('--cached')
2606 difftool_args
.append('--dir-diff')
2609 if left_take_parent
or left_take_magic
:
2610 suffix
= '^!' if left_take_magic
else '~'
2611 # Check root commit (no parents and thus cannot execute '~')
2613 status
, out
, err
= git
.rev_list(left
, parents
=True, n
=1)
2614 Interaction
.log_status(status
, out
, err
)
2616 raise OSError('git rev-list command failed')
2618 if len(out
.split()) >= 2:
2619 # Commit has a parent, so we can take its child as requested
2622 # No parent, assume it's the root commit, so we have to diff
2623 # against the empty tree.
2624 left
= EMPTY_TREE_OID
2625 if not right
and left_take_magic
:
2627 difftool_args
.append(left
)
2630 difftool_args
.append(right
)
2633 difftool_args
.append('--')
2634 difftool_args
.extend(paths
)
2636 runtask
= context
.runtask
2638 Interaction
.async_command(N_('Difftool'), difftool_args
, runtask
)
2640 core
.fork(difftool_args
)