2 # (The line above is necessary so that I can use 世界 in the
3 # *comment* below without Python getting all bent out of shape.)
5 # Copyright 2007-2009 Google Inc.
7 # Licensed under the Apache License, Version 2.0 (the "License");
8 # you may not use this file except in compliance with the License.
9 # You may obtain a copy of the License at
11 # http://www.apache.org/licenses/LICENSE-2.0
13 # Unless required by applicable law or agreed to in writing, software
14 # distributed under the License is distributed on an "AS IS" BASIS,
15 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 # See the License for the specific language governing permissions and
17 # limitations under the License.
19 '''Mercurial interface to codereview.appspot.com.
21 To configure, set the following options in
22 your repository's .hg/hgrc file.
25 codereview = /path/to/codereview.py
28 server = codereview.appspot.com
30 The server should be running Rietveld; see http://code.google.com/p/rietveld/.
32 In addition to the new commands, this extension introduces
33 the file pattern syntax @nnnnnn, where nnnnnn is a change list
34 number, to mean the files included in that change list, which
35 must be associated with the current client.
37 For example, if change 123456 contains the files x.go and y.go,
38 "hg diff @123456" is equivalent to"hg diff x.go y.go".
43 if __name__ == "__main__":
44 print >>sys.stderr, "This is a Mercurial extension and should not be invoked directly."
47 # We require Python 2.6 for the json package.
48 if sys.version < '2.6':
49 print >>sys.stderr, "The codereview extension requires Python 2.6 or newer."
50 print >>sys.stderr, "You are running Python " + sys.version
61 from mercurial import commands as hg_commands
62 from mercurial import util as hg_util
64 # bind Plan 9 preferred dotfile location
65 if os.sys.platform == 'plan9':
68 n = plan9.bind(os.path.expanduser("~/lib"), os.path.expanduser("~"), plan9.MBEFORE|plan9.MCREATE)
73 codereview_disabled = None
76 server = "codereview.appspot.com"
77 server_url_base = None
80 #######################################################################
81 # Normally I would split this into multiple files, but it simplifies
82 # import path headaches to keep it all in one file. Sorry.
83 # The different parts of the file are separated by banners like this one.
85 #######################################################################
88 def RelativePath(path, cwd):
90 if path.startswith(cwd) and path[n] == '/':
95 return [l for l in l1 if l not in l2]
102 def Intersect(l1, l2):
103 return [l for l in l1 if l in l2]
105 #######################################################################
106 # RE: UNICODE STRING HANDLING
108 # Python distinguishes between the str (string of bytes)
109 # and unicode (string of code points) types. Most operations
110 # work on either one just fine, but some (like regexp matching)
111 # require unicode, and others (like write) require str.
113 # As befits the language, Python hides the distinction between
114 # unicode and str by converting between them silently, but
115 # *only* if all the bytes/code points involved are 7-bit ASCII.
116 # This means that if you're not careful, your program works
117 # fine on "hello, world" and fails on "hello, 世界". And of course,
118 # the obvious way to be careful - use static types - is unavailable.
119 # So the only way is trial and error to find where to put explicit
122 # Because more functions do implicit conversion to str (string of bytes)
123 # than do implicit conversion to unicode (string of code points),
124 # the convention in this module is to represent all text as str,
125 # converting to unicode only when calling a unicode-only function
126 # and then converting back to str as soon as possible.
130 raise hg_util.Abort("type check failed: %s has type %s != %s" % (repr(s), type(s), t))
132 # If we have to pass unicode instead of str, ustr does that conversion clearly.
135 return s.decode("utf-8")
137 # Even with those, Mercurial still sometimes turns unicode into str
138 # and then tries to use it as ascii. Change Mercurial's default.
139 def set_mercurial_encoding_to_utf8():
140 from mercurial import encoding
141 encoding.encoding = 'utf-8'
143 set_mercurial_encoding_to_utf8()
145 # Even with those we still run into problems.
146 # I tried to do things by the book but could not convince
147 # Mercurial to let me check in a change with UTF-8 in the
148 # CL description or author field, no matter how many conversions
149 # between str and unicode I inserted and despite changing the
150 # default encoding. I'm tired of this game, so set the default
151 # encoding for all of Python to 'utf-8', not 'ascii'.
152 def default_to_utf8():
154 stdout, __stdout__ = sys.stdout, sys.__stdout__
155 reload(sys) # site.py deleted setdefaultencoding; get it back
156 sys.stdout, sys.__stdout__ = stdout, __stdout__
157 sys.setdefaultencoding('utf-8')
161 #######################################################################
162 # Status printer for long-running commands
168 print >>sys.stderr, time.asctime(), s
172 class StatusThread(threading.Thread):
174 threading.Thread.__init__(self)
176 # pause a reasonable amount of time before
177 # starting to display status messages, so that
178 # most hg commands won't ever see them.
181 # now show status every 15 seconds
183 time.sleep(15 - time.time() % 15)
188 s = "(unknown status)"
189 print >>sys.stderr, time.asctime(), s
191 def start_status_thread():
193 t.setDaemon(True) # allowed to exit if t is still running
196 #######################################################################
197 # Change list parsing.
199 # Change lists are stored in .hg/codereview/cl.nnnnnn
200 # where nnnnnn is the number assigned by the code review server.
201 # Most data about a change list is stored on the code review server
202 # too: the description, reviewer, and cc list are all stored there.
203 # The only thing in the cl.nnnnnn file is the list of relevant files.
204 # Also, the existence of the cl.nnnnnn file marks this repository
205 # as the one where the change list lives.
207 emptydiff = """Index: ~rietveld~placeholder~
208 ===================================================================
209 diff --git a/~rietveld~placeholder~ b/~rietveld~placeholder~
214 def __init__(self, name):
224 self.copied_from = None # None means current user
233 s += "Author: " + cl.copied_from + "\n\n"
235 s += "Private: " + str(self.private) + "\n"
236 s += "Mailed: " + str(self.mailed) + "\n"
237 s += "Description:\n"
238 s += Indent(cl.desc, "\t")
245 def EditorText(self):
250 s += "Author: " + cl.copied_from + "\n"
252 s += 'URL: ' + cl.url + ' # cannot edit\n\n'
254 s += "Private: True\n"
255 s += "Reviewer: " + JoinComma(cl.reviewer) + "\n"
256 s += "CC: " + JoinComma(cl.cc) + "\n"
258 s += "Description:\n"
260 s += "\t<enter description here>\n"
262 s += Indent(cl.desc, "\t")
264 if cl.local or cl.name == "new":
272 def PendingText(self, quick=False):
274 s = cl.name + ":" + "\n"
275 s += Indent(cl.desc, "\t")
278 s += "\tAuthor: " + cl.copied_from + "\n"
280 s += "\tReviewer: " + JoinComma(cl.reviewer) + "\n"
281 for (who, line, _) in cl.lgtm:
282 s += "\t\t" + who + ": " + line + "\n"
283 s += "\tCC: " + JoinComma(cl.cc) + "\n"
286 s += "\t\t" + f + "\n"
290 def Flush(self, ui, repo):
291 if self.name == "new":
292 self.Upload(ui, repo, gofmt_just_warn=True, creating=True)
293 dir = CodeReviewDir(ui, repo)
294 path = dir + '/cl.' + self.name
295 f = open(path+'!', "w")
296 f.write(self.DiskText())
298 if sys.platform == "win32" and os.path.isfile(path):
300 os.rename(path+'!', path)
301 if self.web and not self.copied_from:
302 EditDesc(self.name, desc=self.desc,
303 reviewers=JoinComma(self.reviewer), cc=JoinComma(self.cc),
304 private=self.private)
306 def Delete(self, ui, repo):
307 dir = CodeReviewDir(ui, repo)
308 os.unlink(dir + "/cl." + self.name)
310 def Subject(self, ui, repo):
314 if self.name != "new":
315 s = "code review %s: %s" % (self.name, s)
317 return branch_prefix(ui, repo) + s
319 def Upload(self, ui, repo, send_mail=False, gofmt=True, gofmt_just_warn=False, creating=False, quiet=False):
320 if not self.files and not creating:
321 ui.warn("no files in change list\n")
322 if ui.configbool("codereview", "force_gofmt", True) and gofmt:
323 CheckFormat(ui, repo, self.files, just_warn=gofmt_just_warn)
324 set_status("uploading CL metadata + diffs")
328 ("content_upload", "1"),
329 ("reviewers", JoinComma(self.reviewer)),
330 ("cc", JoinComma(self.cc)),
331 ("description", self.desc),
335 if self.name != "new":
336 form_fields.append(("issue", self.name))
338 # We do not include files when creating the issue,
339 # because we want the patch sets to record the repository
340 # and base revision they are diffs against. We use the patch
341 # set message for that purpose, but there is no message with
342 # the first patch set. Instead the message gets used as the
343 # new CL's overall subject. So omit the diffs when creating
344 # and then we'll run an immediate upload.
345 # This has the effect that every CL begins with an empty "Patch set 1".
346 if self.files and not creating:
347 vcs = MercurialVCS(upload_options, ui, repo)
348 data = vcs.GenerateDiff(self.files)
349 files = vcs.GetBaseFiles(data)
350 if len(data) > MAX_UPLOAD_SIZE:
351 uploaded_diff_file = []
352 form_fields.append(("separate_patches", "1"))
354 uploaded_diff_file = [("data", "data.diff", data)]
356 uploaded_diff_file = [("data", "data.diff", emptydiff)]
358 if vcs and self.name != "new":
359 form_fields.append(("subject", "diff -r " + vcs.base_rev + " " + ui.expandpath("default")))
361 # First upload sets the subject for the CL itself.
362 form_fields.append(("subject", self.Subject(ui, repo)))
364 ctype, body = EncodeMultipartFormData(form_fields, uploaded_diff_file)
365 response_body = MySend("/upload", body, content_type=ctype)
368 lines = msg.splitlines()
371 patchset = lines[1].strip()
372 patches = [x.split(" ", 1) for x in lines[2:]]
374 print >>sys.stderr, "Server says there is nothing to upload (probably wrong):\n" + msg
375 if response_body.startswith("Issue updated.") and quiet:
378 ui.status(msg + "\n")
379 set_status("uploaded CL metadata + diffs")
380 if not response_body.startswith("Issue created.") and not response_body.startswith("Issue updated."):
381 raise hg_util.Abort("failed to update issue: " + response_body)
382 issue = msg[msg.rfind("/")+1:]
385 self.url = server_url_base + self.name
386 if not uploaded_diff_file:
387 set_status("uploading patches")
388 patches = UploadSeparatePatches(issue, rpc, patchset, data, upload_options)
390 set_status("uploading base files")
391 vcs.UploadBaseFiles(issue, rpc, patches, patchset, upload_options, files)
393 MySend("/" + issue + "/upload_complete/" + patchset, payload="")
395 set_status("sending mail")
396 MySend("/" + issue + "/mail", payload="")
398 set_status("flushing changes to disk")
402 def Mail(self, ui, repo):
403 pmsg = "Hello " + JoinComma(self.reviewer)
405 pmsg += " (cc: %s)" % (', '.join(self.cc),)
408 repourl = ui.expandpath("default")
410 pmsg += "I'd like you to review this change to"
411 branch = repo[None].branch()
412 if branch.startswith("dev."):
413 pmsg += " the " + branch + " branch of"
414 pmsg += "\n" + repourl + "\n"
416 pmsg += "Please take another look.\n"
418 PostMessage(ui, self.name, pmsg, subject=self.Subject(ui, repo))
422 def GoodCLName(name):
424 return re.match("^[0-9]+$", name)
426 def ParseCL(text, name):
441 for line in text.split('\n'):
444 if line != '' and line[0] == '#':
446 if line == '' or line[0] == ' ' or line[0] == '\t':
447 if sname == None and line != '':
448 return None, lineno, 'text outside section'
450 sections[sname] += line + '\n'
454 s, val = line[:p].strip(), line[p+1:].strip()
458 sections[sname] += val + '\n'
460 return None, lineno, 'malformed section header'
463 sections[k] = StripCommon(sections[k]).rstrip()
466 if sections['Author']:
467 cl.copied_from = sections['Author']
468 cl.desc = sections['Description']
469 for line in sections['Files'].split('\n'):
472 line = line[0:i].rstrip()
476 cl.files.append(line)
477 cl.reviewer = SplitCommaSpace(sections['Reviewer'])
478 cl.cc = SplitCommaSpace(sections['CC'])
479 cl.url = sections['URL']
480 if sections['Mailed'] != 'False':
481 # Odd default, but avoids spurious mailings when
482 # reading old CLs that do not have a Mailed: line.
483 # CLs created with this update will always have
484 # Mailed: False on disk.
486 if sections['Private'] in ('True', 'true', 'Yes', 'yes'):
488 if cl.desc == '<enter description here>':
492 def SplitCommaSpace(s):
497 return re.split(", *", s)
515 return ", ".join(uniq)
517 def ExceptionDetail():
518 s = str(sys.exc_info()[0])
519 if s.startswith("<type '") and s.endswith("'>"):
521 elif s.startswith("<class '") and s.endswith("'>"):
523 arg = str(sys.exc_info()[1])
528 def IsLocalCL(ui, repo, name):
529 return GoodCLName(name) and os.access(CodeReviewDir(ui, repo) + "/cl." + name, 0)
531 # Load CL from disk and/or the web.
532 def LoadCL(ui, repo, name, web=True):
534 set_status("loading CL " + name)
535 if not GoodCLName(name):
536 return None, "invalid CL name"
537 dir = CodeReviewDir(ui, repo)
538 path = dir + "cl." + name
539 if os.access(path, 0):
543 cl, lineno, err = ParseCL(text, name)
545 return None, "malformed CL data: "+err
550 set_status("getting issue metadata from web")
551 d = JSONGet(ui, "/api/" + name + "?messages=true")
554 return None, "cannot load CL %s from server" % (name,)
555 if 'owner_email' not in d or 'issue' not in d or str(d['issue']) != name:
556 return None, "malformed response loading CL data from code review server"
558 cl.reviewer = d.get('reviewers', [])
559 cl.cc = d.get('cc', [])
560 if cl.local and cl.copied_from and cl.desc:
561 # local copy of CL written by someone else
562 # and we saved a description. use that one,
563 # so that committers can edit the description
564 # before doing hg submit.
567 cl.desc = d.get('description', "")
568 cl.url = server_url_base + name
570 cl.private = d.get('private', False) != False
572 for m in d.get('messages', []):
573 if m.get('approval', False) == True or m.get('disapproval', False) == True:
574 who = re.sub('@.*', '', m.get('sender', ''))
575 text = re.sub("\n(.|\n)*", '', m.get('text', ''))
576 cl.lgtm.append((who, text, m.get('approval', False)))
578 set_status("loaded CL " + name)
581 class LoadCLThread(threading.Thread):
582 def __init__(self, ui, repo, dir, f, web):
583 threading.Thread.__init__(self)
591 cl, err = LoadCL(self.ui, self.repo, self.f[3:], web=self.web)
593 self.ui.warn("loading "+self.dir+self.f+": " + err + "\n")
597 # Load all the CLs from this repository.
598 def LoadAllCL(ui, repo, web=True):
599 dir = CodeReviewDir(ui, repo)
601 files = [f for f in os.listdir(dir) if f.startswith('cl.')]
607 t = LoadCLThread(ui, repo, dir, f, web)
610 # first request: wait in case it needs to authenticate
611 # otherwise we get lots of user/password prompts
612 # running in parallel.
625 # Find repository root. On error, ui.warn and return None
626 def RepoDir(ui, repo):
628 if not url.startswith('file:'):
629 ui.warn("repository %s is not in local file system\n" % (url,))
632 if url.endswith('/'):
637 # Find (or make) code review directory. On error, ui.warn and return None
638 def CodeReviewDir(ui, repo):
639 dir = RepoDir(ui, repo)
642 dir += '/.hg/codereview/'
643 if not os.path.isdir(dir):
647 ui.warn('cannot mkdir %s: %s\n' % (dir, ExceptionDetail()))
652 # Turn leading tabs into spaces, so that the common white space
653 # prefix doesn't get confused when people's editors write out
654 # some lines with spaces, some with tabs. Only a heuristic
655 # (some editors don't use 8 spaces either) but a useful one.
656 def TabsToSpaces(line):
658 while i < len(line) and line[i] == '\t':
660 return ' '*(8*i) + line[i:]
662 # Strip maximal common leading white space prefix from text
663 def StripCommon(text):
666 for line in text.split('\n'):
670 line = TabsToSpaces(line)
671 white = line[:len(line)-len(line.lstrip())]
676 for i in range(min(len(white), len(ws))+1):
677 if white[0:i] == ws[0:i]:
685 for line in text.split('\n'):
687 line = TabsToSpaces(line)
688 if line.startswith(ws):
689 line = line[len(ws):]
690 if line == '' and t == '':
693 while len(t) >= 2 and t[-2:] == '\n\n':
698 # Indent text with indent.
699 def Indent(text, indent):
701 typecheck(indent, str)
703 for line in text.split('\n'):
704 t += indent + line + '\n'
708 # Return the first line of l
711 return text.split('\n')[0]
713 _change_prolog = """# Change list.
714 # Lines beginning with # are ignored.
715 # Multi-line values should be indented.
718 desc_re = '^(.+: |(tag )?(release|weekly)\.|fix build|undo CL)'
720 desc_msg = '''Your CL description appears not to use the standard form.
722 The first line of your change description is conventionally a
723 one-line summary of the change, prefixed by the primary affected package,
724 and is used as the subject for code review mail; the rest of the description
729 encoding/rot13: new package
731 math: add IsInf, IsNaN
733 net: fix cname in LookupHost
735 unicode: update to Unicode 5.0.2
739 def promptyesno(ui, msg):
740 if hgversion >= "2.7":
741 return ui.promptchoice(msg + " $$ &yes $$ &no", 0) == 0
743 return ui.promptchoice(msg, ["&yes", "&no"], 0) == 0
745 def promptremove(ui, repo, f):
746 if promptyesno(ui, "hg remove %s (y/n)?" % (f,)):
747 if hg_commands.remove(ui, repo, 'path:'+f) != 0:
748 ui.warn("error removing %s" % (f,))
750 def promptadd(ui, repo, f):
751 if promptyesno(ui, "hg add %s (y/n)?" % (f,)):
752 if hg_commands.add(ui, repo, 'path:'+f) != 0:
753 ui.warn("error adding %s" % (f,))
755 def EditCL(ui, repo, cl):
756 set_status(None) # do not show status
759 s = ui.edit(s, ui.username())
761 # We can't trust Mercurial + Python not to die before making the change,
762 # so, by popular demand, just scribble the most recent CL edit into
763 # $(hg root)/last-change so that if Mercurial does die, people
764 # can look there for their work.
766 f = open(repo.root+"/last-change", "w")
772 clx, line, err = ParseCL(s, cl.name)
774 if not promptyesno(ui, "error parsing change list: line %d: %s\nre-edit (y/n)?" % (line, err)):
775 return "change list not modified"
780 if promptyesno(ui, "change list should have a description\nre-edit (y/n)?"):
782 elif re.search('<enter reason for undo>', clx.desc):
783 if promptyesno(ui, "change list description omits reason for undo\nre-edit (y/n)?"):
785 elif not re.match(desc_re, clx.desc.split('\n')[0]):
786 if promptyesno(ui, desc_msg + "re-edit (y/n)?"):
789 # Check file list for files that need to be hg added or hg removed
790 # or simply aren't understood.
791 pats = ['path:'+f for f in clx.files]
792 changed = hg_matchPattern(ui, repo, *pats, modified=True, added=True, removed=True)
793 deleted = hg_matchPattern(ui, repo, *pats, deleted=True)
794 unknown = hg_matchPattern(ui, repo, *pats, unknown=True)
795 ignored = hg_matchPattern(ui, repo, *pats, ignored=True)
796 clean = hg_matchPattern(ui, repo, *pats, clean=True)
803 promptremove(ui, repo, f)
807 promptadd(ui, repo, f)
811 ui.warn("error: %s is excluded by .hgignore; omitting\n" % (f,))
814 ui.warn("warning: %s is listed in the CL but unchanged\n" % (f,))
817 p = repo.root + '/' + f
818 if os.path.isfile(p):
819 ui.warn("warning: %s is a file but not known to hg\n" % (f,))
823 ui.warn("error: %s is a directory, not a file; omitting\n" % (f,))
825 ui.warn("error: %s does not exist; omitting\n" % (f,))
829 cl.reviewer = clx.reviewer
832 cl.private = clx.private
836 # For use by submit, etc. (NOT by change)
837 # Get change list number or list of files from command line.
838 # If files are given, make a new change list.
839 def CommandLineCL(ui, repo, pats, opts, op="verb", defaultcc=None):
840 if len(pats) > 0 and GoodCLName(pats[0]):
842 return None, "cannot specify change number and file names"
843 if opts.get('message'):
844 return None, "cannot use -m with existing CL"
845 cl, err = LoadCL(ui, repo, pats[0], web=True)
851 cl.files = ChangedFiles(ui, repo, pats, taken=Taken(ui, repo))
853 return None, "no files changed (use hg %s <number> to use existing CL)" % op
854 if opts.get('reviewer'):
855 cl.reviewer = Add(cl.reviewer, SplitCommaSpace(opts.get('reviewer')))
857 cl.cc = Add(cl.cc, SplitCommaSpace(opts.get('cc')))
858 if defaultcc and not cl.private:
859 cl.cc = Add(cl.cc, defaultcc)
861 if opts.get('message'):
862 cl.desc = opts.get('message')
864 err = EditCL(ui, repo, cl)
869 #######################################################################
870 # Change list file management
872 # Return list of changed files in repository that match pats.
873 # The patterns came from the command line, so we warn
874 # if they have no effect or cannot be understood.
875 def ChangedFiles(ui, repo, pats, taken=None):
877 # Run each pattern separately so that we can warn about
878 # patterns that didn't do anything useful.
880 for f in hg_matchPattern(ui, repo, p, unknown=True):
881 promptadd(ui, repo, f)
882 for f in hg_matchPattern(ui, repo, p, removed=True):
883 promptremove(ui, repo, f)
884 files = hg_matchPattern(ui, repo, p, modified=True, added=True, removed=True)
887 ui.warn("warning: %s already in CL %s\n" % (f, taken[f].name))
889 ui.warn("warning: %s did not match any modified files\n" % (p,))
891 # Again, all at once (eliminates duplicates)
892 l = hg_matchPattern(ui, repo, *pats, modified=True, added=True, removed=True)
895 l = Sub(l, taken.keys())
898 # Return list of changed files in repository that match pats and still exist.
899 def ChangedExistingFiles(ui, repo, pats, opts):
900 l = hg_matchPattern(ui, repo, *pats, modified=True, added=True)
904 # Return list of files claimed by existing CLs
906 all = LoadAllCL(ui, repo, web=False)
908 for _, cl in all.items():
913 # Return list of changed files that are not claimed by other CLs
914 def DefaultFiles(ui, repo, pats):
915 return ChangedFiles(ui, repo, pats, taken=Taken(ui, repo))
917 #######################################################################
918 # File format checking.
920 def CheckFormat(ui, repo, files, just_warn=False):
921 set_status("running gofmt")
922 CheckGofmt(ui, repo, files, just_warn)
923 CheckTabfmt(ui, repo, files, just_warn)
925 # Check that gofmt run on the list of files does not change them
926 def CheckGofmt(ui, repo, files, just_warn):
927 files = gofmt_required(files)
931 files = [RelativePath(repo.root + '/' + f, cwd) for f in files]
932 files = [f for f in files if os.access(f, 0)]
936 cmd = subprocess.Popen(["gofmt", "-l"] + files, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=sys.platform != "win32")
939 raise hg_util.Abort("gofmt: " + ExceptionDetail())
940 data = cmd.stdout.read()
941 errors = cmd.stderr.read()
943 set_status("done with gofmt")
945 ui.warn("gofmt errors:\n" + errors.rstrip() + "\n")
948 msg = "gofmt needs to format these files (run hg gofmt):\n" + Indent(data, "\t").rstrip()
950 ui.warn("warning: " + msg + "\n")
952 raise hg_util.Abort(msg)
955 # Check that *.[chys] files indent using tabs.
956 def CheckTabfmt(ui, repo, files, just_warn):
957 files = [f for f in files if f.startswith('src/') and re.search(r"\.[chys]$", f) and not re.search(r"\.tab\.[ch]$", f)]
961 files = [RelativePath(repo.root + '/' + f, cwd) for f in files]
962 files = [f for f in files if os.access(f, 0)]
966 for line in open(f, 'r'):
967 # Four leading spaces is enough to complain about,
968 # except that some Plan 9 code uses four spaces as the label indent,
970 if line.startswith(' ') and not re.match(' [A-Za-z0-9_]+:', line):
974 # ignore cannot open file, etc.
976 if len(badfiles) > 0:
977 msg = "these files use spaces for indentation (use tabs instead):\n\t" + "\n\t".join(badfiles)
979 ui.warn("warning: " + msg + "\n")
981 raise hg_util.Abort(msg)
984 #######################################################################
985 # CONTRIBUTORS file parsing
987 contributorsCache = None
988 contributorsURL = None
990 def ReadContributors(ui, repo):
991 global contributorsCache
992 if contributorsCache is not None:
993 return contributorsCache
996 if contributorsURL is not None:
997 opening = contributorsURL
998 f = urllib2.urlopen(contributorsURL)
1000 opening = repo.root + '/CONTRIBUTORS'
1001 f = open(repo.root + '/CONTRIBUTORS', 'r')
1003 ui.write("warning: cannot open %s: %s\n" % (opening, ExceptionDetail()))
1008 # CONTRIBUTORS is a list of lines like:
1010 # Person <email> <alt-email>
1011 # The first email address is the one used in commit logs.
1012 if line.startswith('#'):
1014 m = re.match(r"([^<>]+\S)\s+(<[^<>\s]+>)((\s+<[^<>\s]+>)*)\s*$", line)
1017 email = m.group(2)[1:-1]
1018 contributors[email.lower()] = (name, email)
1019 for extra in m.group(3).split():
1020 contributors[extra[1:-1].lower()] = (name, email)
1022 contributorsCache = contributors
1025 def CheckContributor(ui, repo, user=None):
1026 set_status("checking CONTRIBUTORS file")
1027 user, userline = FindContributor(ui, repo, user, warn=False)
1029 raise hg_util.Abort("cannot find %s in CONTRIBUTORS" % (user,))
1032 def FindContributor(ui, repo, user=None, warn=True):
1034 user = ui.config("ui", "username")
1036 raise hg_util.Abort("[ui] username is not configured in .hgrc")
1038 m = re.match(r".*<(.*)>", user)
1042 contributors = ReadContributors(ui, repo)
1043 if user not in contributors:
1045 ui.warn("warning: cannot find %s in CONTRIBUTORS\n" % (user,))
1048 user, email = contributors[user]
1049 return email, "%s <%s>" % (user, email)
1051 #######################################################################
1052 # Mercurial helper functions.
1053 # Read http://mercurial.selenic.com/wiki/MercurialApi before writing any of these.
1054 # We use the ui.pushbuffer/ui.popbuffer + hg_commands.xxx tricks for all interaction
1055 # with Mercurial. It has proved the most stable as they make changes.
1057 hgversion = hg_util.version()
1059 # We require Mercurial 1.9 and suggest Mercurial 2.1.
1060 # The details of the scmutil package changed then,
1061 # so allowing earlier versions would require extra band-aids below.
1062 # Ubuntu 11.10 ships with Mercurial 1.9.1 as the default version.
1064 hg_suggested = "2.1"
1068 The code review extension requires Mercurial """+hg_required+""" or newer.
1069 You are using Mercurial """+hgversion+""".
1071 To install a new Mercurial, visit http://mercurial.selenic.com/downloads/.
1075 You may need to clear your current Mercurial installation by running:
1077 sudo apt-get remove mercurial mercurial-common
1078 sudo rm -rf /etc/mercurial
1081 if hgversion < hg_required:
1083 if os.access("/etc/mercurial", 0):
1084 msg += linux_message
1085 raise hg_util.Abort(msg)
1087 from mercurial.hg import clean as hg_clean
1088 from mercurial import cmdutil as hg_cmdutil
1089 from mercurial import error as hg_error
1090 from mercurial import match as hg_match
1091 from mercurial import node as hg_node
1093 class uiwrap(object):
1094 def __init__(self, ui):
1097 self.oldQuiet = ui.quiet
1099 self.oldVerbose = ui.verbose
1103 ui.quiet = self.oldQuiet
1104 ui.verbose = self.oldVerbose
1105 return ui.popbuffer()
1108 if sys.platform == "win32":
1109 return path.replace('\\', '/')
1112 def hg_matchPattern(ui, repo, *pats, **opts):
1114 hg_commands.status(ui, repo, *pats, **opts)
1117 prefix = to_slash(os.path.realpath(repo.root))+'/'
1118 for line in text.split('\n'):
1122 # Given patterns, Mercurial shows relative to cwd
1123 p = to_slash(os.path.realpath(f[1]))
1124 if not p.startswith(prefix):
1125 print >>sys.stderr, "File %s not in repo root %s.\n" % (p, prefix)
1127 ret.append(p[len(prefix):])
1129 # Without patterns, Mercurial shows relative to root (what we want)
1130 ret.append(to_slash(f[1]))
1133 def hg_heads(ui, repo):
1135 hg_commands.heads(ui, repo)
1140 "resolving manifests",
1141 "searching for changes",
1142 "couldn't find merge tool hgmerge",
1143 "adding changesets",
1145 "adding file changes",
1146 "all local heads known remotely",
1156 def hg_incoming(ui, repo):
1158 ret = hg_commands.incoming(ui, repo, force=False, bundle="")
1159 if ret and ret != 1:
1160 raise hg_util.Abort(ret)
1163 def hg_log(ui, repo, **opts):
1164 for k in ['date', 'keyword', 'rev', 'user']:
1165 if not opts.has_key(k):
1168 ret = hg_commands.log(ui, repo, **opts)
1170 raise hg_util.Abort(ret)
1173 def hg_outgoing(ui, repo, **opts):
1175 ret = hg_commands.outgoing(ui, repo, **opts)
1176 if ret and ret != 1:
1177 raise hg_util.Abort(ret)
1180 def hg_pull(ui, repo, **opts):
1183 ui.verbose = True # for file list
1184 err = hg_commands.pull(ui, repo, **opts)
1185 for line in w.output().split('\n'):
1188 if line.startswith('moving '):
1189 line = 'mv ' + line[len('moving '):]
1190 if line.startswith('getting ') and line.find(' to ') >= 0:
1191 line = 'mv ' + line[len('getting '):]
1192 if line.startswith('getting '):
1193 line = '+ ' + line[len('getting '):]
1194 if line.startswith('removing '):
1195 line = '- ' + line[len('removing '):]
1196 ui.write(line + '\n')
1199 def hg_update(ui, repo, **opts):
1202 ui.verbose = True # for file list
1203 err = hg_commands.update(ui, repo, **opts)
1204 for line in w.output().split('\n'):
1207 if line.startswith('moving '):
1208 line = 'mv ' + line[len('moving '):]
1209 if line.startswith('getting ') and line.find(' to ') >= 0:
1210 line = 'mv ' + line[len('getting '):]
1211 if line.startswith('getting '):
1212 line = '+ ' + line[len('getting '):]
1213 if line.startswith('removing '):
1214 line = '- ' + line[len('removing '):]
1215 ui.write(line + '\n')
1218 def hg_push(ui, repo, **opts):
1222 err = hg_commands.push(ui, repo, **opts)
1223 for line in w.output().split('\n'):
1224 if not isNoise(line):
1225 ui.write(line + '\n')
1228 def hg_commit(ui, repo, *pats, **opts):
1229 return hg_commands.commit(ui, repo, *pats, **opts)
1231 #######################################################################
1232 # Mercurial precommit hook to disable commit except through this interface.
1236 def precommithook(ui, repo, **opts):
1237 if hgversion >= "2.1":
1238 from mercurial import phases
1239 if repo.ui.config('phases', 'new-commit') >= phases.secret:
1242 return False # False means okay.
1243 ui.write("\ncodereview extension enabled; use mail, upload, or submit instead of commit\n\n")
1246 #######################################################################
1247 # @clnumber file pattern support
1249 # We replace scmutil.match with the MatchAt wrapper to add the @clnumber pattern.
1255 def InstallMatch(ui, repo):
1263 from mercurial import scmutil
1264 match_orig = scmutil.match
1265 scmutil.match = MatchAt
1267 def MatchAt(ctx, pats=None, opts=None, globbed=False, default='relpath'):
1274 if p.startswith('@'):
1277 if clname == "default":
1278 files = DefaultFiles(match_ui, match_repo, [])
1280 if not GoodCLName(clname):
1281 raise hg_util.Abort("invalid CL name " + clname)
1282 cl, err = LoadCL(match_repo.ui, match_repo, clname, web=False)
1284 raise hg_util.Abort("loading CL " + clname + ": " + err)
1286 raise hg_util.Abort("no files in CL " + clname)
1287 files = Add(files, cl.files)
1288 pats = Sub(pats, taken) + ['path:'+f for f in files]
1290 # work-around for http://selenic.com/hg/rev/785bbc8634f8
1291 if not hasattr(ctx, 'match'):
1293 return match_orig(ctx, pats=pats, opts=opts, globbed=globbed, default=default)
1295 #######################################################################
1296 # Commands added by code review extension.
1301 #######################################################################
1305 def change(ui, repo, *pats, **opts):
1306 """create, edit or delete a change list
1308 Create, edit or delete a change list.
1309 A change list is a group of files to be reviewed and submitted together,
1310 plus a textual description of the change.
1311 Change lists are referred to by simple alphanumeric names.
1313 Changes must be reviewed before they can be submitted.
1315 In the absence of options, the change command opens the
1316 change list for editing in the default editor.
1318 Deleting a change with the -d or -D flag does not affect
1319 the contents of the files listed in that change. To revert
1320 the files listed in a change, use
1324 before running hg change -d 123456.
1327 if codereview_disabled:
1328 raise hg_util.Abort(codereview_disabled)
1331 if len(pats) > 0 and GoodCLName(pats[0]):
1334 raise hg_util.Abort("cannot specify CL name and file patterns")
1336 cl, err = LoadCL(ui, repo, name, web=True)
1338 raise hg_util.Abort(err)
1339 if not cl.local and (opts["stdin"] or not opts["stdout"]):
1340 raise hg_util.Abort("cannot change non-local CL " + name)
1344 if not workbranch(repo[None].branch()):
1345 raise hg_util.Abort("cannot create CL outside default branch; switch with 'hg update default'")
1347 files = ChangedFiles(ui, repo, pats, taken=Taken(ui, repo))
1349 if opts["delete"] or opts["deletelocal"]:
1350 if opts["delete"] and opts["deletelocal"]:
1351 raise hg_util.Abort("cannot use -d and -D together")
1353 if opts["deletelocal"]:
1356 raise hg_util.Abort("cannot use "+flag+" with file patterns")
1357 if opts["stdin"] or opts["stdout"]:
1358 raise hg_util.Abort("cannot use "+flag+" with -i or -o")
1360 raise hg_util.Abort("cannot change non-local CL " + name)
1363 raise hg_util.Abort("original author must delete CL; hg change -D will remove locally")
1364 PostMessage(ui, cl.name, "*** Abandoned ***", send_mail=cl.mailed)
1365 EditDesc(cl.name, closed=True, private=cl.private)
1370 s = sys.stdin.read()
1371 clx, line, err = ParseCL(s, name)
1373 raise hg_util.Abort("error parsing change list: line %d: %s" % (line, err))
1374 if clx.desc is not None:
1377 if clx.reviewer is not None:
1378 cl.reviewer = clx.reviewer
1380 if clx.cc is not None:
1383 if clx.files is not None:
1384 cl.files = clx.files
1386 if clx.private != cl.private:
1387 cl.private = clx.private
1390 if not opts["stdin"] and not opts["stdout"]:
1393 err = EditCL(ui, repo, cl)
1395 raise hg_util.Abort(err)
1398 for d, _ in dirty.items():
1402 d.Upload(ui, repo, quiet=True)
1405 ui.write(cl.EditorText())
1406 elif opts["pending"]:
1407 ui.write(cl.PendingText())
1412 ui.write("CL created: " + cl.url + "\n")
1415 #######################################################################
1416 # hg code-login (broken?)
1419 def code_login(ui, repo, **opts):
1420 """log in to code review server
1422 Logs in to the code review server, saving a cookie in
1423 a file in your home directory.
1425 if codereview_disabled:
1426 raise hg_util.Abort(codereview_disabled)
1430 #######################################################################
1431 # hg clpatch / undo / release-apply / download
1432 # All concerned with applying or unapplying patches to the repository.
1435 def clpatch(ui, repo, clname, **opts):
1436 """import a patch from the code review server
1438 Imports a patch from the code review server into the local client.
1439 If the local client has already modified any of the files that the
1440 patch modifies, this command will refuse to apply the patch.
1442 Submitting an imported patch will keep the original author's
1443 name as the Author: line but add your own name to a Committer: line.
1445 if not workbranch(repo[None].branch()):
1446 raise hg_util.Abort("cannot run hg clpatch outside default branch")
1447 err = clpatch_or_undo(ui, repo, clname, opts, mode="clpatch")
1449 raise hg_util.Abort(err)
1452 def undo(ui, repo, clname, **opts):
1453 """undo the effect of a CL
1455 Creates a new CL that undoes an earlier CL.
1456 After creating the CL, opens the CL text for editing so that
1457 you can add the reason for the undo to the description.
1459 if not workbranch(repo[None].branch()):
1460 raise hg_util.Abort("cannot run hg undo outside default branch")
1461 err = clpatch_or_undo(ui, repo, clname, opts, mode="undo")
1463 raise hg_util.Abort(err)
1466 def release_apply(ui, repo, clname, **opts):
1467 """apply a CL to the release branch
1469 Creates a new CL copying a previously committed change
1470 from the main branch to the release branch.
1471 The current client must either be clean or already be in
1474 The release branch must be created by starting with a
1475 clean client, disabling the code review plugin, and running:
1477 hg update weekly.YYYY-MM-DD
1478 hg branch release-branch.rNN
1479 hg commit -m 'create release-branch.rNN'
1480 hg push --new-branch
1482 Then re-enable the code review plugin.
1484 People can test the release branch by running
1486 hg update release-branch.rNN
1488 in a clean client. To return to the normal tree,
1492 Move changes since the weekly into the release branch
1493 using hg release-apply followed by the usual code review
1494 process and hg submit.
1496 When it comes time to tag the release, record the
1497 final long-form tag of the release-branch.rNN
1498 in the *default* branch's .hgtags file. That is, run
1502 and then edit .hgtags as you would for a weekly.
1506 if not releaseBranch:
1507 raise hg_util.Abort("no active release branches")
1508 if c.branch() != releaseBranch:
1509 if c.modified() or c.added() or c.removed():
1510 raise hg_util.Abort("uncommitted local changes - cannot switch branches")
1511 err = hg_clean(repo, releaseBranch)
1513 raise hg_util.Abort(err)
1515 err = clpatch_or_undo(ui, repo, clname, opts, mode="backport")
1517 raise hg_util.Abort(err)
1518 except Exception, e:
1519 hg_clean(repo, "default")
1522 def rev2clname(rev):
1523 # Extract CL name from revision description.
1524 # The last line in the description that is a codereview URL is the real one.
1525 # Earlier lines might be part of the user-written description.
1526 all = re.findall('(?m)^https?://codereview.appspot.com/([0-9]+)$', rev.description())
1531 undoHeader = """undo CL %s / %s
1533 <enter reason for undo>
1535 ««« original CL description
1542 backportHeader = """[%s] %s
1547 backportFooter = """
1551 # Implementation of clpatch/undo.
1552 def clpatch_or_undo(ui, repo, clname, opts, mode):
1553 if codereview_disabled:
1554 return codereview_disabled
1556 if mode == "undo" or mode == "backport":
1557 # Find revision in Mercurial repository.
1558 # Assume CL number is 7+ decimal digits.
1559 # Otherwise is either change log sequence number (fewer decimal digits),
1560 # hexadecimal hash, or tag name.
1561 # Mercurial will fall over long before the change log
1562 # sequence numbers get to be 7 digits long.
1563 if re.match('^[0-9]{7,}$', clname):
1565 for r in hg_log(ui, repo, keyword="codereview.appspot.com/"+clname, limit=100, template="{node}\n").split():
1567 # Last line with a code review URL is the actual review URL.
1568 # Earlier ones might be part of the CL description.
1574 return "cannot find CL %s in local repository" % clname
1578 return "unknown revision %s" % clname
1579 clname = rev2clname(rev)
1581 return "cannot find CL name in revision description"
1583 # Create fresh CL and start with patch that would reverse the change.
1584 vers = hg_node.short(rev.node())
1586 desc = str(rev.description())
1588 cl.desc = (undoHeader % (clname, vers)) + desc + undoFooter
1590 cl.desc = (backportHeader % (releaseBranch, line1(desc), clname, vers)) + desc + undoFooter
1592 v0 = hg_node.short(rev.parents()[0].node())
1598 patch = RunShell(["hg", "diff", "--git", "-r", arg])
1601 cl, vers, patch, err = DownloadCL(ui, repo, clname)
1604 if patch == emptydiff:
1605 return "codereview issue %s has no diff" % clname
1607 # find current hg version (hg identify)
1609 parents = ctx.parents()
1610 id = '+'.join([hg_node.short(p.node()) for p in parents])
1612 # if version does not match the patch version,
1613 # try to update the patch line numbers.
1614 if vers != "" and id != vers:
1615 # "vers in repo" gives the wrong answer
1616 # on some versions of Mercurial. Instead, do the actual
1617 # lookup and catch the exception.
1619 repo[vers].description()
1621 return "local repository is out of date; sync to get %s" % (vers)
1622 patch1, err = portPatch(repo, patch, vers, id)
1624 if not opts["ignore_hgapplydiff_failure"]:
1625 return "codereview issue %s is out of date: %s (%s->%s)" % (clname, err, vers, id)
1628 argv = ["hgapplydiff"]
1629 if opts["no_incoming"] or mode == "backport":
1630 argv += ["--checksync=false"]
1632 cmd = subprocess.Popen(argv, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=None, close_fds=sys.platform != "win32")
1634 return "hgapplydiff: " + ExceptionDetail() + "\nInstall hgapplydiff with:\n$ go get golang.org/x/codereview/cmd/hgapplydiff\n"
1636 out, err = cmd.communicate(patch)
1637 if cmd.returncode != 0 and not opts["ignore_hgapplydiff_failure"]:
1638 return "hgapplydiff failed"
1640 cl.files = out.strip().split()
1641 if not cl.files and not opts["ignore_hgapplydiff_failure"]:
1642 return "codereview issue %s has no changed files" % clname
1643 files = ChangedFiles(ui, repo, [])
1644 extra = Sub(cl.files, files)
1646 ui.warn("warning: these files were listed in the patch but not changed:\n\t" + "\n\t".join(extra) + "\n")
1649 err = EditCL(ui, repo, cl)
1651 return "CL created, but error editing: " + err
1654 ui.write(cl.PendingText() + "\n")
1656 # portPatch rewrites patch from being a patch against
1657 # oldver to being a patch against newver.
1658 def portPatch(repo, patch, oldver, newver):
1659 lines = patch.splitlines(True) # True = keep \n
1661 for i in range(len(lines)):
1663 if line.startswith('--- a/'):
1665 delta = fileDeltas(repo, file, oldver, newver)
1666 if not delta or not line.startswith('@@ '):
1668 # @@ -x,y +z,w @@ means the patch chunk replaces
1669 # the original file's line numbers x up to x+y with the
1670 # line numbers z up to z+w in the new file.
1671 # Find the delta from x in the original to the same
1672 # line in the current version and add that delta to both
1674 m = re.match('@@ -([0-9]+),([0-9]+) \+([0-9]+),([0-9]+) @@', line)
1676 return None, "error parsing patch line numbers"
1677 n1, len1, n2, len2 = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))
1678 d, err = lineDelta(delta, n1, len1)
1683 lines[i] = "@@ -%d,%d +%d,%d @@\n" % (n1, len1, n2, len2)
1685 newpatch = ''.join(lines)
1688 # fileDelta returns the line number deltas for the given file's
1689 # changes from oldver to newver.
1690 # The deltas are a list of (n, len, newdelta) triples that say
1691 # lines [n, n+len) were modified, and after that range the
1692 # line numbers are +newdelta from what they were before.
1693 def fileDeltas(repo, file, oldver, newver):
1694 cmd = ["hg", "diff", "--git", "-r", oldver + ":" + newver, "path:" + file]
1695 data = RunShell(cmd, silent_ok=True)
1697 for line in data.splitlines():
1698 m = re.match('@@ -([0-9]+),([0-9]+) \+([0-9]+),([0-9]+) @@', line)
1701 n1, len1, n2, len2 = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))
1702 deltas.append((n1, len1, n2+len2-(n1+len1)))
1705 # lineDelta finds the appropriate line number delta to apply to the lines [n, n+len).
1706 # It returns an error if those lines were rewritten by the patch.
1707 def lineDelta(deltas, n, len):
1709 for (old, oldlen, newdelta) in deltas:
1713 return 0, "patch and recent changes conflict"
1718 def download(ui, repo, clname, **opts):
1719 """download a change from the code review server
1721 Download prints a description of the given change list
1722 followed by its diff, downloaded from the code review server.
1724 if codereview_disabled:
1725 raise hg_util.Abort(codereview_disabled)
1727 cl, vers, patch, err = DownloadCL(ui, repo, clname)
1730 ui.write(cl.EditorText() + "\n")
1731 ui.write(patch + "\n")
1734 #######################################################################
1738 def file(ui, repo, clname, pat, *pats, **opts):
1739 """assign files to or remove files from a change list
1741 Assign files to or (with -d) remove files from a change list.
1743 The -d option only removes files from the change list.
1744 It does not edit them or remove them from the repository.
1746 if codereview_disabled:
1747 raise hg_util.Abort(codereview_disabled)
1749 pats = tuple([pat] + list(pats))
1750 if not GoodCLName(clname):
1751 return "invalid CL name " + clname
1754 cl, err = LoadCL(ui, repo, clname, web=False)
1758 return "cannot change non-local CL " + clname
1760 files = ChangedFiles(ui, repo, pats)
1763 oldfiles = Intersect(files, cl.files)
1766 ui.status("# Removing files from CL. To undo:\n")
1767 ui.status("# cd %s\n" % (repo.root))
1769 ui.status("# hg file %s %s\n" % (cl.name, f))
1770 cl.files = Sub(cl.files, oldfiles)
1773 ui.status("no such files in CL")
1777 return "no such modified files"
1779 files = Sub(files, cl.files)
1780 taken = Taken(ui, repo)
1784 if not warned and not ui.quiet:
1785 ui.status("# Taking files from other CLs. To undo:\n")
1786 ui.status("# cd %s\n" % (repo.root))
1790 ui.status("# hg file %s %s\n" % (ocl.name, f))
1791 if ocl not in dirty:
1792 ocl.files = Sub(ocl.files, files)
1794 cl.files = Add(cl.files, files)
1796 for d, _ in dirty.items():
1800 #######################################################################
1804 def gofmt(ui, repo, *pats, **opts):
1805 """apply gofmt to modified files
1807 Applies gofmt to the modified files in the repository that match
1810 if codereview_disabled:
1811 raise hg_util.Abort(codereview_disabled)
1813 files = ChangedExistingFiles(ui, repo, pats, opts)
1814 files = gofmt_required(files)
1816 ui.status("no modified go files\n")
1819 files = [RelativePath(repo.root + '/' + f, cwd) for f in files]
1821 cmd = ["gofmt", "-l"]
1822 if not opts["list"]:
1824 if subprocess.call(cmd + files) != 0:
1825 raise hg_util.Abort("gofmt did not exit cleanly")
1826 except hg_error.Abort, e:
1829 raise hg_util.Abort("gofmt: " + ExceptionDetail())
1832 def gofmt_required(files):
1833 return [f for f in files if (not f.startswith('test/') or f.startswith('test/bench/')) and f.endswith('.go')]
1835 #######################################################################
1839 def mail(ui, repo, *pats, **opts):
1840 """mail a change for review
1842 Uploads a patch to the code review server and then sends mail
1843 to the reviewer and CC list asking for a review.
1845 if codereview_disabled:
1846 raise hg_util.Abort(codereview_disabled)
1848 cl, err = CommandLineCL(ui, repo, pats, opts, op="mail", defaultcc=defaultcc)
1850 raise hg_util.Abort(err)
1851 cl.Upload(ui, repo, gofmt_just_warn=True)
1853 # If no reviewer is listed, assign the review to defaultcc.
1854 # This makes sure that it appears in the
1855 # codereview.appspot.com/user/defaultcc
1856 # page, so that it doesn't get dropped on the floor.
1857 if not defaultcc or cl.private:
1858 raise hg_util.Abort("no reviewers listed in CL")
1859 cl.cc = Sub(cl.cc, defaultcc)
1860 cl.reviewer = defaultcc
1864 raise hg_util.Abort("no changed files, not sending mail")
1868 #######################################################################
1869 # hg p / hg pq / hg ps / hg pending
1872 def ps(ui, repo, *pats, **opts):
1873 """alias for hg p --short
1875 opts['short'] = True
1876 return pending(ui, repo, *pats, **opts)
1879 def pq(ui, repo, *pats, **opts):
1880 """alias for hg p --quick
1882 opts['quick'] = True
1883 return pending(ui, repo, *pats, **opts)
1886 def pending(ui, repo, *pats, **opts):
1887 """show pending changes
1889 Lists pending changes followed by a list of unassigned but modified files.
1891 if codereview_disabled:
1892 raise hg_util.Abort(codereview_disabled)
1894 quick = opts.get('quick', False)
1895 short = opts.get('short', False)
1896 m = LoadAllCL(ui, repo, web=not quick and not short)
1902 ui.write(name + "\t" + line1(cl.desc) + "\n")
1904 ui.write(cl.PendingText(quick=quick) + "\n")
1908 files = DefaultFiles(ui, repo, [])
1910 s = "Changed files not in any CL:\n"
1912 s += "\t" + f + "\n"
1915 #######################################################################
1919 raise hg_util.Abort("local repository out of date; must sync before submit")
1921 def branch_prefix(ui, repo):
1923 branch = repo[None].branch()
1924 if branch.startswith("dev."):
1925 prefix = "[" + branch + "] "
1929 def submit(ui, repo, *pats, **opts):
1930 """submit change to remote repository
1932 Submits change to remote repository.
1933 Bails out if the local repository is not in sync with the remote one.
1935 if codereview_disabled:
1936 raise hg_util.Abort(codereview_disabled)
1938 # We already called this on startup but sometimes Mercurial forgets.
1939 set_mercurial_encoding_to_utf8()
1941 if not opts["no_incoming"] and hg_incoming(ui, repo):
1944 cl, err = CommandLineCL(ui, repo, pats, opts, op="submit", defaultcc=defaultcc)
1946 raise hg_util.Abort(err)
1950 user = cl.copied_from
1951 userline = CheckContributor(ui, repo, user)
1952 typecheck(userline, str)
1956 if not cl.lgtm and not opts.get('tbr') and needLGTM(cl):
1957 raise hg_util.Abort("this CL has not been LGTM'ed")
1959 about += "LGTM=" + JoinComma([CutDomain(who) for (who, line, approval) in cl.lgtm if approval]) + "\n"
1960 reviewer = cl.reviewer
1962 tbr = SplitCommaSpace(opts.get('tbr'))
1964 if name.startswith('golang-'):
1965 raise hg_util.Abort("--tbr requires a person, not a mailing list")
1966 cl.reviewer = Add(cl.reviewer, tbr)
1967 about += "TBR=" + JoinComma([CutDomain(s) for s in tbr]) + "\n"
1969 about += "R=" + JoinComma([CutDomain(s) for s in reviewer]) + "\n"
1971 about += "CC=" + JoinComma([CutDomain(s) for s in cl.cc]) + "\n"
1973 if not cl.reviewer and needLGTM(cl):
1974 raise hg_util.Abort("no reviewers listed in CL")
1977 raise hg_util.Abort("cannot submit non-local CL")
1979 # upload, to sync current patch and also get change number if CL is new.
1980 if not cl.copied_from:
1981 cl.Upload(ui, repo, gofmt_just_warn=True)
1983 # check gofmt for real; allowed upload to warn in order to save CL.
1985 CheckFormat(ui, repo, cl.files)
1987 about += "%s%s\n" % (server_url_base, cl.name)
1990 about += "\nCommitter: " + CheckContributor(ui, repo, None) + "\n"
1991 typecheck(about, str)
1993 if not cl.mailed and not cl.copied_from: # in case this is TBR
1996 # submit changes locally
1997 message = branch_prefix(ui, repo) + cl.desc.rstrip() + "\n\n" + about
1998 typecheck(message, str)
2000 set_status("pushing " + cl.name + " to remote server")
2002 if hg_outgoing(ui, repo):
2003 raise hg_util.Abort("local repository corrupt or out-of-phase with remote: found outgoing changes")
2005 old_heads = len(hg_heads(ui, repo).split())
2007 # Normally we commit listing the specific files in the CL.
2008 # If there are no changed files other than those in the CL, however,
2009 # let hg build the list, because then committing a merge works.
2010 # (You cannot name files for a merge commit, even if you name
2011 # all the files that would be committed by not naming any.)
2012 files = ['path:'+f for f in cl.files]
2013 if ChangedFiles(ui, repo, []) == cl.files:
2018 ret = hg_commit(ui, repo, *files, message=message, user=userline)
2021 raise hg_util.Abort("nothing changed")
2023 node = repo["-1"].node()
2024 # push to remote; if it fails for any reason, roll back
2026 new_heads = len(hg_heads(ui, repo).split())
2027 if cl.desc.find("create new branch") < 0 and old_heads != new_heads and not (old_heads == 0 and new_heads == 1):
2028 # Created new head, so we weren't up to date.
2031 # Push changes to remote. If it works, we're committed. If not, roll back.
2033 if hg_push(ui, repo, new_branch=cl.desc.find("create new branch")>=0):
2034 raise hg_util.Abort("push error")
2035 except hg_error.Abort, e:
2036 if e.message.find("push creates new heads") >= 0:
2037 # Remote repository had changes we missed.
2040 except urllib2.HTTPError, e:
2041 print >>sys.stderr, "pushing to remote server failed; do you have commit permissions?"
2047 # We're committed. Upload final patch, close review, add commit message.
2048 changeURL = hg_node.short(node)
2049 url = ui.expandpath("default")
2050 m = re.match("(^https?://([^@/]+@)?([^.]+)\.googlecode\.com/hg/?)" + "|" +
2051 "(^https?://([^@/]+@)?code\.google\.com/p/([^/.]+)(\.[^./]+)?/?)", url)
2053 if m.group(1): # prj.googlecode.com/hg/ case
2054 changeURL = "https://code.google.com/p/%s/source/detail?r=%s" % (m.group(3), changeURL)
2055 elif m.group(4) and m.group(7): # code.google.com/p/prj.subrepo/ case
2056 changeURL = "https://code.google.com/p/%s/source/detail?r=%s&repo=%s" % (m.group(6), changeURL, m.group(7)[1:])
2057 elif m.group(4): # code.google.com/p/prj/ case
2058 changeURL = "https://code.google.com/p/%s/source/detail?r=%s" % (m.group(6), changeURL)
2060 print >>sys.stderr, "URL: ", url
2062 print >>sys.stderr, "URL: ", url
2063 pmsg = "*** Submitted as " + changeURL + " ***\n\n" + message
2065 # When posting, move reviewers to CC line,
2066 # so that the issue stops showing up in their "My Issues" page.
2067 PostMessage(ui, cl.name, pmsg, reviewers="", cc=JoinComma(cl.reviewer+cl.cc))
2069 if not cl.copied_from:
2070 EditDesc(cl.name, closed=True, private=cl.private)
2074 if c.branch() == releaseBranch and not c.modified() and not c.added() and not c.removed():
2075 ui.write("switching from %s to default branch.\n" % releaseBranch)
2076 err = hg_clean(repo, "default")
2083 isGobot = 'gobot' in rev or 'gobot@swtch.com' in rev or 'gobot@golang.org' in rev
2085 # A+C CLs generated by addca do not need LGTM
2086 if cl.desc.startswith('A+C:') and 'Generated by a+c.' in cl.desc and isGobot:
2089 # CLs modifying only go1.x.txt do not need LGTM
2090 if len(cl.files) == 1 and cl.files[0].startswith('doc/go1.') and cl.files[0].endswith('.txt'):
2093 # Other CLs need LGTM
2096 #######################################################################
2100 def sync(ui, repo, **opts):
2101 """synchronize with remote repository
2103 Incorporates recent changes from the remote repository
2104 into the local repository.
2106 if codereview_disabled:
2107 raise hg_util.Abort(codereview_disabled)
2109 if not opts["local"]:
2110 # If there are incoming CLs, pull -u will do the update.
2111 # If there are no incoming CLs, do hg update to make sure
2112 # that an update always happens regardless. This is less
2113 # surprising than update depending on incoming CLs.
2114 # It is important not to do both hg pull -u and hg update
2115 # in the same command, because the hg update will end
2116 # up marking resolve conflicts from the hg pull -u as resolved,
2117 # causing files with <<< >>> markers to not show up in
2118 # hg resolve -l. Yay Mercurial.
2119 if hg_incoming(ui, repo):
2120 err = hg_pull(ui, repo, update=True)
2122 err = hg_update(ui, repo)
2125 sync_changes(ui, repo)
2127 def sync_changes(ui, repo):
2128 # Look through recent change log descriptions to find
2129 # potential references to http://.*/our-CL-number.
2130 # Double-check them by looking at the Rietveld log.
2131 for rev in hg_log(ui, repo, limit=100, template="{node}\n").split():
2132 desc = repo[rev].description().strip()
2133 for clname in re.findall('(?m)^https?://(?:[^\n]+)/([0-9]+)$', desc):
2134 if IsLocalCL(ui, repo, clname) and IsRietveldSubmitted(ui, clname, repo[rev].hex()):
2135 ui.warn("CL %s submitted as %s; closing\n" % (clname, repo[rev]))
2136 cl, err = LoadCL(ui, repo, clname, web=False)
2138 ui.warn("loading CL %s: %s\n" % (clname, err))
2140 if not cl.copied_from:
2141 EditDesc(cl.name, closed=True, private=cl.private)
2144 # Remove files that are not modified from the CLs in which they appear.
2145 all = LoadAllCL(ui, repo, web=False)
2146 changed = ChangedFiles(ui, repo, [])
2147 for cl in all.values():
2148 extra = Sub(cl.files, changed)
2150 ui.warn("Removing unmodified files from CL %s:\n" % (cl.name,))
2152 ui.warn("\t%s\n" % (f,))
2153 cl.files = Sub(cl.files, extra)
2156 if not cl.copied_from:
2157 ui.warn("CL %s has no files; delete (abandon) with hg change -d %s\n" % (cl.name, cl.name))
2159 ui.warn("CL %s has no files; delete locally with hg change -D %s\n" % (cl.name, cl.name))
2162 #######################################################################
2166 def upload(ui, repo, name, **opts):
2167 """upload diffs to the code review server
2169 Uploads the current modifications for a given change to the server.
2171 if codereview_disabled:
2172 raise hg_util.Abort(codereview_disabled)
2174 repo.ui.quiet = True
2175 cl, err = LoadCL(ui, repo, name, web=True)
2177 raise hg_util.Abort(err)
2179 raise hg_util.Abort("cannot upload non-local change")
2181 print "%s%s\n" % (server_url_base, cl.name)
2184 #######################################################################
2185 # Table of commands, supplied to Mercurial for installation.
2188 ('r', 'reviewer', '', 'add reviewer'),
2189 ('', 'cc', '', 'add cc'),
2190 ('', 'tbr', '', 'add future reviewer'),
2191 ('m', 'message', '', 'change description (for new change)'),
2195 # The ^ means to show this command in the help text that
2196 # is printed when running hg with no arguments.
2200 ('d', 'delete', None, 'delete existing change list'),
2201 ('D', 'deletelocal', None, 'delete locally, but do not change CL on server'),
2202 ('i', 'stdin', None, 'read change list from standard input'),
2203 ('o', 'stdout', None, 'print change list to standard output'),
2204 ('p', 'pending', None, 'print pending summary to standard output'),
2206 "[-d | -D] [-i] [-o] change# or FILE ..."
2211 ('', 'ignore_hgapplydiff_failure', None, 'create CL metadata even if hgapplydiff fails'),
2212 ('', 'no_incoming', None, 'disable check for incoming changes'),
2216 # Would prefer to call this codereview-login, but then
2217 # hg help codereview prints the help for this command
2218 # instead of the help for the extension.
2232 ('d', 'delete', None, 'delete files from change list (but not repository)'),
2234 "[-d] change# FILE ..."
2239 ('l', 'list', None, 'list files that would change, but do not edit them'),
2246 ('s', 'short', False, 'show short result form'),
2247 ('', 'quick', False, 'do not consult codereview server'),
2264 ] + hg_commands.walkopts,
2265 "[-r reviewer] [--cc cc] [change# | file ...]"
2270 ('', 'ignore_hgapplydiff_failure', None, 'create CL metadata even if hgapplydiff fails'),
2271 ('', 'no_incoming', None, 'disable check for incoming changes'),
2275 # TODO: release-start, release-tag, weekly-tag
2279 ('', 'no_incoming', None, 'disable initial incoming check (for testing)'),
2280 ] + hg_commands.walkopts + hg_commands.commitopts + hg_commands.commitopts2,
2281 "[-r reviewer] [--cc cc] [change# | file ...]"
2286 ('', 'local', None, 'do not pull changes from remote repository')
2293 ('', 'ignore_hgapplydiff_failure', None, 'create CL metadata even if hgapplydiff fails'),
2294 ('', 'no_incoming', None, 'disable check for incoming changes'),
2305 #######################################################################
2306 # Mercurial extension initialization
2308 def norollback(*pats, **opts):
2309 """(disabled when using this extension)"""
2310 raise hg_util.Abort("codereview extension enabled; use undo instead of rollback")
2312 codereview_init = False
2316 testing = ui.config("codereview", "testing")
2317 # Disable the Mercurial commands that might change the repository.
2318 # Only commands in this extension are supposed to do that.
2319 ui.setconfig("hooks", "pre-commit.codereview", precommithook) # runs before 'hg commit'
2320 ui.setconfig("hooks", "precommit.codereview", precommithook) # catches all cases
2322 def reposetup(ui, repo):
2323 global codereview_disabled
2326 # reposetup gets called both for the local repository
2327 # and also for any repository we are pulling or pushing to.
2328 # Only initialize the first time.
2329 global codereview_init
2332 codereview_init = True
2333 start_status_thread()
2335 # Read repository-specific options from lib/codereview/codereview.cfg or codereview.cfg.
2340 # Yes, repo might not have root; see issue 959.
2341 codereview_disabled = 'codereview disabled: repository has no root'
2344 repo_config_path = ''
2345 p1 = root + '/lib/codereview/codereview.cfg'
2346 p2 = root + '/codereview.cfg'
2347 if os.access(p1, os.F_OK):
2348 repo_config_path = p1
2350 repo_config_path = p2
2352 f = open(repo_config_path)
2354 if line.startswith('defaultcc:'):
2355 defaultcc = SplitCommaSpace(line[len('defaultcc:'):])
2356 if line.startswith('contributors:'):
2357 global contributorsURL
2358 contributorsURL = line[len('contributors:'):].strip()
2360 codereview_disabled = 'codereview disabled: cannot open ' + repo_config_path
2363 remote = ui.config("paths", "default", "")
2364 if remote.find("://") < 0 and not testing:
2365 raise hg_util.Abort("codereview: default path '%s' is not a URL" % (remote,))
2367 InstallMatch(ui, repo)
2368 RietveldSetup(ui, repo)
2370 # Rollback removes an existing commit. Don't do that either.
2371 global real_rollback
2372 real_rollback = repo.rollback
2373 repo.rollback = norollback
2376 #######################################################################
2377 # Wrappers around upload.py for interacting with Rietveld
2379 from HTMLParser import HTMLParser
2382 class FormParser(HTMLParser):
2387 HTMLParser.__init__(self)
2388 def handle_starttag(self, tag, attrs):
2398 self.map[key] = value
2399 if tag == "textarea":
2407 def handle_endtag(self, tag):
2408 if tag == "textarea" and self.curtag is not None:
2409 self.map[self.curtag] = self.curdata
2412 def handle_charref(self, name):
2413 self.handle_data(unichr(int(name)))
2414 def handle_entityref(self, name):
2415 import htmlentitydefs
2416 if name in htmlentitydefs.entitydefs:
2417 self.handle_data(htmlentitydefs.entitydefs[name])
2419 self.handle_data("&" + name + ";")
2420 def handle_data(self, data):
2421 if self.curdata is not None:
2422 self.curdata += data
2424 def JSONGet(ui, path):
2426 data = MySend(path, force_auth=False)
2427 typecheck(data, str)
2428 d = fix_json(json.loads(data))
2430 ui.warn("JSONGet %s: %s\n" % (path, ExceptionDetail()))
2434 # Clean up json parser output to match our expectations:
2435 # * all strings are UTF-8-encoded str, not unicode.
2436 # * missing fields are missing, not None,
2437 # so that d.get("foo", defaultvalue) works.
2439 if type(x) in [str, int, float, bool, type(None)]:
2441 elif type(x) is unicode:
2442 x = x.encode("utf-8")
2443 elif type(x) is list:
2444 for i in range(len(x)):
2445 x[i] = fix_json(x[i])
2446 elif type(x) is dict:
2452 x[k] = fix_json(x[k])
2456 raise hg_util.Abort("unknown type " + str(type(x)) + " in fix_json")
2458 x = x.replace('\r\n', '\n')
2461 def IsRietveldSubmitted(ui, clname, hex):
2462 dict = JSONGet(ui, "/api/" + clname + "?messages=true")
2465 for msg in dict.get("messages", []):
2466 text = msg.get("text", "")
2467 regex = '\*\*\* Submitted as [^*]*?r=([0-9a-f]+)[^ ]* \*\*\*'
2469 regex = '\*\*\* Submitted as ([0-9a-f]+) \*\*\*'
2470 m = re.match(regex, text)
2471 if m is not None and len(m.group(1)) >= 8 and hex.startswith(m.group(1)):
2475 def IsRietveldMailed(cl):
2476 for msg in cl.dict.get("messages", []):
2477 if msg.get("text", "").find("I'd like you to review this change") >= 0:
2481 def DownloadCL(ui, repo, clname):
2482 set_status("downloading CL " + clname)
2483 cl, err = LoadCL(ui, repo, clname, web=True)
2485 return None, None, None, "error loading CL %s: %s" % (clname, err)
2487 # Find most recent diff
2488 diffs = cl.dict.get("patchsets", [])
2490 return None, None, None, "CL has no patch sets"
2493 patchset = JSONGet(ui, "/api/" + clname + "/" + str(patchid))
2494 if patchset is None:
2495 return None, None, None, "error loading CL patchset %s/%d" % (clname, patchid)
2496 if patchset.get("patchset", 0) != patchid:
2497 return None, None, None, "malformed patchset information"
2500 msg = patchset.get("message", "").split()
2501 if len(msg) >= 3 and msg[0] == "diff" and msg[1] == "-r":
2503 diff = "/download/issue" + clname + "_" + str(patchid) + ".diff"
2505 diffdata = MySend(diff, force_auth=False)
2507 # Print warning if email is not in CONTRIBUTORS file.
2508 email = cl.dict.get("owner_email", "")
2510 return None, None, None, "cannot find owner for %s" % (clname)
2511 him = FindContributor(ui, repo, email)
2512 me = FindContributor(ui, repo, None)
2514 cl.mailed = IsRietveldMailed(cl)
2516 cl.copied_from = email
2518 return cl, vers, diffdata, ""
2520 def MySend(request_path, payload=None,
2521 content_type="application/octet-stream",
2522 timeout=None, force_auth=True,
2524 """Run MySend1 maybe twice, because Rietveld is unreliable."""
2526 return MySend1(request_path, payload, content_type, timeout, force_auth, **kwargs)
2527 except Exception, e:
2528 if type(e) != urllib2.HTTPError or e.code != 500: # only retry on HTTP 500 error
2530 print >>sys.stderr, "Loading "+request_path+": "+ExceptionDetail()+"; trying again in 2 seconds."
2532 return MySend1(request_path, payload, content_type, timeout, force_auth, **kwargs)
2534 # Like upload.py Send but only authenticates when the
2535 # redirect is to www.google.com/accounts. This keeps
2536 # unnecessary redirects from happening during testing.
2537 def MySend1(request_path, payload=None,
2538 content_type="application/octet-stream",
2539 timeout=None, force_auth=True,
2541 """Sends an RPC and returns the response.
2544 request_path: The path to send the request to, eg /api/appversion/create.
2545 payload: The body of the request, or None to send an empty request.
2546 content_type: The Content-Type header to use.
2547 timeout: timeout in seconds; default None i.e. no timeout.
2548 (Note: for large requests on OS X, the timeout doesn't work right.)
2549 kwargs: Any keyword arguments are converted into query string parameters.
2552 The response body, as a string.
2554 # TODO: Don't require authentication. Let the server say
2555 # whether it is necessary.
2558 rpc = GetRpcServer(upload_options)
2560 if not self.authenticated and force_auth:
2561 self._Authenticate()
2562 if request_path is None:
2565 timeout = 30 # seconds
2567 old_timeout = socket.getdefaulttimeout()
2568 socket.setdefaulttimeout(timeout)
2574 url = "https://%s%s" % (self.host, request_path)
2576 url = url.replace("https://", "http://")
2578 url += "?" + urllib.urlencode(args)
2579 req = self._CreateRequest(url=url, data=payload)
2580 req.add_header("Content-Type", content_type)
2582 f = self.opener.open(req)
2585 # Translate \r\n into \n, because Rietveld doesn't.
2586 response = response.replace('\r\n', '\n')
2587 # who knows what urllib will give us
2588 if type(response) == unicode:
2589 response = response.encode("utf-8")
2590 typecheck(response, str)
2592 except urllib2.HTTPError, e:
2596 self._Authenticate()
2598 loc = e.info()["location"]
2599 if not loc.startswith('https://www.google.com/a') or loc.find('/ServiceLogin') < 0:
2601 self._Authenticate()
2605 socket.setdefaulttimeout(old_timeout)
2609 f.feed(ustr(MySend(url))) # f.feed wants unicode
2611 # convert back to utf-8 to restore sanity
2613 for k,v in f.map.items():
2614 m[k.encode("utf-8")] = v.replace("\r\n", "\n").encode("utf-8")
2617 def EditDesc(issue, subject=None, desc=None, reviewers=None, cc=None, closed=False, private=False):
2618 set_status("uploading change to description")
2619 form_fields = GetForm("/" + issue + "/edit")
2620 if subject is not None:
2621 form_fields['subject'] = subject
2622 if desc is not None:
2623 form_fields['description'] = desc
2624 if reviewers is not None:
2625 form_fields['reviewers'] = reviewers
2627 form_fields['cc'] = cc
2629 form_fields['closed'] = "checked"
2631 form_fields['private'] = "checked"
2632 ctype, body = EncodeMultipartFormData(form_fields.items(), [])
2633 response = MySend("/" + issue + "/edit", body, content_type=ctype)
2635 print >>sys.stderr, "Error editing description:\n" + "Sent form: \n", form_fields, "\n", response
2638 def PostMessage(ui, issue, message, reviewers=None, cc=None, send_mail=True, subject=None):
2639 set_status("uploading message")
2640 form_fields = GetForm("/" + issue + "/publish")
2641 if reviewers is not None:
2642 form_fields['reviewers'] = reviewers
2644 form_fields['cc'] = cc
2646 form_fields['send_mail'] = "checked"
2648 del form_fields['send_mail']
2649 if subject is not None:
2650 form_fields['subject'] = subject
2651 form_fields['message'] = message
2653 form_fields['message_only'] = '1' # Don't include draft comments
2654 if reviewers is not None or cc is not None:
2655 form_fields['message_only'] = '' # Must set '' in order to override cc/reviewer
2656 ctype = "applications/x-www-form-urlencoded"
2657 body = urllib.urlencode(form_fields)
2658 response = MySend("/" + issue + "/publish", body, content_type=ctype)
2666 def RietveldSetup(ui, repo):
2667 global force_google_account
2670 global server_url_base
2671 global upload_options
2678 x = ui.config("codereview", "server")
2682 # TODO(rsc): Take from ui.username?
2684 x = ui.config("codereview", "email")
2688 server_url_base = "https://" + server + "/"
2690 server_url_base = server_url_base.replace("https://", "http://")
2692 force_google_account = ui.configbool("codereview", "force_google_account", False)
2694 upload_options = opt()
2695 upload_options.email = email
2696 upload_options.host = None
2697 upload_options.verbose = 0
2698 upload_options.description = None
2699 upload_options.description_file = None
2700 upload_options.reviewers = None
2701 upload_options.cc = None
2702 upload_options.message = None
2703 upload_options.issue = None
2704 upload_options.download_base = False
2705 upload_options.send_mail = False
2706 upload_options.vcs = None
2707 upload_options.server = server
2708 upload_options.save_cookies = True
2711 upload_options.save_cookies = False
2712 upload_options.email = "test@example.com"
2716 global releaseBranch
2717 tags = repo.branchmap().keys()
2718 if 'release-branch.go10' in tags:
2719 # NOTE(rsc): This tags.sort is going to get the wrong
2720 # answer when comparing release-branch.go9 with
2721 # release-branch.go10. It will be a while before we care.
2722 raise hg_util.Abort('tags.sort needs to be fixed for release-branch.go10')
2725 if t.startswith('release-branch.go'):
2728 def workbranch(name):
2729 return name == "default" or name.startswith('dev.')
2731 #######################################################################
2732 # http://codereview.appspot.com/static/upload.py, heavily edited.
2734 #!/usr/bin/env python
2736 # Copyright 2007 Google Inc.
2738 # Licensed under the Apache License, Version 2.0 (the "License");
2739 # you may not use this file except in compliance with the License.
2740 # You may obtain a copy of the License at
2742 # http://www.apache.org/licenses/LICENSE-2.0
2744 # Unless required by applicable law or agreed to in writing, software
2745 # distributed under the License is distributed on an "AS IS" BASIS,
2746 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
2747 # See the License for the specific language governing permissions and
2748 # limitations under the License.
2750 """Tool for uploading diffs from a version control system to the codereview app.
2752 Usage summary: upload.py [options] [-- diff_options]
2754 Diff options are passed to the diff command of the underlying system.
2756 Supported version control systems:
2761 It is important for Git/Mercurial users to specify a tree/node/branch to diff
2762 against by using the '--rev' option.
2764 # This code is derived from appcfg.py in the App Engine SDK (open source),
2765 # and from ASPN recipe #146306.
2781 # The md5 module was deprecated in Python 2.5.
2783 from hashlib import md5
2792 # The logging verbosity:
2794 # 1: Status messages.
2799 # Max size of patch or base file.
2800 MAX_UPLOAD_SIZE = 900 * 1024
2802 # whitelist for non-binary filetypes which do not start with "text/"
2803 # .mm (Objective-C) shows up as application/x-freemind on my Linux box.
2805 'application/javascript',
2806 'application/x-javascript',
2807 'application/x-freemind'
2810 def GetEmail(prompt):
2811 """Prompts the user for their email address and returns it.
2813 The last used email address is saved to a file and offered up as a suggestion
2814 to the user. If the user presses enter without typing in anything the last
2815 used email address is used. If the user enters a new address, it is saved
2816 for next time we prompt.
2819 last_email_file_name = os.path.expanduser("~/.last_codereview_email_address")
2821 if os.path.exists(last_email_file_name):
2823 last_email_file = open(last_email_file_name, "r")
2824 last_email = last_email_file.readline().strip("\n")
2825 last_email_file.close()
2826 prompt += " [%s]" % last_email
2829 email = raw_input(prompt + ": ").strip()
2832 last_email_file = open(last_email_file_name, "w")
2833 last_email_file.write(email)
2834 last_email_file.close()
2842 def StatusUpdate(msg):
2843 """Print a status message to stdout.
2845 If 'verbosity' is greater than 0, print the message.
2848 msg: The string to print.
2855 """Print an error message to stderr and exit."""
2856 print >>sys.stderr, msg
2860 class ClientLoginError(urllib2.HTTPError):
2861 """Raised to indicate there was an error authenticating with ClientLogin."""
2863 def __init__(self, url, code, msg, headers, args):
2864 urllib2.HTTPError.__init__(self, url, code, msg, headers, None)
2866 # .reason is now a read-only property based on .msg
2867 # this means we ignore 'msg', but that seems to work fine.
2868 self.msg = args["Error"]
2871 class AbstractRpcServer(object):
2872 """Provides a common interface for a simple RPC server."""
2874 def __init__(self, host, auth_function, host_override=None, extra_headers={}, save_cookies=False):
2875 """Creates a new HttpRpcServer.
2878 host: The host to send requests to.
2879 auth_function: A function that takes no arguments and returns an
2880 (email, password) tuple when called. Will be called if authentication
2882 host_override: The host header to send to the server (defaults to host).
2883 extra_headers: A dict of extra headers to append to every request.
2884 save_cookies: If True, save the authentication cookies to local disk.
2885 If False, use an in-memory cookiejar instead. Subclasses must
2886 implement this functionality. Defaults to False.
2889 self.host_override = host_override
2890 self.auth_function = auth_function
2891 self.authenticated = False
2892 self.extra_headers = extra_headers
2893 self.save_cookies = save_cookies
2894 self.opener = self._GetOpener()
2895 if self.host_override:
2896 logging.info("Server: %s; Host: %s", self.host, self.host_override)
2898 logging.info("Server: %s", self.host)
2900 def _GetOpener(self):
2901 """Returns an OpenerDirector for making HTTP requests.
2904 A urllib2.OpenerDirector object.
2906 raise NotImplementedError()
2908 def _CreateRequest(self, url, data=None):
2909 """Creates a new urllib request."""
2910 logging.debug("Creating request for: '%s' with payload:\n%s", url, data)
2911 req = urllib2.Request(url, data=data)
2912 if self.host_override:
2913 req.add_header("Host", self.host_override)
2914 for key, value in self.extra_headers.iteritems():
2915 req.add_header(key, value)
2918 def _GetAuthToken(self, email, password):
2919 """Uses ClientLogin to authenticate the user, returning an auth token.
2922 email: The user's email address
2923 password: The user's password
2926 ClientLoginError: If there was an error authenticating with ClientLogin.
2927 HTTPError: If there was some other form of HTTP error.
2930 The authentication token returned by ClientLogin.
2932 account_type = "GOOGLE"
2933 if self.host.endswith(".google.com") and not force_google_account:
2934 # Needed for use inside Google.
2935 account_type = "HOSTED"
2936 req = self._CreateRequest(
2937 url="https://www.google.com/accounts/ClientLogin",
2938 data=urllib.urlencode({
2942 "source": "rietveld-codereview-upload",
2943 "accountType": account_type,
2947 response = self.opener.open(req)
2948 response_body = response.read()
2949 response_dict = dict(x.split("=") for x in response_body.split("\n") if x)
2950 return response_dict["Auth"]
2951 except urllib2.HTTPError, e:
2954 response_dict = dict(x.split("=", 1) for x in body.split("\n") if x)
2955 raise ClientLoginError(req.get_full_url(), e.code, e.msg, e.headers, response_dict)
2959 def _GetAuthCookie(self, auth_token):
2960 """Fetches authentication cookies for an authentication token.
2963 auth_token: The authentication token returned by ClientLogin.
2966 HTTPError: If there was an error fetching the authentication cookies.
2968 # This is a dummy value to allow us to identify when we're successful.
2969 continue_location = "http://localhost/"
2970 args = {"continue": continue_location, "auth": auth_token}
2971 reqUrl = "https://%s/_ah/login?%s" % (self.host, urllib.urlencode(args))
2973 reqUrl = reqUrl.replace("https://", "http://")
2974 req = self._CreateRequest(reqUrl)
2976 response = self.opener.open(req)
2977 except urllib2.HTTPError, e:
2979 if (response.code != 302 or
2980 response.info()["location"] != continue_location):
2981 raise urllib2.HTTPError(req.get_full_url(), response.code, response.msg, response.headers, response.fp)
2982 self.authenticated = True
2984 def _Authenticate(self):
2985 """Authenticates the user.
2987 The authentication process works as follows:
2988 1) We get a username and password from the user
2989 2) We use ClientLogin to obtain an AUTH token for the user
2990 (see http://code.google.com/apis/accounts/AuthForInstalledApps.html).
2991 3) We pass the auth token to /_ah/login on the server to obtain an
2992 authentication cookie. If login was successful, it tries to redirect
2993 us to the URL we provided.
2995 If we attempt to access the upload API without first obtaining an
2996 authentication cookie, it returns a 401 response (or a 302) and
2997 directs us to authenticate ourselves with ClientLogin.
3000 credentials = self.auth_function()
3002 auth_token = self._GetAuthToken(credentials[0], credentials[1])
3003 except ClientLoginError, e:
3004 if e.msg == "BadAuthentication":
3005 print >>sys.stderr, "Invalid username or password."
3007 if e.msg == "CaptchaRequired":
3008 print >>sys.stderr, (
3010 "https://www.google.com/accounts/DisplayUnlockCaptcha\n"
3011 "and verify you are a human. Then try again.")
3013 if e.msg == "NotVerified":
3014 print >>sys.stderr, "Account not verified."
3016 if e.msg == "TermsNotAgreed":
3017 print >>sys.stderr, "User has not agreed to TOS."
3019 if e.msg == "AccountDeleted":
3020 print >>sys.stderr, "The user account has been deleted."
3022 if e.msg == "AccountDisabled":
3023 print >>sys.stderr, "The user account has been disabled."
3025 if e.msg == "ServiceDisabled":
3026 print >>sys.stderr, "The user's access to the service has been disabled."
3028 if e.msg == "ServiceUnavailable":
3029 print >>sys.stderr, "The service is not available; try again later."
3032 self._GetAuthCookie(auth_token)
3035 def Send(self, request_path, payload=None,
3036 content_type="application/octet-stream",
3039 """Sends an RPC and returns the response.
3042 request_path: The path to send the request to, eg /api/appversion/create.
3043 payload: The body of the request, or None to send an empty request.
3044 content_type: The Content-Type header to use.
3045 timeout: timeout in seconds; default None i.e. no timeout.
3046 (Note: for large requests on OS X, the timeout doesn't work right.)
3047 kwargs: Any keyword arguments are converted into query string parameters.
3050 The response body, as a string.
3052 # TODO: Don't require authentication. Let the server say
3053 # whether it is necessary.
3054 if not self.authenticated:
3055 self._Authenticate()
3057 old_timeout = socket.getdefaulttimeout()
3058 socket.setdefaulttimeout(timeout)
3064 url = "https://%s%s" % (self.host, request_path)
3066 url = url.replace("https://", "http://")
3068 url += "?" + urllib.urlencode(args)
3069 req = self._CreateRequest(url=url, data=payload)
3070 req.add_header("Content-Type", content_type)
3072 f = self.opener.open(req)
3076 except urllib2.HTTPError, e:
3079 elif e.code == 401 or e.code == 302:
3080 self._Authenticate()
3084 socket.setdefaulttimeout(old_timeout)
3087 class HttpRpcServer(AbstractRpcServer):
3088 """Provides a simplified RPC-style interface for HTTP requests."""
3090 def _Authenticate(self):
3091 """Save the cookie jar after authentication."""
3092 super(HttpRpcServer, self)._Authenticate()
3093 if self.save_cookies:
3094 StatusUpdate("Saving authentication cookies to %s" % self.cookie_file)
3095 self.cookie_jar.save()
3097 def _GetOpener(self):
3098 """Returns an OpenerDirector that supports cookies and ignores redirects.
3101 A urllib2.OpenerDirector object.
3103 opener = urllib2.OpenerDirector()
3104 opener.add_handler(urllib2.ProxyHandler())
3105 opener.add_handler(urllib2.UnknownHandler())
3106 opener.add_handler(urllib2.HTTPHandler())
3107 opener.add_handler(urllib2.HTTPDefaultErrorHandler())
3108 opener.add_handler(urllib2.HTTPSHandler())
3109 opener.add_handler(urllib2.HTTPErrorProcessor())
3110 if self.save_cookies:
3111 self.cookie_file = os.path.expanduser("~/.codereview_upload_cookies_" + server)
3112 self.cookie_jar = cookielib.MozillaCookieJar(self.cookie_file)
3113 if os.path.exists(self.cookie_file):
3115 self.cookie_jar.load()
3116 self.authenticated = True
3117 StatusUpdate("Loaded authentication cookies from %s" % self.cookie_file)
3118 except (cookielib.LoadError, IOError):
3119 # Failed to load cookies - just ignore them.
3122 # Create an empty cookie file with mode 600
3123 fd = os.open(self.cookie_file, os.O_CREAT, 0600)
3125 # Always chmod the cookie file
3126 os.chmod(self.cookie_file, 0600)
3128 # Don't save cookies across runs of update.py.
3129 self.cookie_jar = cookielib.CookieJar()
3130 opener.add_handler(urllib2.HTTPCookieProcessor(self.cookie_jar))
3134 def GetRpcServer(options):
3135 """Returns an instance of an AbstractRpcServer.
3138 A new AbstractRpcServer, on which RPC calls can be made.
3141 rpc_server_class = HttpRpcServer
3143 def GetUserCredentials():
3144 """Prompts the user for a username and password."""
3145 # Disable status prints so they don't obscure the password prompt.
3146 global global_status
3148 global_status = None
3150 email = options.email
3152 email = GetEmail("Email (login for uploading to %s)" % options.server)
3153 password = getpass.getpass("Password for %s: " % email)
3157 return (email, password)
3159 # If this is the dev_appserver, use fake authentication.
3160 host = (options.host or options.server).lower()
3161 if host == "localhost" or host.startswith("localhost:"):
3162 email = options.email
3164 email = "test@example.com"
3165 logging.info("Using debug user %s. Override with --email" % email)
3166 server = rpc_server_class(
3168 lambda: (email, "password"),
3169 host_override=options.host,
3170 extra_headers={"Cookie": 'dev_appserver_login="%s:False"' % email},
3171 save_cookies=options.save_cookies)
3172 # Don't try to talk to ClientLogin.
3173 server.authenticated = True
3176 return rpc_server_class(options.server, GetUserCredentials,
3177 host_override=options.host, save_cookies=options.save_cookies)
3180 def EncodeMultipartFormData(fields, files):
3181 """Encode form fields for multipart/form-data.
3184 fields: A sequence of (name, value) elements for regular form fields.
3185 files: A sequence of (name, filename, value) elements for data to be
3188 (content_type, body) ready for httplib.HTTP instance.
3191 http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/146306
3193 BOUNDARY = '-M-A-G-I-C---B-O-U-N-D-A-R-Y-'
3196 for (key, value) in fields:
3198 typecheck(value, str)
3199 lines.append('--' + BOUNDARY)
3200 lines.append('Content-Disposition: form-data; name="%s"' % key)
3203 for (key, filename, value) in files:
3205 typecheck(filename, str)
3206 typecheck(value, str)
3207 lines.append('--' + BOUNDARY)
3208 lines.append('Content-Disposition: form-data; name="%s"; filename="%s"' % (key, filename))
3209 lines.append('Content-Type: %s' % GetContentType(filename))
3212 lines.append('--' + BOUNDARY + '--')
3214 body = CRLF.join(lines)
3215 content_type = 'multipart/form-data; boundary=%s' % BOUNDARY
3216 return content_type, body
3219 def GetContentType(filename):
3220 """Helper to guess the content-type from the filename."""
3221 return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
3224 # Use a shell for subcommands on Windows to get a PATH search.
3225 use_shell = sys.platform.startswith("win")
3227 def RunShellWithReturnCode(command, print_output=False,
3228 universal_newlines=True, env=os.environ):
3229 """Executes a command and returns the output from stdout and the return code.
3232 command: Command to execute.
3233 print_output: If True, the output is printed to stdout.
3234 If False, both stdout and stderr are ignored.
3235 universal_newlines: Use universal_newlines flag (default: True).
3238 Tuple (output, return code)
3240 logging.info("Running %s", command)
3241 p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
3242 shell=use_shell, universal_newlines=universal_newlines, env=env)
3246 line = p.stdout.readline()
3249 print line.strip("\n")
3250 output_array.append(line)
3251 output = "".join(output_array)
3253 output = p.stdout.read()
3255 errout = p.stderr.read()
3256 if print_output and errout:
3257 print >>sys.stderr, errout
3260 return output, p.returncode
3263 def RunShell(command, silent_ok=False, universal_newlines=True,
3264 print_output=False, env=os.environ):
3265 data, retcode = RunShellWithReturnCode(command, print_output, universal_newlines, env)
3267 ErrorExit("Got error status from %s:\n%s" % (command, data))
3268 if not silent_ok and not data:
3269 ErrorExit("No output from %s" % command)
3273 class VersionControlSystem(object):
3274 """Abstract base class providing an interface to the VCS."""
3276 def __init__(self, options):
3280 options: Command line options.
3282 self.options = options
3284 def GenerateDiff(self, args):
3285 """Return the current diff as a string.
3288 args: Extra arguments to pass to the diff command.
3290 raise NotImplementedError(
3291 "abstract method -- subclass %s must override" % self.__class__)
3293 def GetUnknownFiles(self):
3294 """Return a list of files unknown to the VCS."""
3295 raise NotImplementedError(
3296 "abstract method -- subclass %s must override" % self.__class__)
3298 def CheckForUnknownFiles(self):
3299 """Show an "are you sure?" prompt if there are unknown files."""
3300 unknown_files = self.GetUnknownFiles()
3302 print "The following files are not added to version control:"
3303 for line in unknown_files:
3305 prompt = "Are you sure to continue?(y/N) "
3306 answer = raw_input(prompt).strip()
3308 ErrorExit("User aborted")
3310 def GetBaseFile(self, filename):
3311 """Get the content of the upstream version of a file.
3314 A tuple (base_content, new_content, is_binary, status)
3315 base_content: The contents of the base file.
3316 new_content: For text files, this is empty. For binary files, this is
3317 the contents of the new file, since the diff output won't contain
3318 information to reconstruct the current file.
3319 is_binary: True iff the file is binary.
3320 status: The status of the file.
3323 raise NotImplementedError(
3324 "abstract method -- subclass %s must override" % self.__class__)
3327 def GetBaseFiles(self, diff):
3328 """Helper that calls GetBase file for each file in the patch.
3331 A dictionary that maps from filename to GetBaseFile's tuple. Filenames
3332 are retrieved based on lines that start with "Index:" or
3333 "Property changes on:".
3336 for line in diff.splitlines(True):
3337 if line.startswith('Index:') or line.startswith('Property changes on:'):
3338 unused, filename = line.split(':', 1)
3339 # On Windows if a file has property changes its filename uses '\'
3341 filename = to_slash(filename.strip())
3342 files[filename] = self.GetBaseFile(filename)
3346 def UploadBaseFiles(self, issue, rpc_server, patch_list, patchset, options,
3348 """Uploads the base files (and if necessary, the current ones as well)."""
3350 def UploadFile(filename, file_id, content, is_binary, status, is_base):
3351 """Uploads a file to the server."""
3352 set_status("uploading " + filename)
3353 file_too_large = False
3358 if len(content) > MAX_UPLOAD_SIZE:
3359 print ("Not uploading the %s file for %s because it's too large." %
3361 file_too_large = True
3363 checksum = md5(content).hexdigest()
3364 if options.verbose > 0 and not file_too_large:
3365 print "Uploading %s file for %s" % (type, filename)
3366 url = "/%d/upload_content/%d/%d" % (int(issue), int(patchset), file_id)
3368 ("filename", filename),
3370 ("checksum", checksum),
3371 ("is_binary", str(is_binary)),
3372 ("is_current", str(not is_base)),
3375 form_fields.append(("file_too_large", "1"))
3377 form_fields.append(("user", options.email))
3378 ctype, body = EncodeMultipartFormData(form_fields, [("data", filename, content)])
3379 response_body = rpc_server.Send(url, body, content_type=ctype)
3380 if not response_body.startswith("OK"):
3381 StatusUpdate(" --> %s" % response_body)
3384 # Don't want to spawn too many threads, nor do we want to
3385 # hit Rietveld too hard, or it will start serving 500 errors.
3386 # When 8 works, it's no better than 4, and sometimes 8 is
3387 # too many for Rietveld to handle.
3388 MAX_PARALLEL_UPLOADS = 4
3390 sema = threading.BoundedSemaphore(MAX_PARALLEL_UPLOADS)
3392 finished_upload_threads = []
3394 class UploadFileThread(threading.Thread):
3395 def __init__(self, args):
3396 threading.Thread.__init__(self)
3399 UploadFile(*self.args)
3400 finished_upload_threads.append(self)
3403 def StartUploadFile(*args):
3405 while len(finished_upload_threads) > 0:
3406 t = finished_upload_threads.pop()
3407 upload_threads.remove(t)
3409 t = UploadFileThread(args)
3410 upload_threads.append(t)
3413 def WaitForUploads():
3414 for t in upload_threads:
3418 [patches.setdefault(v, k) for k, v in patch_list]
3419 for filename in patches.keys():
3420 base_content, new_content, is_binary, status = files[filename]
3421 file_id_str = patches.get(filename)
3422 if file_id_str.find("nobase") != -1:
3424 file_id_str = file_id_str[file_id_str.rfind("_") + 1:]
3425 file_id = int(file_id_str)
3426 if base_content != None:
3427 StartUploadFile(filename, file_id, base_content, is_binary, status, True)
3428 if new_content != None:
3429 StartUploadFile(filename, file_id, new_content, is_binary, status, False)
3432 def IsImage(self, filename):
3433 """Returns true if the filename has an image extension."""
3434 mimetype = mimetypes.guess_type(filename)[0]
3437 return mimetype.startswith("image/")
3439 def IsBinary(self, filename):
3440 """Returns true if the guessed mimetyped isnt't in text group."""
3441 mimetype = mimetypes.guess_type(filename)[0]
3443 return False # e.g. README, "real" binaries usually have an extension
3444 # special case for text files which don't start with text/
3445 if mimetype in TEXT_MIMETYPES:
3447 return not mimetype.startswith("text/")
3450 class FakeMercurialUI(object):
3454 self.debugflag = False
3456 def write(self, *args, **opts):
3457 self.output += ' '.join(args)
3460 def status(self, *args, **opts):
3463 def formatter(self, topic, opts):
3464 from mercurial.formatter import plainformatter
3465 return plainformatter(self, topic, opts)
3467 def readconfig(self, *args, **opts):
3469 def expandpath(self, *args, **opts):
3470 return global_ui.expandpath(*args, **opts)
3471 def configitems(self, *args, **opts):
3472 return global_ui.configitems(*args, **opts)
3473 def config(self, *args, **opts):
3474 return global_ui.config(*args, **opts)
3476 use_hg_shell = False # set to True to shell out to hg always; slower
3478 class MercurialVCS(VersionControlSystem):
3479 """Implementation of the VersionControlSystem interface for Mercurial."""
3481 def __init__(self, options, ui, repo):
3482 super(MercurialVCS, self).__init__(options)
3486 # Absolute path to repository (we can be in a subdir)
3487 self.repo_dir = os.path.normpath(repo.root)
3488 # Compute the subdir
3489 cwd = os.path.normpath(os.getcwd())
3490 assert cwd.startswith(self.repo_dir)
3491 self.subdir = cwd[len(self.repo_dir):].lstrip(r"\/")
3492 mqparent, err = RunShellWithReturnCode(['hg', 'log', '--rev', 'qparent', '--template={node}'])
3493 if not err and mqparent != "":
3494 self.base_rev = mqparent
3496 out = RunShell(["hg", "parents", "-q", "--template={node} {branch}"], silent_ok=True).strip()
3498 # No revisions; use 0 to mean a repository with nothing.
3501 # Find parent along current branch.
3502 branch = repo[None].branch()
3504 for line in out.splitlines():
3505 fields = line.strip().split(' ')
3506 if fields[1] == branch:
3510 # Use the first parent
3511 base = out.strip().split(' ')[0]
3512 self.base_rev = base
3514 def _GetRelPath(self, filename):
3515 """Get relative path of a file according to the current directory,
3516 given its logical path in the repo."""
3517 assert filename.startswith(self.subdir), (filename, self.subdir)
3518 return filename[len(self.subdir):].lstrip(r"\/")
3520 def GenerateDiff(self, extra_args):
3521 # If no file specified, restrict to the current subdir
3522 extra_args = extra_args or ["."]
3523 cmd = ["hg", "diff", "--git", "-r", self.base_rev] + extra_args
3524 data = RunShell(cmd, silent_ok=True)
3527 for line in data.splitlines():
3528 m = re.match("diff --git a/(\S+) b/(\S+)", line)
3530 # Modify line to make it look like as it comes from svn diff.
3531 # With this modification no changes on the server side are required
3532 # to make upload.py work with Mercurial repos.
3533 # NOTE: for proper handling of moved/copied files, we have to use
3534 # the second filename.
3535 filename = m.group(2)
3536 svndiff.append("Index: %s" % filename)
3537 svndiff.append("=" * 67)
3541 svndiff.append(line)
3543 ErrorExit("No valid patches found in output from hg diff")
3544 return "\n".join(svndiff) + "\n"
3546 def GetUnknownFiles(self):
3547 """Return a list of files unknown to the VCS."""
3549 status = RunShell(["hg", "status", "--rev", self.base_rev, "-u", "."],
3552 for line in status.splitlines():
3553 st, fn = line.split(" ", 1)
3555 unknown_files.append(fn)
3556 return unknown_files
3558 def get_hg_status(self, rev, path):
3559 # We'd like to use 'hg status -C path', but that is buggy
3560 # (see http://mercurial.selenic.com/bts/issue3023).
3561 # Instead, run 'hg status -C' without a path
3562 # and skim the output for the path we want.
3563 if self.status is None:
3565 out = RunShell(["hg", "status", "-C", "--rev", rev])
3567 fui = FakeMercurialUI()
3568 ret = hg_commands.status(fui, self.repo, *[], **{'rev': [rev], 'copies': True})
3570 raise hg_util.Abort(ret)
3572 self.status = out.splitlines()
3573 for i in range(len(self.status)):
3578 line = to_slash(self.status[i])
3579 if line[2:] == path:
3580 if i+1 < len(self.status) and self.status[i+1][:2] == ' ':
3581 return self.status[i:i+2]
3582 return self.status[i:i+1]
3583 raise hg_util.Abort("no status for " + path)
3585 def GetBaseFile(self, filename):
3586 set_status("inspecting " + filename)
3587 # "hg status" and "hg cat" both take a path relative to the current subdir
3588 # rather than to the repo root, but "hg diff" has given us the full path
3593 oldrelpath = relpath = self._GetRelPath(filename)
3594 out = self.get_hg_status(self.base_rev, relpath)
3595 status, what = out[0].split(' ', 1)
3596 if len(out) > 1 and status == "A" and what == relpath:
3597 oldrelpath = out[1].strip()
3599 if ":" in self.base_rev:
3600 base_rev = self.base_rev.split(":", 1)[0]
3602 base_rev = self.base_rev
3605 base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath], silent_ok=True)
3608 base_content = str(self.repo[base_rev][oldrelpath].data())
3611 is_binary = "\0" in base_content # Mercurial's heuristic
3614 new_content = open(relpath, "rb").read()
3615 is_binary = is_binary or "\0" in new_content
3618 if is_binary and base_content and use_hg_shell:
3619 # Fetch again without converting newlines
3620 base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath],
3621 silent_ok=True, universal_newlines=False)
3622 if not is_binary or not self.IsImage(relpath):
3624 return base_content, new_content, is_binary, status
3627 # NOTE: The SplitPatch function is duplicated in engine.py, keep them in sync.
3628 def SplitPatch(data):
3629 """Splits a patch into separate pieces for each file.
3632 data: A string containing the output of svn diff.
3635 A list of 2-tuple (filename, text) where text is the svn diff output
3636 pertaining to filename.
3641 for line in data.splitlines(True):
3643 if line.startswith('Index:'):
3644 unused, new_filename = line.split(':', 1)
3645 new_filename = new_filename.strip()
3646 elif line.startswith('Property changes on:'):
3647 unused, temp_filename = line.split(':', 1)
3648 # When a file is modified, paths use '/' between directories, however
3649 # when a property is modified '\' is used on Windows. Make them the same
3650 # otherwise the file shows up twice.
3651 temp_filename = to_slash(temp_filename.strip())
3652 if temp_filename != filename:
3653 # File has property changes but no modifications, create a new diff.
3654 new_filename = temp_filename
3656 if filename and diff:
3657 patches.append((filename, ''.join(diff)))
3658 filename = new_filename
3661 if diff is not None:
3663 if filename and diff:
3664 patches.append((filename, ''.join(diff)))
3668 def UploadSeparatePatches(issue, rpc_server, patchset, data, options):
3669 """Uploads a separate patch for each file in the diff output.
3671 Returns a list of [patch_key, filename] for each file.
3673 patches = SplitPatch(data)
3675 for patch in patches:
3676 set_status("uploading patch for " + patch[0])
3677 if len(patch[1]) > MAX_UPLOAD_SIZE:
3678 print ("Not uploading the patch for " + patch[0] +
3679 " because the file is too large.")
3681 form_fields = [("filename", patch[0])]
3682 if not options.download_base:
3683 form_fields.append(("content_upload", "1"))
3684 files = [("data", "data.diff", patch[1])]
3685 ctype, body = EncodeMultipartFormData(form_fields, files)
3686 url = "/%d/upload_patch/%d" % (int(issue), int(patchset))
3687 print "Uploading patch for " + patch[0]
3688 response_body = rpc_server.Send(url, body, content_type=ctype)
3689 lines = response_body.splitlines()
3690 if not lines or lines[0] != "OK":
3691 StatusUpdate(" --> %s" % response_body)
3693 rv.append([lines[1], patch[0]])