OLD | NEW |
1 #!/usr/bin/python | 1 #!/usr/bin/python |
2 # git-cl -- a git-command for integrating reviews on Rietveld | 2 # git-cl -- a git-command for integrating reviews on Rietveld |
3 # Copyright (C) 2008 Evan Martin <martine@danga.com> | 3 # Copyright (C) 2008 Evan Martin <martine@danga.com> |
4 | 4 |
5 import errno | 5 import errno |
6 import logging | 6 import logging |
7 import optparse | 7 import optparse |
8 import os | 8 import os |
9 import re | 9 import re |
10 import subprocess | 10 import subprocess |
11 import sys | 11 import sys |
12 import tempfile | |
13 import textwrap | 12 import textwrap |
14 import urlparse | 13 import urlparse |
15 import urllib2 | 14 import urllib2 |
16 | 15 |
17 try: | 16 try: |
18 import readline # pylint: disable=W0611 | 17 import readline # pylint: disable=W0611 |
19 except ImportError: | 18 except ImportError: |
20 pass | 19 pass |
21 | 20 |
22 # TODO(dpranke): don't use relative import. | 21 # TODO(dpranke): don't use relative import. |
23 import upload # pylint: disable=W0403 | 22 import upload # pylint: disable=W0403 |
24 try: | 23 |
25 # TODO(dpranke): We wrap this in a try block for a limited form of | 24 # TODO(dpranke): move this file up a directory so we don't need this. |
26 # backwards-compatibility with older versions of git-cl that weren't | 25 depot_tools_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
27 # dependent on depot_tools. This version should still work outside of | 26 sys.path.append(depot_tools_path) |
28 # depot_tools as long as --bypass-hooks is used. We should remove this | 27 |
29 # once this has baked for a while and things seem safe. | 28 import breakpad # pylint: disable=W0611 |
30 depot_tools_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | 29 |
31 sys.path.append(depot_tools_path) | 30 import presubmit_support |
32 import breakpad # pylint: disable=W0611 | 31 import scm |
33 except ImportError: | 32 import watchlists |
34 pass | 33 |
35 | 34 |
36 DEFAULT_SERVER = 'http://codereview.appspot.com' | 35 DEFAULT_SERVER = 'http://codereview.appspot.com' |
37 POSTUPSTREAM_HOOK_PATTERN = '.git/hooks/post-cl-%s' | 36 POSTUPSTREAM_HOOK_PATTERN = '.git/hooks/post-cl-%s' |
38 DESCRIPTION_BACKUP_FILE = '~/.git_cl_description_backup' | 37 DESCRIPTION_BACKUP_FILE = '~/.git_cl_description_backup' |
39 | 38 |
40 def DieWithError(message): | 39 def DieWithError(message): |
41 print >> sys.stderr, message | 40 print >> sys.stderr, message |
42 sys.exit(1) | 41 sys.exit(1) |
43 | 42 |
44 | 43 |
(...skipping 281 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
326 else: | 325 else: |
327 self.branch = None | 326 self.branch = None |
328 self.rietveld_server = None | 327 self.rietveld_server = None |
329 self.upstream_branch = None | 328 self.upstream_branch = None |
330 self.has_issue = False | 329 self.has_issue = False |
331 self.issue = None | 330 self.issue = None |
332 self.has_description = False | 331 self.has_description = False |
333 self.description = None | 332 self.description = None |
334 self.has_patchset = False | 333 self.has_patchset = False |
335 self.patchset = None | 334 self.patchset = None |
| 335 self.tbr = False |
336 | 336 |
337 def GetBranch(self): | 337 def GetBranch(self): |
338 """Returns the short branch name, e.g. 'master'.""" | 338 """Returns the short branch name, e.g. 'master'.""" |
339 if not self.branch: | 339 if not self.branch: |
340 self.branchref = RunGit(['symbolic-ref', 'HEAD']).strip() | 340 self.branchref = RunGit(['symbolic-ref', 'HEAD']).strip() |
341 self.branch = ShortBranchName(self.branchref) | 341 self.branch = ShortBranchName(self.branchref) |
342 return self.branch | 342 return self.branch |
343 | 343 |
344 def GetBranchRef(self): | 344 def GetBranchRef(self): |
345 """Returns the full branch name, e.g. 'refs/heads/master'.""" | 345 """Returns the full branch name, e.g. 'refs/heads/master'.""" |
(...skipping 182 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
528 | 528 |
529 SetProperty(settings.GetCCList(), 'CC list', 'cc') | 529 SetProperty(settings.GetCCList(), 'CC list', 'cc') |
530 SetProperty(settings.GetTreeStatusUrl(error_ok=True), 'Tree status URL', | 530 SetProperty(settings.GetTreeStatusUrl(error_ok=True), 'Tree status URL', |
531 'tree-status-url') | 531 'tree-status-url') |
532 SetProperty(settings.GetViewVCUrl(), 'ViewVC URL', 'viewvc-url') | 532 SetProperty(settings.GetViewVCUrl(), 'ViewVC URL', 'viewvc-url') |
533 | 533 |
534 # TODO: configure a default branch to diff against, rather than this | 534 # TODO: configure a default branch to diff against, rather than this |
535 # svn-based hackery. | 535 # svn-based hackery. |
536 | 536 |
537 | 537 |
538 class ChangeDescription(object): | |
539 """Contains a parsed form of the change description.""" | |
540 def __init__(self, subject, log_desc, reviewers): | |
541 self.subject = subject | |
542 self.log_desc = log_desc | |
543 self.reviewers = reviewers | |
544 self.description = self.log_desc | |
545 | |
546 def Update(self): | |
547 initial_text = """# Enter a description of the change. | |
548 # This will displayed on the codereview site. | |
549 # The first line will also be used as the subject of the review. | |
550 """ | |
551 initial_text += self.description | |
552 if 'R=' not in self.description and self.reviewers: | |
553 initial_text += '\nR=' + self.reviewers | |
554 if 'BUG=' not in self.description: | |
555 initial_text += '\nBUG=' | |
556 if 'TEST=' not in self.description: | |
557 initial_text += '\nTEST=' | |
558 self._ParseDescription(UserEditedLog(initial_text)) | |
559 | |
560 def _ParseDescription(self, description): | |
561 if not description: | |
562 self.description = description | |
563 return | |
564 | |
565 parsed_lines = [] | |
566 reviewers_regexp = re.compile('\s*R=(.+)') | |
567 reviewers = '' | |
568 subject = '' | |
569 for l in description.splitlines(): | |
570 if not subject: | |
571 subject = l | |
572 matched_reviewers = reviewers_regexp.match(l) | |
573 if matched_reviewers: | |
574 reviewers = matched_reviewers.group(1) | |
575 parsed_lines.append(l) | |
576 | |
577 self.description = '\n'.join(parsed_lines) + '\n' | |
578 self.subject = subject | |
579 self.reviewers = reviewers | |
580 | |
581 def IsEmpty(self): | |
582 return not self.description | |
583 | |
584 | |
585 def FindCodereviewSettingsFile(filename='codereview.settings'): | 538 def FindCodereviewSettingsFile(filename='codereview.settings'): |
586 """Finds the given file starting in the cwd and going up. | 539 """Finds the given file starting in the cwd and going up. |
587 | 540 |
588 Only looks up to the top of the repository unless an | 541 Only looks up to the top of the repository unless an |
589 'inherit-review-settings-ok' file exists in the root of the repository. | 542 'inherit-review-settings-ok' file exists in the root of the repository. |
590 """ | 543 """ |
591 inherit_ok_file = 'inherit-review-settings-ok' | 544 inherit_ok_file = 'inherit-review-settings-ok' |
592 cwd = os.getcwd() | 545 cwd = os.getcwd() |
593 root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip()) | 546 root = os.path.abspath(RunGit(['rev-parse', '--show-cdup']).strip()) |
594 if os.path.isfile(os.path.join(root, inherit_ok_file)): | 547 if os.path.isfile(os.path.join(root, inherit_ok_file)): |
(...skipping 129 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
724 log_args = [args[0] + '..'] | 677 log_args = [args[0] + '..'] |
725 elif len(args) == 1 and args[0].endswith('...'): | 678 elif len(args) == 1 and args[0].endswith('...'): |
726 log_args = [args[0][:-1]] | 679 log_args = [args[0][:-1]] |
727 elif len(args) == 2: | 680 elif len(args) == 2: |
728 log_args = [args[0] + '..' + args[1]] | 681 log_args = [args[0] + '..' + args[1]] |
729 else: | 682 else: |
730 log_args = args[:] # Hope for the best! | 683 log_args = args[:] # Hope for the best! |
731 return RunGit(['log', '--pretty=format:%s\n\n%b'] + log_args) | 684 return RunGit(['log', '--pretty=format:%s\n\n%b'] + log_args) |
732 | 685 |
733 | 686 |
734 def UserEditedLog(starting_text): | |
735 """Given some starting text, let the user edit it and return the result.""" | |
736 editor = os.getenv('EDITOR', 'vi') | |
737 | |
738 (file_handle, filename) = tempfile.mkstemp() | |
739 fileobj = os.fdopen(file_handle, 'w') | |
740 fileobj.write(starting_text) | |
741 fileobj.close() | |
742 | |
743 # Open up the default editor in the system to get the CL description. | |
744 try: | |
745 cmd = '%s %s' % (editor, filename) | |
746 if sys.platform == 'win32' and os.environ.get('TERM') == 'msys': | |
747 # Msysgit requires the usage of 'env' to be present. | |
748 cmd = 'env ' + cmd | |
749 # shell=True to allow the shell to handle all forms of quotes in $EDITOR. | |
750 subprocess.check_call(cmd, shell=True) | |
751 fileobj = open(filename) | |
752 text = fileobj.read() | |
753 fileobj.close() | |
754 finally: | |
755 os.remove(filename) | |
756 | |
757 if not text: | |
758 return | |
759 | |
760 stripcomment_re = re.compile(r'^#.*$', re.MULTILINE) | |
761 return stripcomment_re.sub('', text).strip() | |
762 | |
763 | |
764 def ConvertToInteger(inputval): | 687 def ConvertToInteger(inputval): |
765 """Convert a string to integer, but returns either an int or None.""" | 688 """Convert a string to integer, but returns either an int or None.""" |
766 try: | 689 try: |
767 return int(inputval) | 690 return int(inputval) |
768 except (TypeError, ValueError): | 691 except (TypeError, ValueError): |
769 return None | 692 return None |
770 | 693 |
771 | 694 |
| 695 class GitChangeDescription(presubmit_support.ChangeDescription): |
| 696 def UserEdit(self): |
| 697 header = ( |
| 698 "# Enter a description of the change.\n" |
| 699 "# This will displayed on the codereview site.\n" |
| 700 "# The first line will also be used as the subject of the review.\n" |
| 701 "\n") |
| 702 edited_text = self.editor(header + self.EditableDescription()) |
| 703 stripcomment_re = re.compile(r'^#.*$', re.MULTILINE) |
| 704 self.Parse(stripcomment_re.sub('', edited_text).strip()) |
| 705 |
| 706 |
772 def RunHook(committing, upstream_branch, rietveld_server, tbr, may_prompt): | 707 def RunHook(committing, upstream_branch, rietveld_server, tbr, may_prompt): |
773 """Calls sys.exit() if the hook fails; returns a HookResults otherwise.""" | 708 """Calls sys.exit() if the hook fails; returns a HookResults otherwise.""" |
774 import presubmit_support | |
775 import scm | |
776 import watchlists | |
777 | |
778 root = RunCommand(['git', 'rev-parse', '--show-cdup']).strip() | 709 root = RunCommand(['git', 'rev-parse', '--show-cdup']).strip() |
779 if not root: | 710 if not root: |
780 root = '.' | 711 root = '.' |
781 absroot = os.path.abspath(root) | 712 absroot = os.path.abspath(root) |
782 if not root: | 713 if not root: |
783 raise Exception('Could not get root directory.') | 714 raise Exception('Could not get root directory.') |
784 | 715 |
785 # We use the sha1 of HEAD as a name of this change. | 716 # We use the sha1 of HEAD as a name of this change. |
786 name = RunCommand(['git', 'rev-parse', 'HEAD']).strip() | 717 name = RunCommand(['git', 'rev-parse', 'HEAD']).strip() |
787 files = scm.GIT.CaptureStatus([root], upstream_branch) | 718 files = scm.GIT.CaptureStatus([root], upstream_branch) |
(...skipping 48 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
836 cl = Changelist() | 767 cl = Changelist() |
837 if args: | 768 if args: |
838 base_branch = args[0] | 769 base_branch = args[0] |
839 else: | 770 else: |
840 # Default to diffing against the "upstream" branch. | 771 # Default to diffing against the "upstream" branch. |
841 base_branch = cl.GetUpstreamBranch() | 772 base_branch = cl.GetUpstreamBranch() |
842 | 773 |
843 if options.upload: | 774 if options.upload: |
844 print '*** Presubmit checks for UPLOAD would report: ***' | 775 print '*** Presubmit checks for UPLOAD would report: ***' |
845 RunHook(committing=False, upstream_branch=base_branch, | 776 RunHook(committing=False, upstream_branch=base_branch, |
846 rietveld_server=cl.GetRietveldServer(), tbr=False, | 777 rietveld_server=cl.GetRietveldServer(), tbr=cl.tbr, |
847 may_prompt=False) | 778 may_prompt=False) |
848 return 0 | 779 return 0 |
849 else: | 780 else: |
850 print '*** Presubmit checks for DCOMMIT would report: ***' | 781 print '*** Presubmit checks for DCOMMIT would report: ***' |
851 RunHook(committing=True, upstream_branch=base_branch, | 782 RunHook(committing=True, upstream_branch=base_branch, |
852 rietveld_server=cl.GetRietveldServer, tbr=False, | 783 rietveld_server=cl.GetRietveldServer, tbr=cl.tbr, |
853 may_prompt=False) | 784 may_prompt=False) |
854 return 0 | 785 return 0 |
855 | 786 |
856 | 787 |
857 @usage('[args to "git diff"]') | 788 @usage('[args to "git diff"]') |
858 def CMDupload(parser, args): | 789 def CMDupload(parser, args): |
859 """upload the current changelist to codereview""" | 790 """upload the current changelist to codereview""" |
860 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks', | 791 parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks', |
861 help='bypass upload presubmit hook') | 792 help='bypass upload presubmit hook') |
862 parser.add_option('-f', action='store_true', dest='force', | 793 parser.add_option('-f', action='store_true', dest='force', |
(...skipping 22 matching lines...) Expand all Loading... |
885 | 816 |
886 cl = Changelist() | 817 cl = Changelist() |
887 if args: | 818 if args: |
888 base_branch = args[0] | 819 base_branch = args[0] |
889 else: | 820 else: |
890 # Default to diffing against the "upstream" branch. | 821 # Default to diffing against the "upstream" branch. |
891 base_branch = cl.GetUpstreamBranch() | 822 base_branch = cl.GetUpstreamBranch() |
892 args = [base_branch + "..."] | 823 args = [base_branch + "..."] |
893 | 824 |
894 if not options.bypass_hooks and not options.force: | 825 if not options.bypass_hooks and not options.force: |
895 hook_results = RunHook(committing=False, upstream_branch=base_branch, | 826 RunHook(committing=False, upstream_branch=base_branch, |
896 rietveld_server=cl.GetRietveldServer(), tbr=False, | 827 rietveld_server=cl.GetRietveldServer(), tbr=cl.tbr, |
897 may_prompt=True) | 828 may_prompt=True) |
898 if not options.reviewers and hook_results.reviewers: | |
899 options.reviewers = hook_results.reviewers | |
900 | |
901 | 829 |
902 # --no-ext-diff is broken in some versions of Git, so try to work around | 830 # --no-ext-diff is broken in some versions of Git, so try to work around |
903 # this by overriding the environment (but there is still a problem if the | 831 # this by overriding the environment (but there is still a problem if the |
904 # git config key "diff.external" is used). | 832 # git config key "diff.external" is used). |
905 env = os.environ.copy() | 833 env = os.environ.copy() |
906 if 'GIT_EXTERNAL_DIFF' in env: | 834 if 'GIT_EXTERNAL_DIFF' in env: |
907 del env['GIT_EXTERNAL_DIFF'] | 835 del env['GIT_EXTERNAL_DIFF'] |
908 subprocess.call(['git', 'diff', '--no-ext-diff', '--stat', '-M'] + args, | 836 subprocess.call(['git', 'diff', '--no-ext-diff', '--stat', '-M'] + args, |
909 env=env) | 837 env=env) |
910 | 838 |
(...skipping 12 matching lines...) Expand all Loading... |
923 change_desc = None | 851 change_desc = None |
924 | 852 |
925 if cl.GetIssue(): | 853 if cl.GetIssue(): |
926 if options.message: | 854 if options.message: |
927 upload_args.extend(['--message', options.message]) | 855 upload_args.extend(['--message', options.message]) |
928 upload_args.extend(['--issue', cl.GetIssue()]) | 856 upload_args.extend(['--issue', cl.GetIssue()]) |
929 print ("This branch is associated with issue %s. " | 857 print ("This branch is associated with issue %s. " |
930 "Adding patch to that issue." % cl.GetIssue()) | 858 "Adding patch to that issue." % cl.GetIssue()) |
931 else: | 859 else: |
932 log_desc = CreateDescriptionFromLog(args) | 860 log_desc = CreateDescriptionFromLog(args) |
933 change_desc = ChangeDescription(options.message, log_desc, | 861 change_desc = GitChangeDescription(subject=options.message, |
934 options.reviewers) | 862 description=log_desc, reviewers=options.reviewers, tbr=cl.tbr) |
935 if not options.from_logs: | 863 if not options.from_logs and (not options.force): |
936 change_desc.Update() | 864 change_desc.UserEdit() |
937 | 865 |
938 if change_desc.IsEmpty(): | 866 if change_desc.IsEmpty(): |
939 print "Description is empty; aborting." | 867 print "Description is empty; aborting." |
940 return 1 | 868 return 1 |
941 | 869 |
942 upload_args.extend(['--message', change_desc.subject]) | 870 upload_args.extend(['--message', change_desc.subject]) |
943 upload_args.extend(['--description', change_desc.description]) | 871 upload_args.extend(['--description', change_desc.description]) |
944 if change_desc.reviewers: | 872 if change_desc.reviewers: |
945 upload_args.extend(['--reviewers', change_desc.reviewers]) | 873 upload_args.extend(['--reviewers', change_desc.reviewers]) |
946 cc = ','.join(filter(None, (settings.GetCCList(), options.cc))) | 874 cc = ','.join(filter(None, (settings.GetCCList(), options.cc))) |
(...skipping 90 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
1037 extra_commits = RunGit(['rev-list', '^' + svn_head, base_branch]) | 965 extra_commits = RunGit(['rev-list', '^' + svn_head, base_branch]) |
1038 if extra_commits: | 966 if extra_commits: |
1039 print ('This branch has %d additional commits not upstreamed yet.' | 967 print ('This branch has %d additional commits not upstreamed yet.' |
1040 % len(extra_commits.splitlines())) | 968 % len(extra_commits.splitlines())) |
1041 print ('Upstream "%s" or rebase this branch on top of the upstream trunk ' | 969 print ('Upstream "%s" or rebase this branch on top of the upstream trunk ' |
1042 'before attempting to %s.' % (base_branch, cmd)) | 970 'before attempting to %s.' % (base_branch, cmd)) |
1043 return 1 | 971 return 1 |
1044 | 972 |
1045 if not options.bypass_hooks and not options.force: | 973 if not options.bypass_hooks and not options.force: |
1046 RunHook(committing=True, upstream_branch=base_branch, | 974 RunHook(committing=True, upstream_branch=base_branch, |
1047 rietveld_server=cl.GetRietveldServer(), tbr=options.tbr, | 975 rietveld_server=cl.GetRietveldServer(), tbr=(cl.tbr or options.tbr), |
1048 may_prompt=True) | 976 may_prompt=True) |
1049 | 977 |
1050 if cmd == 'dcommit': | 978 if cmd == 'dcommit': |
1051 # Check the tree status if the tree status URL is set. | 979 # Check the tree status if the tree status URL is set. |
1052 status = GetTreeStatus() | 980 status = GetTreeStatus() |
1053 if 'closed' == status: | 981 if 'closed' == status: |
1054 print ('The tree is closed. Please wait for it to reopen. Use ' | 982 print ('The tree is closed. Please wait for it to reopen. Use ' |
1055 '"git cl dcommit -f" to commit on a closed tree.') | 983 '"git cl dcommit -f" to commit on a closed tree.') |
1056 return 1 | 984 return 1 |
1057 elif 'unknown' == status: | 985 elif 'unknown' == status: |
(...skipping 18 matching lines...) Expand all Loading... |
1076 print 'Visit %s/edit to set it.' % (cl.GetIssueURL()) | 1004 print 'Visit %s/edit to set it.' % (cl.GetIssueURL()) |
1077 return 1 | 1005 return 1 |
1078 | 1006 |
1079 description += "\n\nReview URL: %s" % cl.GetIssueURL() | 1007 description += "\n\nReview URL: %s" % cl.GetIssueURL() |
1080 else: | 1008 else: |
1081 if not description: | 1009 if not description: |
1082 # Submitting TBR. See if there's already a description in Rietveld, else | 1010 # Submitting TBR. See if there's already a description in Rietveld, else |
1083 # create a template description. Eitherway, give the user a chance to edit | 1011 # create a template description. Eitherway, give the user a chance to edit |
1084 # it to fill in the TBR= field. | 1012 # it to fill in the TBR= field. |
1085 if cl.GetIssue(): | 1013 if cl.GetIssue(): |
1086 description = cl.GetDescription() | 1014 change_desc = GitChangeDescription(description=cl.GetDescription()) |
1087 | 1015 |
1088 # TODO(dpranke): Update to use ChangeDescription object. | |
1089 if not description: | 1016 if not description: |
1090 description = """# Enter a description of the change. | 1017 log_desc = CreateDescriptionFromLog(args) |
1091 # This will be used as the change log for the commit. | 1018 change_desc = GitChangeDescription(description=log_desc, tbr=True) |
1092 | 1019 |
1093 """ | 1020 if not options.force: |
1094 description += CreateDescriptionFromLog(args) | 1021 change_desc.UserEdit() |
1095 | 1022 description = change_desc.description |
1096 description = UserEditedLog(description + '\nTBR=') | |
1097 | 1023 |
1098 if not description: | 1024 if not description: |
1099 print "Description empty; aborting." | 1025 print "Description empty; aborting." |
1100 return 1 | 1026 return 1 |
1101 | 1027 |
1102 if options.contributor: | 1028 if options.contributor: |
1103 if not re.match('^.*\s<\S+@\S+>$', options.contributor): | 1029 if not re.match('^.*\s<\S+@\S+>$', options.contributor): |
1104 print "Please provide contibutor as 'First Last <email@example.com>'" | 1030 print "Please provide contibutor as 'First Last <email@example.com>'" |
1105 return 1 | 1031 return 1 |
1106 description += "\nPatch from %s." % options.contributor | 1032 description += "\nPatch from %s." % options.contributor |
(...skipping 316 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
1423 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith ' | 1349 ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith ' |
1424 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e))) | 1350 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e))) |
1425 | 1351 |
1426 # Not a known command. Default to help. | 1352 # Not a known command. Default to help. |
1427 GenUsage(parser, 'help') | 1353 GenUsage(parser, 'help') |
1428 return CMDhelp(parser, argv) | 1354 return CMDhelp(parser, argv) |
1429 | 1355 |
1430 | 1356 |
1431 if __name__ == '__main__': | 1357 if __name__ == '__main__': |
1432 sys.exit(main(sys.argv[1:])) | 1358 sys.exit(main(sys.argv[1:])) |
OLD | NEW |