1"""Editor commands""" 2from __future__ import division, absolute_import, unicode_literals 3import os 4import re 5import sys 6from fnmatch import fnmatch 7from io import StringIO 8 9try: 10 from send2trash import send2trash 11except ImportError: 12 send2trash = None 13 14from . import compat 15from . import core 16from . import gitcmds 17from . import icons 18from . import resources 19from . import textwrap 20from . import utils 21from . import version 22from .cmd import ContextCommand 23from .diffparse import DiffParser 24from .git import STDOUT 25from .git import EMPTY_TREE_OID 26from .git import MISSING_BLOB_OID 27from .i18n import N_ 28from .interaction import Interaction 29from .models import main 30from .models import prefs 31 32 33class UsageError(Exception): 34 """Exception class for usage errors.""" 35 36 def __init__(self, title, message): 37 Exception.__init__(self, message) 38 self.title = title 39 self.msg = message 40 41 42class EditModel(ContextCommand): 43 """Commands that mutate the main model diff data""" 44 45 UNDOABLE = True 46 47 def __init__(self, context): 48 """Common edit operations on the main model""" 49 super(EditModel, self).__init__(context) 50 51 self.old_diff_text = self.model.diff_text 52 self.old_filename = self.model.filename 53 self.old_mode = self.model.mode 54 self.old_diff_type = self.model.diff_type 55 self.old_file_type = self.model.file_type 56 57 self.new_diff_text = self.old_diff_text 58 self.new_filename = self.old_filename 59 self.new_mode = self.old_mode 60 self.new_diff_type = self.old_diff_type 61 self.new_file_type = self.old_file_type 62 63 def do(self): 64 """Perform the operation.""" 65 self.model.set_filename(self.new_filename) 66 self.model.set_mode(self.new_mode) 67 self.model.set_diff_text(self.new_diff_text) 68 self.model.set_diff_type(self.new_diff_type) 69 self.model.set_file_type(self.new_file_type) 70 71 def undo(self): 72 """Undo the operation.""" 73 self.model.set_filename(self.old_filename) 74 self.model.set_mode(self.old_mode) 75 self.model.set_diff_text(self.old_diff_text) 76 self.model.set_diff_type(self.old_diff_type) 77 self.model.set_file_type(self.old_file_type) 78 79 80class ConfirmAction(ContextCommand): 81 """Confirm an action before running it""" 82 83 # pylint: disable=no-self-use 84 def ok_to_run(self): 85 """Return True when the command is ok to run""" 86 return True 87 88 # pylint: disable=no-self-use 89 def confirm(self): 90 """Prompt for confirmation""" 91 return True 92 93 # pylint: disable=no-self-use 94 def action(self): 95 """Run the command and return (status, out, err)""" 96 return (-1, '', '') 97 98 # pylint: disable=no-self-use 99 def success(self): 100 """Callback run on success""" 101 return 102 103 # pylint: disable=no-self-use 104 def command(self): 105 """Command name, for error messages""" 106 return 'git' 107 108 # pylint: disable=no-self-use 109 def error_message(self): 110 """Command error message""" 111 return '' 112 113 def do(self): 114 """Prompt for confirmation before running a command""" 115 status = -1 116 out = err = '' 117 ok = self.ok_to_run() and self.confirm() 118 if ok: 119 status, out, err = self.action() 120 if status == 0: 121 self.success() 122 title = self.error_message() 123 cmd = self.command() 124 Interaction.command(title, cmd, status, out, err) 125 126 return ok, status, out, err 127 128 129class AbortMerge(ConfirmAction): 130 """Reset an in-progress merge back to HEAD""" 131 132 def confirm(self): 133 title = N_('Abort Merge...') 134 question = N_('Aborting the current merge?') 135 info = N_( 136 'Aborting the current merge will cause ' 137 '*ALL* uncommitted changes to be lost.\n' 138 'Recovering uncommitted changes is not possible.' 139 ) 140 ok_txt = N_('Abort Merge') 141 return Interaction.confirm( 142 title, question, info, ok_txt, default=False, icon=icons.undo() 143 ) 144 145 def action(self): 146 status, out, err = gitcmds.abort_merge(self.context) 147 self.model.update_file_status() 148 return status, out, err 149 150 def success(self): 151 self.model.set_commitmsg('') 152 153 def error_message(self): 154 return N_('Error') 155 156 def command(self): 157 return 'git merge' 158 159 160class AmendMode(EditModel): 161 """Try to amend a commit.""" 162 163 UNDOABLE = True 164 LAST_MESSAGE = None 165 166 @staticmethod 167 def name(): 168 return N_('Amend') 169 170 def __init__(self, context, amend=True): 171 super(AmendMode, self).__init__(context) 172 self.skip = False 173 self.amending = amend 174 self.old_commitmsg = self.model.commitmsg 175 self.old_mode = self.model.mode 176 177 if self.amending: 178 self.new_mode = self.model.mode_amend 179 self.new_commitmsg = gitcmds.prev_commitmsg(context) 180 AmendMode.LAST_MESSAGE = self.model.commitmsg 181 return 182 # else, amend unchecked, regular commit 183 self.new_mode = self.model.mode_none 184 self.new_diff_text = '' 185 self.new_commitmsg = self.model.commitmsg 186 # If we're going back into new-commit-mode then search the 187 # undo stack for a previous amend-commit-mode and grab the 188 # commit message at that point in time. 189 if AmendMode.LAST_MESSAGE is not None: 190 self.new_commitmsg = AmendMode.LAST_MESSAGE 191 AmendMode.LAST_MESSAGE = None 192 193 def do(self): 194 """Leave/enter amend mode.""" 195 # Attempt to enter amend mode. Do not allow this when merging. 196 if self.amending: 197 if self.model.is_merging: 198 self.skip = True 199 self.model.set_mode(self.old_mode) 200 Interaction.information( 201 N_('Cannot Amend'), 202 N_( 203 'You are in the middle of a merge.\n' 204 'Cannot amend while merging.' 205 ), 206 ) 207 return 208 self.skip = False 209 super(AmendMode, self).do() 210 self.model.set_commitmsg(self.new_commitmsg) 211 self.model.update_file_status() 212 213 def undo(self): 214 if self.skip: 215 return 216 self.model.set_commitmsg(self.old_commitmsg) 217 super(AmendMode, self).undo() 218 self.model.update_file_status() 219 220 221class AnnexAdd(ContextCommand): 222 """Add to Git Annex""" 223 224 def __init__(self, context): 225 super(AnnexAdd, self).__init__(context) 226 self.filename = self.selection.filename() 227 228 def do(self): 229 status, out, err = self.git.annex('add', self.filename) 230 Interaction.command(N_('Error'), 'git annex add', status, out, err) 231 self.model.update_status() 232 233 234class AnnexInit(ContextCommand): 235 """Initialize Git Annex""" 236 237 def do(self): 238 status, out, err = self.git.annex('init') 239 Interaction.command(N_('Error'), 'git annex init', status, out, err) 240 self.model.cfg.reset() 241 self.model.emit_updated() 242 243 244class LFSTrack(ContextCommand): 245 """Add a file to git lfs""" 246 247 def __init__(self, context): 248 super(LFSTrack, self).__init__(context) 249 self.filename = self.selection.filename() 250 self.stage_cmd = Stage(context, [self.filename]) 251 252 def do(self): 253 status, out, err = self.git.lfs('track', self.filename) 254 Interaction.command(N_('Error'), 'git lfs track', status, out, err) 255 if status == 0: 256 self.stage_cmd.do() 257 258 259class LFSInstall(ContextCommand): 260 """Initialize git lfs""" 261 262 def do(self): 263 status, out, err = self.git.lfs('install') 264 Interaction.command(N_('Error'), 'git lfs install', status, out, err) 265 self.model.update_config(reset=True, emit=True) 266 267 268class ApplyDiffSelection(ContextCommand): 269 """Apply the selected diff to the worktree or index""" 270 271 def __init__( 272 self, 273 context, 274 first_line_idx, 275 last_line_idx, 276 has_selection, 277 reverse, 278 apply_to_worktree, 279 ): 280 super(ApplyDiffSelection, self).__init__(context) 281 self.first_line_idx = first_line_idx 282 self.last_line_idx = last_line_idx 283 self.has_selection = has_selection 284 self.reverse = reverse 285 self.apply_to_worktree = apply_to_worktree 286 287 def do(self): 288 context = self.context 289 cfg = self.context.cfg 290 diff_text = self.model.diff_text 291 292 parser = DiffParser(self.model.filename, diff_text) 293 if self.has_selection: 294 patch = parser.generate_patch( 295 self.first_line_idx, self.last_line_idx, reverse=self.reverse 296 ) 297 else: 298 patch = parser.generate_hunk_patch( 299 self.first_line_idx, reverse=self.reverse 300 ) 301 if patch is None: 302 return 303 304 if isinstance(diff_text, core.UStr): 305 # original encoding must prevail 306 encoding = diff_text.encoding 307 else: 308 encoding = cfg.file_encoding(self.model.filename) 309 310 tmp_file = utils.tmp_filename('patch') 311 try: 312 core.write(tmp_file, patch, encoding=encoding) 313 if self.apply_to_worktree: 314 status, out, err = gitcmds.apply_diff_to_worktree(context, tmp_file) 315 else: 316 status, out, err = gitcmds.apply_diff(context, tmp_file) 317 finally: 318 core.unlink(tmp_file) 319 320 Interaction.log_status(status, out, err) 321 self.model.update_file_status(update_index=True) 322 323 324class ApplyPatches(ContextCommand): 325 """Apply patches using the "git am" command""" 326 327 def __init__(self, context, patches): 328 super(ApplyPatches, self).__init__(context) 329 self.patches = patches 330 331 def do(self): 332 status, out, err = self.git.am('-3', *self.patches) 333 Interaction.log_status(status, out, err) 334 335 # Display a diffstat 336 self.model.update_file_status() 337 338 patch_basenames = [os.path.basename(p) for p in self.patches] 339 if len(patch_basenames) > 25: 340 patch_basenames = patch_basenames[:25] 341 patch_basenames.append('...') 342 343 basenames = '\n'.join(patch_basenames) 344 Interaction.information( 345 N_('Patch(es) Applied'), 346 (N_('%d patch(es) applied.') + '\n\n%s') % (len(self.patches), basenames), 347 ) 348 349 350class Archive(ContextCommand): 351 """"Export archives using the "git archive" command""" 352 353 def __init__(self, context, ref, fmt, prefix, filename): 354 super(Archive, self).__init__(context) 355 self.ref = ref 356 self.fmt = fmt 357 self.prefix = prefix 358 self.filename = filename 359 360 def do(self): 361 fp = core.xopen(self.filename, 'wb') 362 cmd = ['git', 'archive', '--format=' + self.fmt] 363 if self.fmt in ('tgz', 'tar.gz'): 364 cmd.append('-9') 365 if self.prefix: 366 cmd.append('--prefix=' + self.prefix) 367 cmd.append(self.ref) 368 proc = core.start_command(cmd, stdout=fp) 369 out, err = proc.communicate() 370 fp.close() 371 status = proc.returncode 372 Interaction.log_status(status, out or '', err or '') 373 374 375class Checkout(EditModel): 376 """A command object for git-checkout. 377 378 'argv' is handed off directly to git. 379 380 """ 381 382 def __init__(self, context, argv, checkout_branch=False): 383 super(Checkout, self).__init__(context) 384 self.argv = argv 385 self.checkout_branch = checkout_branch 386 self.new_diff_text = '' 387 self.new_diff_type = main.Types.TEXT 388 self.new_file_type = main.Types.TEXT 389 390 def do(self): 391 super(Checkout, self).do() 392 status, out, err = self.git.checkout(*self.argv) 393 if self.checkout_branch: 394 self.model.update_status() 395 else: 396 self.model.update_file_status() 397 Interaction.command(N_('Error'), 'git checkout', status, out, err) 398 399 400class BlamePaths(ContextCommand): 401 """Blame view for paths.""" 402 403 @staticmethod 404 def name(): 405 return N_('Blame...') 406 407 def __init__(self, context, paths=None): 408 super(BlamePaths, self).__init__(context) 409 if not paths: 410 paths = context.selection.union() 411 viewer = utils.shell_split(prefs.blame_viewer(context)) 412 self.argv = viewer + list(paths) 413 414 def do(self): 415 try: 416 core.fork(self.argv) 417 except OSError as e: 418 _, details = utils.format_exception(e) 419 title = N_('Error Launching Blame Viewer') 420 msg = N_('Cannot exec "%s": please configure a blame viewer') % ' '.join( 421 self.argv 422 ) 423 Interaction.critical(title, message=msg, details=details) 424 425 426class CheckoutBranch(Checkout): 427 """Checkout a branch.""" 428 429 def __init__(self, context, branch): 430 args = [branch] 431 super(CheckoutBranch, self).__init__(context, args, checkout_branch=True) 432 433 434class CherryPick(ContextCommand): 435 """Cherry pick commits into the current branch.""" 436 437 def __init__(self, context, commits): 438 super(CherryPick, self).__init__(context) 439 self.commits = commits 440 441 def do(self): 442 self.model.cherry_pick_list(self.commits) 443 self.model.update_file_status() 444 445 446class Revert(ContextCommand): 447 """Cherry pick commits into the current branch.""" 448 449 def __init__(self, context, oid): 450 super(Revert, self).__init__(context) 451 self.oid = oid 452 453 def do(self): 454 self.git.revert(self.oid, no_edit=True) 455 self.model.update_file_status() 456 457 458class ResetMode(EditModel): 459 """Reset the mode and clear the model's diff text.""" 460 461 def __init__(self, context): 462 super(ResetMode, self).__init__(context) 463 self.new_mode = self.model.mode_none 464 self.new_diff_text = '' 465 self.new_diff_type = main.Types.TEXT 466 self.new_file_type = main.Types.TEXT 467 self.new_filename = '' 468 469 def do(self): 470 super(ResetMode, self).do() 471 self.model.update_file_status() 472 473 474class ResetCommand(ConfirmAction): 475 """Reset state using the "git reset" command""" 476 477 def __init__(self, context, ref): 478 super(ResetCommand, self).__init__(context) 479 self.ref = ref 480 481 def action(self): 482 return self.reset() 483 484 def command(self): 485 return 'git reset' 486 487 def error_message(self): 488 return N_('Error') 489 490 def success(self): 491 self.model.update_file_status() 492 493 def confirm(self): 494 raise NotImplementedError('confirm() must be overridden') 495 496 def reset(self): 497 raise NotImplementedError('reset() must be overridden') 498 499 500class ResetMixed(ResetCommand): 501 @staticmethod 502 def tooltip(ref): 503 tooltip = N_('The branch will be reset using "git reset --mixed %s"') 504 return tooltip % ref 505 506 def confirm(self): 507 title = N_('Reset Branch and Stage (Mixed)') 508 question = N_('Point the current branch head to a new commit?') 509 info = self.tooltip(self.ref) 510 ok_text = N_('Reset Branch') 511 return Interaction.confirm(title, question, info, ok_text) 512 513 def reset(self): 514 return self.git.reset(self.ref, '--', mixed=True) 515 516 517class ResetKeep(ResetCommand): 518 @staticmethod 519 def tooltip(ref): 520 tooltip = N_('The repository will be reset using "git reset --keep %s"') 521 return tooltip % ref 522 523 def confirm(self): 524 title = N_('Restore Worktree and Reset All (Keep Unstaged Changes)') 525 question = N_('Restore worktree, reset, and preserve unstaged edits?') 526 info = self.tooltip(self.ref) 527 ok_text = N_('Reset and Restore') 528 return Interaction.confirm(title, question, info, ok_text) 529 530 def reset(self): 531 return self.git.reset(self.ref, '--', keep=True) 532 533 534class ResetMerge(ResetCommand): 535 @staticmethod 536 def tooltip(ref): 537 tooltip = N_('The repository will be reset using "git reset --merge %s"') 538 return tooltip % ref 539 540 def confirm(self): 541 title = N_('Restore Worktree and Reset All (Merge)') 542 question = N_('Reset Worktree and Reset All?') 543 info = self.tooltip(self.ref) 544 ok_text = N_('Reset and Restore') 545 return Interaction.confirm(title, question, info, ok_text) 546 547 def reset(self): 548 return self.git.reset(self.ref, '--', merge=True) 549 550 551class ResetSoft(ResetCommand): 552 @staticmethod 553 def tooltip(ref): 554 tooltip = N_('The branch will be reset using "git reset --soft %s"') 555 return tooltip % ref 556 557 def confirm(self): 558 title = N_('Reset Branch (Soft)') 559 question = N_('Reset branch?') 560 info = self.tooltip(self.ref) 561 ok_text = N_('Reset Branch') 562 return Interaction.confirm(title, question, info, ok_text) 563 564 def reset(self): 565 return self.git.reset(self.ref, '--', soft=True) 566 567 568class ResetHard(ResetCommand): 569 @staticmethod 570 def tooltip(ref): 571 tooltip = N_('The repository will be reset using "git reset --hard %s"') 572 return tooltip % ref 573 574 def confirm(self): 575 title = N_('Restore Worktree and Reset All (Hard)') 576 question = N_('Restore Worktree and Reset All?') 577 info = self.tooltip(self.ref) 578 ok_text = N_('Reset and Restore') 579 return Interaction.confirm(title, question, info, ok_text) 580 581 def reset(self): 582 return self.git.reset(self.ref, '--', hard=True) 583 584 585class RestoreWorktree(ConfirmAction): 586 """Reset the worktree using the "git read-tree" command""" 587 588 @staticmethod 589 def tooltip(ref): 590 tooltip = N_( 591 'The worktree will be restored using "git read-tree --reset -u %s"' 592 ) 593 return tooltip % ref 594 595 def __init__(self, context, ref): 596 super(RestoreWorktree, self).__init__(context) 597 self.ref = ref 598 599 def action(self): 600 return self.git.read_tree(self.ref, reset=True, u=True) 601 602 def command(self): 603 return 'git read-tree --reset -u %s' % self.ref 604 605 def error_message(self): 606 return N_('Error') 607 608 def success(self): 609 self.model.update_file_status() 610 611 def confirm(self): 612 title = N_('Restore Worktree') 613 question = N_('Restore Worktree to %s?') % self.ref 614 info = self.tooltip(self.ref) 615 ok_text = N_('Restore Worktree') 616 return Interaction.confirm(title, question, info, ok_text) 617 618 619class UndoLastCommit(ResetCommand): 620 """Undo the last commit""" 621 622 # NOTE: this is the similar to ResetSoft() with an additional check for 623 # published commits and different messages. 624 def __init__(self, context): 625 super(UndoLastCommit, self).__init__(context, 'HEAD^') 626 627 def confirm(self): 628 check_published = prefs.check_published_commits(self.context) 629 if check_published and self.model.is_commit_published(): 630 return Interaction.confirm( 631 N_('Rewrite Published Commit?'), 632 N_( 633 'This commit has already been published.\n' 634 'This operation will rewrite published history.\n' 635 'You probably don\'t want to do this.' 636 ), 637 N_('Undo the published commit?'), 638 N_('Undo Last Commit'), 639 default=False, 640 icon=icons.save(), 641 ) 642 643 title = N_('Undo Last Commit') 644 question = N_('Undo last commit?') 645 info = N_('The branch will be reset using "git reset --soft %s"') 646 ok_text = N_('Undo Last Commit') 647 info_text = info % self.ref 648 return Interaction.confirm(title, question, info_text, ok_text) 649 650 def reset(self): 651 return self.git.reset('HEAD^', '--', soft=True) 652 653 654class Commit(ResetMode): 655 """Attempt to create a new commit.""" 656 657 def __init__(self, context, amend, msg, sign, no_verify=False): 658 super(Commit, self).__init__(context) 659 self.amend = amend 660 self.msg = msg 661 self.sign = sign 662 self.no_verify = no_verify 663 self.old_commitmsg = self.model.commitmsg 664 self.new_commitmsg = '' 665 666 def do(self): 667 # Create the commit message file 668 context = self.context 669 comment_char = prefs.comment_char(context) 670 msg = self.strip_comments(self.msg, comment_char=comment_char) 671 tmp_file = utils.tmp_filename('commit-message') 672 try: 673 core.write(tmp_file, msg) 674 # Run 'git commit' 675 status, out, err = self.git.commit( 676 F=tmp_file, 677 v=True, 678 gpg_sign=self.sign, 679 amend=self.amend, 680 no_verify=self.no_verify, 681 ) 682 finally: 683 core.unlink(tmp_file) 684 if status == 0: 685 super(Commit, self).do() 686 if context.cfg.get(prefs.AUTOTEMPLATE): 687 template_loader = LoadCommitMessageFromTemplate(context) 688 template_loader.do() 689 else: 690 self.model.set_commitmsg(self.new_commitmsg) 691 692 title = N_('Commit failed') 693 Interaction.command(title, 'git commit', status, out, err) 694 695 return status, out, err 696 697 @staticmethod 698 def strip_comments(msg, comment_char='#'): 699 # Strip off comments 700 message_lines = [ 701 line for line in msg.split('\n') if not line.startswith(comment_char) 702 ] 703 msg = '\n'.join(message_lines) 704 if not msg.endswith('\n'): 705 msg += '\n' 706 707 return msg 708 709 710class CycleReferenceSort(ContextCommand): 711 """Choose the next reference sort type""" 712 713 def do(self): 714 self.model.cycle_ref_sort() 715 716 717class Ignore(ContextCommand): 718 """Add files to an exclusion file""" 719 720 def __init__(self, context, filenames, local=False): 721 super(Ignore, self).__init__(context) 722 self.filenames = list(filenames) 723 self.local = local 724 725 def do(self): 726 if not self.filenames: 727 return 728 new_additions = '\n'.join(self.filenames) + '\n' 729 for_status = new_additions 730 if self.local: 731 filename = os.path.join('.git', 'info', 'exclude') 732 else: 733 filename = '.gitignore' 734 if core.exists(filename): 735 current_list = core.read(filename) 736 new_additions = current_list.rstrip() + '\n' + new_additions 737 core.write(filename, new_additions) 738 Interaction.log_status(0, 'Added to %s:\n%s' % (filename, for_status), '') 739 self.model.update_file_status() 740 741 742def file_summary(files): 743 txt = core.list2cmdline(files) 744 if len(txt) > 768: 745 txt = txt[:768].rstrip() + '...' 746 wrap = textwrap.TextWrapper() 747 return '\n'.join(wrap.wrap(txt)) 748 749 750class RemoteCommand(ConfirmAction): 751 def __init__(self, context, remote): 752 super(RemoteCommand, self).__init__(context) 753 self.remote = remote 754 755 def success(self): 756 self.cfg.reset() 757 self.model.update_remotes() 758 759 760class RemoteAdd(RemoteCommand): 761 def __init__(self, context, remote, url): 762 super(RemoteAdd, self).__init__(context, remote) 763 self.url = url 764 765 def action(self): 766 return self.git.remote('add', self.remote, self.url) 767 768 def error_message(self): 769 return N_('Error creating remote "%s"') % self.remote 770 771 def command(self): 772 return 'git remote add "%s" "%s"' % (self.remote, self.url) 773 774 775class RemoteRemove(RemoteCommand): 776 def confirm(self): 777 title = N_('Delete Remote') 778 question = N_('Delete remote?') 779 info = N_('Delete remote "%s"') % self.remote 780 ok_text = N_('Delete') 781 return Interaction.confirm(title, question, info, ok_text) 782 783 def action(self): 784 return self.git.remote('rm', self.remote) 785 786 def error_message(self): 787 return N_('Error deleting remote "%s"') % self.remote 788 789 def command(self): 790 return 'git remote rm "%s"' % self.remote 791 792 793class RemoteRename(RemoteCommand): 794 def __init__(self, context, remote, new_name): 795 super(RemoteRename, self).__init__(context, remote) 796 self.new_name = new_name 797 798 def confirm(self): 799 title = N_('Rename Remote') 800 text = N_('Rename remote "%(current)s" to "%(new)s"?') % dict( 801 current=self.remote, new=self.new_name 802 ) 803 info_text = '' 804 ok_text = title 805 return Interaction.confirm(title, text, info_text, ok_text) 806 807 def action(self): 808 return self.git.remote('rename', self.remote, self.new_name) 809 810 def error_message(self): 811 return N_('Error renaming "%(name)s" to "%(new_name)s"') % dict( 812 name=self.remote, new_name=self.new_name 813 ) 814 815 def command(self): 816 return 'git remote rename "%s" "%s"' % (self.remote, self.new_name) 817 818 819class RemoteSetURL(RemoteCommand): 820 def __init__(self, context, remote, url): 821 super(RemoteSetURL, self).__init__(context, remote) 822 self.url = url 823 824 def action(self): 825 return self.git.remote('set-url', self.remote, self.url) 826 827 def error_message(self): 828 return N_('Unable to set URL for "%(name)s" to "%(url)s"') % dict( 829 name=self.remote, url=self.url 830 ) 831 832 def command(self): 833 return 'git remote set-url "%s" "%s"' % (self.remote, self.url) 834 835 836class RemoteEdit(ContextCommand): 837 """Combine RemoteRename and RemoteSetURL""" 838 839 def __init__(self, context, old_name, remote, url): 840 super(RemoteEdit, self).__init__(context) 841 self.rename = RemoteRename(context, old_name, remote) 842 self.set_url = RemoteSetURL(context, remote, url) 843 844 def do(self): 845 result = self.rename.do() 846 name_ok = result[0] 847 url_ok = False 848 if name_ok: 849 result = self.set_url.do() 850 url_ok = result[0] 851 return name_ok, url_ok 852 853 854class RemoveFromSettings(ConfirmAction): 855 def __init__(self, context, repo, entry, icon=None): 856 super(RemoveFromSettings, self).__init__(context) 857 self.context = context 858 self.repo = repo 859 self.entry = entry 860 self.icon = icon 861 862 def success(self): 863 self.context.settings.save() 864 865 866class RemoveBookmark(RemoveFromSettings): 867 def confirm(self): 868 entry = self.entry 869 title = msg = N_('Delete Bookmark?') 870 info = N_('%s will be removed from your bookmarks.') % entry 871 ok_text = N_('Delete Bookmark') 872 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon) 873 874 def action(self): 875 self.context.settings.remove_bookmark(self.repo, self.entry) 876 return (0, '', '') 877 878 879class RemoveRecent(RemoveFromSettings): 880 def confirm(self): 881 repo = self.repo 882 title = msg = N_('Remove %s from the recent list?') % repo 883 info = N_('%s will be removed from your recent repositories.') % repo 884 ok_text = N_('Remove') 885 return Interaction.confirm(title, msg, info, ok_text, icon=self.icon) 886 887 def action(self): 888 self.context.settings.remove_recent(self.repo) 889 return (0, '', '') 890 891 892class RemoveFiles(ContextCommand): 893 """Removes files""" 894 895 def __init__(self, context, remover, filenames): 896 super(RemoveFiles, self).__init__(context) 897 if remover is None: 898 remover = os.remove 899 self.remover = remover 900 self.filenames = filenames 901 # We could git-hash-object stuff and provide undo-ability 902 # as an option. Heh. 903 904 def do(self): 905 files = self.filenames 906 if not files: 907 return 908 909 rescan = False 910 bad_filenames = [] 911 remove = self.remover 912 for filename in files: 913 if filename: 914 try: 915 remove(filename) 916 rescan = True 917 except OSError: 918 bad_filenames.append(filename) 919 920 if bad_filenames: 921 Interaction.information( 922 N_('Error'), N_('Deleting "%s" failed') % file_summary(bad_filenames) 923 ) 924 925 if rescan: 926 self.model.update_file_status() 927 928 929class Delete(RemoveFiles): 930 """Delete files.""" 931 932 def __init__(self, context, filenames): 933 super(Delete, self).__init__(context, os.remove, filenames) 934 935 def do(self): 936 files = self.filenames 937 if not files: 938 return 939 940 title = N_('Delete Files?') 941 msg = N_('The following files will be deleted:') + '\n\n' 942 msg += file_summary(files) 943 info_txt = N_('Delete %d file(s)?') % len(files) 944 ok_txt = N_('Delete Files') 945 946 if Interaction.confirm( 947 title, msg, info_txt, ok_txt, default=True, icon=icons.remove() 948 ): 949 super(Delete, self).do() 950 951 952class MoveToTrash(RemoveFiles): 953 """Move files to the trash using send2trash""" 954 955 AVAILABLE = send2trash is not None 956 957 def __init__(self, context, filenames): 958 super(MoveToTrash, self).__init__(context, send2trash, filenames) 959 960 961class DeleteBranch(ConfirmAction): 962 """Delete a git branch.""" 963 964 def __init__(self, context, branch): 965 super(DeleteBranch, self).__init__(context) 966 self.branch = branch 967 968 def confirm(self): 969 title = N_('Delete Branch') 970 question = N_('Delete branch "%s"?') % self.branch 971 info = N_('The branch will be no longer available.') 972 ok_txt = N_('Delete Branch') 973 return Interaction.confirm( 974 title, question, info, ok_txt, default=True, icon=icons.discard() 975 ) 976 977 def action(self): 978 return self.model.delete_branch(self.branch) 979 980 def error_message(self): 981 return N_('Error deleting branch "%s"' % self.branch) 982 983 def command(self): 984 command = 'git branch -D %s' 985 return command % self.branch 986 987 988class Rename(ContextCommand): 989 """Rename a set of paths.""" 990 991 def __init__(self, context, paths): 992 super(Rename, self).__init__(context) 993 self.paths = paths 994 995 def do(self): 996 msg = N_('Untracking: %s') % (', '.join(self.paths)) 997 Interaction.log(msg) 998 999 for path in self.paths: 1000 ok = self.rename(path) 1001 if not ok: 1002 return 1003 1004 self.model.update_status() 1005 1006 def rename(self, path): 1007 git = self.git 1008 title = N_('Rename "%s"') % path 1009 1010 if os.path.isdir(path): 1011 base_path = os.path.dirname(path) 1012 else: 1013 base_path = path 1014 new_path = Interaction.save_as(base_path, title) 1015 if not new_path: 1016 return False 1017 1018 status, out, err = git.mv(path, new_path, force=True, verbose=True) 1019 Interaction.command(N_('Error'), 'git mv', status, out, err) 1020 return status == 0 1021 1022 1023class RenameBranch(ContextCommand): 1024 """Rename a git branch.""" 1025 1026 def __init__(self, context, branch, new_branch): 1027 super(RenameBranch, self).__init__(context) 1028 self.branch = branch 1029 self.new_branch = new_branch 1030 1031 def do(self): 1032 branch = self.branch 1033 new_branch = self.new_branch 1034 status, out, err = self.model.rename_branch(branch, new_branch) 1035 Interaction.log_status(status, out, err) 1036 1037 1038class DeleteRemoteBranch(DeleteBranch): 1039 """Delete a remote git branch.""" 1040 1041 def __init__(self, context, remote, branch): 1042 super(DeleteRemoteBranch, self).__init__(context, branch) 1043 self.remote = remote 1044 1045 def action(self): 1046 return self.git.push(self.remote, self.branch, delete=True) 1047 1048 def success(self): 1049 self.model.update_status() 1050 Interaction.information( 1051 N_('Remote Branch Deleted'), 1052 N_('"%(branch)s" has been deleted from "%(remote)s".') 1053 % dict(branch=self.branch, remote=self.remote), 1054 ) 1055 1056 def error_message(self): 1057 return N_('Error Deleting Remote Branch') 1058 1059 def command(self): 1060 command = 'git push --delete %s %s' 1061 return command % (self.remote, self.branch) 1062 1063 1064def get_mode(model, staged, modified, unmerged, untracked): 1065 if staged: 1066 mode = model.mode_index 1067 elif modified or unmerged: 1068 mode = model.mode_worktree 1069 elif untracked: 1070 mode = model.mode_untracked 1071 else: 1072 mode = model.mode 1073 return mode 1074 1075 1076class DiffText(EditModel): 1077 """Set the diff type to text""" 1078 1079 def __init__(self, context): 1080 super(DiffText, self).__init__(context) 1081 self.new_file_type = main.Types.TEXT 1082 self.new_diff_type = main.Types.TEXT 1083 1084 1085class ToggleDiffType(ContextCommand): 1086 """Toggle the diff type between image and text""" 1087 1088 def __init__(self, context): 1089 super(ToggleDiffType, self).__init__(context) 1090 if self.model.diff_type == main.Types.IMAGE: 1091 self.new_diff_type = main.Types.TEXT 1092 self.new_value = False 1093 else: 1094 self.new_diff_type = main.Types.IMAGE 1095 self.new_value = True 1096 1097 def do(self): 1098 diff_type = self.new_diff_type 1099 value = self.new_value 1100 1101 self.model.set_diff_type(diff_type) 1102 1103 filename = self.model.filename 1104 _, ext = os.path.splitext(filename) 1105 if ext.startswith('.'): 1106 cfg = 'cola.imagediff' + ext 1107 self.cfg.set_repo(cfg, value) 1108 1109 1110class DiffImage(EditModel): 1111 def __init__( 1112 self, context, filename, deleted, staged, modified, unmerged, untracked 1113 ): 1114 super(DiffImage, self).__init__(context) 1115 1116 self.new_filename = filename 1117 self.new_diff_type = self.get_diff_type(filename) 1118 self.new_file_type = main.Types.IMAGE 1119 self.new_mode = get_mode(self.model, staged, modified, unmerged, untracked) 1120 self.staged = staged 1121 self.modified = modified 1122 self.unmerged = unmerged 1123 self.untracked = untracked 1124 self.deleted = deleted 1125 self.annex = self.cfg.is_annex() 1126 1127 def get_diff_type(self, filename): 1128 """Query the diff type to use based on cola.imagediff.<extension>""" 1129 _, ext = os.path.splitext(filename) 1130 if ext.startswith('.'): 1131 # Check eg. "cola.imagediff.svg" to see if we should imagediff. 1132 cfg = 'cola.imagediff' + ext 1133 if self.cfg.get(cfg, True): 1134 result = main.Types.IMAGE 1135 else: 1136 result = main.Types.TEXT 1137 else: 1138 result = main.Types.IMAGE 1139 return result 1140 1141 def do(self): 1142 filename = self.new_filename 1143 1144 if self.staged: 1145 images = self.staged_images() 1146 elif self.modified: 1147 images = self.modified_images() 1148 elif self.unmerged: 1149 images = self.unmerged_images() 1150 elif self.untracked: 1151 images = [(filename, False)] 1152 else: 1153 images = [] 1154 1155 self.model.set_images(images) 1156 super(DiffImage, self).do() 1157 1158 def staged_images(self): 1159 context = self.context 1160 git = self.git 1161 head = self.model.head 1162 filename = self.new_filename 1163 annex = self.annex 1164 1165 images = [] 1166 index = git.diff_index(head, '--', filename, cached=True)[STDOUT] 1167 if index: 1168 # Example: 1169 # :100644 100644 fabadb8... 4866510... M describe.c 1170 parts = index.split(' ') 1171 if len(parts) > 3: 1172 old_oid = parts[2] 1173 new_oid = parts[3] 1174 1175 if old_oid != MISSING_BLOB_OID: 1176 # First, check if we can get a pre-image from git-annex 1177 annex_image = None 1178 if annex: 1179 annex_image = gitcmds.annex_path(context, head, filename) 1180 if annex_image: 1181 images.append((annex_image, False)) # git annex HEAD 1182 else: 1183 image = gitcmds.write_blob_path(context, head, old_oid, filename) 1184 if image: 1185 images.append((image, True)) 1186 1187 if new_oid != MISSING_BLOB_OID: 1188 found_in_annex = False 1189 if annex and core.islink(filename): 1190 status, out, _ = git.annex('status', '--', filename) 1191 if status == 0: 1192 details = out.split(' ') 1193 if details and details[0] == 'A': # newly added file 1194 images.append((filename, False)) 1195 found_in_annex = True 1196 1197 if not found_in_annex: 1198 image = gitcmds.write_blob(context, new_oid, filename) 1199 if image: 1200 images.append((image, True)) 1201 1202 return images 1203 1204 def unmerged_images(self): 1205 context = self.context 1206 git = self.git 1207 head = self.model.head 1208 filename = self.new_filename 1209 annex = self.annex 1210 1211 candidate_merge_heads = ('HEAD', 'CHERRY_HEAD', 'MERGE_HEAD') 1212 merge_heads = [ 1213 merge_head 1214 for merge_head in candidate_merge_heads 1215 if core.exists(git.git_path(merge_head)) 1216 ] 1217 1218 if annex: # Attempt to find files in git-annex 1219 annex_images = [] 1220 for merge_head in merge_heads: 1221 image = gitcmds.annex_path(context, merge_head, filename) 1222 if image: 1223 annex_images.append((image, False)) 1224 if annex_images: 1225 annex_images.append((filename, False)) 1226 return annex_images 1227 1228 # DIFF FORMAT FOR MERGES 1229 # "git-diff-tree", "git-diff-files" and "git-diff --raw" 1230 # can take -c or --cc option to generate diff output also 1231 # for merge commits. The output differs from the format 1232 # described above in the following way: 1233 # 1234 # 1. there is a colon for each parent 1235 # 2. there are more "src" modes and "src" sha1 1236 # 3. status is concatenated status characters for each parent 1237 # 4. no optional "score" number 1238 # 5. single path, only for "dst" 1239 # Example: 1240 # ::100644 100644 100644 fabadb8... cc95eb0... 4866510... \ 1241 # MM describe.c 1242 images = [] 1243 index = git.diff_index(head, '--', filename, cached=True, cc=True)[STDOUT] 1244 if index: 1245 parts = index.split(' ') 1246 if len(parts) > 3: 1247 first_mode = parts[0] 1248 num_parents = first_mode.count(':') 1249 # colon for each parent, but for the index, the "parents" 1250 # are really entries in stages 1,2,3 (head, base, remote) 1251 # remote, base, head 1252 for i in range(num_parents): 1253 offset = num_parents + i + 1 1254 oid = parts[offset] 1255 try: 1256 merge_head = merge_heads[i] 1257 except IndexError: 1258 merge_head = 'HEAD' 1259 if oid != MISSING_BLOB_OID: 1260 image = gitcmds.write_blob_path( 1261 context, merge_head, oid, filename 1262 ) 1263 if image: 1264 images.append((image, True)) 1265 1266 images.append((filename, False)) 1267 return images 1268 1269 def modified_images(self): 1270 context = self.context 1271 git = self.git 1272 head = self.model.head 1273 filename = self.new_filename 1274 annex = self.annex 1275 1276 images = [] 1277 annex_image = None 1278 if annex: # Check for a pre-image from git-annex 1279 annex_image = gitcmds.annex_path(context, head, filename) 1280 if annex_image: 1281 images.append((annex_image, False)) # git annex HEAD 1282 else: 1283 worktree = git.diff_files('--', filename)[STDOUT] 1284 parts = worktree.split(' ') 1285 if len(parts) > 3: 1286 oid = parts[2] 1287 if oid != MISSING_BLOB_OID: 1288 image = gitcmds.write_blob_path(context, head, oid, filename) 1289 if image: 1290 images.append((image, True)) # HEAD 1291 1292 images.append((filename, False)) # worktree 1293 return images 1294 1295 1296class Diff(EditModel): 1297 """Perform a diff and set the model's current text.""" 1298 1299 def __init__(self, context, filename, cached=False, deleted=False): 1300 super(Diff, self).__init__(context) 1301 opts = {} 1302 if cached: 1303 opts['ref'] = self.model.head 1304 self.new_filename = filename 1305 self.new_mode = self.model.mode_worktree 1306 self.new_diff_text = gitcmds.diff_helper( 1307 self.context, filename=filename, cached=cached, deleted=deleted, **opts 1308 ) 1309 1310 1311class Diffstat(EditModel): 1312 """Perform a diffstat and set the model's diff text.""" 1313 1314 def __init__(self, context): 1315 super(Diffstat, self).__init__(context) 1316 cfg = self.cfg 1317 diff_context = cfg.get('diff.context', 3) 1318 diff = self.git.diff( 1319 self.model.head, 1320 unified=diff_context, 1321 no_ext_diff=True, 1322 no_color=True, 1323 M=True, 1324 stat=True, 1325 )[STDOUT] 1326 self.new_diff_text = diff 1327 self.new_diff_type = main.Types.TEXT 1328 self.new_file_type = main.Types.TEXT 1329 self.new_mode = self.model.mode_diffstat 1330 1331 1332class DiffStaged(Diff): 1333 """Perform a staged diff on a file.""" 1334 1335 def __init__(self, context, filename, deleted=None): 1336 super(DiffStaged, self).__init__( 1337 context, filename, cached=True, deleted=deleted 1338 ) 1339 self.new_mode = self.model.mode_index 1340 1341 1342class DiffStagedSummary(EditModel): 1343 def __init__(self, context): 1344 super(DiffStagedSummary, self).__init__(context) 1345 diff = self.git.diff( 1346 self.model.head, 1347 cached=True, 1348 no_color=True, 1349 no_ext_diff=True, 1350 patch_with_stat=True, 1351 M=True, 1352 )[STDOUT] 1353 self.new_diff_text = diff 1354 self.new_diff_type = main.Types.TEXT 1355 self.new_file_type = main.Types.TEXT 1356 self.new_mode = self.model.mode_index 1357 1358 1359class Difftool(ContextCommand): 1360 """Run git-difftool limited by path.""" 1361 1362 def __init__(self, context, staged, filenames): 1363 super(Difftool, self).__init__(context) 1364 self.staged = staged 1365 self.filenames = filenames 1366 1367 def do(self): 1368 difftool_launch_with_head( 1369 self.context, self.filenames, self.staged, self.model.head 1370 ) 1371 1372 1373class Edit(ContextCommand): 1374 """Edit a file using the configured gui.editor.""" 1375 1376 @staticmethod 1377 def name(): 1378 return N_('Launch Editor') 1379 1380 def __init__(self, context, filenames, line_number=None, background_editor=False): 1381 super(Edit, self).__init__(context) 1382 self.filenames = filenames 1383 self.line_number = line_number 1384 self.background_editor = background_editor 1385 1386 def do(self): 1387 context = self.context 1388 if not self.filenames: 1389 return 1390 filename = self.filenames[0] 1391 if not core.exists(filename): 1392 return 1393 if self.background_editor: 1394 editor = prefs.background_editor(context) 1395 else: 1396 editor = prefs.editor(context) 1397 opts = [] 1398 1399 if self.line_number is None: 1400 opts = self.filenames 1401 else: 1402 # Single-file w/ line-numbers (likely from grep) 1403 editor_opts = { 1404 '*vim*': [filename, '+%s' % self.line_number], 1405 '*emacs*': ['+%s' % self.line_number, filename], 1406 '*textpad*': ['%s(%s,0)' % (filename, self.line_number)], 1407 '*notepad++*': ['-n%s' % self.line_number, filename], 1408 '*subl*': ['%s:%s' % (filename, self.line_number)], 1409 } 1410 1411 opts = self.filenames 1412 for pattern, opt in editor_opts.items(): 1413 if fnmatch(editor, pattern): 1414 opts = opt 1415 break 1416 1417 try: 1418 core.fork(utils.shell_split(editor) + opts) 1419 except (OSError, ValueError) as e: 1420 message = N_('Cannot exec "%s": please configure your editor') % editor 1421 _, details = utils.format_exception(e) 1422 Interaction.critical(N_('Error Editing File'), message, details) 1423 1424 1425class FormatPatch(ContextCommand): 1426 """Output a patch series given all revisions and a selected subset.""" 1427 1428 def __init__(self, context, to_export, revs, output='patches'): 1429 super(FormatPatch, self).__init__(context) 1430 self.to_export = list(to_export) 1431 self.revs = list(revs) 1432 self.output = output 1433 1434 def do(self): 1435 context = self.context 1436 status, out, err = gitcmds.format_patchsets( 1437 context, self.to_export, self.revs, self.output 1438 ) 1439 Interaction.log_status(status, out, err) 1440 1441 1442class LaunchDifftool(ContextCommand): 1443 @staticmethod 1444 def name(): 1445 return N_('Launch Diff Tool') 1446 1447 def do(self): 1448 s = self.selection.selection() 1449 if s.unmerged: 1450 paths = s.unmerged 1451 if utils.is_win32(): 1452 core.fork(['git', 'mergetool', '--no-prompt', '--'] + paths) 1453 else: 1454 cfg = self.cfg 1455 cmd = cfg.terminal() 1456 argv = utils.shell_split(cmd) 1457 1458 terminal = os.path.basename(argv[0]) 1459 shellquote_terms = set(['xfce4-terminal']) 1460 shellquote_default = terminal in shellquote_terms 1461 1462 mergetool = ['git', 'mergetool', '--no-prompt', '--'] 1463 mergetool.extend(paths) 1464 needs_shellquote = cfg.get( 1465 'cola.terminalshellquote', shellquote_default 1466 ) 1467 1468 if needs_shellquote: 1469 argv.append(core.list2cmdline(mergetool)) 1470 else: 1471 argv.extend(mergetool) 1472 1473 core.fork(argv) 1474 else: 1475 difftool_run(self.context) 1476 1477 1478class LaunchTerminal(ContextCommand): 1479 @staticmethod 1480 def name(): 1481 return N_('Launch Terminal') 1482 1483 @staticmethod 1484 def is_available(context): 1485 return context.cfg.terminal() is not None 1486 1487 def __init__(self, context, path): 1488 super(LaunchTerminal, self).__init__(context) 1489 self.path = path 1490 1491 def do(self): 1492 cmd = self.context.cfg.terminal() 1493 if cmd is None: 1494 return 1495 if utils.is_win32(): 1496 argv = ['start', '', cmd, '--login'] 1497 shell = True 1498 else: 1499 argv = utils.shell_split(cmd) 1500 argv.append(os.getenv('SHELL', '/bin/sh')) 1501 shell = False 1502 core.fork(argv, cwd=self.path, shell=shell) 1503 1504 1505class LaunchEditor(Edit): 1506 @staticmethod 1507 def name(): 1508 return N_('Launch Editor') 1509 1510 def __init__(self, context): 1511 s = context.selection.selection() 1512 filenames = s.staged + s.unmerged + s.modified + s.untracked 1513 super(LaunchEditor, self).__init__(context, filenames, background_editor=True) 1514 1515 1516class LaunchEditorAtLine(LaunchEditor): 1517 """Launch an editor at the specified line""" 1518 1519 def __init__(self, context): 1520 super(LaunchEditorAtLine, self).__init__(context) 1521 self.line_number = context.selection.line_number 1522 1523 1524class LoadCommitMessageFromFile(ContextCommand): 1525 """Loads a commit message from a path.""" 1526 1527 UNDOABLE = True 1528 1529 def __init__(self, context, path): 1530 super(LoadCommitMessageFromFile, self).__init__(context) 1531 self.path = path 1532 self.old_commitmsg = self.model.commitmsg 1533 self.old_directory = self.model.directory 1534 1535 def do(self): 1536 path = os.path.expanduser(self.path) 1537 if not path or not core.isfile(path): 1538 raise UsageError( 1539 N_('Error: Cannot find commit template'), 1540 N_('%s: No such file or directory.') % path, 1541 ) 1542 self.model.set_directory(os.path.dirname(path)) 1543 self.model.set_commitmsg(core.read(path)) 1544 1545 def undo(self): 1546 self.model.set_commitmsg(self.old_commitmsg) 1547 self.model.set_directory(self.old_directory) 1548 1549 1550class LoadCommitMessageFromTemplate(LoadCommitMessageFromFile): 1551 """Loads the commit message template specified by commit.template.""" 1552 1553 def __init__(self, context): 1554 cfg = context.cfg 1555 template = cfg.get('commit.template') 1556 super(LoadCommitMessageFromTemplate, self).__init__(context, template) 1557 1558 def do(self): 1559 if self.path is None: 1560 raise UsageError( 1561 N_('Error: Unconfigured commit template'), 1562 N_( 1563 'A commit template has not been configured.\n' 1564 'Use "git config" to define "commit.template"\n' 1565 'so that it points to a commit template.' 1566 ), 1567 ) 1568 return LoadCommitMessageFromFile.do(self) 1569 1570 1571class LoadCommitMessageFromOID(ContextCommand): 1572 """Load a previous commit message""" 1573 1574 UNDOABLE = True 1575 1576 def __init__(self, context, oid, prefix=''): 1577 super(LoadCommitMessageFromOID, self).__init__(context) 1578 self.oid = oid 1579 self.old_commitmsg = self.model.commitmsg 1580 self.new_commitmsg = prefix + gitcmds.prev_commitmsg(context, oid) 1581 1582 def do(self): 1583 self.model.set_commitmsg(self.new_commitmsg) 1584 1585 def undo(self): 1586 self.model.set_commitmsg(self.old_commitmsg) 1587 1588 1589class PrepareCommitMessageHook(ContextCommand): 1590 """Use the cola-prepare-commit-msg hook to prepare the commit message""" 1591 1592 UNDOABLE = True 1593 1594 def __init__(self, context): 1595 super(PrepareCommitMessageHook, self).__init__(context) 1596 self.old_commitmsg = self.model.commitmsg 1597 1598 def get_message(self): 1599 1600 title = N_('Error running prepare-commitmsg hook') 1601 hook = gitcmds.prepare_commit_message_hook(self.context) 1602 1603 if os.path.exists(hook): 1604 filename = self.model.save_commitmsg() 1605 status, out, err = core.run_command([hook, filename]) 1606 1607 if status == 0: 1608 result = core.read(filename) 1609 else: 1610 result = self.old_commitmsg 1611 Interaction.command_error(title, hook, status, out, err) 1612 else: 1613 message = N_('A hook must be provided at "%s"') % hook 1614 Interaction.critical(title, message=message) 1615 result = self.old_commitmsg 1616 1617 return result 1618 1619 def do(self): 1620 msg = self.get_message() 1621 self.model.set_commitmsg(msg) 1622 1623 def undo(self): 1624 self.model.set_commitmsg(self.old_commitmsg) 1625 1626 1627class LoadFixupMessage(LoadCommitMessageFromOID): 1628 """Load a fixup message""" 1629 1630 def __init__(self, context, oid): 1631 super(LoadFixupMessage, self).__init__(context, oid, prefix='fixup! ') 1632 if self.new_commitmsg: 1633 self.new_commitmsg = self.new_commitmsg.splitlines()[0] 1634 1635 1636class Merge(ContextCommand): 1637 """Merge commits""" 1638 1639 def __init__(self, context, revision, no_commit, squash, no_ff, sign): 1640 super(Merge, self).__init__(context) 1641 self.revision = revision 1642 self.no_ff = no_ff 1643 self.no_commit = no_commit 1644 self.squash = squash 1645 self.sign = sign 1646 1647 def do(self): 1648 squash = self.squash 1649 revision = self.revision 1650 no_ff = self.no_ff 1651 no_commit = self.no_commit 1652 sign = self.sign 1653 1654 status, out, err = self.git.merge( 1655 revision, gpg_sign=sign, no_ff=no_ff, no_commit=no_commit, squash=squash 1656 ) 1657 self.model.update_status() 1658 title = N_('Merge failed. Conflict resolution is required.') 1659 Interaction.command(title, 'git merge', status, out, err) 1660 1661 return status, out, err 1662 1663 1664class OpenDefaultApp(ContextCommand): 1665 """Open a file using the OS default.""" 1666 1667 @staticmethod 1668 def name(): 1669 return N_('Open Using Default Application') 1670 1671 def __init__(self, context, filenames): 1672 super(OpenDefaultApp, self).__init__(context) 1673 if utils.is_darwin(): 1674 launcher = 'open' 1675 else: 1676 launcher = 'xdg-open' 1677 self.launcher = launcher 1678 self.filenames = filenames 1679 1680 def do(self): 1681 if not self.filenames: 1682 return 1683 core.fork([self.launcher] + self.filenames) 1684 1685 1686class OpenParentDir(OpenDefaultApp): 1687 """Open parent directories using the OS default.""" 1688 1689 @staticmethod 1690 def name(): 1691 return N_('Open Parent Directory') 1692 1693 def __init__(self, context, filenames): 1694 OpenDefaultApp.__init__(self, context, filenames) 1695 1696 def do(self): 1697 if not self.filenames: 1698 return 1699 dirnames = list(set([os.path.dirname(x) for x in self.filenames])) 1700 # os.path.dirname() can return an empty string so we fallback to 1701 # the current directory 1702 dirs = [(dirname or core.getcwd()) for dirname in dirnames] 1703 core.fork([self.launcher] + dirs) 1704 1705 1706class OpenNewRepo(ContextCommand): 1707 """Launches git-cola on a repo.""" 1708 1709 def __init__(self, context, repo_path): 1710 super(OpenNewRepo, self).__init__(context) 1711 self.repo_path = repo_path 1712 1713 def do(self): 1714 self.model.set_directory(self.repo_path) 1715 core.fork([sys.executable, sys.argv[0], '--repo', self.repo_path]) 1716 1717 1718class OpenRepo(EditModel): 1719 def __init__(self, context, repo_path): 1720 super(OpenRepo, self).__init__(context) 1721 self.repo_path = repo_path 1722 self.new_mode = self.model.mode_none 1723 self.new_diff_text = '' 1724 self.new_diff_type = main.Types.TEXT 1725 self.new_file_type = main.Types.TEXT 1726 self.new_commitmsg = '' 1727 self.new_filename = '' 1728 1729 def do(self): 1730 old_repo = self.git.getcwd() 1731 if self.model.set_worktree(self.repo_path): 1732 self.fsmonitor.stop() 1733 self.fsmonitor.start() 1734 self.model.update_status() 1735 # Check if template should be loaded 1736 if self.context.cfg.get(prefs.AUTOTEMPLATE): 1737 template_loader = LoadCommitMessageFromTemplate(self.context) 1738 template_loader.do() 1739 else: 1740 self.model.set_commitmsg(self.new_commitmsg) 1741 settings = self.context.settings 1742 settings.load() 1743 settings.add_recent(self.repo_path, prefs.maxrecent(self.context)) 1744 settings.save() 1745 super(OpenRepo, self).do() 1746 else: 1747 self.model.set_worktree(old_repo) 1748 1749 1750class OpenParentRepo(OpenRepo): 1751 def __init__(self, context): 1752 path = '' 1753 if version.check_git(context, 'show-superproject-working-tree'): 1754 status, out, _ = context.git.rev_parse(show_superproject_working_tree=True) 1755 if status == 0: 1756 path = out 1757 if not path: 1758 path = os.path.dirname(core.getcwd()) 1759 super(OpenParentRepo, self).__init__(context, path) 1760 1761 1762class Clone(ContextCommand): 1763 """Clones a repository and optionally spawns a new cola session.""" 1764 1765 def __init__( 1766 self, context, url, new_directory, submodules=False, shallow=False, spawn=True 1767 ): 1768 super(Clone, self).__init__(context) 1769 self.url = url 1770 self.new_directory = new_directory 1771 self.submodules = submodules 1772 self.shallow = shallow 1773 self.spawn = spawn 1774 self.status = -1 1775 self.out = '' 1776 self.err = '' 1777 1778 def do(self): 1779 kwargs = {} 1780 if self.shallow: 1781 kwargs['depth'] = 1 1782 recurse_submodules = self.submodules 1783 shallow_submodules = self.submodules and self.shallow 1784 1785 status, out, err = self.git.clone( 1786 self.url, 1787 self.new_directory, 1788 recurse_submodules=recurse_submodules, 1789 shallow_submodules=shallow_submodules, 1790 **kwargs 1791 ) 1792 1793 self.status = status 1794 self.out = out 1795 self.err = err 1796 if status == 0 and self.spawn: 1797 executable = sys.executable 1798 core.fork([executable, sys.argv[0], '--repo', self.new_directory]) 1799 return self 1800 1801 1802class NewBareRepo(ContextCommand): 1803 """Create a new shared bare repository""" 1804 1805 def __init__(self, context, path): 1806 super(NewBareRepo, self).__init__(context) 1807 self.path = path 1808 1809 def do(self): 1810 path = self.path 1811 status, out, err = self.git.init(path, bare=True, shared=True) 1812 Interaction.command( 1813 N_('Error'), 'git init --bare --shared "%s"' % path, status, out, err 1814 ) 1815 return status == 0 1816 1817 1818def unix_path(path, is_win32=utils.is_win32): 1819 """Git for Windows requires unix paths, so force them here""" 1820 if is_win32(): 1821 path = path.replace('\\', '/') 1822 first = path[0] 1823 second = path[1] 1824 if second == ':': # sanity check, this better be a Windows-style path 1825 path = '/' + first + path[2:] 1826 1827 return path 1828 1829 1830def sequence_editor(): 1831 """Set GIT_SEQUENCE_EDITOR for running git-cola-sequence-editor""" 1832 xbase = unix_path(resources.command('git-cola-sequence-editor')) 1833 if utils.is_win32(): 1834 editor = core.list2cmdline([unix_path(sys.executable), xbase]) 1835 else: 1836 editor = core.list2cmdline([xbase]) 1837 return editor 1838 1839 1840class SequenceEditorEnvironment(object): 1841 """Set environment variables to enable git-cola-sequence-editor""" 1842 1843 def __init__(self, context, **kwargs): 1844 self.env = { 1845 'GIT_EDITOR': prefs.editor(context), 1846 'GIT_SEQUENCE_EDITOR': sequence_editor(), 1847 'GIT_COLA_SEQ_EDITOR_CANCEL_ACTION': 'save', 1848 } 1849 self.env.update(kwargs) 1850 1851 def __enter__(self): 1852 for var, value in self.env.items(): 1853 compat.setenv(var, value) 1854 return self 1855 1856 def __exit__(self, exc_type, exc_val, exc_tb): 1857 for var in self.env: 1858 compat.unsetenv(var) 1859 1860 1861class Rebase(ContextCommand): 1862 def __init__(self, context, upstream=None, branch=None, **kwargs): 1863 """Start an interactive rebase session 1864 1865 :param upstream: upstream branch 1866 :param branch: optional branch to checkout 1867 :param kwargs: forwarded directly to `git.rebase()` 1868 1869 """ 1870 super(Rebase, self).__init__(context) 1871 1872 self.upstream = upstream 1873 self.branch = branch 1874 self.kwargs = kwargs 1875 1876 def prepare_arguments(self, upstream): 1877 args = [] 1878 kwargs = {} 1879 1880 # Rebase actions must be the only option specified 1881 for action in ('continue', 'abort', 'skip', 'edit_todo'): 1882 if self.kwargs.get(action, False): 1883 kwargs[action] = self.kwargs[action] 1884 return args, kwargs 1885 1886 kwargs['interactive'] = True 1887 kwargs['autosquash'] = self.kwargs.get('autosquash', True) 1888 kwargs.update(self.kwargs) 1889 1890 if upstream: 1891 args.append(upstream) 1892 if self.branch: 1893 args.append(self.branch) 1894 1895 return args, kwargs 1896 1897 def do(self): 1898 (status, out, err) = (1, '', '') 1899 context = self.context 1900 cfg = self.cfg 1901 model = self.model 1902 1903 if not cfg.get('rebase.autostash', False): 1904 if model.staged or model.unmerged or model.modified: 1905 Interaction.information( 1906 N_('Unable to rebase'), 1907 N_('You cannot rebase with uncommitted changes.'), 1908 ) 1909 return status, out, err 1910 1911 upstream = self.upstream or Interaction.choose_ref( 1912 context, 1913 N_('Select New Upstream'), 1914 N_('Interactive Rebase'), 1915 default='@{upstream}', 1916 ) 1917 if not upstream: 1918 return status, out, err 1919 1920 self.model.is_rebasing = True 1921 self.model.emit_updated() 1922 1923 args, kwargs = self.prepare_arguments(upstream) 1924 upstream_title = upstream or '@{upstream}' 1925 with SequenceEditorEnvironment( 1926 self.context, 1927 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase onto %s') % upstream_title, 1928 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'), 1929 ): 1930 # TODO this blocks the user interface window for the duration 1931 # of git-cola-sequence-editor. We would need to implement 1932 # signals for QProcess and continue running the main thread. 1933 # Alternatively, we can hide the main window while rebasing. 1934 # That doesn't require as much effort. 1935 status, out, err = self.git.rebase( 1936 *args, _no_win32_startupinfo=True, **kwargs 1937 ) 1938 self.model.update_status() 1939 if err.strip() != 'Nothing to do': 1940 title = N_('Rebase stopped') 1941 Interaction.command(title, 'git rebase', status, out, err) 1942 return status, out, err 1943 1944 1945class RebaseEditTodo(ContextCommand): 1946 def do(self): 1947 (status, out, err) = (1, '', '') 1948 with SequenceEditorEnvironment( 1949 self.context, 1950 GIT_COLA_SEQ_EDITOR_TITLE=N_('Edit Rebase'), 1951 GIT_COLA_SEQ_EDITOR_ACTION=N_('Save'), 1952 ): 1953 status, out, err = self.git.rebase(edit_todo=True) 1954 Interaction.log_status(status, out, err) 1955 self.model.update_status() 1956 return status, out, err 1957 1958 1959class RebaseContinue(ContextCommand): 1960 def do(self): 1961 (status, out, err) = (1, '', '') 1962 with SequenceEditorEnvironment( 1963 self.context, 1964 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase'), 1965 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'), 1966 ): 1967 status, out, err = self.git.rebase('--continue') 1968 Interaction.log_status(status, out, err) 1969 self.model.update_status() 1970 return status, out, err 1971 1972 1973class RebaseSkip(ContextCommand): 1974 def do(self): 1975 (status, out, err) = (1, '', '') 1976 with SequenceEditorEnvironment( 1977 self.context, 1978 GIT_COLA_SEQ_EDITOR_TITLE=N_('Rebase'), 1979 GIT_COLA_SEQ_EDITOR_ACTION=N_('Rebase'), 1980 ): 1981 status, out, err = self.git.rebase(skip=True) 1982 Interaction.log_status(status, out, err) 1983 self.model.update_status() 1984 return status, out, err 1985 1986 1987class RebaseAbort(ContextCommand): 1988 def do(self): 1989 status, out, err = self.git.rebase(abort=True) 1990 Interaction.log_status(status, out, err) 1991 self.model.update_status() 1992 1993 1994class Rescan(ContextCommand): 1995 """Rescan for changes""" 1996 1997 def do(self): 1998 self.model.update_status() 1999 2000 2001class Refresh(ContextCommand): 2002 """Update refs, refresh the index, and update config""" 2003 2004 @staticmethod 2005 def name(): 2006 return N_('Refresh') 2007 2008 def do(self): 2009 self.model.update_status(update_index=True) 2010 self.cfg.update() 2011 self.fsmonitor.refresh() 2012 2013 2014class RefreshConfig(ContextCommand): 2015 """Refresh the git config cache""" 2016 2017 def do(self): 2018 self.cfg.update() 2019 2020 2021class RevertEditsCommand(ConfirmAction): 2022 def __init__(self, context): 2023 super(RevertEditsCommand, self).__init__(context) 2024 self.icon = icons.undo() 2025 2026 def ok_to_run(self): 2027 return self.model.undoable() 2028 2029 # pylint: disable=no-self-use 2030 def checkout_from_head(self): 2031 return False 2032 2033 def checkout_args(self): 2034 args = [] 2035 s = self.selection.selection() 2036 if self.checkout_from_head(): 2037 args.append(self.model.head) 2038 args.append('--') 2039 2040 if s.staged: 2041 items = s.staged 2042 else: 2043 items = s.modified 2044 args.extend(items) 2045 2046 return args 2047 2048 def action(self): 2049 checkout_args = self.checkout_args() 2050 return self.git.checkout(*checkout_args) 2051 2052 def success(self): 2053 self.model.set_diff_type(main.Types.TEXT) 2054 self.model.update_file_status() 2055 2056 2057class RevertUnstagedEdits(RevertEditsCommand): 2058 @staticmethod 2059 def name(): 2060 return N_('Revert Unstaged Edits...') 2061 2062 def checkout_from_head(self): 2063 # Being in amend mode should not affect the behavior of this command. 2064 # The only sensible thing to do is to checkout from the index. 2065 return False 2066 2067 def confirm(self): 2068 title = N_('Revert Unstaged Changes?') 2069 text = N_( 2070 'This operation removes unstaged edits from selected files.\n' 2071 'These changes cannot be recovered.' 2072 ) 2073 info = N_('Revert the unstaged changes?') 2074 ok_text = N_('Revert Unstaged Changes') 2075 return Interaction.confirm( 2076 title, text, info, ok_text, default=True, icon=self.icon 2077 ) 2078 2079 2080class RevertUncommittedEdits(RevertEditsCommand): 2081 @staticmethod 2082 def name(): 2083 return N_('Revert Uncommitted Edits...') 2084 2085 def checkout_from_head(self): 2086 return True 2087 2088 def confirm(self): 2089 """Prompt for reverting changes""" 2090 title = N_('Revert Uncommitted Changes?') 2091 text = N_( 2092 'This operation removes uncommitted edits from selected files.\n' 2093 'These changes cannot be recovered.' 2094 ) 2095 info = N_('Revert the uncommitted changes?') 2096 ok_text = N_('Revert Uncommitted Changes') 2097 return Interaction.confirm( 2098 title, text, info, ok_text, default=True, icon=self.icon 2099 ) 2100 2101 2102class RunConfigAction(ContextCommand): 2103 """Run a user-configured action, typically from the "Tools" menu""" 2104 2105 def __init__(self, context, action_name): 2106 super(RunConfigAction, self).__init__(context) 2107 self.action_name = action_name 2108 2109 def do(self): 2110 """Run the user-configured action""" 2111 for env in ('ARGS', 'DIRNAME', 'FILENAME', 'REVISION'): 2112 try: 2113 compat.unsetenv(env) 2114 except KeyError: 2115 pass 2116 rev = None 2117 args = None 2118 context = self.context 2119 cfg = self.cfg 2120 opts = cfg.get_guitool_opts(self.action_name) 2121 cmd = opts.get('cmd') 2122 if 'title' not in opts: 2123 opts['title'] = cmd 2124 2125 if 'prompt' not in opts or opts.get('prompt') is True: 2126 prompt = N_('Run "%s"?') % cmd 2127 opts['prompt'] = prompt 2128 2129 if opts.get('needsfile'): 2130 filename = self.selection.filename() 2131 if not filename: 2132 Interaction.information( 2133 N_('Please select a file'), 2134 N_('"%s" requires a selected file.') % cmd, 2135 ) 2136 return False 2137 dirname = utils.dirname(filename, current_dir='.') 2138 compat.setenv('FILENAME', filename) 2139 compat.setenv('DIRNAME', dirname) 2140 2141 if opts.get('revprompt') or opts.get('argprompt'): 2142 while True: 2143 ok = Interaction.confirm_config_action(context, cmd, opts) 2144 if not ok: 2145 return False 2146 rev = opts.get('revision') 2147 args = opts.get('args') 2148 if opts.get('revprompt') and not rev: 2149 title = N_('Invalid Revision') 2150 msg = N_('The revision expression cannot be empty.') 2151 Interaction.critical(title, msg) 2152 continue 2153 break 2154 2155 elif opts.get('confirm'): 2156 title = os.path.expandvars(opts.get('title')) 2157 prompt = os.path.expandvars(opts.get('prompt')) 2158 if not Interaction.question(title, prompt): 2159 return False 2160 if rev: 2161 compat.setenv('REVISION', rev) 2162 if args: 2163 compat.setenv('ARGS', args) 2164 title = os.path.expandvars(cmd) 2165 Interaction.log(N_('Running command: %s') % title) 2166 cmd = ['sh', '-c', cmd] 2167 2168 if opts.get('background'): 2169 core.fork(cmd) 2170 status, out, err = (0, '', '') 2171 elif opts.get('noconsole'): 2172 status, out, err = core.run_command(cmd) 2173 else: 2174 status, out, err = Interaction.run_command(title, cmd) 2175 2176 if not opts.get('background') and not opts.get('norescan'): 2177 self.model.update_status() 2178 2179 title = N_('Error') 2180 Interaction.command(title, cmd, status, out, err) 2181 2182 return status == 0 2183 2184 2185class SetDefaultRepo(ContextCommand): 2186 """Set the default repository""" 2187 2188 def __init__(self, context, repo): 2189 super(SetDefaultRepo, self).__init__(context) 2190 self.repo = repo 2191 2192 def do(self): 2193 self.cfg.set_user('cola.defaultrepo', self.repo) 2194 2195 2196class SetDiffText(EditModel): 2197 """Set the diff text""" 2198 2199 UNDOABLE = True 2200 2201 def __init__(self, context, text): 2202 super(SetDiffText, self).__init__(context) 2203 self.new_diff_text = text 2204 self.new_diff_type = main.Types.TEXT 2205 self.new_file_type = main.Types.TEXT 2206 2207 2208class SetUpstreamBranch(ContextCommand): 2209 """Set the upstream branch""" 2210 2211 def __init__(self, context, branch, remote, remote_branch): 2212 super(SetUpstreamBranch, self).__init__(context) 2213 self.branch = branch 2214 self.remote = remote 2215 self.remote_branch = remote_branch 2216 2217 def do(self): 2218 cfg = self.cfg 2219 remote = self.remote 2220 branch = self.branch 2221 remote_branch = self.remote_branch 2222 cfg.set_repo('branch.%s.remote' % branch, remote) 2223 cfg.set_repo('branch.%s.merge' % branch, 'refs/heads/' + remote_branch) 2224 2225 2226def format_hex(data): 2227 """Translate binary data into a hex dump""" 2228 hexdigits = '0123456789ABCDEF' 2229 result = '' 2230 offset = 0 2231 byte_offset_to_int = compat.byte_offset_to_int_converter() 2232 while offset < len(data): 2233 result += '%04u |' % offset 2234 textpart = '' 2235 for i in range(0, 16): 2236 if i > 0 and i % 4 == 0: 2237 result += ' ' 2238 if offset < len(data): 2239 v = byte_offset_to_int(data[offset]) 2240 result += ' ' + hexdigits[v >> 4] + hexdigits[v & 0xF] 2241 textpart += chr(v) if 32 <= v < 127 else '.' 2242 offset += 1 2243 else: 2244 result += ' ' 2245 textpart += ' ' 2246 result += ' | ' + textpart + ' |\n' 2247 2248 return result 2249 2250 2251class ShowUntracked(EditModel): 2252 """Show an untracked file.""" 2253 2254 def __init__(self, context, filename): 2255 super(ShowUntracked, self).__init__(context) 2256 self.new_filename = filename 2257 self.new_mode = self.model.mode_untracked 2258 self.new_diff_text = self.read(filename) 2259 self.new_diff_type = main.Types.TEXT 2260 self.new_file_type = main.Types.TEXT 2261 2262 def read(self, filename): 2263 """Read file contents""" 2264 cfg = self.cfg 2265 size = cfg.get('cola.readsize', 2048) 2266 try: 2267 result = core.read(filename, size=size, encoding='bytes') 2268 except (IOError, OSError): 2269 result = '' 2270 2271 truncated = len(result) == size 2272 2273 encoding = cfg.file_encoding(filename) or core.ENCODING 2274 try: 2275 text_result = core.decode_maybe(result, encoding) 2276 except UnicodeError: 2277 text_result = format_hex(result) 2278 2279 if truncated: 2280 text_result += '...' 2281 return text_result 2282 2283 2284class SignOff(ContextCommand): 2285 """Append a signoff to the commit message""" 2286 2287 UNDOABLE = True 2288 2289 @staticmethod 2290 def name(): 2291 return N_('Sign Off') 2292 2293 def __init__(self, context): 2294 super(SignOff, self).__init__(context) 2295 self.old_commitmsg = self.model.commitmsg 2296 2297 def do(self): 2298 """Add a signoff to the commit message""" 2299 signoff = self.signoff() 2300 if signoff in self.model.commitmsg: 2301 return 2302 msg = self.model.commitmsg.rstrip() 2303 self.model.set_commitmsg(msg + '\n' + signoff) 2304 2305 def undo(self): 2306 """Restore the commit message""" 2307 self.model.set_commitmsg(self.old_commitmsg) 2308 2309 def signoff(self): 2310 """Generate the signoff string""" 2311 try: 2312 import pwd # pylint: disable=all 2313 2314 user = pwd.getpwuid(os.getuid()).pw_name 2315 except ImportError: 2316 user = os.getenv('USER', N_('unknown')) 2317 2318 cfg = self.cfg 2319 name = cfg.get('user.name', user) 2320 email = cfg.get('user.email', '%s@%s' % (user, core.node())) 2321 return '\nSigned-off-by: %s <%s>' % (name, email) 2322 2323 2324def check_conflicts(context, unmerged): 2325 """Check paths for conflicts 2326 2327 Conflicting files can be filtered out one-by-one. 2328 2329 """ 2330 if prefs.check_conflicts(context): 2331 unmerged = [path for path in unmerged if is_conflict_free(path)] 2332 return unmerged 2333 2334 2335def is_conflict_free(path): 2336 """Return True if `path` contains no conflict markers""" 2337 rgx = re.compile(r'^(<<<<<<<|\|\|\|\|\|\|\||>>>>>>>) ') 2338 try: 2339 with core.xopen(path, 'r') as f: 2340 for line in f: 2341 line = core.decode(line, errors='ignore') 2342 if rgx.match(line): 2343 return should_stage_conflicts(path) 2344 except IOError: 2345 # We can't read this file ~ we may be staging a removal 2346 pass 2347 return True 2348 2349 2350def should_stage_conflicts(path): 2351 """Inform the user that a file contains merge conflicts 2352 2353 Return `True` if we should stage the path nonetheless. 2354 2355 """ 2356 title = msg = N_('Stage conflicts?') 2357 info = ( 2358 N_( 2359 '%s appears to contain merge conflicts.\n\n' 2360 'You should probably skip this file.\n' 2361 'Stage it anyways?' 2362 ) 2363 % path 2364 ) 2365 ok_text = N_('Stage conflicts') 2366 cancel_text = N_('Skip') 2367 return Interaction.confirm( 2368 title, msg, info, ok_text, default=False, cancel_text=cancel_text 2369 ) 2370 2371 2372class Stage(ContextCommand): 2373 """Stage a set of paths.""" 2374 2375 @staticmethod 2376 def name(): 2377 return N_('Stage') 2378 2379 def __init__(self, context, paths): 2380 super(Stage, self).__init__(context) 2381 self.paths = paths 2382 2383 def do(self): 2384 msg = N_('Staging: %s') % (', '.join(self.paths)) 2385 Interaction.log(msg) 2386 return self.stage_paths() 2387 2388 def stage_paths(self): 2389 """Stages add/removals to git.""" 2390 context = self.context 2391 paths = self.paths 2392 if not paths: 2393 if self.model.cfg.get('cola.safemode', False): 2394 return (0, '', '') 2395 return self.stage_all() 2396 2397 add = [] 2398 remove = [] 2399 2400 for path in set(paths): 2401 if core.exists(path) or core.islink(path): 2402 if path.endswith('/'): 2403 path = path.rstrip('/') 2404 add.append(path) 2405 else: 2406 remove.append(path) 2407 2408 self.model.emit_about_to_update() 2409 2410 # `git add -u` doesn't work on untracked files 2411 if add: 2412 status, out, err = gitcmds.add(context, add) 2413 Interaction.command(N_('Error'), 'git add', status, out, err) 2414 2415 # If a path doesn't exist then that means it should be removed 2416 # from the index. We use `git add -u` for that. 2417 if remove: 2418 status, out, err = gitcmds.add(context, remove, u=True) 2419 Interaction.command(N_('Error'), 'git add -u', status, out, err) 2420 2421 self.model.update_files(emit=True) 2422 return status, out, err 2423 2424 def stage_all(self): 2425 """Stage all files""" 2426 status, out, err = self.git.add(v=True, u=True) 2427 Interaction.command(N_('Error'), 'git add -u', status, out, err) 2428 self.model.update_file_status() 2429 return (status, out, err) 2430 2431 2432class StageCarefully(Stage): 2433 """Only stage when the path list is non-empty 2434 2435 We use "git add -u -- <pathspec>" to stage, and it stages everything by 2436 default when no pathspec is specified, so this class ensures that paths 2437 are specified before calling git. 2438 2439 When no paths are specified, the command does nothing. 2440 2441 """ 2442 2443 def __init__(self, context): 2444 super(StageCarefully, self).__init__(context, None) 2445 self.init_paths() 2446 2447 # pylint: disable=no-self-use 2448 def init_paths(self): 2449 """Initialize path data""" 2450 return 2451 2452 def ok_to_run(self): 2453 """Prevent catch-all "git add -u" from adding unmerged files""" 2454 return self.paths or not self.model.unmerged 2455 2456 def do(self): 2457 """Stage files when ok_to_run() return True""" 2458 if self.ok_to_run(): 2459 return super(StageCarefully, self).do() 2460 return (0, '', '') 2461 2462 2463class StageModified(StageCarefully): 2464 """Stage all modified files.""" 2465 2466 @staticmethod 2467 def name(): 2468 return N_('Stage Modified') 2469 2470 def init_paths(self): 2471 self.paths = self.model.modified 2472 2473 2474class StageUnmerged(StageCarefully): 2475 """Stage unmerged files.""" 2476 2477 @staticmethod 2478 def name(): 2479 return N_('Stage Unmerged') 2480 2481 def init_paths(self): 2482 self.paths = check_conflicts(self.context, self.model.unmerged) 2483 2484 2485class StageUntracked(StageCarefully): 2486 """Stage all untracked files.""" 2487 2488 @staticmethod 2489 def name(): 2490 return N_('Stage Untracked') 2491 2492 def init_paths(self): 2493 self.paths = self.model.untracked 2494 2495 2496class StageModifiedAndUntracked(StageCarefully): 2497 """Stage all untracked files.""" 2498 2499 @staticmethod 2500 def name(): 2501 return N_('Stage Modified and Untracked') 2502 2503 def init_paths(self): 2504 self.paths = self.model.modified + self.model.untracked 2505 2506 2507class StageOrUnstageAll(ContextCommand): 2508 """If the selection is staged, unstage it, otherwise stage""" 2509 @staticmethod 2510 def name(): 2511 return N_('Stage / Unstage All') 2512 2513 def do(self): 2514 if self.model.staged: 2515 do(Unstage, self.context, self.model.staged) 2516 else: 2517 if self.cfg.get('cola.safemode', False): 2518 unstaged = self.model.modified 2519 else: 2520 unstaged = self.model.modified + self.model.untracked 2521 do(Stage, self.context, unstaged) 2522 2523 2524class StageOrUnstage(ContextCommand): 2525 """If the selection is staged, unstage it, otherwise stage""" 2526 2527 @staticmethod 2528 def name(): 2529 return N_('Stage / Unstage') 2530 2531 def do(self): 2532 s = self.selection.selection() 2533 if s.staged: 2534 do(Unstage, self.context, s.staged) 2535 2536 unstaged = [] 2537 unmerged = check_conflicts(self.context, s.unmerged) 2538 if unmerged: 2539 unstaged.extend(unmerged) 2540 if s.modified: 2541 unstaged.extend(s.modified) 2542 if s.untracked: 2543 unstaged.extend(s.untracked) 2544 if unstaged: 2545 do(Stage, self.context, unstaged) 2546 2547 2548class Tag(ContextCommand): 2549 """Create a tag object.""" 2550 2551 def __init__(self, context, name, revision, sign=False, message=''): 2552 super(Tag, self).__init__(context) 2553 self._name = name 2554 self._message = message 2555 self._revision = revision 2556 self._sign = sign 2557 2558 def do(self): 2559 result = False 2560 git = self.git 2561 revision = self._revision 2562 tag_name = self._name 2563 tag_message = self._message 2564 2565 if not revision: 2566 Interaction.critical( 2567 N_('Missing Revision'), N_('Please specify a revision to tag.') 2568 ) 2569 return result 2570 2571 if not tag_name: 2572 Interaction.critical( 2573 N_('Missing Name'), N_('Please specify a name for the new tag.') 2574 ) 2575 return result 2576 2577 title = N_('Missing Tag Message') 2578 message = N_('Tag-signing was requested but the tag message is empty.') 2579 info = N_( 2580 'An unsigned, lightweight tag will be created instead.\n' 2581 'Create an unsigned tag?' 2582 ) 2583 ok_text = N_('Create Unsigned Tag') 2584 sign = self._sign 2585 if sign and not tag_message: 2586 # We require a message in order to sign the tag, so if they 2587 # choose to create an unsigned tag we have to clear the sign flag. 2588 if not Interaction.confirm( 2589 title, message, info, ok_text, default=False, icon=icons.save() 2590 ): 2591 return result 2592 sign = False 2593 2594 opts = {} 2595 tmp_file = None 2596 try: 2597 if tag_message: 2598 tmp_file = utils.tmp_filename('tag-message') 2599 opts['file'] = tmp_file 2600 core.write(tmp_file, tag_message) 2601 2602 if sign: 2603 opts['sign'] = True 2604 if tag_message: 2605 opts['annotate'] = True 2606 status, out, err = git.tag(tag_name, revision, **opts) 2607 finally: 2608 if tmp_file: 2609 core.unlink(tmp_file) 2610 2611 title = N_('Error: could not create tag "%s"') % tag_name 2612 Interaction.command(title, 'git tag', status, out, err) 2613 2614 if status == 0: 2615 result = True 2616 self.model.update_status() 2617 Interaction.information( 2618 N_('Tag Created'), 2619 N_('Created a new tag named "%s"') % tag_name, 2620 details=tag_message or None, 2621 ) 2622 2623 return result 2624 2625 2626class Unstage(ContextCommand): 2627 """Unstage a set of paths.""" 2628 2629 @staticmethod 2630 def name(): 2631 return N_('Unstage') 2632 2633 def __init__(self, context, paths): 2634 super(Unstage, self).__init__(context) 2635 self.paths = paths 2636 2637 def do(self): 2638 """Unstage paths""" 2639 context = self.context 2640 head = self.model.head 2641 paths = self.paths 2642 2643 msg = N_('Unstaging: %s') % (', '.join(paths)) 2644 Interaction.log(msg) 2645 if not paths: 2646 return unstage_all(context) 2647 status, out, err = gitcmds.unstage_paths(context, paths, head=head) 2648 Interaction.command(N_('Error'), 'git reset', status, out, err) 2649 self.model.update_file_status() 2650 return (status, out, err) 2651 2652 2653class UnstageAll(ContextCommand): 2654 """Unstage all files; resets the index.""" 2655 2656 def do(self): 2657 return unstage_all(self.context) 2658 2659 2660def unstage_all(context): 2661 """Unstage all files, even while amending""" 2662 model = context.model 2663 git = context.git 2664 head = model.head 2665 status, out, err = git.reset(head, '--', '.') 2666 Interaction.command(N_('Error'), 'git reset', status, out, err) 2667 model.update_file_status() 2668 return (status, out, err) 2669 2670 2671class StageSelected(ContextCommand): 2672 """Stage selected files, or all files if no selection exists.""" 2673 2674 def do(self): 2675 context = self.context 2676 paths = self.selection.unstaged 2677 if paths: 2678 do(Stage, context, paths) 2679 elif self.cfg.get('cola.safemode', False): 2680 do(StageModified, context) 2681 2682 2683class UnstageSelected(Unstage): 2684 """Unstage selected files.""" 2685 2686 def __init__(self, context): 2687 staged = self.selection.staged 2688 super(UnstageSelected, self).__init__(context, staged) 2689 2690 2691class Untrack(ContextCommand): 2692 """Unstage a set of paths.""" 2693 2694 def __init__(self, context, paths): 2695 super(Untrack, self).__init__(context) 2696 self.paths = paths 2697 2698 def do(self): 2699 msg = N_('Untracking: %s') % (', '.join(self.paths)) 2700 Interaction.log(msg) 2701 status, out, err = self.model.untrack_paths(self.paths) 2702 Interaction.log_status(status, out, err) 2703 2704 2705class UntrackedSummary(EditModel): 2706 """List possible .gitignore rules as the diff text.""" 2707 2708 def __init__(self, context): 2709 super(UntrackedSummary, self).__init__(context) 2710 untracked = self.model.untracked 2711 suffix = 's' if untracked else '' 2712 io = StringIO() 2713 io.write('# %s untracked file%s\n' % (len(untracked), suffix)) 2714 if untracked: 2715 io.write('# possible .gitignore rule%s:\n' % suffix) 2716 for u in untracked: 2717 io.write('/' + u + '\n') 2718 self.new_diff_text = io.getvalue() 2719 self.new_diff_type = main.Types.TEXT 2720 self.new_file_type = main.Types.TEXT 2721 self.new_mode = self.model.mode_untracked 2722 2723 2724class VisualizeAll(ContextCommand): 2725 """Visualize all branches.""" 2726 2727 def do(self): 2728 context = self.context 2729 browser = utils.shell_split(prefs.history_browser(context)) 2730 launch_history_browser(browser + ['--all']) 2731 2732 2733class VisualizeCurrent(ContextCommand): 2734 """Visualize all branches.""" 2735 2736 def do(self): 2737 context = self.context 2738 browser = utils.shell_split(prefs.history_browser(context)) 2739 launch_history_browser(browser + [self.model.currentbranch] + ['--']) 2740 2741 2742class VisualizePaths(ContextCommand): 2743 """Path-limited visualization.""" 2744 2745 def __init__(self, context, paths): 2746 super(VisualizePaths, self).__init__(context) 2747 context = self.context 2748 browser = utils.shell_split(prefs.history_browser(context)) 2749 if paths: 2750 self.argv = browser + ['--'] + list(paths) 2751 else: 2752 self.argv = browser 2753 2754 def do(self): 2755 launch_history_browser(self.argv) 2756 2757 2758class VisualizeRevision(ContextCommand): 2759 """Visualize a specific revision.""" 2760 2761 def __init__(self, context, revision, paths=None): 2762 super(VisualizeRevision, self).__init__(context) 2763 self.revision = revision 2764 self.paths = paths 2765 2766 def do(self): 2767 context = self.context 2768 argv = utils.shell_split(prefs.history_browser(context)) 2769 if self.revision: 2770 argv.append(self.revision) 2771 if self.paths: 2772 argv.append('--') 2773 argv.extend(self.paths) 2774 launch_history_browser(argv) 2775 2776 2777class SubmoduleAdd(ConfirmAction): 2778 """Add specified submodules""" 2779 2780 def __init__(self, context, url, path, branch, depth, reference): 2781 super(SubmoduleAdd, self).__init__(context) 2782 self.url = url 2783 self.path = path 2784 self.branch = branch 2785 self.depth = depth 2786 self.reference = reference 2787 2788 def confirm(self): 2789 title = N_('Add Submodule...') 2790 question = N_('Add this submodule?') 2791 info = N_('The submodule will be added using\n' '"%s"' % self.command()) 2792 ok_txt = N_('Add Submodule') 2793 return Interaction.confirm(title, question, info, ok_txt, icon=icons.ok()) 2794 2795 def action(self): 2796 context = self.context 2797 args = self.get_args() 2798 return context.git.submodule('add', *args) 2799 2800 def success(self): 2801 self.model.update_file_status() 2802 self.model.update_submodules_list() 2803 2804 def error_message(self): 2805 return N_('Error updating submodule %s' % self.path) 2806 2807 def command(self): 2808 cmd = ['git', 'submodule', 'add'] 2809 cmd.extend(self.get_args()) 2810 return core.list2cmdline(cmd) 2811 2812 def get_args(self): 2813 args = [] 2814 if self.branch: 2815 args.extend(['--branch', self.branch]) 2816 if self.reference: 2817 args.extend(['--reference', self.reference]) 2818 if self.depth: 2819 args.extend(['--depth', '%d' % self.depth]) 2820 args.extend(['--', self.url]) 2821 if self.path: 2822 args.append(self.path) 2823 return args 2824 2825 2826class SubmoduleUpdate(ConfirmAction): 2827 """Update specified submodule""" 2828 2829 def __init__(self, context, path): 2830 super(SubmoduleUpdate, self).__init__(context) 2831 self.path = path 2832 2833 def confirm(self): 2834 title = N_('Update Submodule...') 2835 question = N_('Update this submodule?') 2836 info = N_('The submodule will be updated using\n' '"%s"' % self.command()) 2837 ok_txt = N_('Update Submodule') 2838 return Interaction.confirm( 2839 title, question, info, ok_txt, default=False, icon=icons.pull() 2840 ) 2841 2842 def action(self): 2843 context = self.context 2844 args = self.get_args() 2845 return context.git.submodule(*args) 2846 2847 def success(self): 2848 self.model.update_file_status() 2849 2850 def error_message(self): 2851 return N_('Error updating submodule %s' % self.path) 2852 2853 def command(self): 2854 cmd = ['git', 'submodule'] 2855 cmd.extend(self.get_args()) 2856 return core.list2cmdline(cmd) 2857 2858 def get_args(self): 2859 cmd = ['update'] 2860 if version.check_git(self.context, 'submodule-update-recursive'): 2861 cmd.append('--recursive') 2862 cmd.extend(['--', self.path]) 2863 return cmd 2864 2865 2866class SubmodulesUpdate(ConfirmAction): 2867 """Update all submodules""" 2868 2869 def confirm(self): 2870 title = N_('Update submodules...') 2871 question = N_('Update all submodules?') 2872 info = N_('All submodules will be updated using\n' '"%s"' % self.command()) 2873 ok_txt = N_('Update Submodules') 2874 return Interaction.confirm( 2875 title, question, info, ok_txt, default=False, icon=icons.pull() 2876 ) 2877 2878 def action(self): 2879 context = self.context 2880 args = self.get_args() 2881 return context.git.submodule(*args) 2882 2883 def success(self): 2884 self.model.update_file_status() 2885 2886 def error_message(self): 2887 return N_('Error updating submodules') 2888 2889 def command(self): 2890 cmd = ['git', 'submodule'] 2891 cmd.extend(self.get_args()) 2892 return core.list2cmdline(cmd) 2893 2894 def get_args(self): 2895 cmd = ['update'] 2896 if version.check_git(self.context, 'submodule-update-recursive'): 2897 cmd.append('--recursive') 2898 return cmd 2899 2900 2901def launch_history_browser(argv): 2902 """Launch the configured history browser""" 2903 try: 2904 core.fork(argv) 2905 except OSError as e: 2906 _, details = utils.format_exception(e) 2907 title = N_('Error Launching History Browser') 2908 msg = N_('Cannot exec "%s": please configure a history browser') % ' '.join( 2909 argv 2910 ) 2911 Interaction.critical(title, message=msg, details=details) 2912 2913 2914def run(cls, *args, **opts): 2915 """ 2916 Returns a callback that runs a command 2917 2918 If the caller of run() provides args or opts then those are 2919 used instead of the ones provided by the invoker of the callback. 2920 2921 """ 2922 2923 def runner(*local_args, **local_opts): 2924 """Closure return by run() which runs the command""" 2925 if args or opts: 2926 do(cls, *args, **opts) 2927 else: 2928 do(cls, *local_args, **local_opts) 2929 2930 return runner 2931 2932 2933def do(cls, *args, **opts): 2934 """Run a command in-place""" 2935 try: 2936 cmd = cls(*args, **opts) 2937 return cmd.do() 2938 except Exception as e: # pylint: disable=broad-except 2939 msg, details = utils.format_exception(e) 2940 if hasattr(cls, '__name__'): 2941 msg = '%s exception:\n%s' % (cls.__name__, msg) 2942 Interaction.critical(N_('Error'), message=msg, details=details) 2943 return None 2944 2945 2946def difftool_run(context): 2947 """Start a default difftool session""" 2948 selection = context.selection 2949 files = selection.group() 2950 if not files: 2951 return 2952 s = selection.selection() 2953 head = context.model.head 2954 difftool_launch_with_head(context, files, bool(s.staged), head) 2955 2956 2957def difftool_launch_with_head(context, filenames, staged, head): 2958 """Launch difftool against the provided head""" 2959 if head == 'HEAD': 2960 left = None 2961 else: 2962 left = head 2963 difftool_launch(context, left=left, staged=staged, paths=filenames) 2964 2965 2966def difftool_launch( 2967 context, 2968 left=None, 2969 right=None, 2970 paths=None, 2971 staged=False, 2972 dir_diff=False, 2973 left_take_magic=False, 2974 left_take_parent=False, 2975): 2976 """Launches 'git difftool' with given parameters 2977 2978 :param left: first argument to difftool 2979 :param right: second argument to difftool_args 2980 :param paths: paths to diff 2981 :param staged: activate `git difftool --staged` 2982 :param dir_diff: activate `git difftool --dir-diff` 2983 :param left_take_magic: whether to append the magic ^! diff expression 2984 :param left_take_parent: whether to append the first-parent ~ for diffing 2985 2986 """ 2987 2988 difftool_args = ['git', 'difftool', '--no-prompt'] 2989 if staged: 2990 difftool_args.append('--cached') 2991 if dir_diff: 2992 difftool_args.append('--dir-diff') 2993 2994 if left: 2995 if left_take_parent or left_take_magic: 2996 suffix = '^!' if left_take_magic else '~' 2997 # Check root commit (no parents and thus cannot execute '~') 2998 git = context.git 2999 status, out, err = git.rev_list(left, parents=True, n=1) 3000 Interaction.log_status(status, out, err) 3001 if status: 3002 raise OSError('git rev-list command failed') 3003 3004 if len(out.split()) >= 2: 3005 # Commit has a parent, so we can take its child as requested 3006 left += suffix 3007 else: 3008 # No parent, assume it's the root commit, so we have to diff 3009 # against the empty tree. 3010 left = EMPTY_TREE_OID 3011 if not right and left_take_magic: 3012 right = left 3013 difftool_args.append(left) 3014 3015 if right: 3016 difftool_args.append(right) 3017 3018 if paths: 3019 difftool_args.append('--') 3020 difftool_args.extend(paths) 3021 3022 runtask = context.runtask 3023 if runtask: 3024 Interaction.async_command(N_('Difftool'), difftool_args, runtask) 3025 else: 3026 core.fork(difftool_args) 3027