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."""
154 def __init__(self
, amend
):
155 Command
.__init
__(self
)
158 self
.amending
= amend
159 self
.old_commitmsg
= self
.model
.commitmsg
160 self
.old_mode
= self
.model
.mode
163 self
.new_mode
= self
.model
.mode_amend
164 self
.new_commitmsg
= self
.model
.prev_commitmsg()
165 AmendMode
.LAST_MESSAGE
= self
.model
.commitmsg
167 # else, amend unchecked, regular commit
168 self
.new_mode
= self
.model
.mode_none
169 self
.new_diff_text
= ''
170 self
.new_commitmsg
= self
.model
.commitmsg
171 # If we're going back into new-commit-mode then search the
172 # undo stack for a previous amend-commit-mode and grab the
173 # commit message at that point in time.
174 if AmendMode
.LAST_MESSAGE
is not None:
175 self
.new_commitmsg
= AmendMode
.LAST_MESSAGE
176 AmendMode
.LAST_MESSAGE
= None
179 """Leave/enter amend mode."""
180 """Attempt to enter amend mode. Do not allow this when merging."""
182 if self
.model
.is_merging
:
184 self
.model
.set_mode(self
.old_mode
)
185 Interaction
.information(
187 N_('You are in the middle of a merge.\n'
188 'Cannot amend while merging.'))
192 self
.model
.set_commitmsg(self
.new_commitmsg
)
193 self
.model
.update_file_status()
198 self
.model
.set_commitmsg(self
.old_commitmsg
)
200 self
.model
.update_file_status()
203 class ApplyDiffSelection(Command
):
205 def __init__(self
, first_line_idx
, last_line_idx
, has_selection
,
206 reverse
, apply_to_worktree
):
207 Command
.__init
__(self
)
208 self
.first_line_idx
= first_line_idx
209 self
.last_line_idx
= last_line_idx
210 self
.has_selection
= has_selection
211 self
.reverse
= reverse
212 self
.apply_to_worktree
= apply_to_worktree
215 parser
= DiffParser(self
.model
.filename
, self
.model
.diff_text
)
216 if self
.has_selection
:
217 patch
= parser
.generate_patch(self
.first_line_idx
,
219 reverse
=self
.reverse
)
221 patch
= parser
.generate_hunk_patch(self
.first_line_idx
,
222 reverse
=self
.reverse
)
226 cfg
= gitcfg
.current()
227 tmp_path
= utils
.tmp_filename('patch')
229 core
.write(tmp_path
, patch
,
230 encoding
=cfg
.file_encoding(self
.model
.filename
))
231 if self
.apply_to_worktree
:
232 status
, out
, err
= self
.model
.apply_diff_to_worktree(tmp_path
)
234 status
, out
, err
= self
.model
.apply_diff(tmp_path
)
237 Interaction
.log_status(status
, out
, err
)
238 self
.model
.update_file_status(update_index
=True)
241 class ApplyPatches(Command
):
243 def __init__(self
, patches
):
244 Command
.__init
__(self
)
245 self
.patches
= patches
249 num_patches
= len(self
.patches
)
250 orig_head
= self
.model
.git
.rev_parse('HEAD')[STDOUT
]
252 for idx
, patch
in enumerate(self
.patches
):
253 status
, out
, err
= self
.model
.git
.am(patch
)
254 # Log the git-am command
255 Interaction
.log_status(status
, out
, err
)
258 diff
= self
.model
.git
.diff('HEAD^!', stat
=True)[STDOUT
]
259 diff_text
+= (N_('PATCH %(current)d/%(count)d') %
260 dict(current
=idx
+1, count
=num_patches
))
261 diff_text
+= ' - %s:\n%s\n\n' % (os
.path
.basename(patch
), diff
)
263 diff_text
+= N_('Summary:') + '\n'
264 diff_text
+= self
.model
.git
.diff(orig_head
, stat
=True)[STDOUT
]
267 self
.model
.set_diff_text(diff_text
)
268 self
.model
.update_file_status()
270 basenames
= '\n'.join([os
.path
.basename(p
) for p
in self
.patches
])
271 Interaction
.information(
272 N_('Patch(es) Applied'),
273 (N_('%d patch(es) applied.') + '\n\n%s') %
274 (len(self
.patches
), basenames
))
277 class Archive(BaseCommand
):
279 def __init__(self
, ref
, fmt
, prefix
, filename
):
280 BaseCommand
.__init
__(self
)
284 self
.filename
= filename
287 fp
= core
.xopen(self
.filename
, 'wb')
288 cmd
= ['git', 'archive', '--format='+self
.fmt
]
289 if self
.fmt
in ('tgz', 'tar.gz'):
292 cmd
.append('--prefix=' + self
.prefix
)
294 proc
= core
.start_command(cmd
, stdout
=fp
)
295 out
, err
= proc
.communicate()
297 status
= proc
.returncode
298 Interaction
.log_status(status
, out
or '', err
or '')
301 class Checkout(Command
):
303 A command object for git-checkout.
305 'argv' is handed off directly to git.
309 def __init__(self
, argv
, checkout_branch
=False):
310 Command
.__init
__(self
)
312 self
.checkout_branch
= checkout_branch
313 self
.new_diff_text
= ''
316 status
, out
, err
= self
.model
.git
.checkout(*self
.argv
)
317 Interaction
.log_status(status
, out
, err
)
318 if self
.checkout_branch
:
319 self
.model
.update_status()
321 self
.model
.update_file_status()
324 class CheckoutBranch(Checkout
):
325 """Checkout a branch."""
327 def __init__(self
, branch
):
329 Checkout
.__init
__(self
, args
, checkout_branch
=True)
332 class CherryPick(Command
):
333 """Cherry pick commits into the current branch."""
335 def __init__(self
, commits
):
336 Command
.__init
__(self
)
337 self
.commits
= commits
340 self
.model
.cherry_pick_list(self
.commits
)
341 self
.model
.update_file_status()
344 class ResetMode(Command
):
345 """Reset the mode and clear the model's diff text."""
348 Command
.__init
__(self
)
349 self
.new_mode
= self
.model
.mode_none
350 self
.new_diff_text
= ''
354 self
.model
.update_file_status()
357 class Commit(ResetMode
):
358 """Attempt to create a new commit."""
360 def __init__(self
, amend
, msg
, sign
, no_verify
=False):
361 ResetMode
.__init
__(self
)
365 self
.no_verify
= no_verify
366 self
.old_commitmsg
= self
.model
.commitmsg
367 self
.new_commitmsg
= ''
370 # Create the commit message file
371 comment_char
= prefs
.comment_char()
372 msg
= self
.strip_comments(self
.msg
, comment_char
=comment_char
)
373 tmpfile
= utils
.tmp_filename('commit-message')
375 core
.write(tmpfile
, msg
)
378 status
, out
, err
= self
.model
.git
.commit(F
=tmpfile
,
382 no_verify
=self
.no_verify
)
388 self
.model
.set_commitmsg(self
.new_commitmsg
)
389 msg
= N_('Created commit: %s') % out
391 msg
= N_('Commit failed: %s') % out
392 Interaction
.log_status(status
, msg
, err
)
394 return status
, out
, err
397 def strip_comments(msg
, comment_char
='#'):
399 message_lines
= [line
for line
in msg
.split('\n')
400 if not line
.startswith(comment_char
)]
401 msg
= '\n'.join(message_lines
)
402 if not msg
.endswith('\n'):
408 class Ignore(Command
):
409 """Add files to .gitignore"""
411 def __init__(self
, filenames
):
412 Command
.__init
__(self
)
413 self
.filenames
= list(filenames
)
416 if not self
.filenames
:
418 new_additions
= '\n'.join(self
.filenames
) + '\n'
419 for_status
= new_additions
420 if core
.exists('.gitignore'):
421 current_list
= core
.read('.gitignore')
422 new_additions
= current_list
.rstrip() + '\n' + new_additions
423 core
.write('.gitignore', new_additions
)
424 Interaction
.log_status(0, 'Added to .gitignore:\n%s' % for_status
, '')
425 self
.model
.update_file_status()
428 def file_summary(files
):
429 txt
= subprocess
.list2cmdline(files
)
431 txt
= txt
[:768].rstrip() + '...'
435 class RemoteCommand(ConfirmAction
):
437 def __init__(self
, remote
):
438 ConfirmAction
.__init
__(self
)
439 self
.model
= main
.model()
443 self
.model
.update_remotes()
446 class RemoteAdd(RemoteCommand
):
448 def __init__(self
, remote
, url
):
449 RemoteCommand
.__init
__(self
, remote
)
454 return git
.remote('add', self
.remote
, self
.url
)
456 def error_message(self
):
457 return N_('Error creating remote "%s"') % self
.remote
460 class RemoteRemove(RemoteCommand
):
463 title
= N_('Delete Remote')
464 question
= N_('Delete remote?')
465 info
= N_('Delete remote "%s"') % self
.remote
466 ok_btn
= N_('Delete')
467 return Interaction
.confirm(title
, question
, info
, ok_btn
)
471 return git
.remote('rm', self
.remote
)
473 def error_message(self
):
474 return N_('Error deleting remote "%s"') % self
.remote
477 class RemoteRename(RemoteCommand
):
479 def __init__(self
, remote
, new_remote
):
480 RemoteCommand
.__init
__(self
, remote
)
481 self
.new_remote
= new_remote
484 title
= N_('Rename Remote')
485 question
= N_('Rename remote?')
486 info
= (N_('Rename remote "%(current)s" to "%(new)s"?') %
487 dict(current
=self
.remote
, new
=self
.new_remote
))
488 ok_btn
= N_('Rename')
489 return Interaction
.confirm(title
, question
, info
, ok_btn
)
493 return git
.remote('rename', self
.remote
, self
.new_remote
)
496 class RemoveFromSettings(ConfirmAction
):
498 def __init__(self
, settings
, repo
, icon
=None):
499 ConfirmAction
.__init
__(self
)
500 self
.settings
= settings
508 class RemoveBookmark(RemoveFromSettings
):
512 title
= msg
= N_('Delete Bookmark?')
513 info
= N_('%s will be removed from your bookmarks.') % repo
514 ok_text
= N_('Delete Bookmark')
515 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
518 self
.settings
.remove_bookmark(self
.repo
)
522 class RemoveRecent(RemoveFromSettings
):
526 title
= msg
= N_('Remove %s from the recent list?') % repo
527 info
= N_('%s will be removed from your recent repositories.') % repo
528 ok_text
= N_('Remove')
529 return Interaction
.confirm(title
, msg
, info
, ok_text
, icon
=self
.icon
)
532 self
.settings
.remove_recent(self
.repo
)
536 class RemoveFiles(Command
):
539 def __init__(self
, remover
, filenames
):
540 Command
.__init
__(self
)
543 self
.remover
= remover
544 self
.filenames
= filenames
545 # We could git-hash-object stuff and provide undo-ability
549 files
= self
.filenames
555 remove
= self
.remover
556 for filename
in files
:
562 bad_filenames
.append(filename
)
565 Interaction
.information(
567 N_('Deleting "%s" failed') % file_summary(files
))
570 self
.model
.update_file_status()
573 class Delete(RemoveFiles
):
576 def __init__(self
, filenames
):
577 RemoveFiles
.__init
__(self
, os
.remove
, filenames
)
580 files
= self
.filenames
584 title
= N_('Delete Files?')
585 msg
= N_('The following files will be deleted:') + '\n\n'
586 msg
+= file_summary(files
)
587 info_txt
= N_('Delete %d file(s)?') % len(files
)
588 ok_txt
= N_('Delete Files')
590 if not Interaction
.confirm(title
, msg
, info_txt
, ok_txt
,
592 icon
=resources
.icon('remove.svg')):
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
, noff
, 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
927 msg
= gitcmds
.merge_message(revision
)
929 status
, out
, err
= self
.model
.git
.merge('-m', msg
, revision
,
934 Interaction
.log_status(status
, out
, err
)
935 self
.model
.update_status()
938 class OpenDefaultApp(BaseCommand
):
939 """Open a file using the OS default."""
943 return N_('Open Using Default Application')
945 def __init__(self
, filenames
):
946 BaseCommand
.__init
__(self
)
947 if utils
.is_darwin():
950 launcher
= 'xdg-open'
951 self
.launcher
= launcher
952 self
.filenames
= filenames
955 if not self
.filenames
:
957 core
.fork([self
.launcher
] + self
.filenames
)
960 class OpenParentDir(OpenDefaultApp
):
961 """Open parent directories using the OS default."""
965 return N_('Open Parent Directory')
967 def __init__(self
, filenames
):
968 OpenDefaultApp
.__init
__(self
, filenames
)
971 if not self
.filenames
:
973 dirs
= list(set(map(os
.path
.dirname
, self
.filenames
)))
974 core
.fork([self
.launcher
] + dirs
)
977 class OpenNewRepo(Command
):
978 """Launches git-cola on a repo."""
980 def __init__(self
, repo_path
):
981 Command
.__init
__(self
)
982 self
.repo_path
= repo_path
985 self
.model
.set_directory(self
.repo_path
)
986 core
.fork([sys
.executable
, sys
.argv
[0], '--repo', self
.repo_path
])
989 class OpenRepo(Command
):
990 def __init__(self
, repo_path
):
991 Command
.__init
__(self
)
992 self
.repo_path
= repo_path
996 old_worktree
= git
.worktree()
997 if not self
.model
.set_worktree(self
.repo_path
):
998 self
.model
.set_worktree(old_worktree
)
1000 new_worktree
= git
.worktree()
1001 core
.chdir(new_worktree
)
1002 self
.model
.set_directory(self
.repo_path
)
1003 cfg
= gitcfg
.current()
1007 self
.model
.update_status()
1010 class Clone(Command
):
1011 """Clones a repository and optionally spawns a new cola session."""
1013 def __init__(self
, url
, new_directory
, spawn
=True):
1014 Command
.__init
__(self
)
1016 self
.new_directory
= new_directory
1020 self
.error_message
= ''
1021 self
.error_details
= ''
1024 status
, out
, err
= self
.model
.git
.clone(self
.url
, self
.new_directory
)
1025 self
.ok
= status
== 0
1029 core
.fork([sys
.executable
, sys
.argv
[0],
1030 '--repo', self
.new_directory
])
1032 self
.error_message
= N_('Error: could not clone "%s"') % self
.url
1033 self
.error_details
= (
1034 (N_('git clone returned exit code %s') % status
) +
1035 ((out
+err
) and ('\n\n' + out
+ err
) or ''))
1040 def unix_path(path
, is_win32
=utils
.is_win32
):
1041 """Git for Windows requires unix paths, so force them here
1047 if second
== ':': # sanity check, this better be a Windows-style path
1048 unix_path
= '/' + first
+ path
[2:].replace('\\', '/')
1053 class GitXBaseContext(object):
1055 def __init__(self
, **kwargs
):
1056 self
.env
= {'GIT_EDITOR': prefs
.editor()}
1057 self
.env
.update(kwargs
)
1059 def __enter__(self
):
1060 compat
.setenv('GIT_SEQUENCE_EDITOR',
1061 unix_path(resources
.share('bin', 'git-xbase')))
1062 for var
, value
in self
.env
.items():
1063 compat
.setenv(var
, value
)
1066 def __exit__(self
, exc_type
, exc_val
, exc_tb
):
1067 compat
.unsetenv('GIT_SEQUENCE_EDITOR')
1068 for var
in self
.env
:
1069 compat
.unsetenv(var
)
1072 class Rebase(Command
):
1075 upstream
=None, branch
=None, capture_output
=True, **kwargs
):
1076 """Start an interactive rebase session
1078 :param upstream: upstream branch
1079 :param branch: optional branch to checkout
1080 :param capture_output: whether to capture stdout and stderr
1081 :param kwargs: forwarded directly to `git.rebase()`
1084 Command
.__init
__(self
)
1086 self
.upstream
= upstream
1087 self
.branch
= branch
1088 self
.capture_output
= capture_output
1089 self
.kwargs
= kwargs
1091 def prepare_arguments(self
):
1095 if self
.capture_output
:
1096 kwargs
['_stderr'] = None
1097 kwargs
['_stdout'] = None
1099 # Rebase actions must be the only option specified
1100 for action
in ('continue', 'abort', 'skip', 'edit_todo'):
1101 if self
.kwargs
.get(action
, False):
1102 kwargs
[action
] = self
.kwargs
[action
]
1105 kwargs
['interactive'] = True
1106 kwargs
['autosquash'] = self
.kwargs
.get('autosquash', True)
1107 kwargs
.update(self
.kwargs
)
1110 args
.append(self
.upstream
)
1112 args
.append(self
.branch
)
1117 (status
, out
, err
) = (1, '', '')
1118 args
, kwargs
= self
.prepare_arguments()
1119 upstream_title
= self
.upstream
or '@{upstream}'
1120 with
GitXBaseContext(
1121 GIT_XBASE_TITLE
=N_('Rebase onto %s') % upstream_title
,
1122 GIT_XBASE_ACTION
=N_('Rebase')):
1123 status
, out
, err
= self
.model
.git
.rebase(*args
, **kwargs
)
1124 Interaction
.log_status(status
, out
, err
)
1125 self
.model
.update_status()
1126 return status
, out
, err
1129 class RebaseEditTodo(Command
):
1132 (status
, out
, err
) = (1, '', '')
1133 with
GitXBaseContext(
1134 GIT_XBASE_TITLE
=N_('Edit Rebase'),
1135 GIT_XBASE_ACTION
=N_('Save')):
1136 status
, out
, err
= self
.model
.git
.rebase(edit_todo
=True)
1137 Interaction
.log_status(status
, out
, err
)
1138 self
.model
.update_status()
1139 return status
, out
, err
1142 class RebaseContinue(Command
):
1145 (status
, out
, err
) = (1, '', '')
1146 with
GitXBaseContext(
1147 GIT_XBASE_TITLE
=N_('Rebase'),
1148 GIT_XBASE_ACTION
=N_('Rebase')):
1149 status
, out
, err
= self
.model
.git
.rebase('--continue')
1150 Interaction
.log_status(status
, out
, err
)
1151 self
.model
.update_status()
1152 return status
, out
, err
1155 class RebaseSkip(Command
):
1158 (status
, out
, err
) = (1, '', '')
1159 with
GitXBaseContext(
1160 GIT_XBASE_TITLE
=N_('Rebase'),
1161 GIT_XBASE_ACTION
=N_('Rebase')):
1162 status
, out
, err
= self
.model
.git
.rebase(skip
=True)
1163 Interaction
.log_status(status
, out
, err
)
1164 self
.model
.update_status()
1165 return status
, out
, err
1168 class RebaseAbort(Command
):
1171 status
, out
, err
= self
.model
.git
.rebase(abort
=True)
1172 Interaction
.log_status(status
, out
, err
)
1173 self
.model
.update_status()
1176 class Rescan(Command
):
1177 """Rescan for changes"""
1180 self
.model
.update_status()
1183 class Refresh(Command
):
1184 """Update refs and refresh the index"""
1188 return N_('Refresh')
1191 self
.model
.update_status(update_index
=True)
1194 class RevertEditsCommand(ConfirmAction
):
1197 ConfirmAction
.__init
__(self
)
1198 self
.model
= main
.model()
1199 self
.icon
= resources
.icon('edit-undo.svg')
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 SetDiffText(Command
):
1353 def __init__(self
, text
):
1354 Command
.__init
__(self
)
1355 self
.undoable
= True
1356 self
.new_diff_text
= text
1359 class ShowUntracked(Command
):
1360 """Show an untracked file."""
1362 def __init__(self
, filename
):
1363 Command
.__init
__(self
)
1364 self
.new_filename
= filename
1365 self
.new_mode
= self
.model
.mode_untracked
1366 self
.new_diff_text
= self
.diff_text_for(filename
)
1368 def diff_text_for(self
, filename
):
1369 cfg
= gitcfg
.current()
1370 size
= cfg
.get('cola.readsize', 1024 * 2)
1372 result
= core
.read(filename
, size
=size
,
1373 encoding
='utf-8', errors
='ignore')
1377 if len(result
) == size
:
1382 class SignOff(Command
):
1386 return N_('Sign Off')
1389 Command
.__init
__(self
)
1390 self
.undoable
= True
1391 self
.old_commitmsg
= self
.model
.commitmsg
1394 signoff
= self
.signoff()
1395 if signoff
in self
.model
.commitmsg
:
1397 self
.model
.set_commitmsg(self
.model
.commitmsg
+ '\n' + signoff
)
1400 self
.model
.set_commitmsg(self
.old_commitmsg
)
1405 user
= pwd
.getpwuid(os
.getuid()).pw_name
1407 user
= os
.getenv('USER', N_('unknown'))
1409 cfg
= gitcfg
.current()
1410 name
= cfg
.get('user.name', user
)
1411 email
= cfg
.get('user.email', '%s@%s' % (user
, core
.node()))
1412 return '\nSigned-off-by: %s <%s>' % (name
, email
)
1415 def check_conflicts(unmerged
):
1416 """Check paths for conflicts
1418 Conflicting files can be filtered out one-by-one.
1421 if prefs
.check_conflicts():
1422 unmerged
= [path
for path
in unmerged
if is_conflict_free(path
)]
1426 def is_conflict_free(path
):
1427 """Return True if `path` contains no conflict markers
1429 rgx
= re
.compile(r
'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ')
1431 with core
.xopen(path
, 'r') as f
:
1433 line
= core
.decode(line
, errors
='ignore')
1435 if should_stage_conflicts(path
):
1440 # We can't read this file ~ we may be staging a removal
1445 def should_stage_conflicts(path
):
1446 """Inform the user that a file contains merge conflicts
1448 Return `True` if we should stage the path nonetheless.
1451 title
= msg
= N_('Stage conflicts?')
1452 info
= N_('%s appears to contain merge conflicts.\n\n'
1453 'You should probably skip this file.\n'
1454 'Stage it anyways?') % path
1455 ok_text
= N_('Stage conflicts')
1456 cancel_text
= N_('Skip')
1457 return Interaction
.confirm(title
, msg
, info
, ok_text
,
1458 default
=False, cancel_text
=cancel_text
)
1461 class Stage(Command
):
1462 """Stage a set of paths."""
1468 def __init__(self
, paths
):
1469 Command
.__init
__(self
)
1473 msg
= N_('Staging: %s') % (', '.join(self
.paths
))
1474 Interaction
.log(msg
)
1475 # Prevent external updates while we are staging files.
1476 # We update file stats at the end of this operation
1477 # so there's no harm in ignoring updates from other threads
1479 with
CommandDisabled(UpdateFileStatus
):
1480 self
.model
.stage_paths(self
.paths
)
1483 class StageCarefully(Stage
):
1484 """Only stage when the path list is non-empty
1486 We use "git add -u -- <pathspec>" to stage, and it stages everything by
1487 default when no pathspec is specified, so this class ensures that paths
1488 are specified before calling git.
1490 When no paths are specified, the command does nothing.
1494 Stage
.__init
__(self
, None)
1497 def init_paths(self
):
1500 def ok_to_run(self
):
1501 """Prevent catch-all "git add -u" from adding unmerged files"""
1502 return self
.paths
or not self
.model
.unmerged
1505 if self
.ok_to_run():
1509 class StageModified(StageCarefully
):
1510 """Stage all modified files."""
1514 return N_('Stage Modified')
1516 def init_paths(self
):
1517 self
.paths
= self
.model
.modified
1520 class StageUnmerged(StageCarefully
):
1521 """Stage unmerged files."""
1525 return N_('Stage Unmerged')
1527 def init_paths(self
):
1528 self
.paths
= check_conflicts(self
.model
.unmerged
)
1531 class StageUntracked(StageCarefully
):
1532 """Stage all untracked files."""
1536 return N_('Stage Untracked')
1538 def init_paths(self
):
1539 self
.paths
= self
.model
.untracked
1542 class StageOrUnstage(Command
):
1543 """If the selection is staged, unstage it, otherwise stage"""
1547 return N_('Stage / Unstage')
1550 s
= selection
.selection()
1552 do(Unstage
, s
.staged
)
1555 unmerged
= check_conflicts(s
.unmerged
)
1557 unstaged
.extend(unmerged
)
1559 unstaged
.extend(s
.modified
)
1561 unstaged
.extend(s
.untracked
)
1567 """Create a tag object."""
1569 def __init__(self
, name
, revision
, sign
=False, message
=''):
1570 Command
.__init
__(self
)
1572 self
._message
= message
1573 self
._revision
= revision
1577 log_msg
= (N_('Tagging "%(revision)s" as "%(name)s"') %
1578 dict(revision
=self
._revision
, name
=self
._name
))
1582 opts
['F'] = utils
.tmp_filename('tag-message')
1583 core
.write(opts
['F'], self
._message
)
1586 log_msg
+= ' (%s)' % N_('GPG-signed')
1589 opts
['a'] = bool(self
._message
)
1590 status
, output
, err
= self
.model
.git
.tag(self
._name
,
1591 self
._revision
, **opts
)
1594 os
.unlink(opts
['F'])
1597 log_msg
+= '\n' + (N_('Output: %s') % output
)
1599 Interaction
.log_status(status
, log_msg
, err
)
1601 self
.model
.update_status()
1602 return (status
, output
, err
)
1605 class Unstage(Command
):
1606 """Unstage a set of paths."""
1610 return N_('Unstage')
1612 def __init__(self
, paths
):
1613 Command
.__init
__(self
)
1617 msg
= N_('Unstaging: %s') % (', '.join(self
.paths
))
1618 Interaction
.log(msg
)
1619 with
CommandDisabled(UpdateFileStatus
):
1620 self
.model
.unstage_paths(self
.paths
)
1623 class UnstageAll(Command
):
1624 """Unstage all files; resets the index."""
1627 self
.model
.unstage_all()
1630 class UnstageSelected(Unstage
):
1631 """Unstage selected files."""
1634 Unstage
.__init
__(self
, selection
.selection_model().staged
)
1637 class Untrack(Command
):
1638 """Unstage a set of paths."""
1640 def __init__(self
, paths
):
1641 Command
.__init
__(self
)
1645 msg
= N_('Untracking: %s') % (', '.join(self
.paths
))
1646 Interaction
.log(msg
)
1647 with
CommandDisabled(UpdateFileStatus
):
1648 status
, out
, err
= self
.model
.untrack_paths(self
.paths
)
1649 Interaction
.log_status(status
, out
, err
)
1652 class UntrackedSummary(Command
):
1653 """List possible .gitignore rules as the diff text."""
1656 Command
.__init
__(self
)
1657 untracked
= self
.model
.untracked
1658 suffix
= len(untracked
) > 1 and 's' or ''
1660 io
.write('# %s untracked file%s\n' % (len(untracked
), suffix
))
1662 io
.write('# possible .gitignore rule%s:\n' % suffix
)
1664 io
.write('/'+u
+'\n')
1665 self
.new_diff_text
= io
.getvalue()
1666 self
.new_mode
= self
.model
.mode_untracked
1669 class UpdateFileStatus(Command
):
1670 """Rescans for changes."""
1673 self
.model
.update_file_status()
1676 class VisualizeAll(Command
):
1677 """Visualize all branches."""
1680 browser
= utils
.shell_split(prefs
.history_browser())
1681 launch_history_browser(browser
+ ['--all'])
1684 class VisualizeCurrent(Command
):
1685 """Visualize all branches."""
1688 browser
= utils
.shell_split(prefs
.history_browser())
1689 launch_history_browser(browser
+ [self
.model
.currentbranch
])
1692 class VisualizePaths(Command
):
1693 """Path-limited visualization."""
1695 def __init__(self
, paths
):
1696 Command
.__init
__(self
)
1697 browser
= utils
.shell_split(prefs
.history_browser())
1699 self
.argv
= browser
+ list(paths
)
1704 launch_history_browser(self
.argv
)
1707 class VisualizeRevision(Command
):
1708 """Visualize a specific revision."""
1710 def __init__(self
, revision
, paths
=None):
1711 Command
.__init
__(self
)
1712 self
.revision
= revision
1716 argv
= utils
.shell_split(prefs
.history_browser())
1718 argv
.append(self
.revision
)
1721 argv
.extend(self
.paths
)
1722 launch_history_browser(argv
)
1725 def launch_history_browser(argv
):
1728 except Exception as e
:
1729 _
, details
= utils
.format_exception(e
)
1730 title
= N_('Error Launching History Browser')
1731 msg
= (N_('Cannot exec "%s": please configure a history browser') %
1733 Interaction
.critical(title
, message
=msg
, details
=details
)
1736 def run(cls
, *args
, **opts
):
1738 Returns a callback that runs a command
1740 If the caller of run() provides args or opts then those are
1741 used instead of the ones provided by the invoker of the callback.
1744 def runner(*local_args
, **local_opts
):
1746 do(cls
, *args
, **opts
)
1748 do(cls
, *local_args
, **local_opts
)
1753 class CommandDisabled(object):
1755 """Context manager to temporarily disable a command from running"""
1756 def __init__(self
, cmdclass
):
1757 self
.cmdclass
= cmdclass
1759 def __enter__(self
):
1760 self
.cmdclass
.DISABLED
= True
1763 def __exit__(self
, exc_type
, exc_val
, exc_tb
):
1764 self
.cmdclass
.DISABLED
= False
1767 def do(cls
, *args
, **opts
):
1768 """Run a command in-place"""
1769 return do_cmd(cls(*args
, **opts
))
1773 if hasattr(cmd
, 'DISABLED') and cmd
.DISABLED
:
1777 except Exception as e
:
1778 msg
, details
= utils
.format_exception(e
)
1779 Interaction
.critical(N_('Error'), message
=msg
, details
=details
)