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
21 from .cmd
import ContextCommand
22 from .diffparse
import DiffParser
23 from .git
import STDOUT
24 from .git
import EMPTY_TREE_OID
25 from .git
import MISSING_BLOB_OID
27 from .interaction
import Interaction
28 from .models
import prefs
31 class UsageError(Exception):
32 """Exception class for usage errors."""
34 def __init__(self
, title
, message
):
35 Exception.__init
__(self
, message
)
40 class EditModel(ContextCommand
):
41 """Commands that mutate the main model diff data"""
44 def __init__(self
, context
):
45 """Common edit operations on the main model"""
46 super(EditModel
, self
).__init
__(context
)
48 self
.old_diff_text
= self
.model
.diff_text
49 self
.old_filename
= self
.model
.filename
50 self
.old_mode
= self
.model
.mode
51 self
.old_diff_type
= self
.model
.diff_type
53 self
.new_diff_text
= self
.old_diff_text
54 self
.new_filename
= self
.old_filename
55 self
.new_mode
= self
.old_mode
56 self
.new_diff_type
= self
.old_diff_type
59 """Perform the operation."""
60 self
.model
.set_filename(self
.new_filename
)
61 self
.model
.set_mode(self
.new_mode
)
62 self
.model
.set_diff_text(self
.new_diff_text
)
63 self
.model
.set_diff_type(self
.new_diff_type
)
66 """Undo the operation."""
67 self
.model
.set_filename(self
.old_filename
)
68 self
.model
.set_mode(self
.old_mode
)
69 self
.model
.set_diff_text(self
.old_diff_text
)
70 self
.model
.set_diff_type(self
.old_diff_type
)
73 class ConfirmAction(ContextCommand
):
74 """Confirm an action before running it"""
76 # pylint: disable=no-self-use
78 """Return True when the command is ok to run"""
81 # pylint: disable=no-self-use
83 """Prompt for confirmation"""
86 # pylint: disable=no-self-use
88 """Run the command and return (status, out, err)"""
91 # pylint: disable=no-self-use
93 """Callback run on success"""
96 # pylint: disable=no-self-use
98 """Command name, for error messages"""
101 # pylint: disable=no-self-use
102 def error_message(self
):
103 """Command error message"""
107 """Prompt for confirmation before running a command"""
110 ok
= self
.ok_to_run() and self
.confirm()
112 status
, out
, err
= self
.action()
115 title
= self
.error_message()
117 Interaction
.command(title
, cmd
, status
, out
, err
)
119 return ok
, status
, out
, err
122 class AbortMerge(ConfirmAction
):
123 """Reset an in-progress merge back to HEAD"""
126 title
= N_('Abort Merge...')
127 question
= N_('Aborting the current merge?')
128 info
= N_('Aborting the current merge will cause '
129 '*ALL* uncommitted changes to be lost.\n'
130 'Recovering uncommitted changes is not possible.')
131 ok_txt
= N_('Abort Merge')
132 return Interaction
.confirm(title
, question
, info
, ok_txt
,
133 default
=False, icon
=icons
.undo())
136 status
, out
, err
= gitcmds
.abort_merge(self
.context
)
137 self
.model
.update_file_status()
138 return status
, out
, err
141 self
.model
.set_commitmsg('')
143 def error_message(self
):
150 class AmendMode(EditModel
):
151 """Try to amend a commit."""
159 def __init__(self
, context
, amend
=True):
160 super(AmendMode
, self
).__init
__(context
)
162 self
.amending
= amend
163 self
.old_commitmsg
= self
.model
.commitmsg
164 self
.old_mode
= self
.model
.mode
167 self
.new_mode
= self
.model
.mode_amend
168 self
.new_commitmsg
= gitcmds
.prev_commitmsg(context
)
169 AmendMode
.LAST_MESSAGE
= self
.model
.commitmsg
171 # else, amend unchecked, regular commit
172 self
.new_mode
= self
.model
.mode_none
173 self
.new_diff_text
= ''
174 self
.new_commitmsg
= self
.model
.commitmsg
175 # If we're going back into new-commit-mode then search the
176 # undo stack for a previous amend-commit-mode and grab the
177 # commit message at that point in time.
178 if AmendMode
.LAST_MESSAGE
is not None:
179 self
.new_commitmsg
= AmendMode
.LAST_MESSAGE
180 AmendMode
.LAST_MESSAGE
= None
183 """Leave/enter amend mode."""
184 # Attempt to enter amend mode. Do not allow this when merging.
186 if self
.model
.is_merging
:
188 self
.model
.set_mode(self
.old_mode
)
189 Interaction
.information(
191 N_('You are in the middle of a merge.\n'
192 'Cannot amend while merging.'))
195 super(AmendMode
, self
).do()
196 self
.model
.set_commitmsg(self
.new_commitmsg
)
197 self
.model
.update_file_status()
202 self
.model
.set_commitmsg(self
.old_commitmsg
)
203 super(AmendMode
, self
).undo()
204 self
.model
.update_file_status()
207 class AnnexAdd(ContextCommand
):
208 """Add to Git Annex"""
210 def __init__(self
, context
):
211 super(AnnexAdd
, self
).__init
__(context
)
212 self
.filename
= self
.selection
.filename()
215 status
, out
, err
= self
.git
.annex('add', self
.filename
)
216 Interaction
.command(N_('Error'), 'git annex add', status
, out
, err
)
217 self
.model
.update_status()
220 class AnnexInit(ContextCommand
):
221 """Initialize Git Annex"""
224 status
, out
, err
= self
.git
.annex('init')
225 Interaction
.command(N_('Error'), 'git annex init', status
, out
, err
)
226 self
.model
.cfg
.reset()
227 self
.model
.emit_updated()
230 class LFSTrack(ContextCommand
):
231 """Add a file to git lfs"""
233 def __init__(self
, context
):
234 super(LFSTrack
, self
).__init
__(context
)
235 self
.filename
= self
.selection
.filename()
236 self
.stage_cmd
= Stage(context
, [self
.filename
])
239 status
, out
, err
= self
.git
.lfs('track', self
.filename
)
241 N_('Error'), 'git lfs track', status
, out
, err
)
246 class LFSInstall(ContextCommand
):
247 """Initialize git lfs"""
250 status
, out
, err
= self
.git
.lfs('install')
252 N_('Error'), 'git lfs install', status
, out
, err
)
253 self
.model
.update_config(reset
=True, emit
=True)
256 class ApplyDiffSelection(ContextCommand
):
257 """Apply the selected diff to the worktree or index"""
259 def __init__(self
, context
, first_line_idx
, last_line_idx
, has_selection
,
260 reverse
, apply_to_worktree
):
261 super(ApplyDiffSelection
, self
).__init
__(context
)
262 self
.first_line_idx
= first_line_idx
263 self
.last_line_idx
= last_line_idx
264 self
.has_selection
= has_selection
265 self
.reverse
= reverse
266 self
.apply_to_worktree
= apply_to_worktree
269 context
= self
.context
270 cfg
= self
.context
.cfg
271 diff_text
= self
.model
.diff_text
273 parser
= DiffParser(self
.model
.filename
, diff_text
)
274 if self
.has_selection
:
275 patch
= parser
.generate_patch(
276 self
.first_line_idx
, self
.last_line_idx
, reverse
=self
.reverse
)
278 patch
= parser
.generate_hunk_patch(
279 self
.first_line_idx
, reverse
=self
.reverse
)
283 if isinstance(diff_text
, core
.UStr
):
284 # original encoding must prevail
285 encoding
= diff_text
.encoding
287 encoding
= cfg
.file_encoding(self
.model
.filename
)
289 tmp_file
= utils
.tmp_filename('patch')
291 core
.write(tmp_file
, patch
, encoding
=encoding
)
292 if self
.apply_to_worktree
:
293 status
, out
, err
= gitcmds
.apply_diff_to_worktree(
296 status
, out
, err
= gitcmds
.apply_diff(context
, tmp_file
)
298 core
.unlink(tmp_file
)
300 Interaction
.log_status(status
, out
, err
)
301 self
.model
.update_file_status(update_index
=True)
304 class ApplyPatches(ContextCommand
):
305 """Apply patches using the "git am" command"""
307 def __init__(self
, context
, patches
):
308 super(ApplyPatches
, self
).__init
__(context
)
309 self
.patches
= patches
312 status
, out
, err
= self
.git
.am('-3', *self
.patches
)
313 Interaction
.log_status(status
, out
, err
)
316 self
.model
.update_file_status()
318 patch_basenames
= [os
.path
.basename(p
) for p
in self
.patches
]
319 if len(patch_basenames
) > 25:
320 patch_basenames
= patch_basenames
[:25]
321 patch_basenames
.append('...')
323 basenames
= '\n'.join(patch_basenames
)
324 Interaction
.information(
325 N_('Patch(es) Applied'),
326 (N_('%d patch(es) applied.') + '\n\n%s')
327 % (len(self
.patches
), basenames
))
330 class Archive(ContextCommand
):
331 """"Export archives using the "git archive" command"""
333 def __init__(self
, context
, ref
, fmt
, prefix
, filename
):
334 super(Archive
, self
).__init
__(context
)
338 self
.filename
= filename
341 fp
= core
.xopen(self
.filename
, 'wb')
342 cmd
= ['git', 'archive', '--format='+self
.fmt
]
343 if self
.fmt
in ('tgz', 'tar.gz'):
346 cmd
.append('--prefix=' + self
.prefix
)
348 proc
= core
.start_command(cmd
, stdout
=fp
)
349 out
, err
= proc
.communicate()
351 status
= proc
.returncode
352 Interaction
.log_status(status
, out
or '', err
or '')
355 class Checkout(EditModel
):
356 """A command object for git-checkout.
358 'argv' is handed off directly to git.
361 def __init__(self
, context
, argv
, checkout_branch
=False):
362 super(Checkout
, self
).__init
__(context
)
364 self
.checkout_branch
= checkout_branch
365 self
.new_diff_text
= ''
366 self
.new_diff_type
= 'text'
369 super(Checkout
, self
).do()
370 status
, out
, err
= self
.git
.checkout(*self
.argv
)
371 if self
.checkout_branch
:
372 self
.model
.update_status()
374 self
.model
.update_file_status()
375 Interaction
.command(N_('Error'), 'git checkout', status
, out
, err
)
378 class BlamePaths(ContextCommand
):
379 """Blame view for paths."""
381 def __init__(self
, context
, paths
):
382 super(BlamePaths
, self
).__init
__(context
)
383 viewer
= utils
.shell_split(prefs
.blame_viewer(context
))
384 self
.argv
= viewer
+ list(paths
)
390 _
, details
= utils
.format_exception(e
)
391 title
= N_('Error Launching Blame Viewer')
392 msg
= (N_('Cannot exec "%s": please configure a blame viewer')
393 % ' '.join(self
.argv
))
394 Interaction
.critical(title
, message
=msg
, details
=details
)
397 class CheckoutBranch(Checkout
):
398 """Checkout a branch."""
400 def __init__(self
, context
, branch
):
402 super(CheckoutBranch
, self
).__init
__(
403 context
, args
, checkout_branch
=True)
406 class CherryPick(ContextCommand
):
407 """Cherry pick commits into the current branch."""
409 def __init__(self
, context
, commits
):
410 super(CherryPick
, self
).__init
__(context
)
411 self
.commits
= commits
414 self
.model
.cherry_pick_list(self
.commits
)
415 self
.model
.update_file_status()
418 class Revert(ContextCommand
):
419 """Cherry pick commits into the current branch."""
421 def __init__(self
, context
, oid
):
422 super(Revert
, self
).__init
__(context
)
426 self
.git
.revert(self
.oid
)
427 self
.model
.update_file_status()
430 class ResetMode(EditModel
):
431 """Reset the mode and clear the model's diff text."""
433 def __init__(self
, context
):
434 super(ResetMode
, self
).__init
__(context
)
435 self
.new_mode
= self
.model
.mode_none
436 self
.new_diff_text
= ''
437 self
.new_diff_type
= 'text'
438 self
.new_filename
= ''
441 super(ResetMode
, self
).do()
442 self
.model
.update_file_status()
445 class ResetCommand(ConfirmAction
):
446 """Reset state using the "git reset" command"""
448 def __init__(self
, context
, ref
):
449 super(ResetCommand
, self
).__init
__(context
)
458 def error_message(self
):
462 self
.model
.update_file_status()
465 raise NotImplementedError('confirm() must be overridden')
468 raise NotImplementedError('reset() must be overridden')
471 class ResetBranchHead(ResetCommand
):
474 title
= N_('Reset Branch')
475 question
= N_('Point the current branch head to a new commit?')
476 info
= N_('The branch will be reset using "git reset --mixed %s"')
477 ok_text
= N_('Reset Branch')
478 info
= info
% self
.ref
479 return Interaction
.confirm(title
, question
, info
, ok_text
)
482 return self
.git
.reset(self
.ref
, '--', mixed
=True)
485 class ResetWorktree(ResetCommand
):
488 title
= N_('Reset Worktree')
489 question
= N_('Reset worktree?')
490 info
= N_('The worktree will be reset using "git reset --keep %s"')
491 ok_text
= N_('Reset Worktree')
492 info
= info
% self
.ref
493 return Interaction
.confirm(title
, question
, info
, ok_text
)
496 return self
.git
.reset(self
.ref
, '--', keep
=True)
499 class ResetMerge(ResetCommand
):
502 title
= N_('Reset Merge')
503 question
= N_('Reset merge?')
504 info
= N_('The branch will be reset using "git reset --merge %s"')
505 ok_text
= N_('Reset Merge')
506 info
= info
% self
.ref
507 return Interaction
.confirm(title
, question
, info
, ok_text
)
510 return self
.git
.reset(self
.ref
, '--', merge
=True)
513 class ResetSoft(ResetCommand
):
516 title
= N_('Reset Soft')
517 question
= N_('Reset soft?')
518 info
= N_('The branch will be reset using "git reset --soft %s"')
519 ok_text
= N_('Reset Soft')
520 info
= info
% self
.ref
521 return Interaction
.confirm(title
, question
, info
, ok_text
)
524 return self
.git
.reset(self
.ref
, '--', soft
=True)
527 class ResetHard(ResetCommand
):
530 title
= N_('Reset Hard')
531 question
= N_('Reset hard?')
532 info
= N_('The branch will be reset using "git reset --hard %s"')
533 ok_text
= N_('Reset Hard')
534 info
= info
% self
.ref
535 return Interaction
.confirm(title
, question
, info
, ok_text
)
538 return self
.git
.reset(self
.ref
, '--', hard
=True)
541 class Commit(ResetMode
):
542 """Attempt to create a new commit."""
544 def __init__(self
, context
, amend
, msg
, sign
, no_verify
=False):
545 super(Commit
, self
).__init
__(context
)
549 self
.no_verify
= no_verify
550 self
.old_commitmsg
= self
.model
.commitmsg
551 self
.new_commitmsg
= ''
554 # Create the commit message file
555 context
= self
.context
556 comment_char
= prefs
.comment_char(context
)
557 msg
= self
.strip_comments(self
.msg
, comment_char
=comment_char
)
558 tmp_file
= utils
.tmp_filename('commit-message')
560 core
.write(tmp_file
, msg
)
562 status
, out
, err
= self
.git
.commit(
563 F
=tmp_file
, v
=True, gpg_sign
=self
.sign
,
564 amend
=self
.amend
, no_verify
=self
.no_verify
)
566 core
.unlink(tmp_file
)
568 super(Commit
, self
).do()
569 self
.model
.set_commitmsg(self
.new_commitmsg
)
571 title
= N_('Commit failed')
572 Interaction
.command(title
, 'git commit', status
, out
, err
)
574 return status
, out
, err
577 def strip_comments(msg
, comment_char
='#'):
579 message_lines
= [line
for line
in msg
.split('\n')
580 if not line
.startswith(comment_char
)]
581 msg
= '\n'.join(message_lines
)
582 if not msg
.endswith('\n'):
588 class Ignore(ContextCommand
):
589 """Add files to .gitignore"""
591 def __init__(self
, context
, filenames
):
592 super(Ignore
, self
).__init
__(context
)
593 self
.filenames
= list(filenames
)
596 if not self
.filenames
:
598 new_additions
= '\n'.join(self
.filenames
) + '\n'
599 for_status
= new_additions
600 if core
.exists('.gitignore'):
601 current_list
= core
.read('.gitignore')
602 new_additions
= current_list
.rstrip() + '\n' + new_additions
603 core
.write('.gitignore', new_additions
)
604 Interaction
.log_status(0, 'Added to .gitignore:\n%s' % for_status
, '')
605 self
.model
.update_file_status()
608 def file_summary(files
):
609 txt
= core
.list2cmdline(files
)
611 txt
= txt
[:768].rstrip() + '...'
612 wrap
= textwrap
.TextWrapper()
613 return '\n'.join(wrap
.wrap(txt
))
616 class RemoteCommand(ConfirmAction
):
618 def __init__(self
, context
, remote
):
619 super(RemoteCommand
, self
).__init
__(context
)
624 self
.model
.update_remotes()
627 class RemoteAdd(RemoteCommand
):
629 def __init__(self
, context
, remote
, url
):
630 super(RemoteAdd
, self
).__init
__(context
, remote
)
634 return self
.git
.remote('add', self
.remote
, self
.url
)
636 def error_message(self
):
637 return N_('Error creating remote "%s"') % self
.remote
640 return 'git remote add "%s" "%s"' % (self
.remote
, self
.url
)
643 class RemoteRemove(RemoteCommand
):
646 title
= N_('Delete Remote')
647 question
= N_('Delete remote?')
648 info
= N_('Delete remote "%s"') % self
.remote
649 ok_text
= N_('Delete')
650 return Interaction
.confirm(title
, question
, info
, ok_text
)
653 return self
.git
.remote('rm', self
.remote
)
655 def error_message(self
):
656 return N_('Error deleting remote "%s"') % self
.remote
659 return 'git remote rm "%s"' % self
.remote
662 class RemoteRename(RemoteCommand
):
664 def __init__(self
, context
, remote
, new_name
):
665 super(RemoteRename
, self
).__init
__(context
, remote
)
666 self
.new_name
= new_name
669 title
= N_('Rename Remote')
670 text
= (N_('Rename remote "%(current)s" to "%(new)s"?') %
671 dict(current
=self
.remote
, new
=self
.new_name
))
674 return Interaction
.confirm(title
, text
, info_text
, ok_text
)
677 return self
.git
.remote('rename', self
.remote
, self
.new_name
)
679 def error_message(self
):
680 return (N_('Error renaming "%(name)s" to "%(new_name)s"')
681 % dict(name
=self
.remote
, new_name
=self
.new_name
))
684 return 'git remote rename "%s" "%s"' % (self
.remote
, self
.new_name
)
687 class RemoteSetURL(RemoteCommand
):
689 def __init__(self
, context
, remote
, url
):
690 super(RemoteSetURL
, self
).__init
__(context
, remote
)
694 return self
.git
.remote('set-url', self
.remote
, self
.url
)
696 def error_message(self
):
697 return (N_('Unable to set URL for "%(name)s" to "%(url)s"')
698 % dict(name
=self
.remote
, url
=self
.url
))
701 return 'git remote set-url "%s" "%s"' % (self
.remote
, self
.url
)
704 class RemoteEdit(ContextCommand
):
705 """Combine RemoteRename and RemoteSetURL"""
707 def __init__(self
, context
, old_name
, remote
, url
):
708 super(RemoteEdit
, self
).__init
__(context
)
709 self
.rename
= RemoteRename(context
, old_name
, remote
)
710 self
.set_url
= RemoteSetURL(context
, remote
, url
)
713 result
= self
.rename
.do()
717 result
= self
.set_url
.do()
719 return name_ok
, url_ok
722 class RemoveFromSettings(ConfirmAction
):
724 def __init__(self
, context
, settings
, repo
, entry
, icon
=None):
725 super(RemoveFromSettings
, self
).__init
__(context
)
726 self
.settings
= settings
735 class RemoveBookmark(RemoveFromSettings
):
739 title
= msg
= N_('Delete Bookmark?')
740 info
= N_('%s will be removed from your bookmarks.') % entry
741 ok_text
= N_('Delete Bookmark')
742 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
745 self
.settings
.remove_bookmark(self
.repo
, self
.entry
)
749 class RemoveRecent(RemoveFromSettings
):
753 title
= msg
= N_('Remove %s from the recent list?') % repo
754 info
= N_('%s will be removed from your recent repositories.') % repo
755 ok_text
= N_('Remove')
756 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
759 self
.settings
.remove_recent(self
.repo
)
763 class RemoveFiles(ContextCommand
):
766 def __init__(self
, context
, remover
, filenames
):
767 super(RemoveFiles
, self
).__init
__(context
)
770 self
.remover
= remover
771 self
.filenames
= filenames
772 # We could git-hash-object stuff and provide undo-ability
776 files
= self
.filenames
782 remove
= self
.remover
783 for filename
in files
:
789 bad_filenames
.append(filename
)
792 Interaction
.information(
794 N_('Deleting "%s" failed') % file_summary(bad_filenames
))
797 self
.model
.update_file_status()
800 class Delete(RemoveFiles
):
803 def __init__(self
, context
, filenames
):
804 super(Delete
, self
).__init
__(context
, os
.remove
, filenames
)
807 files
= self
.filenames
811 title
= N_('Delete Files?')
812 msg
= N_('The following files will be deleted:') + '\n\n'
813 msg
+= file_summary(files
)
814 info_txt
= N_('Delete %d file(s)?') % len(files
)
815 ok_txt
= N_('Delete Files')
817 if Interaction
.confirm(title
, msg
, info_txt
, ok_txt
,
818 default
=True, icon
=icons
.remove()):
819 super(Delete
, self
).do()
822 class MoveToTrash(RemoveFiles
):
823 """Move files to the trash using send2trash"""
825 AVAILABLE
= send2trash
is not None
827 def __init__(self
, context
, filenames
):
828 super(MoveToTrash
, self
).__init
__(context
, send2trash
, filenames
)
831 class DeleteBranch(ContextCommand
):
832 """Delete a git branch."""
834 def __init__(self
, context
, branch
):
835 super(DeleteBranch
, self
).__init
__(context
)
839 status
, out
, err
= self
.model
.delete_branch(self
.branch
)
840 Interaction
.log_status(status
, out
, err
)
843 class Rename(ContextCommand
):
844 """Rename a set of paths."""
846 def __init__(self
, context
, paths
):
847 super(Rename
, self
).__init
__(context
)
851 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
854 for path
in self
.paths
:
855 ok
= self
.rename(path
)
859 self
.model
.update_status()
861 def rename(self
, path
):
863 title
= N_('Rename "%s"') % path
865 if os
.path
.isdir(path
):
866 base_path
= os
.path
.dirname(path
)
869 new_path
= Interaction
.save_as(base_path
, title
)
873 status
, out
, err
= git
.mv(path
, new_path
, force
=True, verbose
=True)
874 Interaction
.command(N_('Error'), 'git mv', status
, out
, err
)
878 class RenameBranch(ContextCommand
):
879 """Rename a git branch."""
881 def __init__(self
, context
, branch
, new_branch
):
882 super(RenameBranch
, self
).__init
__(context
)
884 self
.new_branch
= new_branch
888 new_branch
= self
.new_branch
889 status
, out
, err
= self
.model
.rename_branch(branch
, new_branch
)
890 Interaction
.log_status(status
, out
, err
)
893 class DeleteRemoteBranch(ContextCommand
):
894 """Delete a remote git branch."""
896 def __init__(self
, context
, remote
, branch
):
897 super(DeleteRemoteBranch
, self
).__init
__(context
)
902 status
, out
, err
= self
.git
.push(self
.remote
, self
.branch
, delete
=True)
903 self
.model
.update_status()
905 title
= N_('Error Deleting Remote Branch')
906 Interaction
.command(title
, 'git push', status
, out
, err
)
908 Interaction
.information(
909 N_('Remote Branch Deleted'),
910 N_('"%(branch)s" has been deleted from "%(remote)s".')
911 % dict(branch
=self
.branch
, remote
=self
.remote
))
914 def get_mode(model
, staged
, modified
, unmerged
, untracked
):
916 mode
= model
.mode_index
917 elif modified
or unmerged
:
918 mode
= model
.mode_worktree
920 mode
= model
.mode_untracked
926 class DiffImage(EditModel
):
928 def __init__(self
, context
, filename
,
929 deleted
, staged
, modified
, unmerged
, untracked
):
930 super(DiffImage
, self
).__init
__(context
)
932 self
.new_filename
= filename
933 self
.new_diff_text
= ''
934 self
.new_diff_type
= 'image'
935 self
.new_mode
= get_mode(
936 self
.model
, staged
, modified
, unmerged
, untracked
)
938 self
.modified
= modified
939 self
.unmerged
= unmerged
940 self
.untracked
= untracked
941 self
.deleted
= deleted
942 self
.annex
= self
.cfg
.is_annex()
945 filename
= self
.new_filename
948 images
= self
.staged_images()
950 images
= self
.modified_images()
952 images
= self
.unmerged_images()
954 images
= [(filename
, False)]
958 self
.model
.set_images(images
)
959 super(DiffImage
, self
).do()
961 def staged_images(self
):
962 context
= self
.context
964 head
= self
.model
.head
965 filename
= self
.new_filename
969 index
= git
.diff_index(head
, '--', filename
, cached
=True)[STDOUT
]
972 # :100644 100644 fabadb8... 4866510... M describe.c
973 parts
= index
.split(' ')
978 if old_oid
!= MISSING_BLOB_OID
:
979 # First, check if we can get a pre-image from git-annex
982 annex_image
= gitcmds
.annex_path(context
, head
, filename
)
984 images
.append((annex_image
, False)) # git annex HEAD
986 image
= gitcmds
.write_blob_path(
987 context
, head
, old_oid
, filename
)
989 images
.append((image
, True))
991 if new_oid
!= MISSING_BLOB_OID
:
992 found_in_annex
= False
993 if annex
and core
.islink(filename
):
994 status
, out
, _
= git
.annex('status', '--', filename
)
996 details
= out
.split(' ')
997 if details
and details
[0] == 'A': # newly added file
998 images
.append((filename
, False))
999 found_in_annex
= True
1001 if not found_in_annex
:
1002 image
= gitcmds
.write_blob(context
, new_oid
, filename
)
1004 images
.append((image
, True))
1008 def unmerged_images(self
):
1009 context
= self
.context
1011 head
= self
.model
.head
1012 filename
= self
.new_filename
1015 candidate_merge_heads
= ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD')
1017 merge_head
for merge_head
in candidate_merge_heads
1018 if core
.exists(git
.git_path(merge_head
))]
1020 if annex
: # Attempt to find files in git-annex
1022 for merge_head
in merge_heads
:
1023 image
= gitcmds
.annex_path(context
, merge_head
, filename
)
1025 annex_images
.append((image
, False))
1027 annex_images
.append((filename
, False))
1030 # DIFF FORMAT FOR MERGES
1031 # "git-diff-tree", "git-diff-files" and "git-diff --raw"
1032 # can take -c or --cc option to generate diff output also
1033 # for merge commits. The output differs from the format
1034 # described above in the following way:
1036 # 1. there is a colon for each parent
1037 # 2. there are more "src" modes and "src" sha1
1038 # 3. status is concatenated status characters for each parent
1039 # 4. no optional "score" number
1040 # 5. single path, only for "dst"
1042 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \
1045 index
= git
.diff_index(head
, '--', filename
,
1046 cached
=True, cc
=True)[STDOUT
]
1048 parts
= index
.split(' ')
1050 first_mode
= parts
[0]
1051 num_parents
= first_mode
.count(':')
1052 # colon for each parent, but for the index, the "parents"
1053 # are really entries in stages 1,2,3 (head, base, remote)
1054 # remote, base, head
1055 for i
in range(num_parents
):
1056 offset
= num_parents
+ i
+ 1
1059 merge_head
= merge_heads
[i
]
1062 if oid
!= MISSING_BLOB_OID
:
1063 image
= gitcmds
.write_blob_path(
1064 context
, merge_head
, oid
, filename
)
1066 images
.append((image
, True))
1068 images
.append((filename
, False))
1071 def modified_images(self
):
1072 context
= self
.context
1074 head
= self
.model
.head
1075 filename
= self
.new_filename
1080 if annex
: # Check for a pre-image from git-annex
1081 annex_image
= gitcmds
.annex_path(context
, head
, filename
)
1083 images
.append((annex_image
, False)) # git annex HEAD
1085 worktree
= git
.diff_files('--', filename
)[STDOUT
]
1086 parts
= worktree
.split(' ')
1089 if oid
!= MISSING_BLOB_OID
:
1090 image
= gitcmds
.write_blob_path(
1091 context
, head
, oid
, filename
)
1093 images
.append((image
, True)) # HEAD
1095 images
.append((filename
, False)) # worktree
1099 class Diff(EditModel
):
1100 """Perform a diff and set the model's current text."""
1102 def __init__(self
, context
, filename
, cached
=False, deleted
=False):
1103 super(Diff
, self
).__init
__(context
)
1106 opts
['ref'] = self
.model
.head
1107 self
.new_filename
= filename
1108 self
.new_mode
= self
.model
.mode_worktree
1109 self
.new_diff_text
= gitcmds
.diff_helper(
1110 self
.context
, filename
=filename
, cached
=cached
,
1111 deleted
=deleted
, **opts
)
1112 self
.new_diff_type
= 'text'
1115 class Diffstat(EditModel
):
1116 """Perform a diffstat and set the model's diff text."""
1118 def __init__(self
, context
):
1119 super(Diffstat
, self
).__init
__(context
)
1121 diff_context
= cfg
.get('diff.context', 3)
1122 diff
= self
.git
.diff(
1123 self
.model
.head
, unified
=diff_context
, no_ext_diff
=True,
1124 no_color
=True, M
=True, stat
=True)[STDOUT
]
1125 self
.new_diff_text
= diff
1126 self
.new_diff_type
= 'text'
1127 self
.new_mode
= self
.model
.mode_diffstat
1130 class DiffStaged(Diff
):
1131 """Perform a staged diff on a file."""
1133 def __init__(self
, context
, filename
, deleted
=None):
1134 super(DiffStaged
, self
).__init
__(
1135 context
, filename
, cached
=True, deleted
=deleted
)
1136 self
.new_mode
= self
.model
.mode_index
1139 class DiffStagedSummary(EditModel
):
1141 def __init__(self
, context
):
1142 super(DiffStagedSummary
, self
).__init
__(context
)
1143 diff
= self
.git
.diff(
1144 self
.model
.head
, cached
=True, no_color
=True,
1145 no_ext_diff
=True, patch_with_stat
=True, M
=True)[STDOUT
]
1146 self
.new_diff_text
= diff
1147 self
.new_diff_type
= 'text'
1148 self
.new_mode
= self
.model
.mode_index
1151 class Difftool(ContextCommand
):
1152 """Run git-difftool limited by path."""
1154 def __init__(self
, context
, staged
, filenames
):
1155 super(Difftool
, self
).__init
__(context
)
1156 self
.staged
= staged
1157 self
.filenames
= filenames
1160 difftool_launch_with_head(
1161 self
.context
, self
.filenames
, self
.staged
, self
.model
.head
)
1164 class Edit(ContextCommand
):
1165 """Edit a file using the configured gui.editor."""
1169 return N_('Launch Editor')
1171 def __init__(self
, context
, filenames
,
1172 line_number
=None, background_editor
=False):
1173 super(Edit
, self
).__init
__(context
)
1174 self
.filenames
= filenames
1175 self
.line_number
= line_number
1176 self
.background_editor
= background_editor
1179 context
= self
.context
1180 if not self
.filenames
:
1182 filename
= self
.filenames
[0]
1183 if not core
.exists(filename
):
1185 if self
.background_editor
:
1186 editor
= prefs
.background_editor(context
)
1188 editor
= prefs
.editor(context
)
1191 if self
.line_number
is None:
1192 opts
= self
.filenames
1194 # Single-file w/ line-numbers (likely from grep)
1196 '*vim*': [filename
, '+%s' % self
.line_number
],
1197 '*emacs*': ['+%s' % self
.line_number
, filename
],
1198 '*textpad*': ['%s(%s,0)' % (filename
, self
.line_number
)],
1199 '*notepad++*': ['-n%s' % self
.line_number
, filename
],
1200 '*subl*': ['%s:%s' % (filename
, self
.line_number
)],
1203 opts
= self
.filenames
1204 for pattern
, opt
in editor_opts
.items():
1205 if fnmatch(editor
, pattern
):
1210 core
.fork(utils
.shell_split(editor
) + opts
)
1211 except (OSError, ValueError) as e
:
1212 message
= (N_('Cannot exec "%s": please configure your editor')
1214 _
, details
= utils
.format_exception(e
)
1215 Interaction
.critical(N_('Error Editing File'), message
, details
)
1218 class FormatPatch(ContextCommand
):
1219 """Output a patch series given all revisions and a selected subset."""
1221 def __init__(self
, context
, to_export
, revs
, output
='patches'):
1222 super(FormatPatch
, self
).__init
__(context
)
1223 self
.to_export
= list(to_export
)
1224 self
.revs
= list(revs
)
1225 self
.output
= output
1228 context
= self
.context
1229 status
, out
, err
= gitcmds
.format_patchsets(
1230 context
, self
.to_export
, self
.revs
, self
.output
)
1231 Interaction
.log_status(status
, out
, err
)
1234 class LaunchDifftool(ContextCommand
):
1238 return N_('Launch Diff Tool')
1241 s
= self
.selection
.selection()
1244 if utils
.is_win32():
1245 core
.fork(['git', 'mergetool', '--no-prompt', '--'] + paths
)
1248 cmd
= cfg
.terminal()
1249 argv
= utils
.shell_split(cmd
)
1250 mergetool
= ['git', 'mergetool', '--no-prompt', '--']
1251 mergetool
.extend(paths
)
1252 needs_shellquote
= set(['gnome-terminal', 'xfce4-terminal'])
1253 if os
.path
.basename(argv
[0]) in needs_shellquote
:
1254 argv
.append(core
.list2cmdline(mergetool
))
1256 argv
.extend(mergetool
)
1259 difftool_run(self
.context
)
1262 class LaunchTerminal(ContextCommand
):
1266 return N_('Launch Terminal')
1268 def __init__(self
, context
, path
):
1269 super(LaunchTerminal
, self
).__init
__(context
)
1273 cmd
= self
.cfg
.terminal()
1274 argv
= utils
.shell_split(cmd
)
1275 argv
.append(os
.getenv('SHELL', '/bin/sh'))
1276 core
.fork(argv
, cwd
=self
.path
)
1279 class LaunchEditor(Edit
):
1283 return N_('Launch Editor')
1285 def __init__(self
, context
):
1286 s
= context
.selection
.selection()
1287 filenames
= s
.staged
+ s
.unmerged
+ s
.modified
+ s
.untracked
1288 super(LaunchEditor
, self
).__init
__(
1289 context
, filenames
, background_editor
=True)
1292 class LaunchEditorAtLine(LaunchEditor
):
1293 """Launch an editor at the specified line"""
1295 def __init__(self
, context
):
1296 super(LaunchEditorAtLine
, self
).__init
__(context
)
1297 self
.line_number
= context
.selection
.line_number
1300 class LoadCommitMessageFromFile(ContextCommand
):
1301 """Loads a commit message from a path."""
1304 def __init__(self
, context
, path
):
1305 super(LoadCommitMessageFromFile
, self
).__init
__(context
)
1307 self
.old_commitmsg
= self
.model
.commitmsg
1308 self
.old_directory
= self
.model
.directory
1311 path
= os
.path
.expanduser(self
.path
)
1312 if not path
or not core
.isfile(path
):
1313 raise UsageError(N_('Error: Cannot find commit template'),
1314 N_('%s: No such file or directory.') % path
)
1315 self
.model
.set_directory(os
.path
.dirname(path
))
1316 self
.model
.set_commitmsg(core
.read(path
))
1319 self
.model
.set_commitmsg(self
.old_commitmsg
)
1320 self
.model
.set_directory(self
.old_directory
)
1323 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile
):
1324 """Loads the commit message template specified by commit.template."""
1326 def __init__(self
, context
):
1328 template
= cfg
.get('commit.template')
1329 super(LoadCommitMessageFromTemplate
, self
).__init
__(context
, template
)
1332 if self
.path
is None:
1334 N_('Error: Unconfigured commit template'),
1335 N_('A commit template has not been configured.\n'
1336 'Use "git config" to define "commit.template"\n'
1337 'so that it points to a commit template.'))
1338 return LoadCommitMessageFromFile
.do(self
)
1341 class LoadCommitMessageFromOID(ContextCommand
):
1342 """Load a previous commit message"""
1345 def __init__(self
, context
, oid
, prefix
=''):
1346 super(LoadCommitMessageFromOID
, self
).__init
__(context
)
1348 self
.old_commitmsg
= self
.model
.commitmsg
1349 self
.new_commitmsg
= prefix
+ gitcmds
.prev_commitmsg(context
, oid
)
1352 self
.model
.set_commitmsg(self
.new_commitmsg
)
1355 self
.model
.set_commitmsg(self
.old_commitmsg
)
1358 class PrepareCommitMessageHook(ContextCommand
):
1359 """Use the cola-prepare-commit-msg hook to prepare the commit message
1363 def __init__(self
, context
):
1364 super(PrepareCommitMessageHook
, self
).__init
__(context
)
1365 self
.old_commitmsg
= self
.model
.commitmsg
1367 def get_message(self
):
1369 title
= N_('Error running prepare-commitmsg hook')
1370 hook
= gitcmds
.prepare_commit_message_hook(self
.context
)
1372 if os
.path
.exists(hook
):
1373 filename
= self
.model
.save_commitmsg()
1374 status
, out
, err
= core
.run_command([hook
, filename
])
1377 result
= core
.read(filename
)
1379 result
= self
.old_commitmsg
1380 Interaction
.command_error(title
, hook
, status
, out
, err
)
1382 message
= N_('A hook must be provided at "%s"') % hook
1383 Interaction
.critical(title
, message
=message
)
1384 result
= self
.old_commitmsg
1389 msg
= self
.get_message()
1390 self
.model
.set_commitmsg(msg
)
1393 self
.model
.set_commitmsg(self
.old_commitmsg
)
1396 class LoadFixupMessage(LoadCommitMessageFromOID
):
1397 """Load a fixup message"""
1399 def __init__(self
, context
, oid
):
1400 super(LoadFixupMessage
, self
).__init
__(context
, oid
, prefix
='fixup! ')
1401 if self
.new_commitmsg
:
1402 self
.new_commitmsg
= self
.new_commitmsg
.splitlines()[0]
1405 class Merge(ContextCommand
):
1408 def __init__(self
, context
, revision
, no_commit
, squash
, no_ff
, sign
):
1409 super(Merge
, self
).__init
__(context
)
1410 self
.revision
= revision
1412 self
.no_commit
= no_commit
1413 self
.squash
= squash
1417 squash
= self
.squash
1418 revision
= self
.revision
1420 no_commit
= self
.no_commit
1423 status
, out
, err
= self
.git
.merge(
1424 revision
, gpg_sign
=sign
, no_ff
=no_ff
,
1425 no_commit
=no_commit
, squash
=squash
)
1426 self
.model
.update_status()
1427 title
= N_('Merge failed. Conflict resolution is required.')
1428 Interaction
.command(title
, 'git merge', status
, out
, err
)
1430 return status
, out
, err
1433 class OpenDefaultApp(ContextCommand
):
1434 """Open a file using the OS default."""
1438 return N_('Open Using Default Application')
1440 def __init__(self
, context
, filenames
):
1441 super(OpenDefaultApp
, self
).__init
__(context
)
1442 if utils
.is_darwin():
1445 launcher
= 'xdg-open'
1446 self
.launcher
= launcher
1447 self
.filenames
= filenames
1450 if not self
.filenames
:
1452 core
.fork([self
.launcher
] + self
.filenames
)
1455 class OpenParentDir(OpenDefaultApp
):
1456 """Open parent directories using the OS default."""
1460 return N_('Open Parent Directory')
1462 def __init__(self
, context
, filenames
):
1463 OpenDefaultApp
.__init
__(self
, context
, filenames
)
1466 if not self
.filenames
:
1468 dirnames
= list(set([os
.path
.dirname(x
) for x
in self
.filenames
]))
1469 # os.path.dirname() can return an empty string so we fallback to
1470 # the current directory
1471 dirs
= [(dirname
or core
.getcwd()) for dirname
in dirnames
]
1472 core
.fork([self
.launcher
] + dirs
)
1475 class OpenNewRepo(ContextCommand
):
1476 """Launches git-cola on a repo."""
1478 def __init__(self
, context
, repo_path
):
1479 super(OpenNewRepo
, self
).__init
__(context
)
1480 self
.repo_path
= repo_path
1483 self
.model
.set_directory(self
.repo_path
)
1484 core
.fork([sys
.executable
, sys
.argv
[0], '--repo', self
.repo_path
])
1487 class OpenRepo(EditModel
):
1489 def __init__(self
, context
, repo_path
):
1490 super(OpenRepo
, self
).__init
__(context
)
1491 self
.repo_path
= repo_path
1492 self
.new_mode
= self
.model
.mode_none
1493 self
.new_diff_text
= ''
1494 self
.new_diff_type
= 'text'
1495 self
.new_commitmsg
= ''
1496 self
.new_filename
= ''
1499 old_repo
= self
.git
.getcwd()
1500 if self
.model
.set_worktree(self
.repo_path
):
1501 self
.fsmonitor
.stop()
1502 self
.fsmonitor
.start()
1503 self
.model
.update_status()
1504 self
.model
.set_commitmsg(self
.new_commitmsg
)
1505 super(OpenRepo
, self
).do()
1507 self
.model
.set_worktree(old_repo
)
1510 class Clone(ContextCommand
):
1511 """Clones a repository and optionally spawns a new cola session."""
1513 def __init__(self
, context
, url
, new_directory
,
1514 submodules
=False, shallow
=False, spawn
=True):
1515 super(Clone
, self
).__init
__(context
)
1517 self
.new_directory
= new_directory
1518 self
.submodules
= submodules
1519 self
.shallow
= shallow
1529 recurse_submodules
= self
.submodules
1530 shallow_submodules
= self
.submodules
and self
.shallow
1532 status
, out
, err
= self
.git
.clone(
1533 self
.url
, self
.new_directory
,
1534 recurse_submodules
=recurse_submodules
,
1535 shallow_submodules
=shallow_submodules
,
1538 self
.status
= status
1541 if status
== 0 and self
.spawn
:
1542 executable
= sys
.executable
1543 core
.fork([executable
, sys
.argv
[0], '--repo', self
.new_directory
])
1547 class NewBareRepo(ContextCommand
):
1548 """Create a new shared bare repository"""
1550 def __init__(self
, context
, path
):
1551 super(NewBareRepo
, self
).__init
__(context
)
1556 status
, out
, err
= self
.git
.init(path
, bare
=True, shared
=True)
1557 Interaction
.command(
1558 N_('Error'), 'git init --bare --shared "%s"' % path
,
1563 def unix_path(path
, is_win32
=utils
.is_win32
):
1564 """Git for Windows requires unix paths, so force them here
1567 path
= path
.replace('\\', '/')
1570 if second
== ':': # sanity check, this better be a Windows-style path
1571 path
= '/' + first
+ path
[2:]
1576 def sequence_editor():
1577 """Return a GIT_SEQUENCE_EDITOR environment value that enables git-xbase"""
1578 xbase
= unix_path(resources
.share('bin', 'git-xbase'))
1579 editor
= core
.list2cmdline([unix_path(sys
.executable
), xbase
])
1583 class GitXBaseContext(object):
1585 def __init__(self
, context
, **kwargs
):
1587 'GIT_EDITOR': prefs
.editor(context
),
1588 'GIT_SEQUENCE_EDITOR': sequence_editor(),
1589 'GIT_XBASE_CANCEL_ACTION': 'save',
1591 self
.env
.update(kwargs
)
1593 def __enter__(self
):
1594 for var
, value
in self
.env
.items():
1595 compat
.setenv(var
, value
)
1598 def __exit__(self
, exc_type
, exc_val
, exc_tb
):
1599 for var
in self
.env
:
1600 compat
.unsetenv(var
)
1603 class Rebase(ContextCommand
):
1605 def __init__(self
, context
, upstream
=None, branch
=None, **kwargs
):
1606 """Start an interactive rebase session
1608 :param upstream: upstream branch
1609 :param branch: optional branch to checkout
1610 :param kwargs: forwarded directly to `git.rebase()`
1613 super(Rebase
, self
).__init
__(context
)
1615 self
.upstream
= upstream
1616 self
.branch
= branch
1617 self
.kwargs
= kwargs
1619 def prepare_arguments(self
, upstream
):
1623 # Rebase actions must be the only option specified
1624 for action
in ('continue', 'abort', 'skip', 'edit_todo'):
1625 if self
.kwargs
.get(action
, False):
1626 kwargs
[action
] = self
.kwargs
[action
]
1629 kwargs
['interactive'] = True
1630 kwargs
['autosquash'] = self
.kwargs
.get('autosquash', True)
1631 kwargs
.update(self
.kwargs
)
1634 args
.append(upstream
)
1636 args
.append(self
.branch
)
1641 (status
, out
, err
) = (1, '', '')
1642 context
= self
.context
1646 if not cfg
.get('rebase.autostash', False):
1647 if model
.staged
or model
.unmerged
or model
.modified
:
1648 Interaction
.information(
1649 N_('Unable to rebase'),
1650 N_('You cannot rebase with uncommitted changes.'))
1651 return status
, out
, err
1653 upstream
= self
.upstream
or Interaction
.choose_ref(
1654 context
, N_('Select New Upstream'), N_('Interactive Rebase'),
1655 default
='@{upstream}')
1657 return status
, out
, err
1659 self
.model
.is_rebasing
= True
1660 self
.model
.emit_updated()
1662 args
, kwargs
= self
.prepare_arguments(upstream
)
1663 upstream_title
= upstream
or '@{upstream}'
1664 with
GitXBaseContext(
1666 GIT_XBASE_TITLE
=N_('Rebase onto %s') % upstream_title
,
1667 GIT_XBASE_ACTION
=N_('Rebase')
1669 # TODO this blocks the user interface window for the duration
1670 # of git-xbase's invocation. We would need to implement
1671 # signals for QProcess and continue running the main thread.
1672 # alternatively we could hide the main window while rebasing.
1673 # that doesn't require as much effort.
1674 status
, out
, err
= self
.git
.rebase(
1675 *args
, _no_win32_startupinfo
=True, **kwargs
)
1676 self
.model
.update_status()
1677 if err
.strip() != 'Nothing to do':
1678 title
= N_('Rebase stopped')
1679 Interaction
.command(title
, 'git rebase', status
, out
, err
)
1680 return status
, out
, err
1683 class RebaseEditTodo(ContextCommand
):
1686 (status
, out
, err
) = (1, '', '')
1687 with
GitXBaseContext(
1689 GIT_XBASE_TITLE
=N_('Edit Rebase'),
1690 GIT_XBASE_ACTION
=N_('Save')
1692 status
, out
, err
= self
.git
.rebase(edit_todo
=True)
1693 Interaction
.log_status(status
, out
, err
)
1694 self
.model
.update_status()
1695 return status
, out
, err
1698 class RebaseContinue(ContextCommand
):
1701 (status
, out
, err
) = (1, '', '')
1702 with
GitXBaseContext(
1704 GIT_XBASE_TITLE
=N_('Rebase'),
1705 GIT_XBASE_ACTION
=N_('Rebase')
1707 status
, out
, err
= self
.git
.rebase('--continue')
1708 Interaction
.log_status(status
, out
, err
)
1709 self
.model
.update_status()
1710 return status
, out
, err
1713 class RebaseSkip(ContextCommand
):
1716 (status
, out
, err
) = (1, '', '')
1717 with
GitXBaseContext(
1719 GIT_XBASE_TITLE
=N_('Rebase'),
1720 GIT_XBASE_ACTION
=N_('Rebase')
1722 status
, out
, err
= self
.git
.rebase(skip
=True)
1723 Interaction
.log_status(status
, out
, err
)
1724 self
.model
.update_status()
1725 return status
, out
, err
1728 class RebaseAbort(ContextCommand
):
1731 status
, out
, err
= self
.git
.rebase(abort
=True)
1732 Interaction
.log_status(status
, out
, err
)
1733 self
.model
.update_status()
1736 class Rescan(ContextCommand
):
1737 """Rescan for changes"""
1740 self
.model
.update_status()
1743 class Refresh(ContextCommand
):
1744 """Update refs, refresh the index, and update config"""
1748 return N_('Refresh')
1751 self
.model
.update_status(update_index
=True)
1753 self
.fsmonitor
.refresh()
1756 class RefreshConfig(ContextCommand
):
1757 """Refresh the git config cache"""
1763 class RevertEditsCommand(ConfirmAction
):
1765 def __init__(self
, context
):
1766 super(RevertEditsCommand
, self
).__init
__(context
)
1767 self
.icon
= icons
.undo()
1769 def ok_to_run(self
):
1770 return self
.model
.undoable()
1772 # pylint: disable=no-self-use
1773 def checkout_from_head(self
):
1776 def checkout_args(self
):
1778 s
= self
.selection
.selection()
1779 if self
.checkout_from_head():
1780 args
.append(self
.model
.head
)
1792 checkout_args
= self
.checkout_args()
1793 return self
.git
.checkout(*checkout_args
)
1796 self
.model
.update_file_status()
1799 class RevertUnstagedEdits(RevertEditsCommand
):
1803 return N_('Revert Unstaged Edits...')
1805 def checkout_from_head(self
):
1806 # If we are amending and a modified file is selected
1807 # then we should include "HEAD^" on the command-line.
1808 selected
= self
.selection
.selection()
1809 return not selected
.staged
and self
.model
.amending()
1812 title
= N_('Revert Unstaged Changes?')
1814 'This operation removes unstaged edits from selected files.\n'
1815 'These changes cannot be recovered.')
1816 info
= N_('Revert the unstaged changes?')
1817 ok_text
= N_('Revert Unstaged Changes')
1818 return Interaction
.confirm(title
, text
, info
, ok_text
,
1819 default
=True, icon
=self
.icon
)
1822 class RevertUncommittedEdits(RevertEditsCommand
):
1826 return N_('Revert Uncommitted Edits...')
1828 def checkout_from_head(self
):
1832 """Prompt for reverting changes"""
1833 title
= N_('Revert Uncommitted Changes?')
1835 'This operation removes uncommitted edits from selected files.\n'
1836 'These changes cannot be recovered.')
1837 info
= N_('Revert the uncommitted changes?')
1838 ok_text
= N_('Revert Uncommitted Changes')
1839 return Interaction
.confirm(title
, text
, info
, ok_text
,
1840 default
=True, icon
=self
.icon
)
1843 class RunConfigAction(ContextCommand
):
1844 """Run a user-configured action, typically from the "Tools" menu"""
1846 def __init__(self
, context
, action_name
):
1847 super(RunConfigAction
, self
).__init
__(context
)
1848 self
.action_name
= action_name
1851 """Run the user-configured action"""
1852 for env
in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'):
1854 compat
.unsetenv(env
)
1859 context
= self
.context
1861 opts
= cfg
.get_guitool_opts(self
.action_name
)
1862 cmd
= opts
.get('cmd')
1863 if 'title' not in opts
:
1866 if 'prompt' not in opts
or opts
.get('prompt') is True:
1867 prompt
= N_('Run "%s"?') % cmd
1868 opts
['prompt'] = prompt
1870 if opts
.get('needsfile'):
1871 filename
= self
.selection
.filename()
1873 Interaction
.information(
1874 N_('Please select a file'),
1875 N_('"%s" requires a selected file.') % cmd
)
1877 dirname
= utils
.dirname(filename
, current_dir
='.')
1878 compat
.setenv('FILENAME', filename
)
1879 compat
.setenv('DIRNAME', dirname
)
1881 if opts
.get('revprompt') or opts
.get('argprompt'):
1883 ok
= Interaction
.confirm_config_action(context
, cmd
, opts
)
1886 rev
= opts
.get('revision')
1887 args
= opts
.get('args')
1888 if opts
.get('revprompt') and not rev
:
1889 title
= N_('Invalid Revision')
1890 msg
= N_('The revision expression cannot be empty.')
1891 Interaction
.critical(title
, msg
)
1895 elif opts
.get('confirm'):
1896 title
= os
.path
.expandvars(opts
.get('title'))
1897 prompt
= os
.path
.expandvars(opts
.get('prompt'))
1898 if not Interaction
.question(title
, prompt
):
1901 compat
.setenv('REVISION', rev
)
1903 compat
.setenv('ARGS', args
)
1904 title
= os
.path
.expandvars(cmd
)
1905 Interaction
.log(N_('Running command: %s') % title
)
1906 cmd
= ['sh', '-c', cmd
]
1908 if opts
.get('background'):
1910 status
, out
, err
= (0, '', '')
1911 elif opts
.get('noconsole'):
1912 status
, out
, err
= core
.run_command(cmd
)
1914 status
, out
, err
= Interaction
.run_command(title
, cmd
)
1916 if not opts
.get('background') and not opts
.get('norescan'):
1917 self
.model
.update_status()
1920 Interaction
.command(title
, cmd
, status
, out
, err
)
1925 class SetDefaultRepo(ContextCommand
):
1926 """Set the default repository"""
1928 def __init__(self
, context
, repo
):
1929 super(SetDefaultRepo
, self
).__init
__(context
)
1933 self
.cfg
.set_user('cola.defaultrepo', self
.repo
)
1936 class SetDiffText(EditModel
):
1937 """Set the diff text"""
1940 def __init__(self
, context
, text
):
1941 super(SetDiffText
, self
).__init
__(context
)
1942 self
.new_diff_text
= text
1943 self
.new_diff_type
= 'text'
1946 class SetUpstreamBranch(ContextCommand
):
1947 """Set the upstream branch"""
1949 def __init__(self
, context
, branch
, remote
, remote_branch
):
1950 super(SetUpstreamBranch
, self
).__init
__(context
)
1951 self
.branch
= branch
1952 self
.remote
= remote
1953 self
.remote_branch
= remote_branch
1957 remote
= self
.remote
1958 branch
= self
.branch
1959 remote_branch
= self
.remote_branch
1960 cfg
.set_repo('branch.%s.remote' % branch
, remote
)
1961 cfg
.set_repo('branch.%s.merge' % branch
, 'refs/heads/' + remote_branch
)
1964 class ShowUntracked(EditModel
):
1965 """Show an untracked file."""
1967 def __init__(self
, context
, filename
):
1968 super(ShowUntracked
, self
).__init
__(context
)
1969 self
.new_filename
= filename
1970 self
.new_mode
= self
.model
.mode_untracked
1971 self
.new_diff_text
= self
.read(filename
)
1972 self
.new_diff_type
= 'text'
1974 def read(self
, filename
):
1975 """Read file contents"""
1977 size
= cfg
.get('cola.readsize', 2048)
1979 result
= core
.read(filename
, size
=size
,
1980 encoding
=core
.ENCODING
, errors
='ignore')
1981 except (IOError, OSError):
1984 if len(result
) == size
:
1989 class SignOff(ContextCommand
):
1990 """Append a signoff to the commit message"""
1995 return N_('Sign Off')
1997 def __init__(self
, context
):
1998 super(SignOff
, self
).__init
__(context
)
1999 self
.old_commitmsg
= self
.model
.commitmsg
2002 """Add a signoff to the commit message"""
2003 signoff
= self
.signoff()
2004 if signoff
in self
.model
.commitmsg
:
2006 msg
= self
.model
.commitmsg
.rstrip()
2007 self
.model
.set_commitmsg(msg
+ '\n' + signoff
)
2010 """Restore the commit message"""
2011 self
.model
.set_commitmsg(self
.old_commitmsg
)
2014 """Generate the signoff string"""
2017 user
= pwd
.getpwuid(os
.getuid()).pw_name
2019 user
= os
.getenv('USER', N_('unknown'))
2022 name
= cfg
.get('user.name', user
)
2023 email
= cfg
.get('user.email', '%s@%s' % (user
, core
.node()))
2024 return '\nSigned-off-by: %s <%s>' % (name
, email
)
2027 def check_conflicts(context
, unmerged
):
2028 """Check paths for conflicts
2030 Conflicting files can be filtered out one-by-one.
2033 if prefs
.check_conflicts(context
):
2034 unmerged
= [path
for path
in unmerged
if is_conflict_free(path
)]
2038 def is_conflict_free(path
):
2039 """Return True if `path` contains no conflict markers
2041 rgx
= re
.compile(r
'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
2043 with core
.xopen(path
, 'r') as f
:
2045 line
= core
.decode(line
, errors
='ignore')
2047 return should_stage_conflicts(path
)
2049 # We can't read this file ~ we may be staging a removal
2054 def should_stage_conflicts(path
):
2055 """Inform the user that a file contains merge conflicts
2057 Return `True` if we should stage the path nonetheless.
2060 title
= msg
= N_('Stage conflicts?')
2061 info
= N_('%s appears to contain merge conflicts.\n\n'
2062 'You should probably skip this file.\n'
2063 'Stage it anyways?') % path
2064 ok_text
= N_('Stage conflicts')
2065 cancel_text
= N_('Skip')
2066 return Interaction
.confirm(title
, msg
, info
, ok_text
,
2067 default
=False, cancel_text
=cancel_text
)
2070 class Stage(ContextCommand
):
2071 """Stage a set of paths."""
2077 def __init__(self
, context
, paths
):
2078 super(Stage
, self
).__init
__(context
)
2082 msg
= N_('Staging: %s') % (', '.join(self
.paths
))
2083 Interaction
.log(msg
)
2084 return self
.stage_paths()
2086 def stage_paths(self
):
2087 """Stages add/removals to git."""
2088 context
= self
.context
2091 if self
.model
.cfg
.get('cola.safemode', False):
2093 return self
.stage_all()
2098 for path
in set(paths
):
2099 if core
.exists(path
) or core
.islink(path
):
2100 if path
.endswith('/'):
2101 path
= path
.rstrip('/')
2106 self
.model
.emit_about_to_update()
2108 # `git add -u` doesn't work on untracked files
2110 status
, out
, err
= gitcmds
.add(context
, add
)
2111 Interaction
.command(N_('Error'), 'git add', status
, out
, err
)
2113 # If a path doesn't exist then that means it should be removed
2114 # from the index. We use `git add -u` for that.
2116 status
, out
, err
= gitcmds
.add(context
, remove
, u
=True)
2117 Interaction
.command(N_('Error'), 'git add -u', status
, out
, err
)
2119 self
.model
.update_files(emit
=True)
2120 return status
, out
, err
2122 def stage_all(self
):
2123 """Stage all files"""
2124 status
, out
, err
= self
.git
.add(v
=True, u
=True)
2125 Interaction
.command(N_('Error'), 'git add -u', status
, out
, err
)
2126 self
.model
.update_file_status()
2127 return (status
, out
, err
)
2130 class StageCarefully(Stage
):
2131 """Only stage when the path list is non-empty
2133 We use "git add -u -- <pathspec>" to stage, and it stages everything by
2134 default when no pathspec is specified, so this class ensures that paths
2135 are specified before calling git.
2137 When no paths are specified, the command does nothing.
2140 def __init__(self
, context
):
2141 super(StageCarefully
, self
).__init
__(context
, None)
2144 # pylint: disable=no-self-use
2145 def init_paths(self
):
2146 """Initialize path data"""
2149 def ok_to_run(self
):
2150 """Prevent catch-all "git add -u" from adding unmerged files"""
2151 return self
.paths
or not self
.model
.unmerged
2154 """Stage files when ok_to_run() return True"""
2155 if self
.ok_to_run():
2156 return super(StageCarefully
, self
).do()
2160 class StageModified(StageCarefully
):
2161 """Stage all modified files."""
2165 return N_('Stage Modified')
2167 def init_paths(self
):
2168 self
.paths
= self
.model
.modified
2171 class StageUnmerged(StageCarefully
):
2172 """Stage unmerged files."""
2176 return N_('Stage Unmerged')
2178 def init_paths(self
):
2179 self
.paths
= check_conflicts(self
.context
, self
.model
.unmerged
)
2182 class StageUntracked(StageCarefully
):
2183 """Stage all untracked files."""
2187 return N_('Stage Untracked')
2189 def init_paths(self
):
2190 self
.paths
= self
.model
.untracked
2193 class StageOrUnstage(ContextCommand
):
2194 """If the selection is staged, unstage it, otherwise stage"""
2198 return N_('Stage / Unstage')
2201 s
= self
.selection
.selection()
2203 do(Unstage
, self
.context
, s
.staged
)
2206 unmerged
= check_conflicts(self
.context
, s
.unmerged
)
2208 unstaged
.extend(unmerged
)
2210 unstaged
.extend(s
.modified
)
2212 unstaged
.extend(s
.untracked
)
2214 do(Stage
, self
.context
, unstaged
)
2217 class Tag(ContextCommand
):
2218 """Create a tag object."""
2220 def __init__(self
, context
, name
, revision
, sign
=False, message
=''):
2221 super(Tag
, self
).__init
__(context
)
2223 self
._message
= message
2224 self
._revision
= revision
2230 revision
= self
._revision
2231 tag_name
= self
._name
2232 tag_message
= self
._message
2235 Interaction
.critical(
2236 N_('Missing Revision'),
2237 N_('Please specify a revision to tag.'))
2241 Interaction
.critical(
2243 N_('Please specify a name for the new tag.'))
2246 title
= N_('Missing Tag Message')
2247 message
= N_('Tag-signing was requested but the tag message is empty.')
2248 info
= N_('An unsigned, lightweight tag will be created instead.\n'
2249 'Create an unsigned tag?')
2250 ok_text
= N_('Create Unsigned Tag')
2252 if sign
and not tag_message
:
2253 # We require a message in order to sign the tag, so if they
2254 # choose to create an unsigned tag we have to clear the sign flag.
2255 if not Interaction
.confirm(title
, message
, info
, ok_text
,
2256 default
=False, icon
=icons
.save()):
2264 tmp_file
= utils
.tmp_filename('tag-message')
2265 opts
['file'] = tmp_file
2266 core
.write(tmp_file
, tag_message
)
2271 opts
['annotate'] = True
2272 status
, out
, err
= git
.tag(tag_name
, revision
, **opts
)
2275 core
.unlink(tmp_file
)
2277 title
= N_('Error: could not create tag "%s"') % tag_name
2278 Interaction
.command(title
, 'git tag', status
, out
, err
)
2282 self
.model
.update_status()
2283 Interaction
.information(
2285 N_('Created a new tag named "%s"') % tag_name
,
2286 details
=tag_message
or None)
2291 class Unstage(ContextCommand
):
2292 """Unstage a set of paths."""
2296 return N_('Unstage')
2298 def __init__(self
, context
, paths
):
2299 super(Unstage
, self
).__init
__(context
)
2304 context
= self
.context
2305 head
= self
.model
.head
2308 msg
= N_('Unstaging: %s') % (', '.join(paths
))
2309 Interaction
.log(msg
)
2311 return unstage_all(context
)
2312 status
, out
, err
= gitcmds
.unstage_paths(context
, paths
, head
=head
)
2313 Interaction
.command(N_('Error'), 'git reset', status
, out
, err
)
2314 self
.model
.update_file_status()
2315 return (status
, out
, err
)
2318 class UnstageAll(ContextCommand
):
2319 """Unstage all files; resets the index."""
2322 return unstage_all(self
.context
)
2325 def unstage_all(context
):
2326 """Unstage all files, even while amending"""
2327 model
= context
.model
2330 status
, out
, err
= git
.reset(head
, '--', '.')
2331 Interaction
.command(N_('Error'), 'git reset', status
, out
, err
)
2332 model
.update_file_status()
2333 return (status
, out
, err
)
2336 class StageSelected(ContextCommand
):
2337 """Stage selected files, or all files if no selection exists."""
2340 context
= self
.context
2341 paths
= self
.selection
.unstaged
2343 do(Stage
, context
, paths
)
2344 elif self
.cfg
.get('cola.safemode', False):
2345 do(StageModified
, context
)
2348 class UnstageSelected(Unstage
):
2349 """Unstage selected files."""
2351 def __init__(self
, context
):
2352 staged
= self
.selection
.staged
2353 super(UnstageSelected
, self
).__init
__(context
, staged
)
2356 class Untrack(ContextCommand
):
2357 """Unstage a set of paths."""
2359 def __init__(self
, context
, paths
):
2360 super(Untrack
, self
).__init
__(context
)
2364 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
2365 Interaction
.log(msg
)
2366 status
, out
, err
= self
.model
.untrack_paths(self
.paths
)
2367 Interaction
.log_status(status
, out
, err
)
2370 class UntrackedSummary(EditModel
):
2371 """List possible .gitignore rules as the diff text."""
2373 def __init__(self
, context
):
2374 super(UntrackedSummary
, self
).__init
__(context
)
2375 untracked
= self
.model
.untracked
2376 suffix
= 's' if untracked
else ''
2378 io
.write('# %s untracked file%s\n' % (len(untracked
), suffix
))
2380 io
.write('# possible .gitignore rule%s:\n' % suffix
)
2382 io
.write('/'+u
+'\n')
2383 self
.new_diff_text
= io
.getvalue()
2384 self
.new_diff_type
= 'text'
2385 self
.new_mode
= self
.model
.mode_untracked
2388 class VisualizeAll(ContextCommand
):
2389 """Visualize all branches."""
2392 context
= self
.context
2393 browser
= utils
.shell_split(prefs
.history_browser(context
))
2394 launch_history_browser(browser
+ ['--all'])
2397 class VisualizeCurrent(ContextCommand
):
2398 """Visualize all branches."""
2401 context
= self
.context
2402 browser
= utils
.shell_split(prefs
.history_browser(context
))
2403 launch_history_browser(browser
+ [self
.model
.currentbranch
] + ['--'])
2406 class VisualizePaths(ContextCommand
):
2407 """Path-limited visualization."""
2409 def __init__(self
, context
, paths
):
2410 super(VisualizePaths
, self
).__init
__(context
)
2411 context
= self
.context
2412 browser
= utils
.shell_split(prefs
.history_browser(context
))
2414 self
.argv
= browser
+ ['--'] + list(paths
)
2419 launch_history_browser(self
.argv
)
2422 class VisualizeRevision(ContextCommand
):
2423 """Visualize a specific revision."""
2425 def __init__(self
, context
, revision
, paths
=None):
2426 super(VisualizeRevision
, self
).__init
__(context
)
2427 self
.revision
= revision
2431 context
= self
.context
2432 argv
= utils
.shell_split(prefs
.history_browser(context
))
2434 argv
.append(self
.revision
)
2437 argv
.extend(self
.paths
)
2438 launch_history_browser(argv
)
2441 def launch_history_browser(argv
):
2442 """Launch the configured history browser"""
2445 except OSError as e
:
2446 _
, details
= utils
.format_exception(e
)
2447 title
= N_('Error Launching History Browser')
2448 msg
= (N_('Cannot exec "%s": please configure a history browser') %
2450 Interaction
.critical(title
, message
=msg
, details
=details
)
2453 def run(cls
, *args
, **opts
):
2455 Returns a callback that runs a command
2457 If the caller of run() provides args or opts then those are
2458 used instead of the ones provided by the invoker of the callback.
2461 def runner(*local_args
, **local_opts
):
2462 """Closure return by run() which runs the command"""
2464 do(cls
, *args
, **opts
)
2466 do(cls
, *local_args
, **local_opts
)
2471 def do(cls
, *args
, **opts
):
2472 """Run a command in-place"""
2474 cmd
= cls(*args
, **opts
)
2476 except Exception as e
: # pylint: disable=broad-except
2477 msg
, details
= utils
.format_exception(e
)
2478 if hasattr(cls
, '__name__'):
2479 msg
= ('%s exception:\n%s' % (cls
.__name
__, msg
))
2480 Interaction
.critical(N_('Error'), message
=msg
, details
=details
)
2484 def difftool_run(context
):
2485 """Start a default difftool session"""
2486 selection
= context
.selection
2487 files
= selection
.group()
2490 s
= selection
.selection()
2491 head
= context
.model
.head
2492 difftool_launch_with_head(context
, files
, bool(s
.staged
), head
)
2495 def difftool_launch_with_head(context
, filenames
, staged
, head
):
2496 """Launch difftool against the provided head"""
2501 difftool_launch(context
, left
=left
, staged
=staged
, paths
=filenames
)
2504 def difftool_launch(context
, left
=None, right
=None, paths
=None,
2505 staged
=False, dir_diff
=False,
2506 left_take_magic
=False, left_take_parent
=False):
2507 """Launches 'git difftool' with given parameters
2509 :param left: first argument to difftool
2510 :param right: second argument to difftool_args
2511 :param paths: paths to diff
2512 :param staged: activate `git difftool --staged`
2513 :param dir_diff: activate `git difftool --dir-diff`
2514 :param left_take_magic: whether to append the magic ^! diff expression
2515 :param left_take_parent: whether to append the first-parent ~ for diffing
2519 difftool_args
= ['git', 'difftool', '--no-prompt']
2521 difftool_args
.append('--cached')
2523 difftool_args
.append('--dir-diff')
2526 if left_take_parent
or left_take_magic
:
2527 suffix
= '^!' if left_take_magic
else '~'
2528 # Check root commit (no parents and thus cannot execute '~')
2530 status
, out
, err
= git
.rev_list(left
, parents
=True, n
=1)
2531 Interaction
.log_status(status
, out
, err
)
2533 raise OSError('git rev-list command failed')
2535 if len(out
.split()) >= 2:
2536 # Commit has a parent, so we can take its child as requested
2539 # No parent, assume it's the root commit, so we have to diff
2540 # against the empty tree.
2541 left
= EMPTY_TREE_OID
2542 if not right
and left_take_magic
:
2544 difftool_args
.append(left
)
2547 difftool_args
.append(right
)
2550 difftool_args
.append('--')
2551 difftool_args
.extend(paths
)
2553 runtask
= context
.runtask
2555 Interaction
.async_command(N_('Difftool'), difftool_args
, runtask
)
2557 core
.fork(difftool_args
)