1 from __future__
import division
, absolute_import
, unicode_literals
7 from fnmatch
import fnmatch
8 from io
import StringIO
11 from send2trash
import send2trash
15 from cola
import compat
17 from cola
import fsmonitor
18 from cola
import gitcfg
19 from cola
import gitcmds
20 from cola
import icons
21 from cola
import utils
22 from cola
import difftool
23 from cola
import resources
24 from cola
.diffparse
import DiffParser
25 from cola
.git
import STDOUT
26 from cola
.i18n
import N_
27 from cola
.interaction
import Interaction
28 from cola
.models
import main
29 from cola
.models
import prefs
30 from cola
.models
import selection
33 class UsageError(Exception):
34 """Exception class for usage errors."""
35 def __init__(self
, title
, message
):
36 Exception.__init
__(self
, message
)
41 class BaseCommand(object):
42 """Base class for all commands; provides the command pattern"""
49 def is_undoable(self
):
50 """Can this be undone?"""
64 class ConfirmAction(BaseCommand
):
67 BaseCommand
.__init
__(self
)
84 def fail(self
, status
, out
, err
):
85 title
= msg
= self
.error_message()
86 details
= self
.error_details() or out
+ err
87 Interaction
.critical(title
, message
=msg
, details
=details
)
89 def error_message(self
):
92 def error_details(self
):
98 ok
= self
.ok_to_run() and self
.confirm()
100 status
, out
, err
= self
.action()
104 self
.fail(status
, out
, err
)
106 return ok
, status
, out
, err
109 class ModelCommand(BaseCommand
):
110 """Commands that manipulate the main models"""
113 BaseCommand
.__init
__(self
)
114 self
.model
= main
.model()
117 class Command(ModelCommand
):
118 """Base class for commands that modify the main model"""
121 """Initialize the command and stash away values for use in do()"""
122 # These are commonly used so let's make it easier to write new commands.
123 ModelCommand
.__init
__(self
)
125 self
.old_diff_text
= self
.model
.diff_text
126 self
.old_filename
= self
.model
.filename
127 self
.old_mode
= self
.model
.mode
129 self
.new_diff_text
= self
.old_diff_text
130 self
.new_filename
= self
.old_filename
131 self
.new_mode
= self
.old_mode
134 """Perform the operation."""
135 self
.model
.set_filename(self
.new_filename
)
136 self
.model
.set_mode(self
.new_mode
)
137 self
.model
.set_diff_text(self
.new_diff_text
)
140 """Undo the operation."""
141 self
.model
.set_diff_text(self
.old_diff_text
)
142 self
.model
.set_filename(self
.old_filename
)
143 self
.model
.set_mode(self
.old_mode
)
146 class AmendMode(Command
):
147 """Try to amend a commit."""
155 def __init__(self
, amend
):
156 Command
.__init
__(self
)
159 self
.amending
= amend
160 self
.old_commitmsg
= self
.model
.commitmsg
161 self
.old_mode
= self
.model
.mode
164 self
.new_mode
= self
.model
.mode_amend
165 self
.new_commitmsg
= self
.model
.prev_commitmsg()
166 AmendMode
.LAST_MESSAGE
= self
.model
.commitmsg
168 # else, amend unchecked, regular commit
169 self
.new_mode
= self
.model
.mode_none
170 self
.new_diff_text
= ''
171 self
.new_commitmsg
= self
.model
.commitmsg
172 # If we're going back into new-commit-mode then search the
173 # undo stack for a previous amend-commit-mode and grab the
174 # commit message at that point in time.
175 if AmendMode
.LAST_MESSAGE
is not None:
176 self
.new_commitmsg
= AmendMode
.LAST_MESSAGE
177 AmendMode
.LAST_MESSAGE
= None
180 """Leave/enter amend mode."""
181 """Attempt to enter amend mode. Do not allow this when merging."""
183 if self
.model
.is_merging
:
185 self
.model
.set_mode(self
.old_mode
)
186 Interaction
.information(
188 N_('You are in the middle of a merge.\n'
189 'Cannot amend while merging.'))
193 self
.model
.set_commitmsg(self
.new_commitmsg
)
194 self
.model
.update_file_status()
199 self
.model
.set_commitmsg(self
.old_commitmsg
)
201 self
.model
.update_file_status()
204 class ApplyDiffSelection(Command
):
206 def __init__(self
, first_line_idx
, last_line_idx
, has_selection
,
207 reverse
, apply_to_worktree
):
208 Command
.__init
__(self
)
209 self
.first_line_idx
= first_line_idx
210 self
.last_line_idx
= last_line_idx
211 self
.has_selection
= has_selection
212 self
.reverse
= reverse
213 self
.apply_to_worktree
= apply_to_worktree
216 parser
= DiffParser(self
.model
.filename
, self
.model
.diff_text
)
217 if self
.has_selection
:
218 patch
= parser
.generate_patch(self
.first_line_idx
,
220 reverse
=self
.reverse
)
222 patch
= parser
.generate_hunk_patch(self
.first_line_idx
,
223 reverse
=self
.reverse
)
227 cfg
= gitcfg
.current()
228 tmp_path
= utils
.tmp_filename('patch')
230 core
.write(tmp_path
, patch
,
231 encoding
=cfg
.file_encoding(self
.model
.filename
))
232 if self
.apply_to_worktree
:
233 status
, out
, err
= self
.model
.apply_diff_to_worktree(tmp_path
)
235 status
, out
, err
= self
.model
.apply_diff(tmp_path
)
238 Interaction
.log_status(status
, out
, err
)
239 self
.model
.update_file_status(update_index
=True)
242 class ApplyPatches(Command
):
244 def __init__(self
, patches
):
245 Command
.__init
__(self
)
246 self
.patches
= patches
250 num_patches
= len(self
.patches
)
251 orig_head
= self
.model
.git
.rev_parse('HEAD')[STDOUT
]
253 for idx
, patch
in enumerate(self
.patches
):
254 status
, out
, err
= self
.model
.git
.am(patch
)
255 # Log the git-am command
256 Interaction
.log_status(status
, out
, err
)
259 diff
= self
.model
.git
.diff('HEAD^!', stat
=True)[STDOUT
]
260 diff_text
+= (N_('PATCH %(current)d/%(count)d') %
261 dict(current
=idx
+1, count
=num_patches
))
262 diff_text
+= ' - %s:\n%s\n\n' % (os
.path
.basename(patch
), diff
)
264 diff_text
+= N_('Summary:') + '\n'
265 diff_text
+= self
.model
.git
.diff(orig_head
, stat
=True)[STDOUT
]
268 self
.model
.set_diff_text(diff_text
)
269 self
.model
.update_file_status()
271 basenames
= '\n'.join([os
.path
.basename(p
) for p
in self
.patches
])
272 Interaction
.information(
273 N_('Patch(es) Applied'),
274 (N_('%d patch(es) applied.') + '\n\n%s') %
275 (len(self
.patches
), basenames
))
278 class Archive(BaseCommand
):
280 def __init__(self
, ref
, fmt
, prefix
, filename
):
281 BaseCommand
.__init
__(self
)
285 self
.filename
= filename
288 fp
= core
.xopen(self
.filename
, 'wb')
289 cmd
= ['git', 'archive', '--format='+self
.fmt
]
290 if self
.fmt
in ('tgz', 'tar.gz'):
293 cmd
.append('--prefix=' + self
.prefix
)
295 proc
= core
.start_command(cmd
, stdout
=fp
)
296 out
, err
= proc
.communicate()
298 status
= proc
.returncode
299 Interaction
.log_status(status
, out
or '', err
or '')
302 class Checkout(Command
):
304 A command object for git-checkout.
306 'argv' is handed off directly to git.
310 def __init__(self
, argv
, checkout_branch
=False):
311 Command
.__init
__(self
)
313 self
.checkout_branch
= checkout_branch
314 self
.new_diff_text
= ''
317 status
, out
, err
= self
.model
.git
.checkout(*self
.argv
)
318 Interaction
.log_status(status
, out
, err
)
319 if self
.checkout_branch
:
320 self
.model
.update_status()
322 self
.model
.update_file_status()
325 class BlamePaths(Command
):
326 """Blame view for paths."""
328 def __init__(self
, paths
):
329 Command
.__init
__(self
)
330 viewer
= utils
.shell_split(prefs
.blame_viewer())
331 self
.argv
= viewer
+ list(paths
)
336 except Exception as e
:
337 _
, details
= utils
.format_exception(e
)
338 title
= N_('Error Launching Blame Viewer')
339 msg
= (N_('Cannot exec "%s": please configure a blame viewer') %
341 Interaction
.critical(title
, message
=msg
, details
=details
)
344 class CheckoutBranch(Checkout
):
345 """Checkout a branch."""
347 def __init__(self
, branch
):
349 Checkout
.__init
__(self
, args
, checkout_branch
=True)
352 class CherryPick(Command
):
353 """Cherry pick commits into the current branch."""
355 def __init__(self
, commits
):
356 Command
.__init
__(self
)
357 self
.commits
= commits
360 self
.model
.cherry_pick_list(self
.commits
)
361 self
.model
.update_file_status()
364 class ResetMode(Command
):
365 """Reset the mode and clear the model's diff text."""
368 Command
.__init
__(self
)
369 self
.new_mode
= self
.model
.mode_none
370 self
.new_diff_text
= ''
374 self
.model
.update_file_status()
377 class Commit(ResetMode
):
378 """Attempt to create a new commit."""
380 def __init__(self
, amend
, msg
, sign
, no_verify
=False):
381 ResetMode
.__init
__(self
)
385 self
.no_verify
= no_verify
386 self
.old_commitmsg
= self
.model
.commitmsg
387 self
.new_commitmsg
= ''
390 # Create the commit message file
391 comment_char
= prefs
.comment_char()
392 msg
= self
.strip_comments(self
.msg
, comment_char
=comment_char
)
393 tmpfile
= utils
.tmp_filename('commit-message')
395 core
.write(tmpfile
, msg
)
398 status
, out
, err
= self
.model
.git
.commit(F
=tmpfile
,
402 no_verify
=self
.no_verify
)
408 self
.model
.set_commitmsg(self
.new_commitmsg
)
409 msg
= N_('Created commit: %s') % out
411 msg
= N_('Commit failed: %s') % out
412 Interaction
.log_status(status
, msg
, err
)
414 return status
, out
, err
417 def strip_comments(msg
, comment_char
='#'):
419 message_lines
= [line
for line
in msg
.split('\n')
420 if not line
.startswith(comment_char
)]
421 msg
= '\n'.join(message_lines
)
422 if not msg
.endswith('\n'):
428 class Ignore(Command
):
429 """Add files to .gitignore"""
431 def __init__(self
, filenames
):
432 Command
.__init
__(self
)
433 self
.filenames
= list(filenames
)
436 if not self
.filenames
:
438 new_additions
= '\n'.join(self
.filenames
) + '\n'
439 for_status
= new_additions
440 if core
.exists('.gitignore'):
441 current_list
= core
.read('.gitignore')
442 new_additions
= current_list
.rstrip() + '\n' + new_additions
443 core
.write('.gitignore', new_additions
)
444 Interaction
.log_status(0, 'Added to .gitignore:\n%s' % for_status
, '')
445 self
.model
.update_file_status()
448 def file_summary(files
):
449 txt
= subprocess
.list2cmdline(files
)
451 txt
= txt
[:768].rstrip() + '...'
455 class RemoteCommand(ConfirmAction
):
457 def __init__(self
, remote
):
458 ConfirmAction
.__init
__(self
)
459 self
.model
= main
.model()
463 self
.model
.update_remotes()
466 class RemoteAdd(RemoteCommand
):
468 def __init__(self
, remote
, url
):
469 RemoteCommand
.__init
__(self
, remote
)
474 return git
.remote('add', self
.remote
, self
.url
)
476 def error_message(self
):
477 return N_('Error creating remote "%s"') % self
.remote
480 class RemoteRemove(RemoteCommand
):
483 title
= N_('Delete Remote')
484 question
= N_('Delete remote?')
485 info
= N_('Delete remote "%s"') % self
.remote
486 ok_btn
= N_('Delete')
487 return Interaction
.confirm(title
, question
, info
, ok_btn
)
491 return git
.remote('rm', self
.remote
)
493 def error_message(self
):
494 return N_('Error deleting remote "%s"') % self
.remote
497 class RemoteRename(RemoteCommand
):
499 def __init__(self
, remote
, new_remote
):
500 RemoteCommand
.__init
__(self
, remote
)
501 self
.new_remote
= new_remote
504 title
= N_('Rename Remote')
505 question
= N_('Rename remote?')
506 info
= (N_('Rename remote "%(current)s" to "%(new)s"?') %
507 dict(current
=self
.remote
, new
=self
.new_remote
))
508 ok_btn
= N_('Rename')
509 return Interaction
.confirm(title
, question
, info
, ok_btn
)
513 return git
.remote('rename', self
.remote
, self
.new_remote
)
516 class RemoveFromSettings(ConfirmAction
):
518 def __init__(self
, settings
, repo
, icon
=None):
519 ConfirmAction
.__init
__(self
)
520 self
.settings
= settings
528 class RemoveBookmark(RemoveFromSettings
):
532 title
= msg
= N_('Delete Bookmark?')
533 info
= N_('%s will be removed from your bookmarks.') % repo
534 ok_text
= N_('Delete Bookmark')
535 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
538 self
.settings
.remove_bookmark(self
.repo
)
542 class RemoveRecent(RemoveFromSettings
):
546 title
= msg
= N_('Remove %s from the recent list?') % repo
547 info
= N_('%s will be removed from your recent repositories.') % repo
548 ok_text
= N_('Remove')
549 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
552 self
.settings
.remove_recent(self
.repo
)
556 class RemoveFiles(Command
):
559 def __init__(self
, remover
, filenames
):
560 Command
.__init
__(self
)
563 self
.remover
= remover
564 self
.filenames
= filenames
565 # We could git-hash-object stuff and provide undo-ability
569 files
= self
.filenames
575 remove
= self
.remover
576 for filename
in files
:
582 bad_filenames
.append(filename
)
585 Interaction
.information(
587 N_('Deleting "%s" failed') % file_summary(files
))
590 self
.model
.update_file_status()
593 class Delete(RemoveFiles
):
596 def __init__(self
, filenames
):
597 RemoveFiles
.__init
__(self
, os
.remove
, filenames
)
600 files
= self
.filenames
604 title
= N_('Delete Files?')
605 msg
= N_('The following files will be deleted:') + '\n\n'
606 msg
+= file_summary(files
)
607 info_txt
= N_('Delete %d file(s)?') % len(files
)
608 ok_txt
= N_('Delete Files')
610 if not Interaction
.confirm(title
, msg
, info_txt
, ok_txt
,
611 default
=True, icon
=icons
.remove()):
614 return RemoveFiles
.do(self
)
617 class MoveToTrash(RemoveFiles
):
618 """Move files to the trash using send2trash"""
620 AVAILABLE
= send2trash
is not None
622 def __init__(self
, filenames
):
623 RemoveFiles
.__init
__(self
, send2trash
, filenames
)
626 class DeleteBranch(Command
):
627 """Delete a git branch."""
629 def __init__(self
, branch
):
630 Command
.__init
__(self
)
634 status
, out
, err
= self
.model
.delete_branch(self
.branch
)
635 Interaction
.log_status(status
, out
, err
)
638 class RenameBranch(Command
):
639 """Rename a git branch."""
641 def __init__(self
, branch
, new_branch
):
642 Command
.__init
__(self
)
644 self
.new_branch
= new_branch
647 status
, out
, err
= self
.model
.rename_branch(self
.branch
, self
.new_branch
)
648 Interaction
.log_status(status
, out
, err
)
650 class DeleteRemoteBranch(Command
):
651 """Delete a remote git branch."""
653 def __init__(self
, remote
, branch
):
654 Command
.__init
__(self
)
659 status
, out
, err
= self
.model
.git
.push(self
.remote
, self
.branch
,
661 Interaction
.log_status(status
, out
, err
)
662 self
.model
.update_status()
665 Interaction
.information(
666 N_('Remote Branch Deleted'),
667 N_('"%(branch)s" has been deleted from "%(remote)s".')
668 % dict(branch
=self
.branch
, remote
=self
.remote
))
671 message
= (N_('"%(command)s" returned exit status %(status)d') %
672 dict(command
=command
, status
=status
))
674 Interaction
.critical(N_('Error Deleting Remote Branch'),
680 """Perform a diff and set the model's current text."""
682 def __init__(self
, filename
, cached
=False, deleted
=False):
683 Command
.__init
__(self
)
686 opts
['ref'] = self
.model
.head
687 self
.new_filename
= filename
688 self
.new_mode
= self
.model
.mode_worktree
689 self
.new_diff_text
= gitcmds
.diff_helper(filename
=filename
,
695 class Diffstat(Command
):
696 """Perform a diffstat and set the model's diff text."""
699 Command
.__init
__(self
)
700 cfg
= gitcfg
.current()
701 diff_context
= cfg
.get('diff.context', 3)
702 diff
= self
.model
.git
.diff(self
.model
.head
,
703 unified
=diff_context
,
708 self
.new_diff_text
= diff
709 self
.new_mode
= self
.model
.mode_worktree
712 class DiffStaged(Diff
):
713 """Perform a staged diff on a file."""
715 def __init__(self
, filename
, deleted
=None):
716 Diff
.__init
__(self
, filename
, cached
=True, deleted
=deleted
)
717 self
.new_mode
= self
.model
.mode_index
720 class DiffStagedSummary(Command
):
723 Command
.__init
__(self
)
724 diff
= self
.model
.git
.diff(self
.model
.head
,
728 patch_with_stat
=True,
730 self
.new_diff_text
= diff
731 self
.new_mode
= self
.model
.mode_index
734 class Difftool(Command
):
735 """Run git-difftool limited by path."""
737 def __init__(self
, staged
, filenames
):
738 Command
.__init
__(self
)
740 self
.filenames
= filenames
743 difftool
.launch_with_head(self
.filenames
,
744 self
.staged
, self
.model
.head
)
748 """Edit a file using the configured gui.editor."""
752 return N_('Launch Editor')
754 def __init__(self
, filenames
, line_number
=None):
755 Command
.__init
__(self
)
756 self
.filenames
= filenames
757 self
.line_number
= line_number
760 if not self
.filenames
:
762 filename
= self
.filenames
[0]
763 if not core
.exists(filename
):
765 editor
= prefs
.editor()
768 if self
.line_number
is None:
769 opts
= self
.filenames
771 # Single-file w/ line-numbers (likely from grep)
773 '*vim*': ['+'+self
.line_number
, filename
],
774 '*emacs*': ['+'+self
.line_number
, filename
],
775 '*textpad*': ['%s(%s,0)' % (filename
, self
.line_number
)],
776 '*notepad++*': ['-n'+self
.line_number
, filename
],
779 opts
= self
.filenames
780 for pattern
, opt
in editor_opts
.items():
781 if fnmatch(editor
, pattern
):
786 core
.fork(utils
.shell_split(editor
) + opts
)
787 except Exception as e
:
788 message
= (N_('Cannot exec "%s": please configure your editor')
790 details
= core
.decode(e
.strerror
)
791 Interaction
.critical(N_('Error Editing File'), message
, details
)
794 class FormatPatch(Command
):
795 """Output a patch series given all revisions and a selected subset."""
797 def __init__(self
, to_export
, revs
):
798 Command
.__init
__(self
)
799 self
.to_export
= list(to_export
)
800 self
.revs
= list(revs
)
803 status
, out
, err
= gitcmds
.format_patchsets(self
.to_export
, self
.revs
)
804 Interaction
.log_status(status
, out
, err
)
807 class LaunchDifftool(BaseCommand
):
811 return N_('Launch Diff Tool')
814 BaseCommand
.__init
__(self
)
817 s
= selection
.selection()
821 core
.fork(['git', 'mergetool', '--no-prompt', '--'] + paths
)
823 cfg
= gitcfg
.current()
825 argv
= utils
.shell_split(cmd
)
826 mergetool
= ['git', 'mergetool', '--no-prompt', '--']
827 mergetool
.extend(paths
)
828 needs_shellquote
= set(['gnome-terminal', 'xfce4-terminal'])
829 if os
.path
.basename(argv
[0]) in needs_shellquote
:
830 argv
.append(subprocess
.list2cmdline(mergetool
))
832 argv
.extend(mergetool
)
838 class LaunchTerminal(BaseCommand
):
842 return N_('Launch Terminal')
844 def __init__(self
, path
):
845 BaseCommand
.__init
__(self
)
849 cfg
= gitcfg
.current()
851 argv
= utils
.shell_split(cmd
)
852 argv
.append(os
.getenv('SHELL', '/bin/sh'))
853 core
.fork(argv
, cwd
=self
.path
)
856 class LaunchEditor(Edit
):
860 return N_('Launch Editor')
863 s
= selection
.selection()
864 allfiles
= s
.staged
+ s
.unmerged
+ s
.modified
+ s
.untracked
865 Edit
.__init
__(self
, allfiles
)
868 class LoadCommitMessageFromFile(Command
):
869 """Loads a commit message from a path."""
871 def __init__(self
, path
):
872 Command
.__init
__(self
)
875 self
.old_commitmsg
= self
.model
.commitmsg
876 self
.old_directory
= self
.model
.directory
880 if not path
or not core
.isfile(path
):
881 raise UsageError(N_('Error: Cannot find commit template'),
882 N_('%s: No such file or directory.') % path
)
883 self
.model
.set_directory(os
.path
.dirname(path
))
884 self
.model
.set_commitmsg(core
.read(path
))
887 self
.model
.set_commitmsg(self
.old_commitmsg
)
888 self
.model
.set_directory(self
.old_directory
)
891 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile
):
892 """Loads the commit message template specified by commit.template."""
895 cfg
= gitcfg
.current()
896 template
= cfg
.get('commit.template')
897 LoadCommitMessageFromFile
.__init
__(self
, template
)
900 if self
.path
is None:
902 N_('Error: Unconfigured commit template'),
903 N_('A commit template has not been configured.\n'
904 'Use "git config" to define "commit.template"\n'
905 'so that it points to a commit template.'))
906 return LoadCommitMessageFromFile
.do(self
)
910 class LoadCommitMessageFromSHA1(Command
):
911 """Load a previous commit message"""
913 def __init__(self
, sha1
, prefix
=''):
914 Command
.__init
__(self
)
916 self
.old_commitmsg
= self
.model
.commitmsg
917 self
.new_commitmsg
= prefix
+ self
.model
.prev_commitmsg(sha1
)
921 self
.model
.set_commitmsg(self
.new_commitmsg
)
924 self
.model
.set_commitmsg(self
.old_commitmsg
)
927 class LoadFixupMessage(LoadCommitMessageFromSHA1
):
928 """Load a fixup message"""
930 def __init__(self
, sha1
):
931 LoadCommitMessageFromSHA1
.__init__(self
, sha1
, prefix
='fixup! ')
934 class Merge(Command
):
937 def __init__(self
, revision
, no_commit
, squash
, no_ff
, sign
):
938 Command
.__init
__(self
)
939 self
.revision
= revision
941 self
.no_commit
= no_commit
947 revision
= self
.revision
949 no_commit
= self
.no_commit
952 status
, out
, err
= self
.model
.git
.merge(revision
,
957 Interaction
.log_status(status
, out
, err
)
958 self
.model
.update_status()
961 class OpenDefaultApp(BaseCommand
):
962 """Open a file using the OS default."""
966 return N_('Open Using Default Application')
968 def __init__(self
, filenames
):
969 BaseCommand
.__init
__(self
)
970 if utils
.is_darwin():
973 launcher
= 'xdg-open'
974 self
.launcher
= launcher
975 self
.filenames
= filenames
978 if not self
.filenames
:
980 core
.fork([self
.launcher
] + self
.filenames
)
983 class OpenParentDir(OpenDefaultApp
):
984 """Open parent directories using the OS default."""
988 return N_('Open Parent Directory')
990 def __init__(self
, filenames
):
991 OpenDefaultApp
.__init
__(self
, filenames
)
994 if not self
.filenames
:
996 dirs
= list(set(map(os
.path
.dirname
, self
.filenames
)))
997 core
.fork([self
.launcher
] + dirs
)
1000 class OpenNewRepo(Command
):
1001 """Launches git-cola on a repo."""
1003 def __init__(self
, repo_path
):
1004 Command
.__init
__(self
)
1005 self
.repo_path
= repo_path
1008 self
.model
.set_directory(self
.repo_path
)
1009 core
.fork([sys
.executable
, sys
.argv
[0], '--repo', self
.repo_path
])
1012 class OpenRepo(Command
):
1013 def __init__(self
, repo_path
):
1014 Command
.__init
__(self
)
1015 self
.repo_path
= repo_path
1018 git
= self
.model
.git
1019 old_worktree
= git
.worktree()
1020 if not self
.model
.set_worktree(self
.repo_path
):
1021 self
.model
.set_worktree(old_worktree
)
1023 new_worktree
= git
.worktree()
1024 core
.chdir(new_worktree
)
1025 self
.model
.set_directory(self
.repo_path
)
1026 cfg
= gitcfg
.current()
1028 fsmonitor
.instance().stop()
1029 fsmonitor
.instance().start()
1030 self
.model
.update_status()
1033 class Clone(Command
):
1034 """Clones a repository and optionally spawns a new cola session."""
1036 def __init__(self
, url
, new_directory
, spawn
=True):
1037 Command
.__init
__(self
)
1039 self
.new_directory
= new_directory
1043 self
.error_message
= ''
1044 self
.error_details
= ''
1047 status
, out
, err
= self
.model
.git
.clone(self
.url
, self
.new_directory
)
1048 self
.ok
= status
== 0
1052 core
.fork([sys
.executable
, sys
.argv
[0],
1053 '--repo', self
.new_directory
])
1055 self
.error_message
= N_('Error: could not clone "%s"') % self
.url
1056 self
.error_details
= (
1057 (N_('git clone returned exit code %s') % status
) +
1058 ((out
+err
) and ('\n\n' + out
+ err
) or ''))
1063 def unix_path(path
, is_win32
=utils
.is_win32
):
1064 """Git for Windows requires unix paths, so force them here
1070 if second
== ':': # sanity check, this better be a Windows-style path
1071 unix_path
= '/' + first
+ path
[2:].replace('\\', '/')
1076 class GitXBaseContext(object):
1078 def __init__(self
, **kwargs
):
1079 self
.env
= {'GIT_EDITOR': prefs
.editor()}
1080 self
.env
.update(kwargs
)
1082 def __enter__(self
):
1083 compat
.setenv('GIT_SEQUENCE_EDITOR',
1084 unix_path(resources
.share('bin', 'git-xbase')))
1085 for var
, value
in self
.env
.items():
1086 compat
.setenv(var
, value
)
1089 def __exit__(self
, exc_type
, exc_val
, exc_tb
):
1090 compat
.unsetenv('GIT_SEQUENCE_EDITOR')
1091 for var
in self
.env
:
1092 compat
.unsetenv(var
)
1095 class Rebase(Command
):
1098 upstream
=None, branch
=None, capture_output
=True, **kwargs
):
1099 """Start an interactive rebase session
1101 :param upstream: upstream branch
1102 :param branch: optional branch to checkout
1103 :param capture_output: whether to capture stdout and stderr
1104 :param kwargs: forwarded directly to `git.rebase()`
1107 Command
.__init
__(self
)
1109 self
.upstream
= upstream
1110 self
.branch
= branch
1111 self
.capture_output
= capture_output
1112 self
.kwargs
= kwargs
1114 def prepare_arguments(self
):
1118 if self
.capture_output
:
1119 kwargs
['_stderr'] = None
1120 kwargs
['_stdout'] = None
1122 # Rebase actions must be the only option specified
1123 for action
in ('continue', 'abort', 'skip', 'edit_todo'):
1124 if self
.kwargs
.get(action
, False):
1125 kwargs
[action
] = self
.kwargs
[action
]
1128 kwargs
['interactive'] = True
1129 kwargs
['autosquash'] = self
.kwargs
.get('autosquash', True)
1130 kwargs
.update(self
.kwargs
)
1133 args
.append(self
.upstream
)
1135 args
.append(self
.branch
)
1140 (status
, out
, err
) = (1, '', '')
1141 args
, kwargs
= self
.prepare_arguments()
1142 upstream_title
= self
.upstream
or '@{upstream}'
1143 with
GitXBaseContext(
1144 GIT_XBASE_TITLE
=N_('Rebase onto %s') % upstream_title
,
1145 GIT_XBASE_ACTION
=N_('Rebase')):
1146 status
, out
, err
= self
.model
.git
.rebase(*args
, **kwargs
)
1147 Interaction
.log_status(status
, out
, err
)
1148 self
.model
.update_status()
1149 return status
, out
, err
1152 class RebaseEditTodo(Command
):
1155 (status
, out
, err
) = (1, '', '')
1156 with
GitXBaseContext(
1157 GIT_XBASE_TITLE
=N_('Edit Rebase'),
1158 GIT_XBASE_ACTION
=N_('Save')):
1159 status
, out
, err
= self
.model
.git
.rebase(edit_todo
=True)
1160 Interaction
.log_status(status
, out
, err
)
1161 self
.model
.update_status()
1162 return status
, out
, err
1165 class RebaseContinue(Command
):
1168 (status
, out
, err
) = (1, '', '')
1169 with
GitXBaseContext(
1170 GIT_XBASE_TITLE
=N_('Rebase'),
1171 GIT_XBASE_ACTION
=N_('Rebase')):
1172 status
, out
, err
= self
.model
.git
.rebase('--continue')
1173 Interaction
.log_status(status
, out
, err
)
1174 self
.model
.update_status()
1175 return status
, out
, err
1178 class RebaseSkip(Command
):
1181 (status
, out
, err
) = (1, '', '')
1182 with
GitXBaseContext(
1183 GIT_XBASE_TITLE
=N_('Rebase'),
1184 GIT_XBASE_ACTION
=N_('Rebase')):
1185 status
, out
, err
= self
.model
.git
.rebase(skip
=True)
1186 Interaction
.log_status(status
, out
, err
)
1187 self
.model
.update_status()
1188 return status
, out
, err
1191 class RebaseAbort(Command
):
1194 status
, out
, err
= self
.model
.git
.rebase(abort
=True)
1195 Interaction
.log_status(status
, out
, err
)
1196 self
.model
.update_status()
1199 class Rescan(Command
):
1200 """Rescan for changes"""
1203 self
.model
.update_status()
1206 class Refresh(Command
):
1207 """Update refs and refresh the index"""
1211 return N_('Refresh')
1214 self
.model
.update_status(update_index
=True)
1215 fsmonitor
.instance().refresh()
1218 class RevertEditsCommand(ConfirmAction
):
1221 ConfirmAction
.__init
__(self
)
1222 self
.model
= main
.model()
1223 self
.icon
= icons
.undo()
1225 def ok_to_run(self
):
1226 return self
.model
.undoable()
1228 def checkout_from_head(self
):
1231 def checkout_args(self
):
1233 s
= selection
.selection()
1234 if self
.checkout_from_head():
1235 args
.append(self
.model
.head
)
1247 git
= self
.model
.git
1248 checkout_args
= self
.checkout_args()
1249 return git
.checkout(*checkout_args
)
1252 self
.model
.update_file_status()
1255 class RevertUnstagedEdits(RevertEditsCommand
):
1259 return N_('Revert Unstaged Edits...')
1261 def checkout_from_head(self
):
1262 # If we are amending and a modified file is selected
1263 # then we should include "HEAD^" on the command-line.
1264 selected
= selection
.selection()
1265 return not selected
.staged
and self
.model
.amending()
1268 title
= N_('Revert Unstaged Changes?')
1269 text
= N_('This operation drops unstaged changes.\n'
1270 'These changes cannot be recovered.')
1271 info
= N_('Revert the unstaged changes?')
1272 ok_text
= N_('Revert Unstaged Changes')
1273 return Interaction
.confirm(title
, text
, info
, ok_text
,
1274 default
=True, icon
=self
.icon
)
1277 class RevertUncommittedEdits(RevertEditsCommand
):
1281 return N_('Revert Uncommitted Edits...')
1283 def checkout_from_head(self
):
1287 title
= N_('Revert Uncommitted Changes?')
1288 text
= N_('This operation drops uncommitted changes.\n'
1289 'These changes cannot be recovered.')
1290 info
= N_('Revert the uncommitted changes?')
1291 ok_text
= N_('Revert Uncommitted Changes')
1292 return Interaction
.confirm(title
, text
, info
, ok_text
,
1293 default
=True, icon
=self
.icon
)
1296 class RunConfigAction(Command
):
1297 """Run a user-configured action, typically from the "Tools" menu"""
1299 def __init__(self
, action_name
):
1300 Command
.__init
__(self
)
1301 self
.action_name
= action_name
1302 self
.model
= main
.model()
1305 for env
in ('FILENAME', 'REVISION', 'ARGS'):
1307 compat
.unsetenv(env
)
1312 cfg
= gitcfg
.current()
1313 opts
= cfg
.get_guitool_opts(self
.action_name
)
1314 cmd
= opts
.get('cmd')
1315 if 'title' not in opts
:
1318 if 'prompt' not in opts
or opts
.get('prompt') is True:
1319 prompt
= N_('Run "%s"?') % cmd
1320 opts
['prompt'] = prompt
1322 if opts
.get('needsfile'):
1323 filename
= selection
.filename()
1325 Interaction
.information(
1326 N_('Please select a file'),
1327 N_('"%s" requires a selected file.') % cmd
)
1329 compat
.setenv('FILENAME', filename
)
1331 if opts
.get('revprompt') or opts
.get('argprompt'):
1333 ok
= Interaction
.confirm_config_action(cmd
, opts
)
1336 rev
= opts
.get('revision')
1337 args
= opts
.get('args')
1338 if opts
.get('revprompt') and not rev
:
1339 title
= N_('Invalid Revision')
1340 msg
= N_('The revision expression cannot be empty.')
1341 Interaction
.critical(title
, msg
)
1345 elif opts
.get('confirm'):
1346 title
= os
.path
.expandvars(opts
.get('title'))
1347 prompt
= os
.path
.expandvars(opts
.get('prompt'))
1348 if Interaction
.question(title
, prompt
):
1351 compat
.setenv('REVISION', rev
)
1353 compat
.setenv('ARGS', args
)
1354 title
= os
.path
.expandvars(cmd
)
1355 Interaction
.log(N_('Running command: %s') % title
)
1356 cmd
= ['sh', '-c', cmd
]
1358 if opts
.get('background'):
1360 status
, out
, err
= (0, '', '')
1361 elif opts
.get('noconsole'):
1362 status
, out
, err
= core
.run_command(cmd
)
1364 status
, out
, err
= Interaction
.run_command(title
, cmd
)
1366 Interaction
.log_status(status
,
1367 out
and (N_('Output: %s') % out
) or '',
1368 err
and (N_('Errors: %s') % err
) or '')
1370 if not opts
.get('background') and not opts
.get('norescan'):
1371 self
.model
.update_status()
1375 class SetDefaultRepo(Command
):
1377 def __init__(self
, repo
):
1378 Command
.__init
__(self
)
1382 gitcfg
.current().set_user('cola.defaultrepo', self
.repo
)
1385 class SetDiffText(Command
):
1387 def __init__(self
, text
):
1388 Command
.__init
__(self
)
1389 self
.undoable
= True
1390 self
.new_diff_text
= text
1393 class ShowUntracked(Command
):
1394 """Show an untracked file."""
1396 def __init__(self
, filename
):
1397 Command
.__init
__(self
)
1398 self
.new_filename
= filename
1399 self
.new_mode
= self
.model
.mode_untracked
1400 self
.new_diff_text
= self
.diff_text_for(filename
)
1402 def diff_text_for(self
, filename
):
1403 cfg
= gitcfg
.current()
1404 size
= cfg
.get('cola.readsize', 1024 * 2)
1406 result
= core
.read(filename
, size
=size
,
1407 encoding
='utf-8', errors
='ignore')
1411 if len(result
) == size
:
1416 class SignOff(Command
):
1420 return N_('Sign Off')
1423 Command
.__init
__(self
)
1424 self
.undoable
= True
1425 self
.old_commitmsg
= self
.model
.commitmsg
1428 signoff
= self
.signoff()
1429 if signoff
in self
.model
.commitmsg
:
1431 self
.model
.set_commitmsg(self
.model
.commitmsg
+ '\n' + signoff
)
1434 self
.model
.set_commitmsg(self
.old_commitmsg
)
1439 user
= pwd
.getpwuid(os
.getuid()).pw_name
1441 user
= os
.getenv('USER', N_('unknown'))
1443 cfg
= gitcfg
.current()
1444 name
= cfg
.get('user.name', user
)
1445 email
= cfg
.get('user.email', '%s@%s' % (user
, core
.node()))
1446 return '\nSigned-off-by: %s <%s>' % (name
, email
)
1449 def check_conflicts(unmerged
):
1450 """Check paths for conflicts
1452 Conflicting files can be filtered out one-by-one.
1455 if prefs
.check_conflicts():
1456 unmerged
= [path
for path
in unmerged
if is_conflict_free(path
)]
1460 def is_conflict_free(path
):
1461 """Return True if `path` contains no conflict markers
1463 rgx
= re
.compile(r
'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
1465 with core
.xopen(path
, 'r') as f
:
1467 line
= core
.decode(line
, errors
='ignore')
1469 if should_stage_conflicts(path
):
1474 # We can't read this file ~ we may be staging a removal
1479 def should_stage_conflicts(path
):
1480 """Inform the user that a file contains merge conflicts
1482 Return `True` if we should stage the path nonetheless.
1485 title
= msg
= N_('Stage conflicts?')
1486 info
= N_('%s appears to contain merge conflicts.\n\n'
1487 'You should probably skip this file.\n'
1488 'Stage it anyways?') % path
1489 ok_text
= N_('Stage conflicts')
1490 cancel_text
= N_('Skip')
1491 return Interaction
.confirm(title
, msg
, info
, ok_text
,
1492 default
=False, cancel_text
=cancel_text
)
1495 class Stage(Command
):
1496 """Stage a set of paths."""
1502 def __init__(self
, paths
):
1503 Command
.__init
__(self
)
1507 msg
= N_('Staging: %s') % (', '.join(self
.paths
))
1508 Interaction
.log(msg
)
1509 # Prevent external updates while we are staging files.
1510 # We update file stats at the end of this operation
1511 # so there's no harm in ignoring updates from other threads
1512 # (e.g. the file system change monitor).
1513 with
CommandDisabled(UpdateFileStatus
):
1514 self
.model
.stage_paths(self
.paths
)
1517 class StageCarefully(Stage
):
1518 """Only stage when the path list is non-empty
1520 We use "git add -u -- <pathspec>" to stage, and it stages everything by
1521 default when no pathspec is specified, so this class ensures that paths
1522 are specified before calling git.
1524 When no paths are specified, the command does nothing.
1528 Stage
.__init
__(self
, None)
1531 def init_paths(self
):
1534 def ok_to_run(self
):
1535 """Prevent catch-all "git add -u" from adding unmerged files"""
1536 return self
.paths
or not self
.model
.unmerged
1539 if self
.ok_to_run():
1543 class StageModified(StageCarefully
):
1544 """Stage all modified files."""
1548 return N_('Stage Modified')
1550 def init_paths(self
):
1551 self
.paths
= self
.model
.modified
1554 class StageUnmerged(StageCarefully
):
1555 """Stage unmerged files."""
1559 return N_('Stage Unmerged')
1561 def init_paths(self
):
1562 self
.paths
= check_conflicts(self
.model
.unmerged
)
1565 class StageUntracked(StageCarefully
):
1566 """Stage all untracked files."""
1570 return N_('Stage Untracked')
1572 def init_paths(self
):
1573 self
.paths
= self
.model
.untracked
1576 class StageOrUnstage(Command
):
1577 """If the selection is staged, unstage it, otherwise stage"""
1581 return N_('Stage / Unstage')
1584 s
= selection
.selection()
1586 do(Unstage
, s
.staged
)
1589 unmerged
= check_conflicts(s
.unmerged
)
1591 unstaged
.extend(unmerged
)
1593 unstaged
.extend(s
.modified
)
1595 unstaged
.extend(s
.untracked
)
1601 """Create a tag object."""
1603 def __init__(self
, name
, revision
, sign
=False, message
=''):
1604 Command
.__init
__(self
)
1606 self
._message
= message
1607 self
._revision
= revision
1611 log_msg
= (N_('Tagging "%(revision)s" as "%(name)s"') %
1612 dict(revision
=self
._revision
, name
=self
._name
))
1616 opts
['F'] = utils
.tmp_filename('tag-message')
1617 core
.write(opts
['F'], self
._message
)
1620 log_msg
+= ' (%s)' % N_('GPG-signed')
1623 opts
['a'] = bool(self
._message
)
1624 status
, output
, err
= self
.model
.git
.tag(self
._name
,
1625 self
._revision
, **opts
)
1628 os
.unlink(opts
['F'])
1631 log_msg
+= '\n' + (N_('Output: %s') % output
)
1633 Interaction
.log_status(status
, log_msg
, err
)
1635 self
.model
.update_status()
1636 return (status
, output
, err
)
1639 class Unstage(Command
):
1640 """Unstage a set of paths."""
1644 return N_('Unstage')
1646 def __init__(self
, paths
):
1647 Command
.__init
__(self
)
1651 msg
= N_('Unstaging: %s') % (', '.join(self
.paths
))
1652 Interaction
.log(msg
)
1653 with
CommandDisabled(UpdateFileStatus
):
1654 self
.model
.unstage_paths(self
.paths
)
1657 class UnstageAll(Command
):
1658 """Unstage all files; resets the index."""
1661 self
.model
.unstage_all()
1664 class UnstageSelected(Unstage
):
1665 """Unstage selected files."""
1668 Unstage
.__init
__(self
, selection
.selection_model().staged
)
1671 class Untrack(Command
):
1672 """Unstage a set of paths."""
1674 def __init__(self
, paths
):
1675 Command
.__init
__(self
)
1679 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
1680 Interaction
.log(msg
)
1681 with
CommandDisabled(UpdateFileStatus
):
1682 status
, out
, err
= self
.model
.untrack_paths(self
.paths
)
1683 Interaction
.log_status(status
, out
, err
)
1686 class UntrackedSummary(Command
):
1687 """List possible .gitignore rules as the diff text."""
1690 Command
.__init
__(self
)
1691 untracked
= self
.model
.untracked
1692 suffix
= len(untracked
) > 1 and 's' or ''
1694 io
.write('# %s untracked file%s\n' % (len(untracked
), suffix
))
1696 io
.write('# possible .gitignore rule%s:\n' % suffix
)
1698 io
.write('/'+u
+'\n')
1699 self
.new_diff_text
= io
.getvalue()
1700 self
.new_mode
= self
.model
.mode_untracked
1703 class UpdateFileStatus(Command
):
1704 """Rescans for changes."""
1707 self
.model
.update_file_status()
1710 class VisualizeAll(Command
):
1711 """Visualize all branches."""
1714 browser
= utils
.shell_split(prefs
.history_browser())
1715 launch_history_browser(browser
+ ['--all'])
1718 class VisualizeCurrent(Command
):
1719 """Visualize all branches."""
1722 browser
= utils
.shell_split(prefs
.history_browser())
1723 launch_history_browser(browser
+ [self
.model
.currentbranch
])
1726 class VisualizePaths(Command
):
1727 """Path-limited visualization."""
1729 def __init__(self
, paths
):
1730 Command
.__init
__(self
)
1731 browser
= utils
.shell_split(prefs
.history_browser())
1733 self
.argv
= browser
+ list(paths
)
1738 launch_history_browser(self
.argv
)
1741 class VisualizeRevision(Command
):
1742 """Visualize a specific revision."""
1744 def __init__(self
, revision
, paths
=None):
1745 Command
.__init
__(self
)
1746 self
.revision
= revision
1750 argv
= utils
.shell_split(prefs
.history_browser())
1752 argv
.append(self
.revision
)
1755 argv
.extend(self
.paths
)
1756 launch_history_browser(argv
)
1759 def launch_history_browser(argv
):
1762 except Exception as e
:
1763 _
, details
= utils
.format_exception(e
)
1764 title
= N_('Error Launching History Browser')
1765 msg
= (N_('Cannot exec "%s": please configure a history browser') %
1767 Interaction
.critical(title
, message
=msg
, details
=details
)
1770 def run(cls
, *args
, **opts
):
1772 Returns a callback that runs a command
1774 If the caller of run() provides args or opts then those are
1775 used instead of the ones provided by the invoker of the callback.
1778 def runner(*local_args
, **local_opts
):
1780 do(cls
, *args
, **opts
)
1782 do(cls
, *local_args
, **local_opts
)
1787 class CommandDisabled(object):
1789 """Context manager to temporarily disable a command from running"""
1790 def __init__(self
, cmdclass
):
1791 self
.cmdclass
= cmdclass
1793 def __enter__(self
):
1794 self
.cmdclass
.DISABLED
= True
1797 def __exit__(self
, exc_type
, exc_val
, exc_tb
):
1798 self
.cmdclass
.DISABLED
= False
1801 def do(cls
, *args
, **opts
):
1802 """Run a command in-place"""
1803 return do_cmd(cls(*args
, **opts
))
1807 if hasattr(cmd
, 'DISABLED') and cmd
.DISABLED
:
1811 except Exception as e
:
1812 msg
, details
= utils
.format_exception(e
)
1813 Interaction
.critical(N_('Error'), message
=msg
, details
=details
)