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 gitcfg
18 from cola
import gitcmds
19 from cola
import inotify
20 from cola
import utils
21 from cola
import difftool
22 from cola
import resources
23 from cola
.diffparse
import DiffParser
24 from cola
.git
import STDOUT
25 from cola
.i18n
import N_
26 from cola
.interaction
import Interaction
27 from cola
.models
import main
28 from cola
.models
import prefs
29 from cola
.models
import selection
32 class UsageError(Exception):
33 """Exception class for usage errors."""
34 def __init__(self
, title
, message
):
35 Exception.__init
__(self
, message
)
40 class BaseCommand(object):
41 """Base class for all commands; provides the command pattern"""
48 def is_undoable(self
):
49 """Can this be undone?"""
63 class ConfirmAction(BaseCommand
):
66 BaseCommand
.__init
__(self
)
83 def fail(self
, status
, out
, err
):
84 title
= msg
= self
.error_message()
85 details
= self
.error_details() or out
+ err
86 Interaction
.critical(title
, message
=msg
, details
=details
)
88 def error_message(self
):
91 def error_details(self
):
97 ok
= self
.ok_to_run() and self
.confirm()
99 status
, out
, err
= self
.action()
103 self
.fail(status
, out
, err
)
105 return ok
, status
, out
, err
108 class ModelCommand(BaseCommand
):
109 """Commands that manipulate the main models"""
112 BaseCommand
.__init
__(self
)
113 self
.model
= main
.model()
116 class Command(ModelCommand
):
117 """Base class for commands that modify the main model"""
120 """Initialize the command and stash away values for use in do()"""
121 # These are commonly used so let's make it easier to write new commands.
122 ModelCommand
.__init
__(self
)
124 self
.old_diff_text
= self
.model
.diff_text
125 self
.old_filename
= self
.model
.filename
126 self
.old_mode
= self
.model
.mode
128 self
.new_diff_text
= self
.old_diff_text
129 self
.new_filename
= self
.old_filename
130 self
.new_mode
= self
.old_mode
133 """Perform the operation."""
134 self
.model
.set_filename(self
.new_filename
)
135 self
.model
.set_mode(self
.new_mode
)
136 self
.model
.set_diff_text(self
.new_diff_text
)
139 """Undo the operation."""
140 self
.model
.set_diff_text(self
.old_diff_text
)
141 self
.model
.set_filename(self
.old_filename
)
142 self
.model
.set_mode(self
.old_mode
)
145 class AmendMode(Command
):
146 """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 CheckoutBranch(Checkout
):
326 """Checkout a branch."""
328 def __init__(self
, branch
):
330 Checkout
.__init
__(self
, args
, checkout_branch
=True)
333 class CherryPick(Command
):
334 """Cherry pick commits into the current branch."""
336 def __init__(self
, commits
):
337 Command
.__init
__(self
)
338 self
.commits
= commits
341 self
.model
.cherry_pick_list(self
.commits
)
342 self
.model
.update_file_status()
345 class ResetMode(Command
):
346 """Reset the mode and clear the model's diff text."""
349 Command
.__init
__(self
)
350 self
.new_mode
= self
.model
.mode_none
351 self
.new_diff_text
= ''
355 self
.model
.update_file_status()
358 class Commit(ResetMode
):
359 """Attempt to create a new commit."""
361 SHORTCUT
= 'Ctrl+Return'
363 def __init__(self
, amend
, msg
, sign
, no_verify
=False):
364 ResetMode
.__init
__(self
)
368 self
.no_verify
= no_verify
369 self
.old_commitmsg
= self
.model
.commitmsg
370 self
.new_commitmsg
= ''
373 # Create the commit message file
374 comment_char
= prefs
.comment_char()
375 msg
= self
.strip_comments(self
.msg
, comment_char
=comment_char
)
376 tmpfile
= utils
.tmp_filename('commit-message')
378 core
.write(tmpfile
, msg
)
381 status
, out
, err
= self
.model
.git
.commit(F
=tmpfile
,
385 no_verify
=self
.no_verify
)
391 self
.model
.set_commitmsg(self
.new_commitmsg
)
392 msg
= N_('Created commit: %s') % out
394 msg
= N_('Commit failed: %s') % out
395 Interaction
.log_status(status
, msg
, err
)
397 return status
, out
, err
400 def strip_comments(msg
, comment_char
='#'):
402 message_lines
= [line
for line
in msg
.split('\n')
403 if not line
.startswith(comment_char
)]
404 msg
= '\n'.join(message_lines
)
405 if not msg
.endswith('\n'):
411 class Ignore(Command
):
412 """Add files to .gitignore"""
414 def __init__(self
, filenames
):
415 Command
.__init
__(self
)
416 self
.filenames
= list(filenames
)
419 if not self
.filenames
:
421 new_additions
= '\n'.join(self
.filenames
) + '\n'
422 for_status
= new_additions
423 if core
.exists('.gitignore'):
424 current_list
= core
.read('.gitignore')
425 new_additions
= current_list
.rstrip() + '\n' + new_additions
426 core
.write('.gitignore', new_additions
)
427 Interaction
.log_status(0, 'Added to .gitignore:\n%s' % for_status
, '')
428 self
.model
.update_file_status()
431 def file_summary(files
):
432 txt
= subprocess
.list2cmdline(files
)
434 txt
= txt
[:768].rstrip() + '...'
438 class RemoteCommand(ConfirmAction
):
440 def __init__(self
, remote
):
441 ConfirmAction
.__init
__(self
)
442 self
.model
= main
.model()
446 self
.model
.update_remotes()
449 class RemoteAdd(RemoteCommand
):
451 def __init__(self
, remote
, url
):
452 RemoteCommand
.__init
__(self
, remote
)
457 return git
.remote('add', self
.remote
, self
.url
)
459 def error_message(self
):
460 return N_('Error creating remote "%s"') % self
.remote
463 class RemoteRemove(RemoteCommand
):
466 title
= N_('Delete Remote')
467 question
= N_('Delete remote?')
468 info
= N_('Delete remote "%s"') % self
.remote
469 ok_btn
= N_('Delete')
470 return Interaction
.confirm(title
, question
, info
, ok_btn
)
474 return git
.remote('rm', self
.remote
)
476 def error_message(self
):
477 return N_('Error deleting remote "%s"') % self
.remote
480 class RemoteRename(RemoteCommand
):
482 def __init__(self
, remote
, new_remote
):
483 RemoteCommand
.__init
__(self
, remote
)
484 self
.new_remote
= new_remote
487 title
= N_('Rename Remote')
488 question
= N_('Rename remote?')
489 info
= (N_('Rename remote "%(current)s" to "%(new)s"?') %
490 dict(current
=self
.remote
, new
=self
.new_remote
))
491 ok_btn
= N_('Rename')
492 return Interaction
.confirm(title
, question
, info
, ok_btn
)
496 return git
.remote('rename', self
.remote
, self
.new_remote
)
499 class RemoveFromSettings(ConfirmAction
):
501 def __init__(self
, settings
, repo
, icon
=None):
502 ConfirmAction
.__init
__(self
)
503 self
.settings
= settings
511 class RemoveBookmark(RemoveFromSettings
):
515 title
= msg
= N_('Delete Bookmark?')
516 info
= N_('%s will be removed from your bookmarks.') % repo
517 ok_text
= N_('Delete Bookmark')
518 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
521 self
.settings
.remove_bookmark(self
.repo
)
525 class RemoveRecent(RemoveFromSettings
):
529 title
= msg
= N_('Remove %s from the recent list?') % repo
530 info
= N_('%s will be removed from your recent repositories.') % repo
531 ok_text
= N_('Remove')
532 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
535 self
.settings
.remove_recent(self
.repo
)
539 class RemoveFiles(Command
):
542 def __init__(self
, remover
, filenames
):
543 Command
.__init
__(self
)
546 self
.remover
= remover
547 self
.filenames
= filenames
548 # We could git-hash-object stuff and provide undo-ability
552 files
= self
.filenames
558 remove
= self
.remover
559 for filename
in files
:
565 bad_filenames
.append(filename
)
568 Interaction
.information(
570 N_('Deleting "%s" failed') % file_summary(files
))
573 self
.model
.update_file_status()
576 class Delete(RemoveFiles
):
579 SHORTCUT
= 'Ctrl+Shift+Backspace'
580 ALT_SHORTCUT
= 'Ctrl+Backspace'
582 def __init__(self
, filenames
):
583 RemoveFiles
.__init
__(self
, os
.remove
, filenames
)
586 files
= self
.filenames
590 title
= N_('Delete Files?')
591 msg
= N_('The following files will be deleted:') + '\n\n'
592 msg
+= file_summary(files
)
593 info_txt
= N_('Delete %d file(s)?') % len(files
)
594 ok_txt
= N_('Delete Files')
596 if not Interaction
.confirm(title
, msg
, info_txt
, ok_txt
,
598 icon
=resources
.icon('remove.svg')):
601 return RemoveFiles
.do(self
)
604 class MoveToTrash(RemoveFiles
):
605 """Move files to the trash using send2trash"""
607 SHORTCUT
= 'Ctrl+Backspace'
608 AVAILABLE
= send2trash
is not None
610 def __init__(self
, filenames
):
611 RemoveFiles
.__init
__(self
, send2trash
, filenames
)
614 class DeleteBranch(Command
):
615 """Delete a git branch."""
617 def __init__(self
, branch
):
618 Command
.__init
__(self
)
622 status
, out
, err
= self
.model
.delete_branch(self
.branch
)
623 Interaction
.log_status(status
, out
, err
)
626 class RenameBranch(Command
):
627 """Rename a git branch."""
629 def __init__(self
, branch
, new_branch
):
630 Command
.__init
__(self
)
632 self
.new_branch
= new_branch
635 status
, out
, err
= self
.model
.rename_branch(self
.branch
, self
.new_branch
)
636 Interaction
.log_status(status
, out
, err
)
638 class DeleteRemoteBranch(Command
):
639 """Delete a remote git branch."""
641 def __init__(self
, remote
, branch
):
642 Command
.__init
__(self
)
647 status
, out
, err
= self
.model
.git
.push(self
.remote
, self
.branch
,
649 Interaction
.log_status(status
, out
, err
)
650 self
.model
.update_status()
653 Interaction
.information(
654 N_('Remote Branch Deleted'),
655 N_('"%(branch)s" has been deleted from "%(remote)s".')
656 % dict(branch
=self
.branch
, remote
=self
.remote
))
659 message
= (N_('"%(command)s" returned exit status %(status)d') %
660 dict(command
=command
, status
=status
))
662 Interaction
.critical(N_('Error Deleting Remote Branch'),
668 """Perform a diff and set the model's current text."""
670 def __init__(self
, filename
, cached
=False, deleted
=False):
671 Command
.__init
__(self
)
674 opts
['ref'] = self
.model
.head
675 self
.new_filename
= filename
676 self
.new_mode
= self
.model
.mode_worktree
677 self
.new_diff_text
= gitcmds
.diff_helper(filename
=filename
,
683 class Diffstat(Command
):
684 """Perform a diffstat and set the model's diff text."""
687 Command
.__init
__(self
)
688 cfg
= gitcfg
.current()
689 diff_context
= cfg
.get('diff.context', 3)
690 diff
= self
.model
.git
.diff(self
.model
.head
,
691 unified
=diff_context
,
696 self
.new_diff_text
= diff
697 self
.new_mode
= self
.model
.mode_worktree
700 class DiffStaged(Diff
):
701 """Perform a staged diff on a file."""
703 def __init__(self
, filename
, deleted
=None):
704 Diff
.__init
__(self
, filename
, cached
=True, deleted
=deleted
)
705 self
.new_mode
= self
.model
.mode_index
708 class DiffStagedSummary(Command
):
711 Command
.__init
__(self
)
712 diff
= self
.model
.git
.diff(self
.model
.head
,
716 patch_with_stat
=True,
718 self
.new_diff_text
= diff
719 self
.new_mode
= self
.model
.mode_index
722 class Difftool(Command
):
723 """Run git-difftool limited by path."""
725 def __init__(self
, staged
, filenames
):
726 Command
.__init
__(self
)
728 self
.filenames
= filenames
731 difftool
.launch_with_head(self
.filenames
,
732 self
.staged
, self
.model
.head
)
736 """Edit a file using the configured gui.editor."""
741 return N_('Launch Editor')
743 def __init__(self
, filenames
, line_number
=None):
744 Command
.__init
__(self
)
745 self
.filenames
= filenames
746 self
.line_number
= line_number
749 if not self
.filenames
:
751 filename
= self
.filenames
[0]
752 if not core
.exists(filename
):
754 editor
= prefs
.editor()
757 if self
.line_number
is None:
758 opts
= self
.filenames
760 # Single-file w/ line-numbers (likely from grep)
762 '*vim*': ['+'+self
.line_number
, filename
],
763 '*emacs*': ['+'+self
.line_number
, filename
],
764 '*textpad*': ['%s(%s,0)' % (filename
, self
.line_number
)],
765 '*notepad++*': ['-n'+self
.line_number
, filename
],
768 opts
= self
.filenames
769 for pattern
, opt
in editor_opts
.items():
770 if fnmatch(editor
, pattern
):
775 core
.fork(utils
.shell_split(editor
) + opts
)
776 except Exception as e
:
777 message
= (N_('Cannot exec "%s": please configure your editor')
779 details
= core
.decode(e
.strerror
)
780 Interaction
.critical(N_('Error Editing File'), message
, details
)
783 class FormatPatch(Command
):
784 """Output a patch series given all revisions and a selected subset."""
786 def __init__(self
, to_export
, revs
):
787 Command
.__init
__(self
)
788 self
.to_export
= list(to_export
)
789 self
.revs
= list(revs
)
792 status
, out
, err
= gitcmds
.format_patchsets(self
.to_export
, self
.revs
)
793 Interaction
.log_status(status
, out
, err
)
796 class LaunchDifftool(BaseCommand
):
802 return N_('Launch Diff Tool')
805 BaseCommand
.__init
__(self
)
808 s
= selection
.selection()
812 core
.fork(['git', 'mergetool', '--no-prompt', '--'] + paths
)
814 cfg
= gitcfg
.current()
816 argv
= utils
.shell_split(cmd
)
817 argv
.extend(['git', 'mergetool', '--no-prompt', '--'])
824 class LaunchTerminal(BaseCommand
):
826 SHORTCUT
= 'Ctrl+Shift+T'
830 return N_('Launch Terminal')
832 def __init__(self
, path
):
833 BaseCommand
.__init
__(self
)
837 cfg
= gitcfg
.current()
839 argv
= utils
.shell_split(cmd
)
840 argv
.append(os
.getenv('SHELL', '/bin/sh'))
841 core
.fork(argv
, cwd
=self
.path
)
844 class LaunchEditor(Edit
):
849 return N_('Launch Editor')
852 s
= selection
.selection()
853 allfiles
= s
.staged
+ s
.unmerged
+ s
.modified
+ s
.untracked
854 Edit
.__init
__(self
, allfiles
)
857 class LoadCommitMessageFromFile(Command
):
858 """Loads a commit message from a path."""
860 def __init__(self
, path
):
861 Command
.__init
__(self
)
864 self
.old_commitmsg
= self
.model
.commitmsg
865 self
.old_directory
= self
.model
.directory
869 if not path
or not core
.isfile(path
):
870 raise UsageError(N_('Error: Cannot find commit template'),
871 N_('%s: No such file or directory.') % path
)
872 self
.model
.set_directory(os
.path
.dirname(path
))
873 self
.model
.set_commitmsg(core
.read(path
))
876 self
.model
.set_commitmsg(self
.old_commitmsg
)
877 self
.model
.set_directory(self
.old_directory
)
880 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile
):
881 """Loads the commit message template specified by commit.template."""
884 cfg
= gitcfg
.current()
885 template
= cfg
.get('commit.template')
886 LoadCommitMessageFromFile
.__init
__(self
, template
)
889 if self
.path
is None:
891 N_('Error: Unconfigured commit template'),
892 N_('A commit template has not been configured.\n'
893 'Use "git config" to define "commit.template"\n'
894 'so that it points to a commit template.'))
895 return LoadCommitMessageFromFile
.do(self
)
899 class LoadCommitMessageFromSHA1(Command
):
900 """Load a previous commit message"""
902 def __init__(self
, sha1
, prefix
=''):
903 Command
.__init
__(self
)
905 self
.old_commitmsg
= self
.model
.commitmsg
906 self
.new_commitmsg
= prefix
+ self
.model
.prev_commitmsg(sha1
)
910 self
.model
.set_commitmsg(self
.new_commitmsg
)
913 self
.model
.set_commitmsg(self
.old_commitmsg
)
916 class LoadFixupMessage(LoadCommitMessageFromSHA1
):
917 """Load a fixup message"""
919 def __init__(self
, sha1
):
920 LoadCommitMessageFromSHA1
.__init__(self
, sha1
, prefix
='fixup! ')
923 class Merge(Command
):
926 def __init__(self
, revision
, no_commit
, squash
, noff
, sign
):
927 Command
.__init
__(self
)
928 self
.revision
= revision
930 self
.no_commit
= no_commit
936 revision
= self
.revision
938 no_commit
= self
.no_commit
940 msg
= gitcmds
.merge_message(revision
)
942 status
, out
, err
= self
.model
.git
.merge('-m', msg
, revision
,
947 Interaction
.log_status(status
, out
, err
)
948 self
.model
.update_status()
951 class OpenDefaultApp(BaseCommand
):
952 """Open a file using the OS default."""
957 return N_('Open Using Default Application')
959 def __init__(self
, filenames
):
960 BaseCommand
.__init
__(self
)
961 if utils
.is_darwin():
964 launcher
= 'xdg-open'
965 self
.launcher
= launcher
966 self
.filenames
= filenames
969 if not self
.filenames
:
971 core
.fork([self
.launcher
] + self
.filenames
)
974 class OpenParentDir(OpenDefaultApp
):
975 """Open parent directories using the OS default."""
976 SHORTCUT
= 'Shift+Space'
980 return N_('Open Parent Directory')
982 def __init__(self
, filenames
):
983 OpenDefaultApp
.__init
__(self
, filenames
)
986 if not self
.filenames
:
988 dirs
= list(set(map(os
.path
.dirname
, self
.filenames
)))
989 core
.fork([self
.launcher
] + dirs
)
992 class OpenNewRepo(Command
):
993 """Launches git-cola on a repo."""
995 def __init__(self
, repo_path
):
996 Command
.__init
__(self
)
997 self
.repo_path
= repo_path
1000 self
.model
.set_directory(self
.repo_path
)
1001 core
.fork([sys
.executable
, sys
.argv
[0], '--repo', self
.repo_path
])
1004 class OpenRepo(Command
):
1005 def __init__(self
, repo_path
):
1006 Command
.__init
__(self
)
1007 self
.repo_path
= repo_path
1010 git
= self
.model
.git
1011 old_worktree
= git
.worktree()
1012 if not self
.model
.set_worktree(self
.repo_path
):
1013 self
.model
.set_worktree(old_worktree
)
1015 new_worktree
= git
.worktree()
1016 core
.chdir(new_worktree
)
1017 self
.model
.set_directory(self
.repo_path
)
1018 cfg
= gitcfg
.current()
1022 self
.model
.update_status()
1025 class Clone(Command
):
1026 """Clones a repository and optionally spawns a new cola session."""
1028 def __init__(self
, url
, new_directory
, spawn
=True):
1029 Command
.__init
__(self
)
1031 self
.new_directory
= new_directory
1035 self
.error_message
= ''
1036 self
.error_details
= ''
1039 status
, out
, err
= self
.model
.git
.clone(self
.url
, self
.new_directory
)
1040 self
.ok
= status
== 0
1044 core
.fork([sys
.executable
, sys
.argv
[0],
1045 '--repo', self
.new_directory
])
1047 self
.error_message
= N_('Error: could not clone "%s"') % self
.url
1048 self
.error_details
= (
1049 (N_('git clone returned exit code %s') % status
) +
1050 ((out
+err
) and ('\n\n' + out
+ err
) or ''))
1055 def unix_path(path
, is_win32
=utils
.is_win32
):
1056 """Git for Windows requires unix paths, so force them here
1062 if second
== ':': # sanity check, this better be a Windows-style path
1063 unix_path
= '/' + first
+ path
[2:].replace('\\', '/')
1068 class GitXBaseContext(object):
1070 def __init__(self
, **kwargs
):
1071 self
.env
= {'GIT_EDITOR': prefs
.editor()}
1072 self
.env
.update(kwargs
)
1074 def __enter__(self
):
1075 compat
.setenv('GIT_SEQUENCE_EDITOR',
1076 unix_path(resources
.share('bin', 'git-xbase')))
1077 for var
, value
in self
.env
.items():
1078 compat
.setenv(var
, value
)
1081 def __exit__(self
, exc_type
, exc_val
, exc_tb
):
1082 compat
.unsetenv('GIT_SEQUENCE_EDITOR')
1083 for var
in self
.env
:
1084 compat
.unsetenv(var
)
1087 class Rebase(Command
):
1090 upstream
=None, branch
=None, capture_output
=True, **kwargs
):
1091 """Start an interactive rebase session
1093 :param upstream: upstream branch
1094 :param branch: optional branch to checkout
1095 :param capture_output: whether to capture stdout and stderr
1096 :param kwargs: forwarded directly to `git.rebase()`
1099 Command
.__init
__(self
)
1101 self
.upstream
= upstream
1102 self
.branch
= branch
1103 self
.capture_output
= capture_output
1104 self
.kwargs
= kwargs
1106 def prepare_arguments(self
):
1110 if self
.capture_output
:
1111 kwargs
['_stderr'] = None
1112 kwargs
['_stdout'] = None
1114 # Rebase actions must be the only option specified
1115 for action
in ('continue', 'abort', 'skip', 'edit_todo'):
1116 if self
.kwargs
.get(action
, False):
1117 kwargs
[action
] = self
.kwargs
[action
]
1120 kwargs
['interactive'] = True
1121 kwargs
['autosquash'] = self
.kwargs
.get('autosquash', True)
1122 kwargs
.update(self
.kwargs
)
1125 args
.append(self
.upstream
)
1127 args
.append(self
.branch
)
1132 (status
, out
, err
) = (1, '', '')
1133 args
, kwargs
= self
.prepare_arguments()
1134 upstream_title
= self
.upstream
or '@{upstream}'
1135 with
GitXBaseContext(
1136 GIT_XBASE_TITLE
=N_('Rebase onto %s') % upstream_title
,
1137 GIT_XBASE_ACTION
=N_('Rebase')):
1138 status
, out
, err
= self
.model
.git
.rebase(*args
, **kwargs
)
1139 Interaction
.log_status(status
, out
, err
)
1140 self
.model
.update_status()
1141 return status
, out
, err
1144 class RebaseEditTodo(Command
):
1147 (status
, out
, err
) = (1, '', '')
1148 with
GitXBaseContext(
1149 GIT_XBASE_TITLE
=N_('Edit Rebase'),
1150 GIT_XBASE_ACTION
=N_('Save')):
1151 status
, out
, err
= self
.model
.git
.rebase(edit_todo
=True)
1152 Interaction
.log_status(status
, out
, err
)
1153 self
.model
.update_status()
1154 return status
, out
, err
1157 class RebaseContinue(Command
):
1160 (status
, out
, err
) = (1, '', '')
1161 with
GitXBaseContext(
1162 GIT_XBASE_TITLE
=N_('Rebase'),
1163 GIT_XBASE_ACTION
=N_('Rebase')):
1164 status
, out
, err
= self
.model
.git
.rebase('--continue')
1165 Interaction
.log_status(status
, out
, err
)
1166 self
.model
.update_status()
1167 return status
, out
, err
1170 class RebaseSkip(Command
):
1173 (status
, out
, err
) = (1, '', '')
1174 with
GitXBaseContext(
1175 GIT_XBASE_TITLE
=N_('Rebase'),
1176 GIT_XBASE_ACTION
=N_('Rebase')):
1177 status
, out
, err
= self
.model
.git
.rebase(skip
=True)
1178 Interaction
.log_status(status
, out
, err
)
1179 self
.model
.update_status()
1180 return status
, out
, err
1183 class RebaseAbort(Command
):
1186 status
, out
, err
= self
.model
.git
.rebase(abort
=True)
1187 Interaction
.log_status(status
, out
, err
)
1188 self
.model
.update_status()
1191 class Rescan(Command
):
1192 """Rescan for changes"""
1195 self
.model
.update_status()
1198 class Refresh(Command
):
1199 """Update refs and refresh the index"""
1201 SHORTCUTS
= ('Ctrl+R', 'F5')
1205 return N_('Refresh')
1208 self
.model
.update_status(update_index
=True)
1211 class RevertEditsCommand(ConfirmAction
):
1214 ConfirmAction
.__init
__(self
)
1215 self
.model
= main
.model()
1216 self
.icon
= resources
.icon('edit-undo.svg')
1218 def ok_to_run(self
):
1219 return self
.model
.undoable()
1221 def checkout_from_head(self
):
1224 def checkout_args(self
):
1226 s
= selection
.selection()
1227 if self
.checkout_from_head():
1228 args
.append(self
.model
.head
)
1240 git
= self
.model
.git
1241 checkout_args
= self
.checkout_args()
1242 return git
.checkout(*checkout_args
)
1245 self
.model
.update_file_status()
1248 class RevertUnstagedEdits(RevertEditsCommand
):
1254 return N_('Revert Unstaged Edits...')
1256 def checkout_from_head(self
):
1257 # If we are amending and a modified file is selected
1258 # then we should include "HEAD^" on the command-line.
1259 selected
= selection
.selection()
1260 return not selected
.staged
and self
.model
.amending()
1263 title
= N_('Revert Unstaged Changes?')
1264 text
= N_('This operation drops unstaged changes.\n'
1265 'These changes cannot be recovered.')
1266 info
= N_('Revert the unstaged changes?')
1267 ok_text
= N_('Revert Unstaged Changes')
1268 return Interaction
.confirm(title
, text
, info
, ok_text
,
1269 default
=True, icon
=self
.icon
)
1272 class RevertUncommittedEdits(RevertEditsCommand
):
1278 return N_('Revert Uncommitted Edits...')
1280 def checkout_from_head(self
):
1284 title
= N_('Revert Uncommitted Changes?')
1285 text
= N_('This operation drops uncommitted changes.\n'
1286 'These changes cannot be recovered.')
1287 info
= N_('Revert the uncommitted changes?')
1288 ok_text
= N_('Revert Uncommitted Changes')
1289 return Interaction
.confirm(title
, text
, info
, ok_text
,
1290 default
=True, icon
=self
.icon
)
1293 class RunConfigAction(Command
):
1294 """Run a user-configured action, typically from the "Tools" menu"""
1296 def __init__(self
, action_name
):
1297 Command
.__init
__(self
)
1298 self
.action_name
= action_name
1299 self
.model
= main
.model()
1302 for env
in ('FILENAME', 'REVISION', 'ARGS'):
1304 compat
.unsetenv(env
)
1309 cfg
= gitcfg
.current()
1310 opts
= cfg
.get_guitool_opts(self
.action_name
)
1311 cmd
= opts
.get('cmd')
1312 if 'title' not in opts
:
1315 if 'prompt' not in opts
or opts
.get('prompt') is True:
1316 prompt
= N_('Run "%s"?') % cmd
1317 opts
['prompt'] = prompt
1319 if opts
.get('needsfile'):
1320 filename
= selection
.filename()
1322 Interaction
.information(
1323 N_('Please select a file'),
1324 N_('"%s" requires a selected file.') % cmd
)
1326 compat
.setenv('FILENAME', filename
)
1328 if opts
.get('revprompt') or opts
.get('argprompt'):
1330 ok
= Interaction
.confirm_config_action(cmd
, opts
)
1333 rev
= opts
.get('revision')
1334 args
= opts
.get('args')
1335 if opts
.get('revprompt') and not rev
:
1336 title
= N_('Invalid Revision')
1337 msg
= N_('The revision expression cannot be empty.')
1338 Interaction
.critical(title
, msg
)
1342 elif opts
.get('confirm'):
1343 title
= os
.path
.expandvars(opts
.get('title'))
1344 prompt
= os
.path
.expandvars(opts
.get('prompt'))
1345 if Interaction
.question(title
, prompt
):
1348 compat
.setenv('REVISION', rev
)
1350 compat
.setenv('ARGS', args
)
1351 title
= os
.path
.expandvars(cmd
)
1352 Interaction
.log(N_('Running command: %s') % title
)
1353 cmd
= ['sh', '-c', cmd
]
1355 if opts
.get('background'):
1357 status
, out
, err
= (0, '', '')
1358 elif opts
.get('noconsole'):
1359 status
, out
, err
= core
.run_command(cmd
)
1361 status
, out
, err
= Interaction
.run_command(title
, cmd
)
1363 Interaction
.log_status(status
,
1364 out
and (N_('Output: %s') % out
) or '',
1365 err
and (N_('Errors: %s') % err
) or '')
1367 if not opts
.get('background') and not opts
.get('norescan'):
1368 self
.model
.update_status()
1372 class SetDiffText(Command
):
1374 def __init__(self
, text
):
1375 Command
.__init
__(self
)
1376 self
.undoable
= True
1377 self
.new_diff_text
= text
1380 class ShowUntracked(Command
):
1381 """Show an untracked file."""
1383 def __init__(self
, filename
):
1384 Command
.__init
__(self
)
1385 self
.new_filename
= filename
1386 self
.new_mode
= self
.model
.mode_untracked
1387 self
.new_diff_text
= self
.diff_text_for(filename
)
1389 def diff_text_for(self
, filename
):
1390 cfg
= gitcfg
.current()
1391 size
= cfg
.get('cola.readsize', 1024 * 2)
1393 result
= core
.read(filename
, size
=size
,
1394 encoding
='utf-8', errors
='ignore')
1398 if len(result
) == size
:
1403 class SignOff(Command
):
1408 return N_('Sign Off')
1411 Command
.__init
__(self
)
1412 self
.undoable
= True
1413 self
.old_commitmsg
= self
.model
.commitmsg
1416 signoff
= self
.signoff()
1417 if signoff
in self
.model
.commitmsg
:
1419 self
.model
.set_commitmsg(self
.model
.commitmsg
+ '\n' + signoff
)
1422 self
.model
.set_commitmsg(self
.old_commitmsg
)
1427 user
= pwd
.getpwuid(os
.getuid()).pw_name
1429 user
= os
.getenv('USER', N_('unknown'))
1431 cfg
= gitcfg
.current()
1432 name
= cfg
.get('user.name', user
)
1433 email
= cfg
.get('user.email', '%s@%s' % (user
, core
.node()))
1434 return '\nSigned-off-by: %s <%s>' % (name
, email
)
1437 def check_conflicts(unmerged
):
1438 """Check paths for conflicts
1440 Conflicting files can be filtered out one-by-one.
1443 if prefs
.check_conflicts():
1444 unmerged
= [path
for path
in unmerged
if is_conflict_free(path
)]
1448 def is_conflict_free(path
):
1449 """Return True if `path` contains no conflict markers
1451 rgx
= re
.compile(r
'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
1453 with core
.xopen(path
, 'r') as f
:
1455 line
= core
.decode(line
, errors
='ignore')
1457 if should_stage_conflicts(path
):
1462 # We can't read this file ~ we may be staging a removal
1467 def should_stage_conflicts(path
):
1468 """Inform the user that a file contains merge conflicts
1470 Return `True` if we should stage the path nonetheless.
1473 title
= msg
= N_('Stage conflicts?')
1474 info
= N_('%s appears to contain merge conflicts.\n\n'
1475 'You should probably skip this file.\n'
1476 'Stage it anyways?') % path
1477 ok_text
= N_('Stage conflicts')
1478 cancel_text
= N_('Skip')
1479 return Interaction
.confirm(title
, msg
, info
, ok_text
,
1480 default
=False, cancel_text
=cancel_text
)
1483 class Stage(Command
):
1484 """Stage a set of paths."""
1491 def __init__(self
, paths
):
1492 Command
.__init
__(self
)
1496 msg
= N_('Staging: %s') % (', '.join(self
.paths
))
1497 Interaction
.log(msg
)
1498 # Prevent external updates while we are staging files.
1499 # We update file stats at the end of this operation
1500 # so there's no harm in ignoring updates from other threads
1502 with
CommandDisabled(UpdateFileStatus
):
1503 self
.model
.stage_paths(self
.paths
)
1506 class StageCarefully(Stage
):
1507 """Only stage when the path list is non-empty
1509 We use "git add -u -- <pathspec>" to stage, and it stages everything by
1510 default when no pathspec is specified, so this class ensures that paths
1511 are specified before calling git.
1513 When no paths are specified, the command does nothing.
1517 Stage
.__init
__(self
, None)
1520 def init_paths(self
):
1523 def ok_to_run(self
):
1524 """Prevent catch-all "git add -u" from adding unmerged files"""
1525 return self
.paths
or not self
.model
.unmerged
1528 if self
.ok_to_run():
1532 class StageModified(StageCarefully
):
1533 """Stage all modified files."""
1539 return N_('Stage Modified')
1541 def init_paths(self
):
1542 self
.paths
= self
.model
.modified
1545 class StageUnmerged(StageCarefully
):
1546 """Stage unmerged files."""
1552 return N_('Stage Unmerged')
1554 def init_paths(self
):
1555 self
.paths
= check_conflicts(self
.model
.unmerged
)
1558 class StageUntracked(StageCarefully
):
1559 """Stage all untracked files."""
1565 return N_('Stage Untracked')
1567 def init_paths(self
):
1568 self
.paths
= self
.model
.untracked
1571 class StageOrUnstage(Command
):
1572 """If the selection is staged, unstage it, otherwise stage"""
1578 return N_('Stage / Unstage')
1581 s
= selection
.selection()
1583 do(Unstage
, s
.staged
)
1586 unmerged
= check_conflicts(s
.unmerged
)
1588 unstaged
.extend(unmerged
)
1590 unstaged
.extend(s
.modified
)
1592 unstaged
.extend(s
.untracked
)
1598 """Create a tag object."""
1600 def __init__(self
, name
, revision
, sign
=False, message
=''):
1601 Command
.__init
__(self
)
1603 self
._message
= message
1604 self
._revision
= revision
1608 log_msg
= (N_('Tagging "%(revision)s" as "%(name)s"') %
1609 dict(revision
=self
._revision
, name
=self
._name
))
1613 opts
['F'] = utils
.tmp_filename('tag-message')
1614 core
.write(opts
['F'], self
._message
)
1617 log_msg
+= ' (%s)' % N_('GPG-signed')
1620 opts
['a'] = bool(self
._message
)
1621 status
, output
, err
= self
.model
.git
.tag(self
._name
,
1622 self
._revision
, **opts
)
1625 os
.unlink(opts
['F'])
1628 log_msg
+= '\n' + (N_('Output: %s') % output
)
1630 Interaction
.log_status(status
, log_msg
, err
)
1632 self
.model
.update_status()
1633 return (status
, output
, err
)
1636 class Unstage(Command
):
1637 """Unstage a set of paths."""
1643 return N_('Unstage')
1645 def __init__(self
, paths
):
1646 Command
.__init
__(self
)
1650 msg
= N_('Unstaging: %s') % (', '.join(self
.paths
))
1651 Interaction
.log(msg
)
1652 with
CommandDisabled(UpdateFileStatus
):
1653 self
.model
.unstage_paths(self
.paths
)
1656 class UnstageAll(Command
):
1657 """Unstage all files; resets the index."""
1660 self
.model
.unstage_all()
1663 class UnstageSelected(Unstage
):
1664 """Unstage selected files."""
1667 Unstage
.__init
__(self
, selection
.selection_model().staged
)
1670 class Untrack(Command
):
1671 """Unstage a set of paths."""
1673 def __init__(self
, paths
):
1674 Command
.__init
__(self
)
1678 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
1679 Interaction
.log(msg
)
1680 with
CommandDisabled(UpdateFileStatus
):
1681 status
, out
, err
= self
.model
.untrack_paths(self
.paths
)
1682 Interaction
.log_status(status
, out
, err
)
1685 class UntrackedSummary(Command
):
1686 """List possible .gitignore rules as the diff text."""
1689 Command
.__init
__(self
)
1690 untracked
= self
.model
.untracked
1691 suffix
= len(untracked
) > 1 and 's' or ''
1693 io
.write('# %s untracked file%s\n' % (len(untracked
), suffix
))
1695 io
.write('# possible .gitignore rule%s:\n' % suffix
)
1697 io
.write('/'+u
+'\n')
1698 self
.new_diff_text
= io
.getvalue()
1699 self
.new_mode
= self
.model
.mode_untracked
1702 class UpdateFileStatus(Command
):
1703 """Rescans for changes."""
1706 self
.model
.update_file_status()
1709 class VisualizeAll(Command
):
1710 """Visualize all branches."""
1713 browser
= utils
.shell_split(prefs
.history_browser())
1714 launch_history_browser(browser
+ ['--all'])
1717 class VisualizeCurrent(Command
):
1718 """Visualize all branches."""
1721 browser
= utils
.shell_split(prefs
.history_browser())
1722 launch_history_browser(browser
+ [self
.model
.currentbranch
])
1725 class VisualizePaths(Command
):
1726 """Path-limited visualization."""
1728 def __init__(self
, paths
):
1729 Command
.__init
__(self
)
1730 browser
= utils
.shell_split(prefs
.history_browser())
1732 self
.argv
= browser
+ list(paths
)
1737 launch_history_browser(self
.argv
)
1740 class VisualizeRevision(Command
):
1741 """Visualize a specific revision."""
1743 def __init__(self
, revision
, paths
=None):
1744 Command
.__init
__(self
)
1745 self
.revision
= revision
1749 argv
= utils
.shell_split(prefs
.history_browser())
1751 argv
.append(self
.revision
)
1754 argv
.extend(self
.paths
)
1755 launch_history_browser(argv
)
1758 def launch_history_browser(argv
):
1761 except Exception as e
:
1762 _
, details
= utils
.format_exception(e
)
1763 title
= N_('Error Launching History Browser')
1764 msg
= (N_('Cannot exec "%s": please configure a history browser') %
1766 Interaction
.critical(title
, message
=msg
, details
=details
)
1769 def run(cls
, *args
, **opts
):
1771 Returns a callback that runs a command
1773 If the caller of run() provides args or opts then those are
1774 used instead of the ones provided by the invoker of the callback.
1777 def runner(*local_args
, **local_opts
):
1779 do(cls
, *args
, **opts
)
1781 do(cls
, *local_args
, **local_opts
)
1786 class CommandDisabled(object):
1788 """Context manager to temporarily disable a command from running"""
1789 def __init__(self
, cmdclass
):
1790 self
.cmdclass
= cmdclass
1792 def __enter__(self
):
1793 self
.cmdclass
.DISABLED
= True
1796 def __exit__(self
, exc_type
, exc_val
, exc_tb
):
1797 self
.cmdclass
.DISABLED
= False
1800 def do(cls
, *args
, **opts
):
1801 """Run a command in-place"""
1802 return do_cmd(cls(*args
, **opts
))
1806 if hasattr(cmd
, 'DISABLED') and cmd
.DISABLED
:
1810 except Exception as e
:
1811 msg
, details
= utils
.format_exception(e
)
1812 Interaction
.critical(N_('Error'), message
=msg
, details
=details
)