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 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 def __init__(self
, amend
, msg
, sign
, no_verify
=False):
362 ResetMode
.__init
__(self
)
366 self
.no_verify
= no_verify
367 self
.old_commitmsg
= self
.model
.commitmsg
368 self
.new_commitmsg
= ''
371 # Create the commit message file
372 comment_char
= prefs
.comment_char()
373 msg
= self
.strip_comments(self
.msg
, comment_char
=comment_char
)
374 tmpfile
= utils
.tmp_filename('commit-message')
376 core
.write(tmpfile
, msg
)
379 status
, out
, err
= self
.model
.git
.commit(F
=tmpfile
,
383 no_verify
=self
.no_verify
)
389 self
.model
.set_commitmsg(self
.new_commitmsg
)
390 msg
= N_('Created commit: %s') % out
392 msg
= N_('Commit failed: %s') % out
393 Interaction
.log_status(status
, msg
, err
)
395 return status
, out
, err
398 def strip_comments(msg
, comment_char
='#'):
400 message_lines
= [line
for line
in msg
.split('\n')
401 if not line
.startswith(comment_char
)]
402 msg
= '\n'.join(message_lines
)
403 if not msg
.endswith('\n'):
409 class Ignore(Command
):
410 """Add files to .gitignore"""
412 def __init__(self
, filenames
):
413 Command
.__init
__(self
)
414 self
.filenames
= list(filenames
)
417 if not self
.filenames
:
419 new_additions
= '\n'.join(self
.filenames
) + '\n'
420 for_status
= new_additions
421 if core
.exists('.gitignore'):
422 current_list
= core
.read('.gitignore')
423 new_additions
= current_list
.rstrip() + '\n' + new_additions
424 core
.write('.gitignore', new_additions
)
425 Interaction
.log_status(0, 'Added to .gitignore:\n%s' % for_status
, '')
426 self
.model
.update_file_status()
429 def file_summary(files
):
430 txt
= subprocess
.list2cmdline(files
)
432 txt
= txt
[:768].rstrip() + '...'
436 class RemoteCommand(ConfirmAction
):
438 def __init__(self
, remote
):
439 ConfirmAction
.__init
__(self
)
440 self
.model
= main
.model()
444 self
.model
.update_remotes()
447 class RemoteAdd(RemoteCommand
):
449 def __init__(self
, remote
, url
):
450 RemoteCommand
.__init
__(self
, remote
)
455 return git
.remote('add', self
.remote
, self
.url
)
457 def error_message(self
):
458 return N_('Error creating remote "%s"') % self
.remote
461 class RemoteRemove(RemoteCommand
):
464 title
= N_('Delete Remote')
465 question
= N_('Delete remote?')
466 info
= N_('Delete remote "%s"') % self
.remote
467 ok_btn
= N_('Delete')
468 return Interaction
.confirm(title
, question
, info
, ok_btn
)
472 return git
.remote('rm', self
.remote
)
474 def error_message(self
):
475 return N_('Error deleting remote "%s"') % self
.remote
478 class RemoteRename(RemoteCommand
):
480 def __init__(self
, remote
, new_remote
):
481 RemoteCommand
.__init
__(self
, remote
)
482 self
.new_remote
= new_remote
485 title
= N_('Rename Remote')
486 question
= N_('Rename remote?')
487 info
= (N_('Rename remote "%(current)s" to "%(new)s"?') %
488 dict(current
=self
.remote
, new
=self
.new_remote
))
489 ok_btn
= N_('Rename')
490 return Interaction
.confirm(title
, question
, info
, ok_btn
)
494 return git
.remote('rename', self
.remote
, self
.new_remote
)
497 class RemoveFromSettings(ConfirmAction
):
499 def __init__(self
, settings
, repo
, icon
=None):
500 ConfirmAction
.__init
__(self
)
501 self
.settings
= settings
509 class RemoveBookmark(RemoveFromSettings
):
513 title
= msg
= N_('Delete Bookmark?')
514 info
= N_('%s will be removed from your bookmarks.') % repo
515 ok_text
= N_('Delete Bookmark')
516 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
519 self
.settings
.remove_bookmark(self
.repo
)
523 class RemoveRecent(RemoveFromSettings
):
527 title
= msg
= N_('Remove %s from the recent list?') % repo
528 info
= N_('%s will be removed from your recent repositories.') % repo
529 ok_text
= N_('Remove')
530 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
533 self
.settings
.remove_recent(self
.repo
)
537 class RemoveFiles(Command
):
540 def __init__(self
, remover
, filenames
):
541 Command
.__init
__(self
)
544 self
.remover
= remover
545 self
.filenames
= filenames
546 # We could git-hash-object stuff and provide undo-ability
550 files
= self
.filenames
556 remove
= self
.remover
557 for filename
in files
:
563 bad_filenames
.append(filename
)
566 Interaction
.information(
568 N_('Deleting "%s" failed') % file_summary(files
))
571 self
.model
.update_file_status()
574 class Delete(RemoveFiles
):
577 def __init__(self
, filenames
):
578 RemoveFiles
.__init
__(self
, os
.remove
, filenames
)
581 files
= self
.filenames
585 title
= N_('Delete Files?')
586 msg
= N_('The following files will be deleted:') + '\n\n'
587 msg
+= file_summary(files
)
588 info_txt
= N_('Delete %d file(s)?') % len(files
)
589 ok_txt
= N_('Delete Files')
591 if not Interaction
.confirm(title
, msg
, info_txt
, ok_txt
,
592 default
=True, icon
=icons
.remove()):
595 return RemoveFiles
.do(self
)
598 class MoveToTrash(RemoveFiles
):
599 """Move files to the trash using send2trash"""
601 AVAILABLE
= send2trash
is not None
603 def __init__(self
, filenames
):
604 RemoveFiles
.__init
__(self
, send2trash
, filenames
)
607 class DeleteBranch(Command
):
608 """Delete a git branch."""
610 def __init__(self
, branch
):
611 Command
.__init
__(self
)
615 status
, out
, err
= self
.model
.delete_branch(self
.branch
)
616 Interaction
.log_status(status
, out
, err
)
619 class RenameBranch(Command
):
620 """Rename a git branch."""
622 def __init__(self
, branch
, new_branch
):
623 Command
.__init
__(self
)
625 self
.new_branch
= new_branch
628 status
, out
, err
= self
.model
.rename_branch(self
.branch
, self
.new_branch
)
629 Interaction
.log_status(status
, out
, err
)
631 class DeleteRemoteBranch(Command
):
632 """Delete a remote git branch."""
634 def __init__(self
, remote
, branch
):
635 Command
.__init
__(self
)
640 status
, out
, err
= self
.model
.git
.push(self
.remote
, self
.branch
,
642 Interaction
.log_status(status
, out
, err
)
643 self
.model
.update_status()
646 Interaction
.information(
647 N_('Remote Branch Deleted'),
648 N_('"%(branch)s" has been deleted from "%(remote)s".')
649 % dict(branch
=self
.branch
, remote
=self
.remote
))
652 message
= (N_('"%(command)s" returned exit status %(status)d') %
653 dict(command
=command
, status
=status
))
655 Interaction
.critical(N_('Error Deleting Remote Branch'),
661 """Perform a diff and set the model's current text."""
663 def __init__(self
, filename
, cached
=False, deleted
=False):
664 Command
.__init
__(self
)
667 opts
['ref'] = self
.model
.head
668 self
.new_filename
= filename
669 self
.new_mode
= self
.model
.mode_worktree
670 self
.new_diff_text
= gitcmds
.diff_helper(filename
=filename
,
676 class Diffstat(Command
):
677 """Perform a diffstat and set the model's diff text."""
680 Command
.__init
__(self
)
681 cfg
= gitcfg
.current()
682 diff_context
= cfg
.get('diff.context', 3)
683 diff
= self
.model
.git
.diff(self
.model
.head
,
684 unified
=diff_context
,
689 self
.new_diff_text
= diff
690 self
.new_mode
= self
.model
.mode_worktree
693 class DiffStaged(Diff
):
694 """Perform a staged diff on a file."""
696 def __init__(self
, filename
, deleted
=None):
697 Diff
.__init
__(self
, filename
, cached
=True, deleted
=deleted
)
698 self
.new_mode
= self
.model
.mode_index
701 class DiffStagedSummary(Command
):
704 Command
.__init
__(self
)
705 diff
= self
.model
.git
.diff(self
.model
.head
,
709 patch_with_stat
=True,
711 self
.new_diff_text
= diff
712 self
.new_mode
= self
.model
.mode_index
715 class Difftool(Command
):
716 """Run git-difftool limited by path."""
718 def __init__(self
, staged
, filenames
):
719 Command
.__init
__(self
)
721 self
.filenames
= filenames
724 difftool
.launch_with_head(self
.filenames
,
725 self
.staged
, self
.model
.head
)
729 """Edit a file using the configured gui.editor."""
733 return N_('Launch Editor')
735 def __init__(self
, filenames
, line_number
=None):
736 Command
.__init
__(self
)
737 self
.filenames
= filenames
738 self
.line_number
= line_number
741 if not self
.filenames
:
743 filename
= self
.filenames
[0]
744 if not core
.exists(filename
):
746 editor
= prefs
.editor()
749 if self
.line_number
is None:
750 opts
= self
.filenames
752 # Single-file w/ line-numbers (likely from grep)
754 '*vim*': ['+'+self
.line_number
, filename
],
755 '*emacs*': ['+'+self
.line_number
, filename
],
756 '*textpad*': ['%s(%s,0)' % (filename
, self
.line_number
)],
757 '*notepad++*': ['-n'+self
.line_number
, filename
],
760 opts
= self
.filenames
761 for pattern
, opt
in editor_opts
.items():
762 if fnmatch(editor
, pattern
):
767 core
.fork(utils
.shell_split(editor
) + opts
)
768 except Exception as e
:
769 message
= (N_('Cannot exec "%s": please configure your editor')
771 details
= core
.decode(e
.strerror
)
772 Interaction
.critical(N_('Error Editing File'), message
, details
)
775 class FormatPatch(Command
):
776 """Output a patch series given all revisions and a selected subset."""
778 def __init__(self
, to_export
, revs
):
779 Command
.__init
__(self
)
780 self
.to_export
= list(to_export
)
781 self
.revs
= list(revs
)
784 status
, out
, err
= gitcmds
.format_patchsets(self
.to_export
, self
.revs
)
785 Interaction
.log_status(status
, out
, err
)
788 class LaunchDifftool(BaseCommand
):
792 return N_('Launch Diff Tool')
795 BaseCommand
.__init
__(self
)
798 s
= selection
.selection()
802 core
.fork(['git', 'mergetool', '--no-prompt', '--'] + paths
)
804 cfg
= gitcfg
.current()
806 argv
= utils
.shell_split(cmd
)
807 argv
.extend(['git', 'mergetool', '--no-prompt', '--'])
814 class LaunchTerminal(BaseCommand
):
818 return N_('Launch Terminal')
820 def __init__(self
, path
):
821 BaseCommand
.__init
__(self
)
825 cfg
= gitcfg
.current()
827 argv
= utils
.shell_split(cmd
)
828 argv
.append(os
.getenv('SHELL', '/bin/sh'))
829 core
.fork(argv
, cwd
=self
.path
)
832 class LaunchEditor(Edit
):
836 return N_('Launch Editor')
839 s
= selection
.selection()
840 allfiles
= s
.staged
+ s
.unmerged
+ s
.modified
+ s
.untracked
841 Edit
.__init
__(self
, allfiles
)
844 class LoadCommitMessageFromFile(Command
):
845 """Loads a commit message from a path."""
847 def __init__(self
, path
):
848 Command
.__init
__(self
)
851 self
.old_commitmsg
= self
.model
.commitmsg
852 self
.old_directory
= self
.model
.directory
856 if not path
or not core
.isfile(path
):
857 raise UsageError(N_('Error: Cannot find commit template'),
858 N_('%s: No such file or directory.') % path
)
859 self
.model
.set_directory(os
.path
.dirname(path
))
860 self
.model
.set_commitmsg(core
.read(path
))
863 self
.model
.set_commitmsg(self
.old_commitmsg
)
864 self
.model
.set_directory(self
.old_directory
)
867 class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile
):
868 """Loads the commit message template specified by commit.template."""
871 cfg
= gitcfg
.current()
872 template
= cfg
.get('commit.template')
873 LoadCommitMessageFromFile
.__init
__(self
, template
)
876 if self
.path
is None:
878 N_('Error: Unconfigured commit template'),
879 N_('A commit template has not been configured.\n'
880 'Use "git config" to define "commit.template"\n'
881 'so that it points to a commit template.'))
882 return LoadCommitMessageFromFile
.do(self
)
886 class LoadCommitMessageFromSHA1(Command
):
887 """Load a previous commit message"""
889 def __init__(self
, sha1
, prefix
=''):
890 Command
.__init
__(self
)
892 self
.old_commitmsg
= self
.model
.commitmsg
893 self
.new_commitmsg
= prefix
+ self
.model
.prev_commitmsg(sha1
)
897 self
.model
.set_commitmsg(self
.new_commitmsg
)
900 self
.model
.set_commitmsg(self
.old_commitmsg
)
903 class LoadFixupMessage(LoadCommitMessageFromSHA1
):
904 """Load a fixup message"""
906 def __init__(self
, sha1
):
907 LoadCommitMessageFromSHA1
.__init__(self
, sha1
, prefix
='fixup! ')
910 class Merge(Command
):
913 def __init__(self
, revision
, no_commit
, squash
, no_ff
, sign
):
914 Command
.__init
__(self
)
915 self
.revision
= revision
917 self
.no_commit
= no_commit
923 revision
= self
.revision
925 no_commit
= self
.no_commit
928 status
, out
, err
= self
.model
.git
.merge(revision
,
933 Interaction
.log_status(status
, out
, err
)
934 self
.model
.update_status()
937 class OpenDefaultApp(BaseCommand
):
938 """Open a file using the OS default."""
942 return N_('Open Using Default Application')
944 def __init__(self
, filenames
):
945 BaseCommand
.__init
__(self
)
946 if utils
.is_darwin():
949 launcher
= 'xdg-open'
950 self
.launcher
= launcher
951 self
.filenames
= filenames
954 if not self
.filenames
:
956 core
.fork([self
.launcher
] + self
.filenames
)
959 class OpenParentDir(OpenDefaultApp
):
960 """Open parent directories using the OS default."""
964 return N_('Open Parent Directory')
966 def __init__(self
, filenames
):
967 OpenDefaultApp
.__init
__(self
, filenames
)
970 if not self
.filenames
:
972 dirs
= list(set(map(os
.path
.dirname
, self
.filenames
)))
973 core
.fork([self
.launcher
] + dirs
)
976 class OpenNewRepo(Command
):
977 """Launches git-cola on a repo."""
979 def __init__(self
, repo_path
):
980 Command
.__init
__(self
)
981 self
.repo_path
= repo_path
984 self
.model
.set_directory(self
.repo_path
)
985 core
.fork([sys
.executable
, sys
.argv
[0], '--repo', self
.repo_path
])
988 class OpenRepo(Command
):
989 def __init__(self
, repo_path
):
990 Command
.__init
__(self
)
991 self
.repo_path
= repo_path
995 old_worktree
= git
.worktree()
996 if not self
.model
.set_worktree(self
.repo_path
):
997 self
.model
.set_worktree(old_worktree
)
999 new_worktree
= git
.worktree()
1000 core
.chdir(new_worktree
)
1001 self
.model
.set_directory(self
.repo_path
)
1002 cfg
= gitcfg
.current()
1004 fsmonitor
.instance().stop()
1005 fsmonitor
.instance().start()
1006 self
.model
.update_status()
1009 class Clone(Command
):
1010 """Clones a repository and optionally spawns a new cola session."""
1012 def __init__(self
, url
, new_directory
, spawn
=True):
1013 Command
.__init
__(self
)
1015 self
.new_directory
= new_directory
1019 self
.error_message
= ''
1020 self
.error_details
= ''
1023 status
, out
, err
= self
.model
.git
.clone(self
.url
, self
.new_directory
)
1024 self
.ok
= status
== 0
1028 core
.fork([sys
.executable
, sys
.argv
[0],
1029 '--repo', self
.new_directory
])
1031 self
.error_message
= N_('Error: could not clone "%s"') % self
.url
1032 self
.error_details
= (
1033 (N_('git clone returned exit code %s') % status
) +
1034 ((out
+err
) and ('\n\n' + out
+ err
) or ''))
1039 def unix_path(path
, is_win32
=utils
.is_win32
):
1040 """Git for Windows requires unix paths, so force them here
1046 if second
== ':': # sanity check, this better be a Windows-style path
1047 unix_path
= '/' + first
+ path
[2:].replace('\\', '/')
1052 class GitXBaseContext(object):
1054 def __init__(self
, **kwargs
):
1055 self
.env
= {'GIT_EDITOR': prefs
.editor()}
1056 self
.env
.update(kwargs
)
1058 def __enter__(self
):
1059 compat
.setenv('GIT_SEQUENCE_EDITOR',
1060 unix_path(resources
.share('bin', 'git-xbase')))
1061 for var
, value
in self
.env
.items():
1062 compat
.setenv(var
, value
)
1065 def __exit__(self
, exc_type
, exc_val
, exc_tb
):
1066 compat
.unsetenv('GIT_SEQUENCE_EDITOR')
1067 for var
in self
.env
:
1068 compat
.unsetenv(var
)
1071 class Rebase(Command
):
1074 upstream
=None, branch
=None, capture_output
=True, **kwargs
):
1075 """Start an interactive rebase session
1077 :param upstream: upstream branch
1078 :param branch: optional branch to checkout
1079 :param capture_output: whether to capture stdout and stderr
1080 :param kwargs: forwarded directly to `git.rebase()`
1083 Command
.__init
__(self
)
1085 self
.upstream
= upstream
1086 self
.branch
= branch
1087 self
.capture_output
= capture_output
1088 self
.kwargs
= kwargs
1090 def prepare_arguments(self
):
1094 if self
.capture_output
:
1095 kwargs
['_stderr'] = None
1096 kwargs
['_stdout'] = None
1098 # Rebase actions must be the only option specified
1099 for action
in ('continue', 'abort', 'skip', 'edit_todo'):
1100 if self
.kwargs
.get(action
, False):
1101 kwargs
[action
] = self
.kwargs
[action
]
1104 kwargs
['interactive'] = True
1105 kwargs
['autosquash'] = self
.kwargs
.get('autosquash', True)
1106 kwargs
.update(self
.kwargs
)
1109 args
.append(self
.upstream
)
1111 args
.append(self
.branch
)
1116 (status
, out
, err
) = (1, '', '')
1117 args
, kwargs
= self
.prepare_arguments()
1118 upstream_title
= self
.upstream
or '@{upstream}'
1119 with
GitXBaseContext(
1120 GIT_XBASE_TITLE
=N_('Rebase onto %s') % upstream_title
,
1121 GIT_XBASE_ACTION
=N_('Rebase')):
1122 status
, out
, err
= self
.model
.git
.rebase(*args
, **kwargs
)
1123 Interaction
.log_status(status
, out
, err
)
1124 self
.model
.update_status()
1125 return status
, out
, err
1128 class RebaseEditTodo(Command
):
1131 (status
, out
, err
) = (1, '', '')
1132 with
GitXBaseContext(
1133 GIT_XBASE_TITLE
=N_('Edit Rebase'),
1134 GIT_XBASE_ACTION
=N_('Save')):
1135 status
, out
, err
= self
.model
.git
.rebase(edit_todo
=True)
1136 Interaction
.log_status(status
, out
, err
)
1137 self
.model
.update_status()
1138 return status
, out
, err
1141 class RebaseContinue(Command
):
1144 (status
, out
, err
) = (1, '', '')
1145 with
GitXBaseContext(
1146 GIT_XBASE_TITLE
=N_('Rebase'),
1147 GIT_XBASE_ACTION
=N_('Rebase')):
1148 status
, out
, err
= self
.model
.git
.rebase('--continue')
1149 Interaction
.log_status(status
, out
, err
)
1150 self
.model
.update_status()
1151 return status
, out
, err
1154 class RebaseSkip(Command
):
1157 (status
, out
, err
) = (1, '', '')
1158 with
GitXBaseContext(
1159 GIT_XBASE_TITLE
=N_('Rebase'),
1160 GIT_XBASE_ACTION
=N_('Rebase')):
1161 status
, out
, err
= self
.model
.git
.rebase(skip
=True)
1162 Interaction
.log_status(status
, out
, err
)
1163 self
.model
.update_status()
1164 return status
, out
, err
1167 class RebaseAbort(Command
):
1170 status
, out
, err
= self
.model
.git
.rebase(abort
=True)
1171 Interaction
.log_status(status
, out
, err
)
1172 self
.model
.update_status()
1175 class Rescan(Command
):
1176 """Rescan for changes"""
1179 self
.model
.update_status()
1182 class Refresh(Command
):
1183 """Update refs and refresh the index"""
1187 return N_('Refresh')
1190 self
.model
.update_status(update_index
=True)
1191 fsmonitor
.instance().refresh()
1194 class RevertEditsCommand(ConfirmAction
):
1197 ConfirmAction
.__init
__(self
)
1198 self
.model
= main
.model()
1199 self
.icon
= icons
.undo()
1201 def ok_to_run(self
):
1202 return self
.model
.undoable()
1204 def checkout_from_head(self
):
1207 def checkout_args(self
):
1209 s
= selection
.selection()
1210 if self
.checkout_from_head():
1211 args
.append(self
.model
.head
)
1223 git
= self
.model
.git
1224 checkout_args
= self
.checkout_args()
1225 return git
.checkout(*checkout_args
)
1228 self
.model
.update_file_status()
1231 class RevertUnstagedEdits(RevertEditsCommand
):
1235 return N_('Revert Unstaged Edits...')
1237 def checkout_from_head(self
):
1238 # If we are amending and a modified file is selected
1239 # then we should include "HEAD^" on the command-line.
1240 selected
= selection
.selection()
1241 return not selected
.staged
and self
.model
.amending()
1244 title
= N_('Revert Unstaged Changes?')
1245 text
= N_('This operation drops unstaged changes.\n'
1246 'These changes cannot be recovered.')
1247 info
= N_('Revert the unstaged changes?')
1248 ok_text
= N_('Revert Unstaged Changes')
1249 return Interaction
.confirm(title
, text
, info
, ok_text
,
1250 default
=True, icon
=self
.icon
)
1253 class RevertUncommittedEdits(RevertEditsCommand
):
1257 return N_('Revert Uncommitted Edits...')
1259 def checkout_from_head(self
):
1263 title
= N_('Revert Uncommitted Changes?')
1264 text
= N_('This operation drops uncommitted changes.\n'
1265 'These changes cannot be recovered.')
1266 info
= N_('Revert the uncommitted changes?')
1267 ok_text
= N_('Revert Uncommitted Changes')
1268 return Interaction
.confirm(title
, text
, info
, ok_text
,
1269 default
=True, icon
=self
.icon
)
1272 class RunConfigAction(Command
):
1273 """Run a user-configured action, typically from the "Tools" menu"""
1275 def __init__(self
, action_name
):
1276 Command
.__init
__(self
)
1277 self
.action_name
= action_name
1278 self
.model
= main
.model()
1281 for env
in ('FILENAME', 'REVISION', 'ARGS'):
1283 compat
.unsetenv(env
)
1288 cfg
= gitcfg
.current()
1289 opts
= cfg
.get_guitool_opts(self
.action_name
)
1290 cmd
= opts
.get('cmd')
1291 if 'title' not in opts
:
1294 if 'prompt' not in opts
or opts
.get('prompt') is True:
1295 prompt
= N_('Run "%s"?') % cmd
1296 opts
['prompt'] = prompt
1298 if opts
.get('needsfile'):
1299 filename
= selection
.filename()
1301 Interaction
.information(
1302 N_('Please select a file'),
1303 N_('"%s" requires a selected file.') % cmd
)
1305 compat
.setenv('FILENAME', filename
)
1307 if opts
.get('revprompt') or opts
.get('argprompt'):
1309 ok
= Interaction
.confirm_config_action(cmd
, opts
)
1312 rev
= opts
.get('revision')
1313 args
= opts
.get('args')
1314 if opts
.get('revprompt') and not rev
:
1315 title
= N_('Invalid Revision')
1316 msg
= N_('The revision expression cannot be empty.')
1317 Interaction
.critical(title
, msg
)
1321 elif opts
.get('confirm'):
1322 title
= os
.path
.expandvars(opts
.get('title'))
1323 prompt
= os
.path
.expandvars(opts
.get('prompt'))
1324 if Interaction
.question(title
, prompt
):
1327 compat
.setenv('REVISION', rev
)
1329 compat
.setenv('ARGS', args
)
1330 title
= os
.path
.expandvars(cmd
)
1331 Interaction
.log(N_('Running command: %s') % title
)
1332 cmd
= ['sh', '-c', cmd
]
1334 if opts
.get('background'):
1336 status
, out
, err
= (0, '', '')
1337 elif opts
.get('noconsole'):
1338 status
, out
, err
= core
.run_command(cmd
)
1340 status
, out
, err
= Interaction
.run_command(title
, cmd
)
1342 Interaction
.log_status(status
,
1343 out
and (N_('Output: %s') % out
) or '',
1344 err
and (N_('Errors: %s') % err
) or '')
1346 if not opts
.get('background') and not opts
.get('norescan'):
1347 self
.model
.update_status()
1351 class SetDefaultRepo(Command
):
1353 def __init__(self
, repo
):
1354 Command
.__init
__(self
)
1358 gitcfg
.current().set_user('cola.defaultrepo', self
.repo
)
1361 class SetDiffText(Command
):
1363 def __init__(self
, text
):
1364 Command
.__init
__(self
)
1365 self
.undoable
= True
1366 self
.new_diff_text
= text
1369 class ShowUntracked(Command
):
1370 """Show an untracked file."""
1372 def __init__(self
, filename
):
1373 Command
.__init
__(self
)
1374 self
.new_filename
= filename
1375 self
.new_mode
= self
.model
.mode_untracked
1376 self
.new_diff_text
= self
.diff_text_for(filename
)
1378 def diff_text_for(self
, filename
):
1379 cfg
= gitcfg
.current()
1380 size
= cfg
.get('cola.readsize', 1024 * 2)
1382 result
= core
.read(filename
, size
=size
,
1383 encoding
='utf-8', errors
='ignore')
1387 if len(result
) == size
:
1392 class SignOff(Command
):
1396 return N_('Sign Off')
1399 Command
.__init
__(self
)
1400 self
.undoable
= True
1401 self
.old_commitmsg
= self
.model
.commitmsg
1404 signoff
= self
.signoff()
1405 if signoff
in self
.model
.commitmsg
:
1407 self
.model
.set_commitmsg(self
.model
.commitmsg
+ '\n' + signoff
)
1410 self
.model
.set_commitmsg(self
.old_commitmsg
)
1415 user
= pwd
.getpwuid(os
.getuid()).pw_name
1417 user
= os
.getenv('USER', N_('unknown'))
1419 cfg
= gitcfg
.current()
1420 name
= cfg
.get('user.name', user
)
1421 email
= cfg
.get('user.email', '%s@%s' % (user
, core
.node()))
1422 return '\nSigned-off-by: %s <%s>' % (name
, email
)
1425 def check_conflicts(unmerged
):
1426 """Check paths for conflicts
1428 Conflicting files can be filtered out one-by-one.
1431 if prefs
.check_conflicts():
1432 unmerged
= [path
for path
in unmerged
if is_conflict_free(path
)]
1436 def is_conflict_free(path
):
1437 """Return True if `path` contains no conflict markers
1439 rgx
= re
.compile(r
'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
1441 with core
.xopen(path
, 'r') as f
:
1443 line
= core
.decode(line
, errors
='ignore')
1445 if should_stage_conflicts(path
):
1450 # We can't read this file ~ we may be staging a removal
1455 def should_stage_conflicts(path
):
1456 """Inform the user that a file contains merge conflicts
1458 Return `True` if we should stage the path nonetheless.
1461 title
= msg
= N_('Stage conflicts?')
1462 info
= N_('%s appears to contain merge conflicts.\n\n'
1463 'You should probably skip this file.\n'
1464 'Stage it anyways?') % path
1465 ok_text
= N_('Stage conflicts')
1466 cancel_text
= N_('Skip')
1467 return Interaction
.confirm(title
, msg
, info
, ok_text
,
1468 default
=False, cancel_text
=cancel_text
)
1471 class Stage(Command
):
1472 """Stage a set of paths."""
1478 def __init__(self
, paths
):
1479 Command
.__init
__(self
)
1483 msg
= N_('Staging: %s') % (', '.join(self
.paths
))
1484 Interaction
.log(msg
)
1485 # Prevent external updates while we are staging files.
1486 # We update file stats at the end of this operation
1487 # so there's no harm in ignoring updates from other threads
1488 # (e.g. the file system change monitor).
1489 with
CommandDisabled(UpdateFileStatus
):
1490 self
.model
.stage_paths(self
.paths
)
1493 class StageCarefully(Stage
):
1494 """Only stage when the path list is non-empty
1496 We use "git add -u -- <pathspec>" to stage, and it stages everything by
1497 default when no pathspec is specified, so this class ensures that paths
1498 are specified before calling git.
1500 When no paths are specified, the command does nothing.
1504 Stage
.__init
__(self
, None)
1507 def init_paths(self
):
1510 def ok_to_run(self
):
1511 """Prevent catch-all "git add -u" from adding unmerged files"""
1512 return self
.paths
or not self
.model
.unmerged
1515 if self
.ok_to_run():
1519 class StageModified(StageCarefully
):
1520 """Stage all modified files."""
1524 return N_('Stage Modified')
1526 def init_paths(self
):
1527 self
.paths
= self
.model
.modified
1530 class StageUnmerged(StageCarefully
):
1531 """Stage unmerged files."""
1535 return N_('Stage Unmerged')
1537 def init_paths(self
):
1538 self
.paths
= check_conflicts(self
.model
.unmerged
)
1541 class StageUntracked(StageCarefully
):
1542 """Stage all untracked files."""
1546 return N_('Stage Untracked')
1548 def init_paths(self
):
1549 self
.paths
= self
.model
.untracked
1552 class StageOrUnstage(Command
):
1553 """If the selection is staged, unstage it, otherwise stage"""
1557 return N_('Stage / Unstage')
1560 s
= selection
.selection()
1562 do(Unstage
, s
.staged
)
1565 unmerged
= check_conflicts(s
.unmerged
)
1567 unstaged
.extend(unmerged
)
1569 unstaged
.extend(s
.modified
)
1571 unstaged
.extend(s
.untracked
)
1577 """Create a tag object."""
1579 def __init__(self
, name
, revision
, sign
=False, message
=''):
1580 Command
.__init
__(self
)
1582 self
._message
= message
1583 self
._revision
= revision
1587 log_msg
= (N_('Tagging "%(revision)s" as "%(name)s"') %
1588 dict(revision
=self
._revision
, name
=self
._name
))
1592 opts
['F'] = utils
.tmp_filename('tag-message')
1593 core
.write(opts
['F'], self
._message
)
1596 log_msg
+= ' (%s)' % N_('GPG-signed')
1599 opts
['a'] = bool(self
._message
)
1600 status
, output
, err
= self
.model
.git
.tag(self
._name
,
1601 self
._revision
, **opts
)
1604 os
.unlink(opts
['F'])
1607 log_msg
+= '\n' + (N_('Output: %s') % output
)
1609 Interaction
.log_status(status
, log_msg
, err
)
1611 self
.model
.update_status()
1612 return (status
, output
, err
)
1615 class Unstage(Command
):
1616 """Unstage a set of paths."""
1620 return N_('Unstage')
1622 def __init__(self
, paths
):
1623 Command
.__init
__(self
)
1627 msg
= N_('Unstaging: %s') % (', '.join(self
.paths
))
1628 Interaction
.log(msg
)
1629 with
CommandDisabled(UpdateFileStatus
):
1630 self
.model
.unstage_paths(self
.paths
)
1633 class UnstageAll(Command
):
1634 """Unstage all files; resets the index."""
1637 self
.model
.unstage_all()
1640 class UnstageSelected(Unstage
):
1641 """Unstage selected files."""
1644 Unstage
.__init
__(self
, selection
.selection_model().staged
)
1647 class Untrack(Command
):
1648 """Unstage a set of paths."""
1650 def __init__(self
, paths
):
1651 Command
.__init
__(self
)
1655 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
1656 Interaction
.log(msg
)
1657 with
CommandDisabled(UpdateFileStatus
):
1658 status
, out
, err
= self
.model
.untrack_paths(self
.paths
)
1659 Interaction
.log_status(status
, out
, err
)
1662 class UntrackedSummary(Command
):
1663 """List possible .gitignore rules as the diff text."""
1666 Command
.__init
__(self
)
1667 untracked
= self
.model
.untracked
1668 suffix
= len(untracked
) > 1 and 's' or ''
1670 io
.write('# %s untracked file%s\n' % (len(untracked
), suffix
))
1672 io
.write('# possible .gitignore rule%s:\n' % suffix
)
1674 io
.write('/'+u
+'\n')
1675 self
.new_diff_text
= io
.getvalue()
1676 self
.new_mode
= self
.model
.mode_untracked
1679 class UpdateFileStatus(Command
):
1680 """Rescans for changes."""
1683 self
.model
.update_file_status()
1686 class VisualizeAll(Command
):
1687 """Visualize all branches."""
1690 browser
= utils
.shell_split(prefs
.history_browser())
1691 launch_history_browser(browser
+ ['--all'])
1694 class VisualizeCurrent(Command
):
1695 """Visualize all branches."""
1698 browser
= utils
.shell_split(prefs
.history_browser())
1699 launch_history_browser(browser
+ [self
.model
.currentbranch
])
1702 class VisualizePaths(Command
):
1703 """Path-limited visualization."""
1705 def __init__(self
, paths
):
1706 Command
.__init
__(self
)
1707 browser
= utils
.shell_split(prefs
.history_browser())
1709 self
.argv
= browser
+ list(paths
)
1714 launch_history_browser(self
.argv
)
1717 class VisualizeRevision(Command
):
1718 """Visualize a specific revision."""
1720 def __init__(self
, revision
, paths
=None):
1721 Command
.__init
__(self
)
1722 self
.revision
= revision
1726 argv
= utils
.shell_split(prefs
.history_browser())
1728 argv
.append(self
.revision
)
1731 argv
.extend(self
.paths
)
1732 launch_history_browser(argv
)
1735 def launch_history_browser(argv
):
1738 except Exception as e
:
1739 _
, details
= utils
.format_exception(e
)
1740 title
= N_('Error Launching History Browser')
1741 msg
= (N_('Cannot exec "%s": please configure a history browser') %
1743 Interaction
.critical(title
, message
=msg
, details
=details
)
1746 def run(cls
, *args
, **opts
):
1748 Returns a callback that runs a command
1750 If the caller of run() provides args or opts then those are
1751 used instead of the ones provided by the invoker of the callback.
1754 def runner(*local_args
, **local_opts
):
1756 do(cls
, *args
, **opts
)
1758 do(cls
, *local_args
, **local_opts
)
1763 class CommandDisabled(object):
1765 """Context manager to temporarily disable a command from running"""
1766 def __init__(self
, cmdclass
):
1767 self
.cmdclass
= cmdclass
1769 def __enter__(self
):
1770 self
.cmdclass
.DISABLED
= True
1773 def __exit__(self
, exc_type
, exc_val
, exc_tb
):
1774 self
.cmdclass
.DISABLED
= False
1777 def do(cls
, *args
, **opts
):
1778 """Run a command in-place"""
1779 return do_cmd(cls(*args
, **opts
))
1783 if hasattr(cmd
, 'DISABLED') and cmd
.DISABLED
:
1787 except Exception as e
:
1788 msg
, details
= utils
.format_exception(e
)
1789 Interaction
.critical(N_('Error'), message
=msg
, details
=details
)