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
65 codereview_disabled
= None
68 server
= "codereview.appspot.com"
69 server_url_base
= None
71 #######################################################################
72 # Normally I would split this into multiple files, but it simplifies
73 # import path headaches to keep it all in one file. Sorry.
74 # The different parts of the file are separated by banners like this one.
76 #######################################################################
79 def RelativePath(path
, cwd
):
81 if path
.startswith(cwd
) and path
[n
] == '/':
86 return [l
for l
in l1
if l
not in l2
]
93 def Intersect(l1
, l2
):
94 return [l
for l
in l1
if l
in l2
]
96 #######################################################################
97 # RE: UNICODE STRING HANDLING
99 # Python distinguishes between the str (string of bytes)
100 # and unicode (string of code points) types. Most operations
101 # work on either one just fine, but some (like regexp matching)
102 # require unicode, and others (like write) require str.
104 # As befits the language, Python hides the distinction between
105 # unicode and str by converting between them silently, but
106 # *only* if all the bytes/code points involved are 7-bit ASCII.
107 # This means that if you're not careful, your program works
108 # fine on "hello, world" and fails on "hello, 世界". And of course,
109 # the obvious way to be careful - use static types - is unavailable.
110 # So the only way is trial and error to find where to put explicit
113 # Because more functions do implicit conversion to str (string of bytes)
114 # than do implicit conversion to unicode (string of code points),
115 # the convention in this module is to represent all text as str,
116 # converting to unicode only when calling a unicode-only function
117 # and then converting back to str as soon as possible.
121 raise hg_util
.Abort("type check failed: %s has type %s != %s" % (repr(s
), type(s
), t
))
123 # If we have to pass unicode instead of str, ustr does that conversion clearly.
126 return s
.decode("utf-8")
128 # Even with those, Mercurial still sometimes turns unicode into str
129 # and then tries to use it as ascii. Change Mercurial's default.
130 def set_mercurial_encoding_to_utf8():
131 from mercurial
import encoding
132 encoding
.encoding
= 'utf-8'
134 set_mercurial_encoding_to_utf8()
136 # Even with those we still run into problems.
137 # I tried to do things by the book but could not convince
138 # Mercurial to let me check in a change with UTF-8 in the
139 # CL description or author field, no matter how many conversions
140 # between str and unicode I inserted and despite changing the
141 # default encoding. I'm tired of this game, so set the default
142 # encoding for all of Python to 'utf-8', not 'ascii'.
143 def default_to_utf8():
145 stdout
, __stdout__
= sys
.stdout
, sys
.__stdout
__
146 reload(sys
) # site.py deleted setdefaultencoding; get it back
147 sys
.stdout
, sys
.__stdout
__ = stdout
, __stdout__
148 sys
.setdefaultencoding('utf-8')
152 #######################################################################
153 # Status printer for long-running commands
158 # print >>sys.stderr, "\t", time.asctime(), s
162 class StatusThread(threading
.Thread
):
164 threading
.Thread
.__init
__(self
)
166 # pause a reasonable amount of time before
167 # starting to display status messages, so that
168 # most hg commands won't ever see them.
171 # now show status every 15 seconds
173 time
.sleep(15 - time
.time() % 15)
178 s
= "(unknown status)"
179 print >>sys
.stderr
, time
.asctime(), s
181 def start_status_thread():
183 t
.setDaemon(True) # allowed to exit if t is still running
186 #######################################################################
187 # Change list parsing.
189 # Change lists are stored in .hg/codereview/cl.nnnnnn
190 # where nnnnnn is the number assigned by the code review server.
191 # Most data about a change list is stored on the code review server
192 # too: the description, reviewer, and cc list are all stored there.
193 # The only thing in the cl.nnnnnn file is the list of relevant files.
194 # Also, the existence of the cl.nnnnnn file marks this repository
195 # as the one where the change list lives.
197 emptydiff
= """Index: ~rietveld~placeholder~
198 ===================================================================
199 diff --git a/~rietveld~placeholder~ b/~rietveld~placeholder~
204 def __init__(self
, name
):
214 self
.copied_from
= None # None means current user
223 s
+= "Author: " + cl
.copied_from
+ "\n\n"
225 s
+= "Private: " + str(self
.private
) + "\n"
226 s
+= "Mailed: " + str(self
.mailed
) + "\n"
227 s
+= "Description:\n"
228 s
+= Indent(cl
.desc
, "\t")
235 def EditorText(self
):
240 s
+= "Author: " + cl
.copied_from
+ "\n"
242 s
+= 'URL: ' + cl
.url
+ ' # cannot edit\n\n'
244 s
+= "Private: True\n"
245 s
+= "Reviewer: " + JoinComma(cl
.reviewer
) + "\n"
246 s
+= "CC: " + JoinComma(cl
.cc
) + "\n"
248 s
+= "Description:\n"
250 s
+= "\t<enter description here>\n"
252 s
+= Indent(cl
.desc
, "\t")
254 if cl
.local
or cl
.name
== "new":
262 def PendingText(self
, quick
=False):
264 s
= cl
.name
+ ":" + "\n"
265 s
+= Indent(cl
.desc
, "\t")
268 s
+= "\tAuthor: " + cl
.copied_from
+ "\n"
270 s
+= "\tReviewer: " + JoinComma(cl
.reviewer
) + "\n"
271 for (who
, line
) in cl
.lgtm
:
272 s
+= "\t\t" + who
+ ": " + line
+ "\n"
273 s
+= "\tCC: " + JoinComma(cl
.cc
) + "\n"
276 s
+= "\t\t" + f
+ "\n"
280 def Flush(self
, ui
, repo
):
281 if self
.name
== "new":
282 self
.Upload(ui
, repo
, gofmt_just_warn
=True, creating
=True)
283 dir = CodeReviewDir(ui
, repo
)
284 path
= dir + '/cl.' + self
.name
285 f
= open(path
+'!', "w")
286 f
.write(self
.DiskText())
288 if sys
.platform
== "win32" and os
.path
.isfile(path
):
290 os
.rename(path
+'!', path
)
291 if self
.web
and not self
.copied_from
:
292 EditDesc(self
.name
, desc
=self
.desc
,
293 reviewers
=JoinComma(self
.reviewer
), cc
=JoinComma(self
.cc
),
294 private
=self
.private
)
296 def Delete(self
, ui
, repo
):
297 dir = CodeReviewDir(ui
, repo
)
298 os
.unlink(dir + "/cl." + self
.name
)
304 if self
.name
!= "new":
305 s
= "code review %s: %s" % (self
.name
, s
)
309 def Upload(self
, ui
, repo
, send_mail
=False, gofmt
=True, gofmt_just_warn
=False, creating
=False, quiet
=False):
310 if not self
.files
and not creating
:
311 ui
.warn("no files in change list\n")
312 if ui
.configbool("codereview", "force_gofmt", True) and gofmt
:
313 CheckFormat(ui
, repo
, self
.files
, just_warn
=gofmt_just_warn
)
314 set_status("uploading CL metadata + diffs")
317 ("content_upload", "1"),
318 ("reviewers", JoinComma(self
.reviewer
)),
319 ("cc", JoinComma(self
.cc
)),
320 ("description", self
.desc
),
324 if self
.name
!= "new":
325 form_fields
.append(("issue", self
.name
))
327 # We do not include files when creating the issue,
328 # because we want the patch sets to record the repository
329 # and base revision they are diffs against. We use the patch
330 # set message for that purpose, but there is no message with
331 # the first patch set. Instead the message gets used as the
332 # new CL's overall subject. So omit the diffs when creating
333 # and then we'll run an immediate upload.
334 # This has the effect that every CL begins with an empty "Patch set 1".
335 if self
.files
and not creating
:
336 vcs
= MercurialVCS(upload_options
, ui
, repo
)
337 data
= vcs
.GenerateDiff(self
.files
)
338 files
= vcs
.GetBaseFiles(data
)
339 if len(data
) > MAX_UPLOAD_SIZE
:
340 uploaded_diff_file
= []
341 form_fields
.append(("separate_patches", "1"))
343 uploaded_diff_file
= [("data", "data.diff", data
)]
345 uploaded_diff_file
= [("data", "data.diff", emptydiff
)]
347 if vcs
and self
.name
!= "new":
348 form_fields
.append(("subject", "diff -r " + vcs
.base_rev
+ " " + ui
.expandpath("default")))
350 # First upload sets the subject for the CL itself.
351 form_fields
.append(("subject", self
.Subject()))
352 ctype
, body
= EncodeMultipartFormData(form_fields
, uploaded_diff_file
)
353 response_body
= MySend("/upload", body
, content_type
=ctype
)
356 lines
= msg
.splitlines()
359 patchset
= lines
[1].strip()
360 patches
= [x
.split(" ", 1) for x
in lines
[2:]]
361 if response_body
.startswith("Issue updated.") and quiet
:
364 ui
.status(msg
+ "\n")
365 set_status("uploaded CL metadata + diffs")
366 if not response_body
.startswith("Issue created.") and not response_body
.startswith("Issue updated."):
367 raise hg_util
.Abort("failed to update issue: " + response_body
)
368 issue
= msg
[msg
.rfind("/")+1:]
371 self
.url
= server_url_base
+ self
.name
372 if not uploaded_diff_file
:
373 set_status("uploading patches")
374 patches
= UploadSeparatePatches(issue
, rpc
, patchset
, data
, upload_options
)
376 set_status("uploading base files")
377 vcs
.UploadBaseFiles(issue
, rpc
, patches
, patchset
, upload_options
, files
)
379 set_status("sending mail")
380 MySend("/" + issue
+ "/mail", payload
="")
382 set_status("flushing changes to disk")
386 def Mail(self
, ui
, repo
):
387 pmsg
= "Hello " + JoinComma(self
.reviewer
)
389 pmsg
+= " (cc: %s)" % (', '.join(self
.cc
),)
392 repourl
= ui
.expandpath("default")
394 pmsg
+= "I'd like you to review this change to\n" + repourl
+ "\n"
396 pmsg
+= "Please take another look.\n"
398 PostMessage(ui
, self
.name
, pmsg
, subject
=self
.Subject())
402 def GoodCLName(name
):
404 return re
.match("^[0-9]+$", name
)
406 def ParseCL(text
, name
):
421 for line
in text
.split('\n'):
424 if line
!= '' and line
[0] == '#':
426 if line
== '' or line
[0] == ' ' or line
[0] == '\t':
427 if sname
== None and line
!= '':
428 return None, lineno
, 'text outside section'
430 sections
[sname
] += line
+ '\n'
434 s
, val
= line
[:p
].strip(), line
[p
+1:].strip()
438 sections
[sname
] += val
+ '\n'
440 return None, lineno
, 'malformed section header'
443 sections
[k
] = StripCommon(sections
[k
]).rstrip()
446 if sections
['Author']:
447 cl
.copied_from
= sections
['Author']
448 cl
.desc
= sections
['Description']
449 for line
in sections
['Files'].split('\n'):
452 line
= line
[0:i
].rstrip()
456 cl
.files
.append(line
)
457 cl
.reviewer
= SplitCommaSpace(sections
['Reviewer'])
458 cl
.cc
= SplitCommaSpace(sections
['CC'])
459 cl
.url
= sections
['URL']
460 if sections
['Mailed'] != 'False':
461 # Odd default, but avoids spurious mailings when
462 # reading old CLs that do not have a Mailed: line.
463 # CLs created with this update will always have
464 # Mailed: False on disk.
466 if sections
['Private'] in ('True', 'true', 'Yes', 'yes'):
468 if cl
.desc
== '<enter description here>':
472 def SplitCommaSpace(s
):
477 return re
.split(", *", s
)
491 def ExceptionDetail():
492 s
= str(sys
.exc_info()[0])
493 if s
.startswith("<type '") and s
.endswith("'>"):
495 elif s
.startswith("<class '") and s
.endswith("'>"):
497 arg
= str(sys
.exc_info()[1])
502 def IsLocalCL(ui
, repo
, name
):
503 return GoodCLName(name
) and os
.access(CodeReviewDir(ui
, repo
) + "/cl." + name
, 0)
505 # Load CL from disk and/or the web.
506 def LoadCL(ui
, repo
, name
, web
=True):
508 set_status("loading CL " + name
)
509 if not GoodCLName(name
):
510 return None, "invalid CL name"
511 dir = CodeReviewDir(ui
, repo
)
512 path
= dir + "cl." + name
513 if os
.access(path
, 0):
517 cl
, lineno
, err
= ParseCL(text
, name
)
519 return None, "malformed CL data: "+err
524 set_status("getting issue metadata from web")
525 d
= JSONGet(ui
, "/api/" + name
+ "?messages=true")
528 return None, "cannot load CL %s from server" % (name
,)
529 if 'owner_email' not in d
or 'issue' not in d
or str(d
['issue']) != name
:
530 return None, "malformed response loading CL data from code review server"
532 cl
.reviewer
= d
.get('reviewers', [])
533 cl
.cc
= d
.get('cc', [])
534 if cl
.local
and cl
.copied_from
and cl
.desc
:
535 # local copy of CL written by someone else
536 # and we saved a description. use that one,
537 # so that committers can edit the description
538 # before doing hg submit.
541 cl
.desc
= d
.get('description', "")
542 cl
.url
= server_url_base
+ name
544 cl
.private
= d
.get('private', False) != False
546 for m
in d
.get('messages', []):
547 if m
.get('approval', False) == True:
548 who
= re
.sub('@.*', '', m
.get('sender', ''))
549 text
= re
.sub("\n(.|\n)*", '', m
.get('text', ''))
550 cl
.lgtm
.append((who
, text
))
552 set_status("loaded CL " + name
)
555 class LoadCLThread(threading
.Thread
):
556 def __init__(self
, ui
, repo
, dir, f
, web
):
557 threading
.Thread
.__init
__(self
)
565 cl
, err
= LoadCL(self
.ui
, self
.repo
, self
.f
[3:], web
=self
.web
)
567 self
.ui
.warn("loading "+self
.dir+self
.f
+": " + err
+ "\n")
571 # Load all the CLs from this repository.
572 def LoadAllCL(ui
, repo
, web
=True):
573 dir = CodeReviewDir(ui
, repo
)
575 files
= [f
for f
in os
.listdir(dir) if f
.startswith('cl.')]
581 t
= LoadCLThread(ui
, repo
, dir, f
, web
)
584 # first request: wait in case it needs to authenticate
585 # otherwise we get lots of user/password prompts
586 # running in parallel.
599 # Find repository root. On error, ui.warn and return None
600 def RepoDir(ui
, repo
):
602 if not url
.startswith('file:'):
603 ui
.warn("repository %s is not in local file system\n" % (url
,))
606 if url
.endswith('/'):
611 # Find (or make) code review directory. On error, ui.warn and return None
612 def CodeReviewDir(ui
, repo
):
613 dir = RepoDir(ui
, repo
)
616 dir += '/.hg/codereview/'
617 if not os
.path
.isdir(dir):
621 ui
.warn('cannot mkdir %s: %s\n' % (dir, ExceptionDetail()))
626 # Turn leading tabs into spaces, so that the common white space
627 # prefix doesn't get confused when people's editors write out
628 # some lines with spaces, some with tabs. Only a heuristic
629 # (some editors don't use 8 spaces either) but a useful one.
630 def TabsToSpaces(line
):
632 while i
< len(line
) and line
[i
] == '\t':
634 return ' '*(8*i
) + line
[i
:]
636 # Strip maximal common leading white space prefix from text
637 def StripCommon(text
):
640 for line
in text
.split('\n'):
644 line
= TabsToSpaces(line
)
645 white
= line
[:len(line
)-len(line
.lstrip())]
650 for i
in range(min(len(white
), len(ws
))+1):
651 if white
[0:i
] == ws
[0:i
]:
659 for line
in text
.split('\n'):
661 line
= TabsToSpaces(line
)
662 if line
.startswith(ws
):
663 line
= line
[len(ws
):]
664 if line
== '' and t
== '':
667 while len(t
) >= 2 and t
[-2:] == '\n\n':
672 # Indent text with indent.
673 def Indent(text
, indent
):
675 typecheck(indent
, str)
677 for line
in text
.split('\n'):
678 t
+= indent
+ line
+ '\n'
682 # Return the first line of l
685 return text
.split('\n')[0]
687 _change_prolog
= """# Change list.
688 # Lines beginning with # are ignored.
689 # Multi-line values should be indented.
692 desc_re
= '^(.+: |(tag )?(release|weekly)\.|fix build|undo CL)'
694 desc_msg
= '''Your CL description appears not to use the standard form.
696 The first line of your change description is conventionally a
697 one-line summary of the change, prefixed by the primary affected package,
698 and is used as the subject for code review mail; the rest of the description
703 encoding/rot13: new package
705 math: add IsInf, IsNaN
707 net: fix cname in LookupHost
709 unicode: update to Unicode 5.0.2
713 def promptyesno(ui
, msg
):
714 if hgversion
>= "2.7":
715 return ui
.promptchoice(msg
+ " $$ &yes $$ &no", 0) == 0
717 return ui
.promptchoice(msg
, ["&yes", "&no"], 0) == 0
719 def promptremove(ui
, repo
, f
):
720 if promptyesno(ui
, "hg remove %s (y/n)?" % (f
,)):
721 if hg_commands
.remove(ui
, repo
, 'path:'+f
) != 0:
722 ui
.warn("error removing %s" % (f
,))
724 def promptadd(ui
, repo
, f
):
725 if promptyesno(ui
, "hg add %s (y/n)?" % (f
,)):
726 if hg_commands
.add(ui
, repo
, 'path:'+f
) != 0:
727 ui
.warn("error adding %s" % (f
,))
729 def EditCL(ui
, repo
, cl
):
730 set_status(None) # do not show status
733 s
= ui
.edit(s
, ui
.username())
735 # We can't trust Mercurial + Python not to die before making the change,
736 # so, by popular demand, just scribble the most recent CL edit into
737 # $(hg root)/last-change so that if Mercurial does die, people
738 # can look there for their work.
740 f
= open(repo
.root
+"/last-change", "w")
746 clx
, line
, err
= ParseCL(s
, cl
.name
)
748 if not promptyesno(ui
, "error parsing change list: line %d: %s\nre-edit (y/n)?" % (line
, err
)):
749 return "change list not modified"
754 if promptyesno(ui
, "change list should have a description\nre-edit (y/n)?"):
756 elif re
.search('<enter reason for undo>', clx
.desc
):
757 if promptyesno(ui
, "change list description omits reason for undo\nre-edit (y/n)?"):
759 elif not re
.match(desc_re
, clx
.desc
.split('\n')[0]):
760 if promptyesno(ui
, desc_msg
+ "re-edit (y/n)?"):
763 # Check file list for files that need to be hg added or hg removed
764 # or simply aren't understood.
765 pats
= ['path:'+f
for f
in clx
.files
]
766 changed
= hg_matchPattern(ui
, repo
, *pats
, modified
=True, added
=True, removed
=True)
767 deleted
= hg_matchPattern(ui
, repo
, *pats
, deleted
=True)
768 unknown
= hg_matchPattern(ui
, repo
, *pats
, unknown
=True)
769 ignored
= hg_matchPattern(ui
, repo
, *pats
, ignored
=True)
770 clean
= hg_matchPattern(ui
, repo
, *pats
, clean
=True)
777 promptremove(ui
, repo
, f
)
781 promptadd(ui
, repo
, f
)
785 ui
.warn("error: %s is excluded by .hgignore; omitting\n" % (f
,))
788 ui
.warn("warning: %s is listed in the CL but unchanged\n" % (f
,))
791 p
= repo
.root
+ '/' + f
792 if os
.path
.isfile(p
):
793 ui
.warn("warning: %s is a file but not known to hg\n" % (f
,))
797 ui
.warn("error: %s is a directory, not a file; omitting\n" % (f
,))
799 ui
.warn("error: %s does not exist; omitting\n" % (f
,))
803 cl
.reviewer
= clx
.reviewer
806 cl
.private
= clx
.private
810 # For use by submit, etc. (NOT by change)
811 # Get change list number or list of files from command line.
812 # If files are given, make a new change list.
813 def CommandLineCL(ui
, repo
, pats
, opts
, defaultcc
=None):
814 if len(pats
) > 0 and GoodCLName(pats
[0]):
816 return None, "cannot specify change number and file names"
817 if opts
.get('message'):
818 return None, "cannot use -m with existing CL"
819 cl
, err
= LoadCL(ui
, repo
, pats
[0], web
=True)
825 cl
.files
= ChangedFiles(ui
, repo
, pats
, taken
=Taken(ui
, repo
))
827 return None, "no files changed"
828 if opts
.get('reviewer'):
829 cl
.reviewer
= Add(cl
.reviewer
, SplitCommaSpace(opts
.get('reviewer')))
831 cl
.cc
= Add(cl
.cc
, SplitCommaSpace(opts
.get('cc')))
833 cl
.cc
= Add(cl
.cc
, defaultcc
)
835 if opts
.get('message'):
836 cl
.desc
= opts
.get('message')
838 err
= EditCL(ui
, repo
, cl
)
843 #######################################################################
844 # Change list file management
846 # Return list of changed files in repository that match pats.
847 # The patterns came from the command line, so we warn
848 # if they have no effect or cannot be understood.
849 def ChangedFiles(ui
, repo
, pats
, taken
=None):
851 # Run each pattern separately so that we can warn about
852 # patterns that didn't do anything useful.
854 for f
in hg_matchPattern(ui
, repo
, p
, unknown
=True):
855 promptadd(ui
, repo
, f
)
856 for f
in hg_matchPattern(ui
, repo
, p
, removed
=True):
857 promptremove(ui
, repo
, f
)
858 files
= hg_matchPattern(ui
, repo
, p
, modified
=True, added
=True, removed
=True)
861 ui
.warn("warning: %s already in CL %s\n" % (f
, taken
[f
].name
))
863 ui
.warn("warning: %s did not match any modified files\n" % (p
,))
865 # Again, all at once (eliminates duplicates)
866 l
= hg_matchPattern(ui
, repo
, *pats
, modified
=True, added
=True, removed
=True)
869 l
= Sub(l
, taken
.keys())
872 # Return list of changed files in repository that match pats and still exist.
873 def ChangedExistingFiles(ui
, repo
, pats
, opts
):
874 l
= hg_matchPattern(ui
, repo
, *pats
, modified
=True, added
=True)
878 # Return list of files claimed by existing CLs
880 all
= LoadAllCL(ui
, repo
, web
=False)
882 for _
, cl
in all
.items():
887 # Return list of changed files that are not claimed by other CLs
888 def DefaultFiles(ui
, repo
, pats
):
889 return ChangedFiles(ui
, repo
, pats
, taken
=Taken(ui
, repo
))
891 #######################################################################
892 # File format checking.
894 def CheckFormat(ui
, repo
, files
, just_warn
=False):
895 set_status("running gofmt")
896 CheckGofmt(ui
, repo
, files
, just_warn
)
897 CheckTabfmt(ui
, repo
, files
, just_warn
)
899 # Check that gofmt run on the list of files does not change them
900 def CheckGofmt(ui
, repo
, files
, just_warn
):
901 files
= gofmt_required(files
)
905 files
= [RelativePath(repo
.root
+ '/' + f
, cwd
) for f
in files
]
906 files
= [f
for f
in files
if os
.access(f
, 0)]
910 cmd
= subprocess
.Popen(["gofmt", "-l"] + files
, shell
=False, stdin
=subprocess
.PIPE
, stdout
=subprocess
.PIPE
, stderr
=subprocess
.PIPE
, close_fds
=sys
.platform
!= "win32")
913 raise hg_util
.Abort("gofmt: " + ExceptionDetail())
914 data
= cmd
.stdout
.read()
915 errors
= cmd
.stderr
.read()
917 set_status("done with gofmt")
919 ui
.warn("gofmt errors:\n" + errors
.rstrip() + "\n")
922 msg
= "gofmt needs to format these files (run hg gofmt):\n" + Indent(data
, "\t").rstrip()
924 ui
.warn("warning: " + msg
+ "\n")
926 raise hg_util
.Abort(msg
)
929 # Check that *.[chys] files indent using tabs.
930 def CheckTabfmt(ui
, repo
, files
, just_warn
):
931 files
= [f
for f
in files
if f
.startswith('src/') and re
.search(r
"\.[chys]$", f
) and not re
.search(r
"\.tab\.[ch]$", f
)]
935 files
= [RelativePath(repo
.root
+ '/' + f
, cwd
) for f
in files
]
936 files
= [f
for f
in files
if os
.access(f
, 0)]
940 for line
in open(f
, 'r'):
941 # Four leading spaces is enough to complain about,
942 # except that some Plan 9 code uses four spaces as the label indent,
944 if line
.startswith(' ') and not re
.match(' [A-Za-z0-9_]+:', line
):
948 # ignore cannot open file, etc.
950 if len(badfiles
) > 0:
951 msg
= "these files use spaces for indentation (use tabs instead):\n\t" + "\n\t".join(badfiles
)
953 ui
.warn("warning: " + msg
+ "\n")
955 raise hg_util
.Abort(msg
)
958 #######################################################################
959 # CONTRIBUTORS file parsing
961 contributorsCache
= None
962 contributorsURL
= None
964 def ReadContributors(ui
, repo
):
965 global contributorsCache
966 if contributorsCache
is not None:
967 return contributorsCache
970 if contributorsURL
is not None:
971 opening
= contributorsURL
972 f
= urllib2
.urlopen(contributorsURL
)
974 opening
= repo
.root
+ '/CONTRIBUTORS'
975 f
= open(repo
.root
+ '/CONTRIBUTORS', 'r')
977 ui
.write("warning: cannot open %s: %s\n" % (opening
, ExceptionDetail()))
982 # CONTRIBUTORS is a list of lines like:
984 # Person <email> <alt-email>
985 # The first email address is the one used in commit logs.
986 if line
.startswith('#'):
988 m
= re
.match(r
"([^<>]+\S)\s+(<[^<>\s]+>)((\s+<[^<>\s]+>)*)\s*$", line
)
991 email
= m
.group(2)[1:-1]
992 contributors
[email
.lower()] = (name
, email
)
993 for extra
in m
.group(3).split():
994 contributors
[extra
[1:-1].lower()] = (name
, email
)
996 contributorsCache
= contributors
999 def CheckContributor(ui
, repo
, user
=None):
1000 set_status("checking CONTRIBUTORS file")
1001 user
, userline
= FindContributor(ui
, repo
, user
, warn
=False)
1003 raise hg_util
.Abort("cannot find %s in CONTRIBUTORS" % (user
,))
1006 def FindContributor(ui
, repo
, user
=None, warn
=True):
1008 user
= ui
.config("ui", "username")
1010 raise hg_util
.Abort("[ui] username is not configured in .hgrc")
1012 m
= re
.match(r
".*<(.*)>", user
)
1016 contributors
= ReadContributors(ui
, repo
)
1017 if user
not in contributors
:
1019 ui
.warn("warning: cannot find %s in CONTRIBUTORS\n" % (user
,))
1022 user
, email
= contributors
[user
]
1023 return email
, "%s <%s>" % (user
, email
)
1025 #######################################################################
1026 # Mercurial helper functions.
1027 # Read http://mercurial.selenic.com/wiki/MercurialApi before writing any of these.
1028 # We use the ui.pushbuffer/ui.popbuffer + hg_commands.xxx tricks for all interaction
1029 # with Mercurial. It has proved the most stable as they make changes.
1031 hgversion
= hg_util
.version()
1033 # We require Mercurial 1.9 and suggest Mercurial 2.0.
1034 # The details of the scmutil package changed then,
1035 # so allowing earlier versions would require extra band-aids below.
1036 # Ubuntu 11.10 ships with Mercurial 1.9.1 as the default version.
1038 hg_suggested
= "2.0"
1042 The code review extension requires Mercurial """+hg_required
+""" or newer.
1043 You are using Mercurial """+hgversion
+""".
1045 To install a new Mercurial, use
1047 sudo easy_install mercurial=="""+hg_suggested
+"""
1049 or visit http://mercurial.selenic.com/downloads/.
1053 You may need to clear your current Mercurial installation by running:
1055 sudo apt-get remove mercurial mercurial-common
1056 sudo rm -rf /etc/mercurial
1059 if hgversion
< hg_required
:
1061 if os
.access("/etc/mercurial", 0):
1062 msg
+= linux_message
1063 raise hg_util
.Abort(msg
)
1065 from mercurial
.hg
import clean
as hg_clean
1066 from mercurial
import cmdutil
as hg_cmdutil
1067 from mercurial
import error
as hg_error
1068 from mercurial
import match
as hg_match
1069 from mercurial
import node
as hg_node
1071 class uiwrap(object):
1072 def __init__(self
, ui
):
1075 self
.oldQuiet
= ui
.quiet
1077 self
.oldVerbose
= ui
.verbose
1081 ui
.quiet
= self
.oldQuiet
1082 ui
.verbose
= self
.oldVerbose
1083 return ui
.popbuffer()
1086 if sys
.platform
== "win32":
1087 return path
.replace('\\', '/')
1090 def hg_matchPattern(ui
, repo
, *pats
, **opts
):
1092 hg_commands
.status(ui
, repo
, *pats
, **opts
)
1095 prefix
= to_slash(os
.path
.realpath(repo
.root
))+'/'
1096 for line
in text
.split('\n'):
1100 # Given patterns, Mercurial shows relative to cwd
1101 p
= to_slash(os
.path
.realpath(f
[1]))
1102 if not p
.startswith(prefix
):
1103 print >>sys
.stderr
, "File %s not in repo root %s.\n" % (p
, prefix
)
1105 ret
.append(p
[len(prefix
):])
1107 # Without patterns, Mercurial shows relative to root (what we want)
1108 ret
.append(to_slash(f
[1]))
1111 def hg_heads(ui
, repo
):
1113 hg_commands
.heads(ui
, repo
)
1118 "resolving manifests",
1119 "searching for changes",
1120 "couldn't find merge tool hgmerge",
1121 "adding changesets",
1123 "adding file changes",
1124 "all local heads known remotely",
1134 def hg_incoming(ui
, repo
):
1136 ret
= hg_commands
.incoming(ui
, repo
, force
=False, bundle
="")
1137 if ret
and ret
!= 1:
1138 raise hg_util
.Abort(ret
)
1141 def hg_log(ui
, repo
, **opts
):
1142 for k
in ['date', 'keyword', 'rev', 'user']:
1143 if not opts
.has_key(k
):
1146 ret
= hg_commands
.log(ui
, repo
, **opts
)
1148 raise hg_util
.Abort(ret
)
1151 def hg_outgoing(ui
, repo
, **opts
):
1153 ret
= hg_commands
.outgoing(ui
, repo
, **opts
)
1154 if ret
and ret
!= 1:
1155 raise hg_util
.Abort(ret
)
1158 def hg_pull(ui
, repo
, **opts
):
1161 ui
.verbose
= True # for file list
1162 err
= hg_commands
.pull(ui
, repo
, **opts
)
1163 for line
in w
.output().split('\n'):
1166 if line
.startswith('moving '):
1167 line
= 'mv ' + line
[len('moving '):]
1168 if line
.startswith('getting ') and line
.find(' to ') >= 0:
1169 line
= 'mv ' + line
[len('getting '):]
1170 if line
.startswith('getting '):
1171 line
= '+ ' + line
[len('getting '):]
1172 if line
.startswith('removing '):
1173 line
= '- ' + line
[len('removing '):]
1174 ui
.write(line
+ '\n')
1177 def hg_push(ui
, repo
, **opts
):
1181 err
= hg_commands
.push(ui
, repo
, **opts
)
1182 for line
in w
.output().split('\n'):
1183 if not isNoise(line
):
1184 ui
.write(line
+ '\n')
1187 def hg_commit(ui
, repo
, *pats
, **opts
):
1188 return hg_commands
.commit(ui
, repo
, *pats
, **opts
)
1190 #######################################################################
1191 # Mercurial precommit hook to disable commit except through this interface.
1195 def precommithook(ui
, repo
, **opts
):
1197 return False # False means okay.
1198 ui
.write("\ncodereview extension enabled; use mail, upload, or submit instead of commit\n\n")
1201 #######################################################################
1202 # @clnumber file pattern support
1204 # We replace scmutil.match with the MatchAt wrapper to add the @clnumber pattern.
1210 def InstallMatch(ui
, repo
):
1218 from mercurial
import scmutil
1219 match_orig
= scmutil
.match
1220 scmutil
.match
= MatchAt
1222 def MatchAt(ctx
, pats
=None, opts
=None, globbed
=False, default
='relpath'):
1229 if p
.startswith('@'):
1232 if clname
== "default":
1233 files
= DefaultFiles(match_ui
, match_repo
, [])
1235 if not GoodCLName(clname
):
1236 raise hg_util
.Abort("invalid CL name " + clname
)
1237 cl
, err
= LoadCL(match_repo
.ui
, match_repo
, clname
, web
=False)
1239 raise hg_util
.Abort("loading CL " + clname
+ ": " + err
)
1241 raise hg_util
.Abort("no files in CL " + clname
)
1242 files
= Add(files
, cl
.files
)
1243 pats
= Sub(pats
, taken
) + ['path:'+f
for f
in files
]
1245 # work-around for http://selenic.com/hg/rev/785bbc8634f8
1246 if not hasattr(ctx
, 'match'):
1248 return match_orig(ctx
, pats
=pats
, opts
=opts
, globbed
=globbed
, default
=default
)
1250 #######################################################################
1251 # Commands added by code review extension.
1253 # As of Mercurial 2.1 the commands are all required to return integer
1254 # exit codes, whereas earlier versions allowed returning arbitrary strings
1255 # to be printed as errors. We wrap the old functions to make sure we
1256 # always return integer exit codes now. Otherwise Mercurial dies
1257 # with a TypeError traceback (unsupported operand type(s) for &: 'str' and 'int').
1258 # Introduce a Python decorator to convert old functions to the new
1259 # stricter convention.
1262 def wrapped(ui
, repo
, *pats
, **opts
):
1263 err
= f(ui
, repo
, *pats
, **opts
)
1264 if type(err
) is int:
1268 raise hg_util
.Abort(err
)
1269 wrapped
.__doc
__ = f
.__doc
__
1272 #######################################################################
1276 def change(ui
, repo
, *pats
, **opts
):
1277 """create, edit or delete a change list
1279 Create, edit or delete a change list.
1280 A change list is a group of files to be reviewed and submitted together,
1281 plus a textual description of the change.
1282 Change lists are referred to by simple alphanumeric names.
1284 Changes must be reviewed before they can be submitted.
1286 In the absence of options, the change command opens the
1287 change list for editing in the default editor.
1289 Deleting a change with the -d or -D flag does not affect
1290 the contents of the files listed in that change. To revert
1291 the files listed in a change, use
1295 before running hg change -d 123456.
1298 if codereview_disabled
:
1299 return codereview_disabled
1302 if len(pats
) > 0 and GoodCLName(pats
[0]):
1305 return "cannot specify CL name and file patterns"
1307 cl
, err
= LoadCL(ui
, repo
, name
, web
=True)
1310 if not cl
.local
and (opts
["stdin"] or not opts
["stdout"]):
1311 return "cannot change non-local CL " + name
1315 if repo
[None].branch() != "default":
1316 return "cannot create CL outside default branch; switch with 'hg update default'"
1318 files
= ChangedFiles(ui
, repo
, pats
, taken
=Taken(ui
, repo
))
1320 if opts
["delete"] or opts
["deletelocal"]:
1321 if opts
["delete"] and opts
["deletelocal"]:
1322 return "cannot use -d and -D together"
1324 if opts
["deletelocal"]:
1327 return "cannot use "+flag
+" with file patterns"
1328 if opts
["stdin"] or opts
["stdout"]:
1329 return "cannot use "+flag
+" with -i or -o"
1331 return "cannot change non-local CL " + name
1334 return "original author must delete CL; hg change -D will remove locally"
1335 PostMessage(ui
, cl
.name
, "*** Abandoned ***", send_mail
=cl
.mailed
)
1336 EditDesc(cl
.name
, closed
=True, private
=cl
.private
)
1341 s
= sys
.stdin
.read()
1342 clx
, line
, err
= ParseCL(s
, name
)
1344 return "error parsing change list: line %d: %s" % (line
, err
)
1345 if clx
.desc
is not None:
1348 if clx
.reviewer
is not None:
1349 cl
.reviewer
= clx
.reviewer
1351 if clx
.cc
is not None:
1354 if clx
.files
is not None:
1355 cl
.files
= clx
.files
1357 if clx
.private
!= cl
.private
:
1358 cl
.private
= clx
.private
1361 if not opts
["stdin"] and not opts
["stdout"]:
1364 err
= EditCL(ui
, repo
, cl
)
1369 for d
, _
in dirty
.items():
1373 d
.Upload(ui
, repo
, quiet
=True)
1376 ui
.write(cl
.EditorText())
1377 elif opts
["pending"]:
1378 ui
.write(cl
.PendingText())
1383 ui
.write("CL created: " + cl
.url
+ "\n")
1386 #######################################################################
1387 # hg code-login (broken?)
1390 def code_login(ui
, repo
, **opts
):
1391 """log in to code review server
1393 Logs in to the code review server, saving a cookie in
1394 a file in your home directory.
1396 if codereview_disabled
:
1397 return codereview_disabled
1401 #######################################################################
1402 # hg clpatch / undo / release-apply / download
1403 # All concerned with applying or unapplying patches to the repository.
1406 def clpatch(ui
, repo
, clname
, **opts
):
1407 """import a patch from the code review server
1409 Imports a patch from the code review server into the local client.
1410 If the local client has already modified any of the files that the
1411 patch modifies, this command will refuse to apply the patch.
1413 Submitting an imported patch will keep the original author's
1414 name as the Author: line but add your own name to a Committer: line.
1416 if repo
[None].branch() != "default":
1417 return "cannot run hg clpatch outside default branch"
1418 return clpatch_or_undo(ui
, repo
, clname
, opts
, mode
="clpatch")
1421 def undo(ui
, repo
, clname
, **opts
):
1422 """undo the effect of a CL
1424 Creates a new CL that undoes an earlier CL.
1425 After creating the CL, opens the CL text for editing so that
1426 you can add the reason for the undo to the description.
1428 if repo
[None].branch() != "default":
1429 return "cannot run hg undo outside default branch"
1430 return clpatch_or_undo(ui
, repo
, clname
, opts
, mode
="undo")
1433 def release_apply(ui
, repo
, clname
, **opts
):
1434 """apply a CL to the release branch
1436 Creates a new CL copying a previously committed change
1437 from the main branch to the release branch.
1438 The current client must either be clean or already be in
1441 The release branch must be created by starting with a
1442 clean client, disabling the code review plugin, and running:
1444 hg update weekly.YYYY-MM-DD
1445 hg branch release-branch.rNN
1446 hg commit -m 'create release-branch.rNN'
1447 hg push --new-branch
1449 Then re-enable the code review plugin.
1451 People can test the release branch by running
1453 hg update release-branch.rNN
1455 in a clean client. To return to the normal tree,
1459 Move changes since the weekly into the release branch
1460 using hg release-apply followed by the usual code review
1461 process and hg submit.
1463 When it comes time to tag the release, record the
1464 final long-form tag of the release-branch.rNN
1465 in the *default* branch's .hgtags file. That is, run
1469 and then edit .hgtags as you would for a weekly.
1473 if not releaseBranch
:
1474 return "no active release branches"
1475 if c
.branch() != releaseBranch
:
1476 if c
.modified() or c
.added() or c
.removed():
1477 raise hg_util
.Abort("uncommitted local changes - cannot switch branches")
1478 err
= hg_clean(repo
, releaseBranch
)
1482 err
= clpatch_or_undo(ui
, repo
, clname
, opts
, mode
="backport")
1484 raise hg_util
.Abort(err
)
1485 except Exception, e
:
1486 hg_clean(repo
, "default")
1490 def rev2clname(rev
):
1491 # Extract CL name from revision description.
1492 # The last line in the description that is a codereview URL is the real one.
1493 # Earlier lines might be part of the user-written description.
1494 all
= re
.findall('(?m)^http://codereview.appspot.com/([0-9]+)$', rev
.description())
1499 undoHeader
= """undo CL %s / %s
1501 <enter reason for undo>
1503 ««« original CL description
1510 backportHeader
= """[%s] %s
1515 backportFooter
= """
1519 # Implementation of clpatch/undo.
1520 def clpatch_or_undo(ui
, repo
, clname
, opts
, mode
):
1521 if codereview_disabled
:
1522 return codereview_disabled
1524 if mode
== "undo" or mode
== "backport":
1525 # Find revision in Mercurial repository.
1526 # Assume CL number is 7+ decimal digits.
1527 # Otherwise is either change log sequence number (fewer decimal digits),
1528 # hexadecimal hash, or tag name.
1529 # Mercurial will fall over long before the change log
1530 # sequence numbers get to be 7 digits long.
1531 if re
.match('^[0-9]{7,}$', clname
):
1533 for r
in hg_log(ui
, repo
, keyword
="codereview.appspot.com/"+clname
, limit
=100, template
="{node}\n").split():
1535 # Last line with a code review URL is the actual review URL.
1536 # Earlier ones might be part of the CL description.
1542 return "cannot find CL %s in local repository" % clname
1546 return "unknown revision %s" % clname
1547 clname
= rev2clname(rev
)
1549 return "cannot find CL name in revision description"
1551 # Create fresh CL and start with patch that would reverse the change.
1552 vers
= hg_node
.short(rev
.node())
1554 desc
= str(rev
.description())
1556 cl
.desc
= (undoHeader
% (clname
, vers
)) + desc
+ undoFooter
1558 cl
.desc
= (backportHeader
% (releaseBranch
, line1(desc
), clname
, vers
)) + desc
+ undoFooter
1560 v0
= hg_node
.short(rev
.parents()[0].node())
1566 patch
= RunShell(["hg", "diff", "--git", "-r", arg
])
1569 cl
, vers
, patch
, err
= DownloadCL(ui
, repo
, clname
)
1572 if patch
== emptydiff
:
1573 return "codereview issue %s has no diff" % clname
1575 # find current hg version (hg identify)
1577 parents
= ctx
.parents()
1578 id = '+'.join([hg_node
.short(p
.node()) for p
in parents
])
1580 # if version does not match the patch version,
1581 # try to update the patch line numbers.
1582 if vers
!= "" and id != vers
:
1583 # "vers in repo" gives the wrong answer
1584 # on some versions of Mercurial. Instead, do the actual
1585 # lookup and catch the exception.
1587 repo
[vers
].description()
1589 return "local repository is out of date; sync to get %s" % (vers
)
1590 patch1
, err
= portPatch(repo
, patch
, vers
, id)
1592 if not opts
["ignore_hgpatch_failure"]:
1593 return "codereview issue %s is out of date: %s (%s->%s)" % (clname
, err
, vers
, id)
1597 if opts
["no_incoming"] or mode
== "backport":
1598 argv
+= ["--checksync=false"]
1600 cmd
= subprocess
.Popen(argv
, shell
=False, stdin
=subprocess
.PIPE
, stdout
=subprocess
.PIPE
, stderr
=None, close_fds
=sys
.platform
!= "win32")
1602 return "hgpatch: " + ExceptionDetail() + "\nInstall hgpatch with:\n$ go get code.google.com/p/go.codereview/cmd/hgpatch\n"
1604 out
, err
= cmd
.communicate(patch
)
1605 if cmd
.returncode
!= 0 and not opts
["ignore_hgpatch_failure"]:
1606 return "hgpatch failed"
1608 cl
.files
= out
.strip().split()
1609 if not cl
.files
and not opts
["ignore_hgpatch_failure"]:
1610 return "codereview issue %s has no changed files" % clname
1611 files
= ChangedFiles(ui
, repo
, [])
1612 extra
= Sub(cl
.files
, files
)
1614 ui
.warn("warning: these files were listed in the patch but not changed:\n\t" + "\n\t".join(extra
) + "\n")
1617 err
= EditCL(ui
, repo
, cl
)
1619 return "CL created, but error editing: " + err
1622 ui
.write(cl
.PendingText() + "\n")
1624 # portPatch rewrites patch from being a patch against
1625 # oldver to being a patch against newver.
1626 def portPatch(repo
, patch
, oldver
, newver
):
1627 lines
= patch
.splitlines(True) # True = keep \n
1629 for i
in range(len(lines
)):
1631 if line
.startswith('--- a/'):
1633 delta
= fileDeltas(repo
, file, oldver
, newver
)
1634 if not delta
or not line
.startswith('@@ '):
1636 # @@ -x,y +z,w @@ means the patch chunk replaces
1637 # the original file's line numbers x up to x+y with the
1638 # line numbers z up to z+w in the new file.
1639 # Find the delta from x in the original to the same
1640 # line in the current version and add that delta to both
1642 m
= re
.match('@@ -([0-9]+),([0-9]+) \+([0-9]+),([0-9]+) @@', line
)
1644 return None, "error parsing patch line numbers"
1645 n1
, len1
, n2
, len2
= int(m
.group(1)), int(m
.group(2)), int(m
.group(3)), int(m
.group(4))
1646 d
, err
= lineDelta(delta
, n1
, len1
)
1651 lines
[i
] = "@@ -%d,%d +%d,%d @@\n" % (n1
, len1
, n2
, len2
)
1653 newpatch
= ''.join(lines
)
1656 # fileDelta returns the line number deltas for the given file's
1657 # changes from oldver to newver.
1658 # The deltas are a list of (n, len, newdelta) triples that say
1659 # lines [n, n+len) were modified, and after that range the
1660 # line numbers are +newdelta from what they were before.
1661 def fileDeltas(repo
, file, oldver
, newver
):
1662 cmd
= ["hg", "diff", "--git", "-r", oldver
+ ":" + newver
, "path:" + file]
1663 data
= RunShell(cmd
, silent_ok
=True)
1665 for line
in data
.splitlines():
1666 m
= re
.match('@@ -([0-9]+),([0-9]+) \+([0-9]+),([0-9]+) @@', line
)
1669 n1
, len1
, n2
, len2
= int(m
.group(1)), int(m
.group(2)), int(m
.group(3)), int(m
.group(4))
1670 deltas
.append((n1
, len1
, n2
+len2
-(n1
+len1
)))
1673 # lineDelta finds the appropriate line number delta to apply to the lines [n, n+len).
1674 # It returns an error if those lines were rewritten by the patch.
1675 def lineDelta(deltas
, n
, len):
1677 for (old
, oldlen
, newdelta
) in deltas
:
1681 return 0, "patch and recent changes conflict"
1686 def download(ui
, repo
, clname
, **opts
):
1687 """download a change from the code review server
1689 Download prints a description of the given change list
1690 followed by its diff, downloaded from the code review server.
1692 if codereview_disabled
:
1693 return codereview_disabled
1695 cl
, vers
, patch
, err
= DownloadCL(ui
, repo
, clname
)
1698 ui
.write(cl
.EditorText() + "\n")
1699 ui
.write(patch
+ "\n")
1702 #######################################################################
1706 def file(ui
, repo
, clname
, pat
, *pats
, **opts
):
1707 """assign files to or remove files from a change list
1709 Assign files to or (with -d) remove files from a change list.
1711 The -d option only removes files from the change list.
1712 It does not edit them or remove them from the repository.
1714 if codereview_disabled
:
1715 return codereview_disabled
1717 pats
= tuple([pat
] + list(pats
))
1718 if not GoodCLName(clname
):
1719 return "invalid CL name " + clname
1722 cl
, err
= LoadCL(ui
, repo
, clname
, web
=False)
1726 return "cannot change non-local CL " + clname
1728 files
= ChangedFiles(ui
, repo
, pats
)
1731 oldfiles
= Intersect(files
, cl
.files
)
1734 ui
.status("# Removing files from CL. To undo:\n")
1735 ui
.status("# cd %s\n" % (repo
.root
))
1737 ui
.status("# hg file %s %s\n" % (cl
.name
, f
))
1738 cl
.files
= Sub(cl
.files
, oldfiles
)
1741 ui
.status("no such files in CL")
1745 return "no such modified files"
1747 files
= Sub(files
, cl
.files
)
1748 taken
= Taken(ui
, repo
)
1752 if not warned
and not ui
.quiet
:
1753 ui
.status("# Taking files from other CLs. To undo:\n")
1754 ui
.status("# cd %s\n" % (repo
.root
))
1758 ui
.status("# hg file %s %s\n" % (ocl
.name
, f
))
1759 if ocl
not in dirty
:
1760 ocl
.files
= Sub(ocl
.files
, files
)
1762 cl
.files
= Add(cl
.files
, files
)
1764 for d
, _
in dirty
.items():
1768 #######################################################################
1772 def gofmt(ui
, repo
, *pats
, **opts
):
1773 """apply gofmt to modified files
1775 Applies gofmt to the modified files in the repository that match
1778 if codereview_disabled
:
1779 return codereview_disabled
1781 files
= ChangedExistingFiles(ui
, repo
, pats
, opts
)
1782 files
= gofmt_required(files
)
1784 return "no modified go files"
1786 files
= [RelativePath(repo
.root
+ '/' + f
, cwd
) for f
in files
]
1788 cmd
= ["gofmt", "-l"]
1789 if not opts
["list"]:
1791 if os
.spawnvp(os
.P_WAIT
, "gofmt", cmd
+ files
) != 0:
1792 raise hg_util
.Abort("gofmt did not exit cleanly")
1793 except hg_error
.Abort
, e
:
1796 raise hg_util
.Abort("gofmt: " + ExceptionDetail())
1799 def gofmt_required(files
):
1800 return [f
for f
in files
if (not f
.startswith('test/') or f
.startswith('test/bench/')) and f
.endswith('.go')]
1802 #######################################################################
1806 def mail(ui
, repo
, *pats
, **opts
):
1807 """mail a change for review
1809 Uploads a patch to the code review server and then sends mail
1810 to the reviewer and CC list asking for a review.
1812 if codereview_disabled
:
1813 return codereview_disabled
1815 cl
, err
= CommandLineCL(ui
, repo
, pats
, opts
, defaultcc
=defaultcc
)
1818 cl
.Upload(ui
, repo
, gofmt_just_warn
=True)
1820 # If no reviewer is listed, assign the review to defaultcc.
1821 # This makes sure that it appears in the
1822 # codereview.appspot.com/user/defaultcc
1823 # page, so that it doesn't get dropped on the floor.
1825 return "no reviewers listed in CL"
1826 cl
.cc
= Sub(cl
.cc
, defaultcc
)
1827 cl
.reviewer
= defaultcc
1831 return "no changed files, not sending mail"
1835 #######################################################################
1836 # hg p / hg pq / hg ps / hg pending
1839 def ps(ui
, repo
, *pats
, **opts
):
1840 """alias for hg p --short
1842 opts
['short'] = True
1843 return pending(ui
, repo
, *pats
, **opts
)
1846 def pq(ui
, repo
, *pats
, **opts
):
1847 """alias for hg p --quick
1849 opts
['quick'] = True
1850 return pending(ui
, repo
, *pats
, **opts
)
1853 def pending(ui
, repo
, *pats
, **opts
):
1854 """show pending changes
1856 Lists pending changes followed by a list of unassigned but modified files.
1858 if codereview_disabled
:
1859 return codereview_disabled
1861 quick
= opts
.get('quick', False)
1862 short
= opts
.get('short', False)
1863 m
= LoadAllCL(ui
, repo
, web
=not quick
and not short
)
1869 ui
.write(name
+ "\t" + line1(cl
.desc
) + "\n")
1871 ui
.write(cl
.PendingText(quick
=quick
) + "\n")
1875 files
= DefaultFiles(ui
, repo
, [])
1877 s
= "Changed files not in any CL:\n"
1879 s
+= "\t" + f
+ "\n"
1882 #######################################################################
1886 raise hg_util
.Abort("local repository out of date; must sync before submit")
1889 def submit(ui
, repo
, *pats
, **opts
):
1890 """submit change to remote repository
1892 Submits change to remote repository.
1893 Bails out if the local repository is not in sync with the remote one.
1895 if codereview_disabled
:
1896 return codereview_disabled
1898 # We already called this on startup but sometimes Mercurial forgets.
1899 set_mercurial_encoding_to_utf8()
1901 if not opts
["no_incoming"] and hg_incoming(ui
, repo
):
1904 cl
, err
= CommandLineCL(ui
, repo
, pats
, opts
, defaultcc
=defaultcc
)
1910 user
= cl
.copied_from
1911 userline
= CheckContributor(ui
, repo
, user
)
1912 typecheck(userline
, str)
1916 about
+= "R=" + JoinComma([CutDomain(s
) for s
in cl
.reviewer
]) + "\n"
1918 tbr
= SplitCommaSpace(opts
.get('tbr'))
1919 cl
.reviewer
= Add(cl
.reviewer
, tbr
)
1920 about
+= "TBR=" + JoinComma([CutDomain(s
) for s
in tbr
]) + "\n"
1922 about
+= "CC=" + JoinComma([CutDomain(s
) for s
in cl
.cc
]) + "\n"
1925 return "no reviewers listed in CL"
1928 return "cannot submit non-local CL"
1930 # upload, to sync current patch and also get change number if CL is new.
1931 if not cl
.copied_from
:
1932 cl
.Upload(ui
, repo
, gofmt_just_warn
=True)
1934 # check gofmt for real; allowed upload to warn in order to save CL.
1936 CheckFormat(ui
, repo
, cl
.files
)
1938 about
+= "%s%s\n" % (server_url_base
, cl
.name
)
1941 about
+= "\nCommitter: " + CheckContributor(ui
, repo
, None) + "\n"
1942 typecheck(about
, str)
1944 if not cl
.mailed
and not cl
.copied_from
: # in case this is TBR
1947 # submit changes locally
1948 message
= cl
.desc
.rstrip() + "\n\n" + about
1949 typecheck(message
, str)
1951 set_status("pushing " + cl
.name
+ " to remote server")
1953 if hg_outgoing(ui
, repo
):
1954 raise hg_util
.Abort("local repository corrupt or out-of-phase with remote: found outgoing changes")
1956 old_heads
= len(hg_heads(ui
, repo
).split())
1960 ret
= hg_commit(ui
, repo
, *['path:'+f
for f
in cl
.files
], message
=message
, user
=userline
)
1963 return "nothing changed"
1964 node
= repo
["-1"].node()
1965 # push to remote; if it fails for any reason, roll back
1967 new_heads
= len(hg_heads(ui
, repo
).split())
1968 if old_heads
!= new_heads
and not (old_heads
== 0 and new_heads
== 1):
1969 # Created new head, so we weren't up to date.
1972 # Push changes to remote. If it works, we're committed. If not, roll back.
1975 except hg_error
.Abort
, e
:
1976 if e
.message
.find("push creates new heads") >= 0:
1977 # Remote repository had changes we missed.
1984 # We're committed. Upload final patch, close review, add commit message.
1985 changeURL
= hg_node
.short(node
)
1986 url
= ui
.expandpath("default")
1987 m
= re
.match("(^https?://([^@/]+@)?([^.]+)\.googlecode\.com/hg/?)" + "|" +
1988 "(^https?://([^@/]+@)?code\.google\.com/p/([^/.]+)(\.[^./]+)?/?)", url
)
1990 if m
.group(1): # prj.googlecode.com/hg/ case
1991 changeURL
= "http://code.google.com/p/%s/source/detail?r=%s" % (m
.group(3), changeURL
)
1992 elif m
.group(4) and m
.group(7): # code.google.com/p/prj.subrepo/ case
1993 changeURL
= "http://code.google.com/p/%s/source/detail?r=%s&repo=%s" % (m
.group(6), changeURL
, m
.group(7)[1:])
1994 elif m
.group(4): # code.google.com/p/prj/ case
1995 changeURL
= "http://code.google.com/p/%s/source/detail?r=%s" % (m
.group(6), changeURL
)
1997 print >>sys
.stderr
, "URL: ", url
1999 print >>sys
.stderr
, "URL: ", url
2000 pmsg
= "*** Submitted as " + changeURL
+ " ***\n\n" + message
2002 # When posting, move reviewers to CC line,
2003 # so that the issue stops showing up in their "My Issues" page.
2004 PostMessage(ui
, cl
.name
, pmsg
, reviewers
="", cc
=JoinComma(cl
.reviewer
+cl
.cc
))
2006 if not cl
.copied_from
:
2007 EditDesc(cl
.name
, closed
=True, private
=cl
.private
)
2011 if c
.branch() == releaseBranch
and not c
.modified() and not c
.added() and not c
.removed():
2012 ui
.write("switching from %s to default branch.\n" % releaseBranch
)
2013 err
= hg_clean(repo
, "default")
2018 #######################################################################
2022 def sync(ui
, repo
, **opts
):
2023 """synchronize with remote repository
2025 Incorporates recent changes from the remote repository
2026 into the local repository.
2028 if codereview_disabled
:
2029 return codereview_disabled
2031 if not opts
["local"]:
2032 err
= hg_pull(ui
, repo
, update
=True)
2035 sync_changes(ui
, repo
)
2037 def sync_changes(ui
, repo
):
2038 # Look through recent change log descriptions to find
2039 # potential references to http://.*/our-CL-number.
2040 # Double-check them by looking at the Rietveld log.
2041 for rev
in hg_log(ui
, repo
, limit
=100, template
="{node}\n").split():
2042 desc
= repo
[rev
].description().strip()
2043 for clname
in re
.findall('(?m)^http://(?:[^\n]+)/([0-9]+)$', desc
):
2044 if IsLocalCL(ui
, repo
, clname
) and IsRietveldSubmitted(ui
, clname
, repo
[rev
].hex()):
2045 ui
.warn("CL %s submitted as %s; closing\n" % (clname
, repo
[rev
]))
2046 cl
, err
= LoadCL(ui
, repo
, clname
, web
=False)
2048 ui
.warn("loading CL %s: %s\n" % (clname
, err
))
2050 if not cl
.copied_from
:
2051 EditDesc(cl
.name
, closed
=True, private
=cl
.private
)
2054 # Remove files that are not modified from the CLs in which they appear.
2055 all
= LoadAllCL(ui
, repo
, web
=False)
2056 changed
= ChangedFiles(ui
, repo
, [])
2057 for cl
in all
.values():
2058 extra
= Sub(cl
.files
, changed
)
2060 ui
.warn("Removing unmodified files from CL %s:\n" % (cl
.name
,))
2062 ui
.warn("\t%s\n" % (f
,))
2063 cl
.files
= Sub(cl
.files
, extra
)
2066 if not cl
.copied_from
:
2067 ui
.warn("CL %s has no files; delete (abandon) with hg change -d %s\n" % (cl
.name
, cl
.name
))
2069 ui
.warn("CL %s has no files; delete locally with hg change -D %s\n" % (cl
.name
, cl
.name
))
2072 #######################################################################
2076 def upload(ui
, repo
, name
, **opts
):
2077 """upload diffs to the code review server
2079 Uploads the current modifications for a given change to the server.
2081 if codereview_disabled
:
2082 return codereview_disabled
2084 repo
.ui
.quiet
= True
2085 cl
, err
= LoadCL(ui
, repo
, name
, web
=True)
2089 return "cannot upload non-local change"
2091 print "%s%s\n" % (server_url_base
, cl
.name
)
2094 #######################################################################
2095 # Table of commands, supplied to Mercurial for installation.
2098 ('r', 'reviewer', '', 'add reviewer'),
2099 ('', 'cc', '', 'add cc'),
2100 ('', 'tbr', '', 'add future reviewer'),
2101 ('m', 'message', '', 'change description (for new change)'),
2105 # The ^ means to show this command in the help text that
2106 # is printed when running hg with no arguments.
2110 ('d', 'delete', None, 'delete existing change list'),
2111 ('D', 'deletelocal', None, 'delete locally, but do not change CL on server'),
2112 ('i', 'stdin', None, 'read change list from standard input'),
2113 ('o', 'stdout', None, 'print change list to standard output'),
2114 ('p', 'pending', None, 'print pending summary to standard output'),
2116 "[-d | -D] [-i] [-o] change# or FILE ..."
2121 ('', 'ignore_hgpatch_failure', None, 'create CL metadata even if hgpatch fails'),
2122 ('', 'no_incoming', None, 'disable check for incoming changes'),
2126 # Would prefer to call this codereview-login, but then
2127 # hg help codereview prints the help for this command
2128 # instead of the help for the extension.
2142 ('d', 'delete', None, 'delete files from change list (but not repository)'),
2144 "[-d] change# FILE ..."
2149 ('l', 'list', None, 'list files that would change, but do not edit them'),
2156 ('s', 'short', False, 'show short result form'),
2157 ('', 'quick', False, 'do not consult codereview server'),
2174 ] + hg_commands
.walkopts
,
2175 "[-r reviewer] [--cc cc] [change# | file ...]"
2180 ('', 'ignore_hgpatch_failure', None, 'create CL metadata even if hgpatch fails'),
2181 ('', 'no_incoming', None, 'disable check for incoming changes'),
2185 # TODO: release-start, release-tag, weekly-tag
2189 ('', 'no_incoming', None, 'disable initial incoming check (for testing)'),
2190 ] + hg_commands
.walkopts
+ hg_commands
.commitopts
+ hg_commands
.commitopts2
,
2191 "[-r reviewer] [--cc cc] [change# | file ...]"
2196 ('', 'local', None, 'do not pull changes from remote repository')
2203 ('', 'ignore_hgpatch_failure', None, 'create CL metadata even if hgpatch fails'),
2204 ('', 'no_incoming', None, 'disable check for incoming changes'),
2215 #######################################################################
2216 # Mercurial extension initialization
2218 def norollback(*pats
, **opts
):
2219 """(disabled when using this extension)"""
2220 raise hg_util
.Abort("codereview extension enabled; use undo instead of rollback")
2222 codereview_init
= False
2224 def reposetup(ui
, repo
):
2225 global codereview_disabled
2228 # reposetup gets called both for the local repository
2229 # and also for any repository we are pulling or pushing to.
2230 # Only initialize the first time.
2231 global codereview_init
2234 codereview_init
= True
2236 # Read repository-specific options from lib/codereview/codereview.cfg or codereview.cfg.
2241 # Yes, repo might not have root; see issue 959.
2242 codereview_disabled
= 'codereview disabled: repository has no root'
2245 repo_config_path
= ''
2246 p1
= root
+ '/lib/codereview/codereview.cfg'
2247 p2
= root
+ '/codereview.cfg'
2248 if os
.access(p1
, os
.F_OK
):
2249 repo_config_path
= p1
2251 repo_config_path
= p2
2253 f
= open(repo_config_path
)
2255 if line
.startswith('defaultcc:'):
2256 defaultcc
= SplitCommaSpace(line
[len('defaultcc:'):])
2257 if line
.startswith('contributors:'):
2258 global contributorsURL
2259 contributorsURL
= line
[len('contributors:'):].strip()
2261 codereview_disabled
= 'codereview disabled: cannot open ' + repo_config_path
2264 remote
= ui
.config("paths", "default", "")
2265 if remote
.find("://") < 0:
2266 raise hg_util
.Abort("codereview: default path '%s' is not a URL" % (remote
,))
2268 InstallMatch(ui
, repo
)
2269 RietveldSetup(ui
, repo
)
2271 # Disable the Mercurial commands that might change the repository.
2272 # Only commands in this extension are supposed to do that.
2273 ui
.setconfig("hooks", "precommit.codereview", precommithook
)
2275 # Rollback removes an existing commit. Don't do that either.
2276 global real_rollback
2277 real_rollback
= repo
.rollback
2278 repo
.rollback
= norollback
2281 #######################################################################
2282 # Wrappers around upload.py for interacting with Rietveld
2284 from HTMLParser
import HTMLParser
2287 class FormParser(HTMLParser
):
2292 HTMLParser
.__init
__(self
)
2293 def handle_starttag(self
, tag
, attrs
):
2303 self
.map[key
] = value
2304 if tag
== "textarea":
2312 def handle_endtag(self
, tag
):
2313 if tag
== "textarea" and self
.curtag
is not None:
2314 self
.map[self
.curtag
] = self
.curdata
2317 def handle_charref(self
, name
):
2318 self
.handle_data(unichr(int(name
)))
2319 def handle_entityref(self
, name
):
2320 import htmlentitydefs
2321 if name
in htmlentitydefs
.entitydefs
:
2322 self
.handle_data(htmlentitydefs
.entitydefs
[name
])
2324 self
.handle_data("&" + name
+ ";")
2325 def handle_data(self
, data
):
2326 if self
.curdata
is not None:
2327 self
.curdata
+= data
2329 def JSONGet(ui
, path
):
2331 data
= MySend(path
, force_auth
=False)
2332 typecheck(data
, str)
2333 d
= fix_json(json
.loads(data
))
2335 ui
.warn("JSONGet %s: %s\n" % (path
, ExceptionDetail()))
2339 # Clean up json parser output to match our expectations:
2340 # * all strings are UTF-8-encoded str, not unicode.
2341 # * missing fields are missing, not None,
2342 # so that d.get("foo", defaultvalue) works.
2344 if type(x
) in [str, int, float, bool, type(None)]:
2346 elif type(x
) is unicode:
2347 x
= x
.encode("utf-8")
2348 elif type(x
) is list:
2349 for i
in range(len(x
)):
2350 x
[i
] = fix_json(x
[i
])
2351 elif type(x
) is dict:
2357 x
[k
] = fix_json(x
[k
])
2361 raise hg_util
.Abort("unknown type " + str(type(x
)) + " in fix_json")
2363 x
= x
.replace('\r\n', '\n')
2366 def IsRietveldSubmitted(ui
, clname
, hex):
2367 dict = JSONGet(ui
, "/api/" + clname
+ "?messages=true")
2370 for msg
in dict.get("messages", []):
2371 text
= msg
.get("text", "")
2372 m
= re
.match('\*\*\* Submitted as [^*]*?([0-9a-f]+) \*\*\*', text
)
2373 if m
is not None and len(m
.group(1)) >= 8 and hex.startswith(m
.group(1)):
2377 def IsRietveldMailed(cl
):
2378 for msg
in cl
.dict.get("messages", []):
2379 if msg
.get("text", "").find("I'd like you to review this change") >= 0:
2383 def DownloadCL(ui
, repo
, clname
):
2384 set_status("downloading CL " + clname
)
2385 cl
, err
= LoadCL(ui
, repo
, clname
, web
=True)
2387 return None, None, None, "error loading CL %s: %s" % (clname
, err
)
2389 # Find most recent diff
2390 diffs
= cl
.dict.get("patchsets", [])
2392 return None, None, None, "CL has no patch sets"
2395 patchset
= JSONGet(ui
, "/api/" + clname
+ "/" + str(patchid
))
2396 if patchset
is None:
2397 return None, None, None, "error loading CL patchset %s/%d" % (clname
, patchid
)
2398 if patchset
.get("patchset", 0) != patchid
:
2399 return None, None, None, "malformed patchset information"
2402 msg
= patchset
.get("message", "").split()
2403 if len(msg
) >= 3 and msg
[0] == "diff" and msg
[1] == "-r":
2405 diff
= "/download/issue" + clname
+ "_" + str(patchid
) + ".diff"
2407 diffdata
= MySend(diff
, force_auth
=False)
2409 # Print warning if email is not in CONTRIBUTORS file.
2410 email
= cl
.dict.get("owner_email", "")
2412 return None, None, None, "cannot find owner for %s" % (clname
)
2413 him
= FindContributor(ui
, repo
, email
)
2414 me
= FindContributor(ui
, repo
, None)
2416 cl
.mailed
= IsRietveldMailed(cl
)
2418 cl
.copied_from
= email
2420 return cl
, vers
, diffdata
, ""
2422 def MySend(request_path
, payload
=None,
2423 content_type
="application/octet-stream",
2424 timeout
=None, force_auth
=True,
2426 """Run MySend1 maybe twice, because Rietveld is unreliable."""
2428 return MySend1(request_path
, payload
, content_type
, timeout
, force_auth
, **kwargs
)
2429 except Exception, e
:
2430 if type(e
) != urllib2
.HTTPError
or e
.code
!= 500: # only retry on HTTP 500 error
2432 print >>sys
.stderr
, "Loading "+request_path
+": "+ExceptionDetail()+"; trying again in 2 seconds."
2434 return MySend1(request_path
, payload
, content_type
, timeout
, force_auth
, **kwargs
)
2436 # Like upload.py Send but only authenticates when the
2437 # redirect is to www.google.com/accounts. This keeps
2438 # unnecessary redirects from happening during testing.
2439 def MySend1(request_path
, payload
=None,
2440 content_type
="application/octet-stream",
2441 timeout
=None, force_auth
=True,
2443 """Sends an RPC and returns the response.
2446 request_path: The path to send the request to, eg /api/appversion/create.
2447 payload: The body of the request, or None to send an empty request.
2448 content_type: The Content-Type header to use.
2449 timeout: timeout in seconds; default None i.e. no timeout.
2450 (Note: for large requests on OS X, the timeout doesn't work right.)
2451 kwargs: Any keyword arguments are converted into query string parameters.
2454 The response body, as a string.
2456 # TODO: Don't require authentication. Let the server say
2457 # whether it is necessary.
2460 rpc
= GetRpcServer(upload_options
)
2462 if not self
.authenticated
and force_auth
:
2463 self
._Authenticate
()
2464 if request_path
is None:
2467 old_timeout
= socket
.getdefaulttimeout()
2468 socket
.setdefaulttimeout(timeout
)
2474 url
= "http://%s%s" % (self
.host
, request_path
)
2476 url
+= "?" + urllib
.urlencode(args
)
2477 req
= self
._CreateRequest
(url
=url
, data
=payload
)
2478 req
.add_header("Content-Type", content_type
)
2480 f
= self
.opener
.open(req
)
2483 # Translate \r\n into \n, because Rietveld doesn't.
2484 response
= response
.replace('\r\n', '\n')
2485 # who knows what urllib will give us
2486 if type(response
) == unicode:
2487 response
= response
.encode("utf-8")
2488 typecheck(response
, str)
2490 except urllib2
.HTTPError
, e
:
2494 self
._Authenticate
()
2496 loc
= e
.info()["location"]
2497 if not loc
.startswith('https://www.google.com/a') or loc
.find('/ServiceLogin') < 0:
2499 self
._Authenticate
()
2503 socket
.setdefaulttimeout(old_timeout
)
2507 f
.feed(ustr(MySend(url
))) # f.feed wants unicode
2509 # convert back to utf-8 to restore sanity
2511 for k
,v
in f
.map.items():
2512 m
[k
.encode("utf-8")] = v
.replace("\r\n", "\n").encode("utf-8")
2515 def EditDesc(issue
, subject
=None, desc
=None, reviewers
=None, cc
=None, closed
=False, private
=False):
2516 set_status("uploading change to description")
2517 form_fields
= GetForm("/" + issue
+ "/edit")
2518 if subject
is not None:
2519 form_fields
['subject'] = subject
2520 if desc
is not None:
2521 form_fields
['description'] = desc
2522 if reviewers
is not None:
2523 form_fields
['reviewers'] = reviewers
2525 form_fields
['cc'] = cc
2527 form_fields
['closed'] = "checked"
2529 form_fields
['private'] = "checked"
2530 ctype
, body
= EncodeMultipartFormData(form_fields
.items(), [])
2531 response
= MySend("/" + issue
+ "/edit", body
, content_type
=ctype
)
2533 print >>sys
.stderr
, "Error editing description:\n" + "Sent form: \n", form_fields
, "\n", response
2536 def PostMessage(ui
, issue
, message
, reviewers
=None, cc
=None, send_mail
=True, subject
=None):
2537 set_status("uploading message")
2538 form_fields
= GetForm("/" + issue
+ "/publish")
2539 if reviewers
is not None:
2540 form_fields
['reviewers'] = reviewers
2542 form_fields
['cc'] = cc
2544 form_fields
['send_mail'] = "checked"
2546 del form_fields
['send_mail']
2547 if subject
is not None:
2548 form_fields
['subject'] = subject
2549 form_fields
['message'] = message
2551 form_fields
['message_only'] = '1' # Don't include draft comments
2552 if reviewers
is not None or cc
is not None:
2553 form_fields
['message_only'] = '' # Must set '' in order to override cc/reviewer
2554 ctype
= "applications/x-www-form-urlencoded"
2555 body
= urllib
.urlencode(form_fields
)
2556 response
= MySend("/" + issue
+ "/publish", body
, content_type
=ctype
)
2564 def RietveldSetup(ui
, repo
):
2565 global force_google_account
2568 global server_url_base
2569 global upload_options
2576 x
= ui
.config("codereview", "server")
2580 # TODO(rsc): Take from ui.username?
2582 x
= ui
.config("codereview", "email")
2586 server_url_base
= "http://" + server
+ "/"
2588 testing
= ui
.config("codereview", "testing")
2589 force_google_account
= ui
.configbool("codereview", "force_google_account", False)
2591 upload_options
= opt()
2592 upload_options
.email
= email
2593 upload_options
.host
= None
2594 upload_options
.verbose
= 0
2595 upload_options
.description
= None
2596 upload_options
.description_file
= None
2597 upload_options
.reviewers
= None
2598 upload_options
.cc
= None
2599 upload_options
.message
= None
2600 upload_options
.issue
= None
2601 upload_options
.download_base
= False
2602 upload_options
.revision
= None
2603 upload_options
.send_mail
= False
2604 upload_options
.vcs
= None
2605 upload_options
.server
= server
2606 upload_options
.save_cookies
= True
2609 upload_options
.save_cookies
= False
2610 upload_options
.email
= "test@example.com"
2614 global releaseBranch
2615 tags
= repo
.branchmap().keys()
2616 if 'release-branch.go10' in tags
:
2617 # NOTE(rsc): This tags.sort is going to get the wrong
2618 # answer when comparing release-branch.go9 with
2619 # release-branch.go10. It will be a while before we care.
2620 raise hg_util
.Abort('tags.sort needs to be fixed for release-branch.go10')
2623 if t
.startswith('release-branch.go'):
2626 #######################################################################
2627 # http://codereview.appspot.com/static/upload.py, heavily edited.
2629 #!/usr/bin/env python
2631 # Copyright 2007 Google Inc.
2633 # Licensed under the Apache License, Version 2.0 (the "License");
2634 # you may not use this file except in compliance with the License.
2635 # You may obtain a copy of the License at
2637 # http://www.apache.org/licenses/LICENSE-2.0
2639 # Unless required by applicable law or agreed to in writing, software
2640 # distributed under the License is distributed on an "AS IS" BASIS,
2641 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
2642 # See the License for the specific language governing permissions and
2643 # limitations under the License.
2645 """Tool for uploading diffs from a version control system to the codereview app.
2647 Usage summary: upload.py [options] [-- diff_options]
2649 Diff options are passed to the diff command of the underlying system.
2651 Supported version control systems:
2656 It is important for Git/Mercurial users to specify a tree/node/branch to diff
2657 against by using the '--rev' option.
2659 # This code is derived from appcfg.py in the App Engine SDK (open source),
2660 # and from ASPN recipe #146306.
2676 # The md5 module was deprecated in Python 2.5.
2678 from hashlib
import md5
2687 # The logging verbosity:
2689 # 1: Status messages.
2694 # Max size of patch or base file.
2695 MAX_UPLOAD_SIZE
= 900 * 1024
2697 # whitelist for non-binary filetypes which do not start with "text/"
2698 # .mm (Objective-C) shows up as application/x-freemind on my Linux box.
2700 'application/javascript',
2701 'application/x-javascript',
2702 'application/x-freemind'
2705 def GetEmail(prompt
):
2706 """Prompts the user for their email address and returns it.
2708 The last used email address is saved to a file and offered up as a suggestion
2709 to the user. If the user presses enter without typing in anything the last
2710 used email address is used. If the user enters a new address, it is saved
2711 for next time we prompt.
2714 last_email_file_name
= os
.path
.expanduser("~/.last_codereview_email_address")
2716 if os
.path
.exists(last_email_file_name
):
2718 last_email_file
= open(last_email_file_name
, "r")
2719 last_email
= last_email_file
.readline().strip("\n")
2720 last_email_file
.close()
2721 prompt
+= " [%s]" % last_email
2724 email
= raw_input(prompt
+ ": ").strip()
2727 last_email_file
= open(last_email_file_name
, "w")
2728 last_email_file
.write(email
)
2729 last_email_file
.close()
2737 def StatusUpdate(msg
):
2738 """Print a status message to stdout.
2740 If 'verbosity' is greater than 0, print the message.
2743 msg: The string to print.
2750 """Print an error message to stderr and exit."""
2751 print >>sys
.stderr
, msg
2755 class ClientLoginError(urllib2
.HTTPError
):
2756 """Raised to indicate there was an error authenticating with ClientLogin."""
2758 def __init__(self
, url
, code
, msg
, headers
, args
):
2759 urllib2
.HTTPError
.__init
__(self
, url
, code
, msg
, headers
, None)
2761 self
.reason
= args
["Error"]
2764 class AbstractRpcServer(object):
2765 """Provides a common interface for a simple RPC server."""
2767 def __init__(self
, host
, auth_function
, host_override
=None, extra_headers
={}, save_cookies
=False):
2768 """Creates a new HttpRpcServer.
2771 host: The host to send requests to.
2772 auth_function: A function that takes no arguments and returns an
2773 (email, password) tuple when called. Will be called if authentication
2775 host_override: The host header to send to the server (defaults to host).
2776 extra_headers: A dict of extra headers to append to every request.
2777 save_cookies: If True, save the authentication cookies to local disk.
2778 If False, use an in-memory cookiejar instead. Subclasses must
2779 implement this functionality. Defaults to False.
2782 self
.host_override
= host_override
2783 self
.auth_function
= auth_function
2784 self
.authenticated
= False
2785 self
.extra_headers
= extra_headers
2786 self
.save_cookies
= save_cookies
2787 self
.opener
= self
._GetOpener
()
2788 if self
.host_override
:
2789 logging
.info("Server: %s; Host: %s", self
.host
, self
.host_override
)
2791 logging
.info("Server: %s", self
.host
)
2793 def _GetOpener(self
):
2794 """Returns an OpenerDirector for making HTTP requests.
2797 A urllib2.OpenerDirector object.
2799 raise NotImplementedError()
2801 def _CreateRequest(self
, url
, data
=None):
2802 """Creates a new urllib request."""
2803 logging
.debug("Creating request for: '%s' with payload:\n%s", url
, data
)
2804 req
= urllib2
.Request(url
, data
=data
)
2805 if self
.host_override
:
2806 req
.add_header("Host", self
.host_override
)
2807 for key
, value
in self
.extra_headers
.iteritems():
2808 req
.add_header(key
, value
)
2811 def _GetAuthToken(self
, email
, password
):
2812 """Uses ClientLogin to authenticate the user, returning an auth token.
2815 email: The user's email address
2816 password: The user's password
2819 ClientLoginError: If there was an error authenticating with ClientLogin.
2820 HTTPError: If there was some other form of HTTP error.
2823 The authentication token returned by ClientLogin.
2825 account_type
= "GOOGLE"
2826 if self
.host
.endswith(".google.com") and not force_google_account
:
2827 # Needed for use inside Google.
2828 account_type
= "HOSTED"
2829 req
= self
._CreateRequest
(
2830 url
="https://www.google.com/accounts/ClientLogin",
2831 data
=urllib
.urlencode({
2835 "source": "rietveld-codereview-upload",
2836 "accountType": account_type
,
2840 response
= self
.opener
.open(req
)
2841 response_body
= response
.read()
2842 response_dict
= dict(x
.split("=") for x
in response_body
.split("\n") if x
)
2843 return response_dict
["Auth"]
2844 except urllib2
.HTTPError
, e
:
2847 response_dict
= dict(x
.split("=", 1) for x
in body
.split("\n") if x
)
2848 raise ClientLoginError(req
.get_full_url(), e
.code
, e
.msg
, e
.headers
, response_dict
)
2852 def _GetAuthCookie(self
, auth_token
):
2853 """Fetches authentication cookies for an authentication token.
2856 auth_token: The authentication token returned by ClientLogin.
2859 HTTPError: If there was an error fetching the authentication cookies.
2861 # This is a dummy value to allow us to identify when we're successful.
2862 continue_location
= "http://localhost/"
2863 args
= {"continue": continue_location
, "auth": auth_token
}
2864 req
= self
._CreateRequest
("http://%s/_ah/login?%s" % (self
.host
, urllib
.urlencode(args
)))
2866 response
= self
.opener
.open(req
)
2867 except urllib2
.HTTPError
, e
:
2869 if (response
.code
!= 302 or
2870 response
.info()["location"] != continue_location
):
2871 raise urllib2
.HTTPError(req
.get_full_url(), response
.code
, response
.msg
, response
.headers
, response
.fp
)
2872 self
.authenticated
= True
2874 def _Authenticate(self
):
2875 """Authenticates the user.
2877 The authentication process works as follows:
2878 1) We get a username and password from the user
2879 2) We use ClientLogin to obtain an AUTH token for the user
2880 (see http://code.google.com/apis/accounts/AuthForInstalledApps.html).
2881 3) We pass the auth token to /_ah/login on the server to obtain an
2882 authentication cookie. If login was successful, it tries to redirect
2883 us to the URL we provided.
2885 If we attempt to access the upload API without first obtaining an
2886 authentication cookie, it returns a 401 response (or a 302) and
2887 directs us to authenticate ourselves with ClientLogin.
2890 credentials
= self
.auth_function()
2892 auth_token
= self
._GetAuthToken
(credentials
[0], credentials
[1])
2893 except ClientLoginError
, e
:
2894 if e
.reason
== "BadAuthentication":
2895 print >>sys
.stderr
, "Invalid username or password."
2897 if e
.reason
== "CaptchaRequired":
2898 print >>sys
.stderr
, (
2900 "https://www.google.com/accounts/DisplayUnlockCaptcha\n"
2901 "and verify you are a human. Then try again.")
2903 if e
.reason
== "NotVerified":
2904 print >>sys
.stderr
, "Account not verified."
2906 if e
.reason
== "TermsNotAgreed":
2907 print >>sys
.stderr
, "User has not agreed to TOS."
2909 if e
.reason
== "AccountDeleted":
2910 print >>sys
.stderr
, "The user account has been deleted."
2912 if e
.reason
== "AccountDisabled":
2913 print >>sys
.stderr
, "The user account has been disabled."
2915 if e
.reason
== "ServiceDisabled":
2916 print >>sys
.stderr
, "The user's access to the service has been disabled."
2918 if e
.reason
== "ServiceUnavailable":
2919 print >>sys
.stderr
, "The service is not available; try again later."
2922 self
._GetAuthCookie
(auth_token
)
2925 def Send(self
, request_path
, payload
=None,
2926 content_type
="application/octet-stream",
2929 """Sends an RPC and returns the response.
2932 request_path: The path to send the request to, eg /api/appversion/create.
2933 payload: The body of the request, or None to send an empty request.
2934 content_type: The Content-Type header to use.
2935 timeout: timeout in seconds; default None i.e. no timeout.
2936 (Note: for large requests on OS X, the timeout doesn't work right.)
2937 kwargs: Any keyword arguments are converted into query string parameters.
2940 The response body, as a string.
2942 # TODO: Don't require authentication. Let the server say
2943 # whether it is necessary.
2944 if not self
.authenticated
:
2945 self
._Authenticate
()
2947 old_timeout
= socket
.getdefaulttimeout()
2948 socket
.setdefaulttimeout(timeout
)
2954 url
= "http://%s%s" % (self
.host
, request_path
)
2956 url
+= "?" + urllib
.urlencode(args
)
2957 req
= self
._CreateRequest
(url
=url
, data
=payload
)
2958 req
.add_header("Content-Type", content_type
)
2960 f
= self
.opener
.open(req
)
2964 except urllib2
.HTTPError
, e
:
2967 elif e
.code
== 401 or e
.code
== 302:
2968 self
._Authenticate
()
2972 socket
.setdefaulttimeout(old_timeout
)
2975 class HttpRpcServer(AbstractRpcServer
):
2976 """Provides a simplified RPC-style interface for HTTP requests."""
2978 def _Authenticate(self
):
2979 """Save the cookie jar after authentication."""
2980 super(HttpRpcServer
, self
)._Authenticate
()
2981 if self
.save_cookies
:
2982 StatusUpdate("Saving authentication cookies to %s" % self
.cookie_file
)
2983 self
.cookie_jar
.save()
2985 def _GetOpener(self
):
2986 """Returns an OpenerDirector that supports cookies and ignores redirects.
2989 A urllib2.OpenerDirector object.
2991 opener
= urllib2
.OpenerDirector()
2992 opener
.add_handler(urllib2
.ProxyHandler())
2993 opener
.add_handler(urllib2
.UnknownHandler())
2994 opener
.add_handler(urllib2
.HTTPHandler())
2995 opener
.add_handler(urllib2
.HTTPDefaultErrorHandler())
2996 opener
.add_handler(urllib2
.HTTPSHandler())
2997 opener
.add_handler(urllib2
.HTTPErrorProcessor())
2998 if self
.save_cookies
:
2999 self
.cookie_file
= os
.path
.expanduser("~/.codereview_upload_cookies_" + server
)
3000 self
.cookie_jar
= cookielib
.MozillaCookieJar(self
.cookie_file
)
3001 if os
.path
.exists(self
.cookie_file
):
3003 self
.cookie_jar
.load()
3004 self
.authenticated
= True
3005 StatusUpdate("Loaded authentication cookies from %s" % self
.cookie_file
)
3006 except (cookielib
.LoadError
, IOError):
3007 # Failed to load cookies - just ignore them.
3010 # Create an empty cookie file with mode 600
3011 fd
= os
.open(self
.cookie_file
, os
.O_CREAT
, 0600)
3013 # Always chmod the cookie file
3014 os
.chmod(self
.cookie_file
, 0600)
3016 # Don't save cookies across runs of update.py.
3017 self
.cookie_jar
= cookielib
.CookieJar()
3018 opener
.add_handler(urllib2
.HTTPCookieProcessor(self
.cookie_jar
))
3022 def GetRpcServer(options
):
3023 """Returns an instance of an AbstractRpcServer.
3026 A new AbstractRpcServer, on which RPC calls can be made.
3029 rpc_server_class
= HttpRpcServer
3031 def GetUserCredentials():
3032 """Prompts the user for a username and password."""
3033 # Disable status prints so they don't obscure the password prompt.
3034 global global_status
3036 global_status
= None
3038 email
= options
.email
3040 email
= GetEmail("Email (login for uploading to %s)" % options
.server
)
3041 password
= getpass
.getpass("Password for %s: " % email
)
3045 return (email
, password
)
3047 # If this is the dev_appserver, use fake authentication.
3048 host
= (options
.host
or options
.server
).lower()
3049 if host
== "localhost" or host
.startswith("localhost:"):
3050 email
= options
.email
3052 email
= "test@example.com"
3053 logging
.info("Using debug user %s. Override with --email" % email
)
3054 server
= rpc_server_class(
3056 lambda: (email
, "password"),
3057 host_override
=options
.host
,
3058 extra_headers
={"Cookie": 'dev_appserver_login="%s:False"' % email
},
3059 save_cookies
=options
.save_cookies
)
3060 # Don't try to talk to ClientLogin.
3061 server
.authenticated
= True
3064 return rpc_server_class(options
.server
, GetUserCredentials
,
3065 host_override
=options
.host
, save_cookies
=options
.save_cookies
)
3068 def EncodeMultipartFormData(fields
, files
):
3069 """Encode form fields for multipart/form-data.
3072 fields: A sequence of (name, value) elements for regular form fields.
3073 files: A sequence of (name, filename, value) elements for data to be
3076 (content_type, body) ready for httplib.HTTP instance.
3079 http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/146306
3081 BOUNDARY
= '-M-A-G-I-C---B-O-U-N-D-A-R-Y-'
3084 for (key
, value
) in fields
:
3086 typecheck(value
, str)
3087 lines
.append('--' + BOUNDARY
)
3088 lines
.append('Content-Disposition: form-data; name="%s"' % key
)
3091 for (key
, filename
, value
) in files
:
3093 typecheck(filename
, str)
3094 typecheck(value
, str)
3095 lines
.append('--' + BOUNDARY
)
3096 lines
.append('Content-Disposition: form-data; name="%s"; filename="%s"' % (key
, filename
))
3097 lines
.append('Content-Type: %s' % GetContentType(filename
))
3100 lines
.append('--' + BOUNDARY
+ '--')
3102 body
= CRLF
.join(lines
)
3103 content_type
= 'multipart/form-data; boundary=%s' % BOUNDARY
3104 return content_type
, body
3107 def GetContentType(filename
):
3108 """Helper to guess the content-type from the filename."""
3109 return mimetypes
.guess_type(filename
)[0] or 'application/octet-stream'
3112 # Use a shell for subcommands on Windows to get a PATH search.
3113 use_shell
= sys
.platform
.startswith("win")
3115 def RunShellWithReturnCode(command
, print_output
=False,
3116 universal_newlines
=True, env
=os
.environ
):
3117 """Executes a command and returns the output from stdout and the return code.
3120 command: Command to execute.
3121 print_output: If True, the output is printed to stdout.
3122 If False, both stdout and stderr are ignored.
3123 universal_newlines: Use universal_newlines flag (default: True).
3126 Tuple (output, return code)
3128 logging
.info("Running %s", command
)
3129 p
= subprocess
.Popen(command
, stdout
=subprocess
.PIPE
, stderr
=subprocess
.PIPE
,
3130 shell
=use_shell
, universal_newlines
=universal_newlines
, env
=env
)
3134 line
= p
.stdout
.readline()
3137 print line
.strip("\n")
3138 output_array
.append(line
)
3139 output
= "".join(output_array
)
3141 output
= p
.stdout
.read()
3143 errout
= p
.stderr
.read()
3144 if print_output
and errout
:
3145 print >>sys
.stderr
, errout
3148 return output
, p
.returncode
3151 def RunShell(command
, silent_ok
=False, universal_newlines
=True,
3152 print_output
=False, env
=os
.environ
):
3153 data
, retcode
= RunShellWithReturnCode(command
, print_output
, universal_newlines
, env
)
3155 ErrorExit("Got error status from %s:\n%s" % (command
, data
))
3156 if not silent_ok
and not data
:
3157 ErrorExit("No output from %s" % command
)
3161 class VersionControlSystem(object):
3162 """Abstract base class providing an interface to the VCS."""
3164 def __init__(self
, options
):
3168 options: Command line options.
3170 self
.options
= options
3172 def GenerateDiff(self
, args
):
3173 """Return the current diff as a string.
3176 args: Extra arguments to pass to the diff command.
3178 raise NotImplementedError(
3179 "abstract method -- subclass %s must override" % self
.__class
__)
3181 def GetUnknownFiles(self
):
3182 """Return a list of files unknown to the VCS."""
3183 raise NotImplementedError(
3184 "abstract method -- subclass %s must override" % self
.__class
__)
3186 def CheckForUnknownFiles(self
):
3187 """Show an "are you sure?" prompt if there are unknown files."""
3188 unknown_files
= self
.GetUnknownFiles()
3190 print "The following files are not added to version control:"
3191 for line
in unknown_files
:
3193 prompt
= "Are you sure to continue?(y/N) "
3194 answer
= raw_input(prompt
).strip()
3196 ErrorExit("User aborted")
3198 def GetBaseFile(self
, filename
):
3199 """Get the content of the upstream version of a file.
3202 A tuple (base_content, new_content, is_binary, status)
3203 base_content: The contents of the base file.
3204 new_content: For text files, this is empty. For binary files, this is
3205 the contents of the new file, since the diff output won't contain
3206 information to reconstruct the current file.
3207 is_binary: True iff the file is binary.
3208 status: The status of the file.
3211 raise NotImplementedError(
3212 "abstract method -- subclass %s must override" % self
.__class
__)
3215 def GetBaseFiles(self
, diff
):
3216 """Helper that calls GetBase file for each file in the patch.
3219 A dictionary that maps from filename to GetBaseFile's tuple. Filenames
3220 are retrieved based on lines that start with "Index:" or
3221 "Property changes on:".
3224 for line
in diff
.splitlines(True):
3225 if line
.startswith('Index:') or line
.startswith('Property changes on:'):
3226 unused
, filename
= line
.split(':', 1)
3227 # On Windows if a file has property changes its filename uses '\'
3229 filename
= to_slash(filename
.strip())
3230 files
[filename
] = self
.GetBaseFile(filename
)
3234 def UploadBaseFiles(self
, issue
, rpc_server
, patch_list
, patchset
, options
,
3236 """Uploads the base files (and if necessary, the current ones as well)."""
3238 def UploadFile(filename
, file_id
, content
, is_binary
, status
, is_base
):
3239 """Uploads a file to the server."""
3240 set_status("uploading " + filename
)
3241 file_too_large
= False
3246 if len(content
) > MAX_UPLOAD_SIZE
:
3247 print ("Not uploading the %s file for %s because it's too large." %
3249 file_too_large
= True
3251 checksum
= md5(content
).hexdigest()
3252 if options
.verbose
> 0 and not file_too_large
:
3253 print "Uploading %s file for %s" % (type, filename
)
3254 url
= "/%d/upload_content/%d/%d" % (int(issue
), int(patchset
), file_id
)
3256 ("filename", filename
),
3258 ("checksum", checksum
),
3259 ("is_binary", str(is_binary
)),
3260 ("is_current", str(not is_base
)),
3263 form_fields
.append(("file_too_large", "1"))
3265 form_fields
.append(("user", options
.email
))
3266 ctype
, body
= EncodeMultipartFormData(form_fields
, [("data", filename
, content
)])
3267 response_body
= rpc_server
.Send(url
, body
, content_type
=ctype
)
3268 if not response_body
.startswith("OK"):
3269 StatusUpdate(" --> %s" % response_body
)
3272 # Don't want to spawn too many threads, nor do we want to
3273 # hit Rietveld too hard, or it will start serving 500 errors.
3274 # When 8 works, it's no better than 4, and sometimes 8 is
3275 # too many for Rietveld to handle.
3276 MAX_PARALLEL_UPLOADS
= 4
3278 sema
= threading
.BoundedSemaphore(MAX_PARALLEL_UPLOADS
)
3280 finished_upload_threads
= []
3282 class UploadFileThread(threading
.Thread
):
3283 def __init__(self
, args
):
3284 threading
.Thread
.__init
__(self
)
3287 UploadFile(*self
.args
)
3288 finished_upload_threads
.append(self
)
3291 def StartUploadFile(*args
):
3293 while len(finished_upload_threads
) > 0:
3294 t
= finished_upload_threads
.pop()
3295 upload_threads
.remove(t
)
3297 t
= UploadFileThread(args
)
3298 upload_threads
.append(t
)
3301 def WaitForUploads():
3302 for t
in upload_threads
:
3306 [patches
.setdefault(v
, k
) for k
, v
in patch_list
]
3307 for filename
in patches
.keys():
3308 base_content
, new_content
, is_binary
, status
= files
[filename
]
3309 file_id_str
= patches
.get(filename
)
3310 if file_id_str
.find("nobase") != -1:
3312 file_id_str
= file_id_str
[file_id_str
.rfind("_") + 1:]
3313 file_id
= int(file_id_str
)
3314 if base_content
!= None:
3315 StartUploadFile(filename
, file_id
, base_content
, is_binary
, status
, True)
3316 if new_content
!= None:
3317 StartUploadFile(filename
, file_id
, new_content
, is_binary
, status
, False)
3320 def IsImage(self
, filename
):
3321 """Returns true if the filename has an image extension."""
3322 mimetype
= mimetypes
.guess_type(filename
)[0]
3325 return mimetype
.startswith("image/")
3327 def IsBinary(self
, filename
):
3328 """Returns true if the guessed mimetyped isnt't in text group."""
3329 mimetype
= mimetypes
.guess_type(filename
)[0]
3331 return False # e.g. README, "real" binaries usually have an extension
3332 # special case for text files which don't start with text/
3333 if mimetype
in TEXT_MIMETYPES
:
3335 return not mimetype
.startswith("text/")
3338 class FakeMercurialUI(object):
3343 def write(self
, *args
, **opts
):
3344 self
.output
+= ' '.join(args
)
3347 def status(self
, *args
, **opts
):
3350 def formatter(self
, topic
, opts
):
3351 from mercurial
.formatter
import plainformatter
3352 return plainformatter(self
, topic
, opts
)
3354 def readconfig(self
, *args
, **opts
):
3356 def expandpath(self
, *args
, **opts
):
3357 return global_ui
.expandpath(*args
, **opts
)
3358 def configitems(self
, *args
, **opts
):
3359 return global_ui
.configitems(*args
, **opts
)
3360 def config(self
, *args
, **opts
):
3361 return global_ui
.config(*args
, **opts
)
3363 use_hg_shell
= False # set to True to shell out to hg always; slower
3365 class MercurialVCS(VersionControlSystem
):
3366 """Implementation of the VersionControlSystem interface for Mercurial."""
3368 def __init__(self
, options
, ui
, repo
):
3369 super(MercurialVCS
, self
).__init
__(options
)
3373 # Absolute path to repository (we can be in a subdir)
3374 self
.repo_dir
= os
.path
.normpath(repo
.root
)
3375 # Compute the subdir
3376 cwd
= os
.path
.normpath(os
.getcwd())
3377 assert cwd
.startswith(self
.repo_dir
)
3378 self
.subdir
= cwd
[len(self
.repo_dir
):].lstrip(r
"\/")
3379 if self
.options
.revision
:
3380 self
.base_rev
= self
.options
.revision
3382 mqparent
, err
= RunShellWithReturnCode(['hg', 'log', '--rev', 'qparent', '--template={node}'])
3383 if not err
and mqparent
!= "":
3384 self
.base_rev
= mqparent
3386 out
= RunShell(["hg", "parents", "-q"], silent_ok
=True).strip()
3388 # No revisions; use 0 to mean a repository with nothing.
3390 self
.base_rev
= out
.split(':')[1].strip()
3391 def _GetRelPath(self
, filename
):
3392 """Get relative path of a file according to the current directory,
3393 given its logical path in the repo."""
3394 assert filename
.startswith(self
.subdir
), (filename
, self
.subdir
)
3395 return filename
[len(self
.subdir
):].lstrip(r
"\/")
3397 def GenerateDiff(self
, extra_args
):
3398 # If no file specified, restrict to the current subdir
3399 extra_args
= extra_args
or ["."]
3400 cmd
= ["hg", "diff", "--git", "-r", self
.base_rev
] + extra_args
3401 data
= RunShell(cmd
, silent_ok
=True)
3404 for line
in data
.splitlines():
3405 m
= re
.match("diff --git a/(\S+) b/(\S+)", line
)
3407 # Modify line to make it look like as it comes from svn diff.
3408 # With this modification no changes on the server side are required
3409 # to make upload.py work with Mercurial repos.
3410 # NOTE: for proper handling of moved/copied files, we have to use
3411 # the second filename.
3412 filename
= m
.group(2)
3413 svndiff
.append("Index: %s" % filename
)
3414 svndiff
.append("=" * 67)
3418 svndiff
.append(line
)
3420 ErrorExit("No valid patches found in output from hg diff")
3421 return "\n".join(svndiff
) + "\n"
3423 def GetUnknownFiles(self
):
3424 """Return a list of files unknown to the VCS."""
3426 status
= RunShell(["hg", "status", "--rev", self
.base_rev
, "-u", "."],
3429 for line
in status
.splitlines():
3430 st
, fn
= line
.split(" ", 1)
3432 unknown_files
.append(fn
)
3433 return unknown_files
3435 def get_hg_status(self
, rev
, path
):
3436 # We'd like to use 'hg status -C path', but that is buggy
3437 # (see http://mercurial.selenic.com/bts/issue3023).
3438 # Instead, run 'hg status -C' without a path
3439 # and skim the output for the path we want.
3440 if self
.status
is None:
3442 out
= RunShell(["hg", "status", "-C", "--rev", rev
])
3444 fui
= FakeMercurialUI()
3445 ret
= hg_commands
.status(fui
, self
.repo
, *[], **{'rev': [rev
], 'copies': True})
3447 raise hg_util
.Abort(ret
)
3449 self
.status
= out
.splitlines()
3450 for i
in range(len(self
.status
)):
3455 line
= to_slash(self
.status
[i
])
3456 if line
[2:] == path
:
3457 if i
+1 < len(self
.status
) and self
.status
[i
+1][:2] == ' ':
3458 return self
.status
[i
:i
+2]
3459 return self
.status
[i
:i
+1]
3460 raise hg_util
.Abort("no status for " + path
)
3462 def GetBaseFile(self
, filename
):
3463 set_status("inspecting " + filename
)
3464 # "hg status" and "hg cat" both take a path relative to the current subdir
3465 # rather than to the repo root, but "hg diff" has given us the full path
3470 oldrelpath
= relpath
= self
._GetRelPath
(filename
)
3471 out
= self
.get_hg_status(self
.base_rev
, relpath
)
3472 status
, what
= out
[0].split(' ', 1)
3473 if len(out
) > 1 and status
== "A" and what
== relpath
:
3474 oldrelpath
= out
[1].strip()
3476 if ":" in self
.base_rev
:
3477 base_rev
= self
.base_rev
.split(":", 1)[0]
3479 base_rev
= self
.base_rev
3482 base_content
= RunShell(["hg", "cat", "-r", base_rev
, oldrelpath
], silent_ok
=True)
3484 base_content
= str(self
.repo
[base_rev
][oldrelpath
].data())
3485 is_binary
= "\0" in base_content
# Mercurial's heuristic
3487 new_content
= open(relpath
, "rb").read()
3488 is_binary
= is_binary
or "\0" in new_content
3489 if is_binary
and base_content
and use_hg_shell
:
3490 # Fetch again without converting newlines
3491 base_content
= RunShell(["hg", "cat", "-r", base_rev
, oldrelpath
],
3492 silent_ok
=True, universal_newlines
=False)
3493 if not is_binary
or not self
.IsImage(relpath
):
3495 return base_content
, new_content
, is_binary
, status
3498 # NOTE: The SplitPatch function is duplicated in engine.py, keep them in sync.
3499 def SplitPatch(data
):
3500 """Splits a patch into separate pieces for each file.
3503 data: A string containing the output of svn diff.
3506 A list of 2-tuple (filename, text) where text is the svn diff output
3507 pertaining to filename.
3512 for line
in data
.splitlines(True):
3514 if line
.startswith('Index:'):
3515 unused
, new_filename
= line
.split(':', 1)
3516 new_filename
= new_filename
.strip()
3517 elif line
.startswith('Property changes on:'):
3518 unused
, temp_filename
= line
.split(':', 1)
3519 # When a file is modified, paths use '/' between directories, however
3520 # when a property is modified '\' is used on Windows. Make them the same
3521 # otherwise the file shows up twice.
3522 temp_filename
= to_slash(temp_filename
.strip())
3523 if temp_filename
!= filename
:
3524 # File has property changes but no modifications, create a new diff.
3525 new_filename
= temp_filename
3527 if filename
and diff
:
3528 patches
.append((filename
, ''.join(diff
)))
3529 filename
= new_filename
3532 if diff
is not None:
3534 if filename
and diff
:
3535 patches
.append((filename
, ''.join(diff
)))
3539 def UploadSeparatePatches(issue
, rpc_server
, patchset
, data
, options
):
3540 """Uploads a separate patch for each file in the diff output.
3542 Returns a list of [patch_key, filename] for each file.
3544 patches
= SplitPatch(data
)
3546 for patch
in patches
:
3547 set_status("uploading patch for " + patch
[0])
3548 if len(patch
[1]) > MAX_UPLOAD_SIZE
:
3549 print ("Not uploading the patch for " + patch
[0] +
3550 " because the file is too large.")
3552 form_fields
= [("filename", patch
[0])]
3553 if not options
.download_base
:
3554 form_fields
.append(("content_upload", "1"))
3555 files
= [("data", "data.diff", patch
[1])]
3556 ctype
, body
= EncodeMultipartFormData(form_fields
, files
)
3557 url
= "/%d/upload_patch/%d" % (int(issue
), int(patchset
))
3558 print "Uploading patch for " + patch
[0]
3559 response_body
= rpc_server
.Send(url
, body
, content_type
=ctype
)
3560 lines
= response_body
.splitlines()
3561 if not lines
or lines
[0] != "OK":
3562 StatusUpdate(" --> %s" % response_body
)
3564 rv
.append([lines
[1], patch
[0]])