1# coding=utf-8
2# (The line above is necessary so that I can use 世界 in the
3# *comment* below without Python getting all bent out of shape.)
4
5# Copyright 2007-2009 Google Inc.
6#
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
10#
11#	http://www.apache.org/licenses/LICENSE-2.0
12#
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.
18
19'''Mercurial interface to codereview.appspot.com.
20
21To configure, set the following options in
22your repository's .hg/hgrc file.
23
24	[extensions]
25	codereview = /path/to/codereview.py
26
27	[codereview]
28	server = codereview.appspot.com
29
30The server should be running Rietveld; see http://code.google.com/p/rietveld/.
31
32In addition to the new commands, this extension introduces
33the file pattern syntax @nnnnnn, where nnnnnn is a change list
34number, to mean the files included in that change list, which
35must be associated with the current client.
36
37For 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".
39'''
40
41import sys
42
43if __name__ == "__main__":
44	print >>sys.stderr, "This is a Mercurial extension and should not be invoked directly."
45	sys.exit(2)
46
47# We require Python 2.6 for the json package.
48if 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
51	sys.exit(2)
52
53import json
54import os
55import re
56import stat
57import subprocess
58import threading
59import time
60
61from mercurial import commands as hg_commands
62from mercurial import util as hg_util
63
64defaultcc = None
65codereview_disabled = None
66real_rollback = None
67releaseBranch = None
68server = "codereview.appspot.com"
69server_url_base = None
70
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.
75
76#######################################################################
77# Helpers
78
79def RelativePath(path, cwd):
80	n = len(cwd)
81	if path.startswith(cwd) and path[n] == '/':
82		return path[n+1:]
83	return path
84
85def Sub(l1, l2):
86	return [l for l in l1 if l not in l2]
87
88def Add(l1, l2):
89	l = l1 + Sub(l2, l1)
90	l.sort()
91	return l
92
93def Intersect(l1, l2):
94	return [l for l in l1 if l in l2]
95
96#######################################################################
97# RE: UNICODE STRING HANDLING
98#
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.
103#
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
111# conversions.
112#
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.
118
119def typecheck(s, t):
120	if type(s) != t:
121		raise hg_util.Abort("type check failed: %s has type %s != %s" % (repr(s), type(s), t))
122
123# If we have to pass unicode instead of str, ustr does that conversion clearly.
124def ustr(s):
125	typecheck(s, str)
126	return s.decode("utf-8")
127
128# Even with those, Mercurial still sometimes turns unicode into str
129# and then tries to use it as ascii.  Change Mercurial's default.
130def set_mercurial_encoding_to_utf8():
131	from mercurial import encoding
132	encoding.encoding = 'utf-8'
133
134set_mercurial_encoding_to_utf8()
135
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'.
143def default_to_utf8():
144	import sys
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')
149
150default_to_utf8()
151
152#######################################################################
153# Status printer for long-running commands
154
155global_status = None
156
157def set_status(s):
158	# print >>sys.stderr, "\t", time.asctime(), s
159	global global_status
160	global_status = s
161
162class StatusThread(threading.Thread):
163	def __init__(self):
164		threading.Thread.__init__(self)
165	def run(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.
169		time.sleep(30)
170
171		# now show status every 15 seconds
172		while True:
173			time.sleep(15 - time.time() % 15)
174			s = global_status
175			if s is None:
176				continue
177			if s == "":
178				s = "(unknown status)"
179			print >>sys.stderr, time.asctime(), s
180
181def start_status_thread():
182	t = StatusThread()
183	t.setDaemon(True)  # allowed to exit if t is still running
184	t.start()
185
186#######################################################################
187# Change list parsing.
188#
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.
196
197emptydiff = """Index: ~rietveld~placeholder~
198===================================================================
199diff --git a/~rietveld~placeholder~ b/~rietveld~placeholder~
200new file mode 100644
201"""
202
203class CL(object):
204	def __init__(self, name):
205		typecheck(name, str)
206		self.name = name
207		self.desc = ''
208		self.files = []
209		self.reviewer = []
210		self.cc = []
211		self.url = ''
212		self.local = False
213		self.web = False
214		self.copied_from = None	# None means current user
215		self.mailed = False
216		self.private = False
217		self.lgtm = []
218
219	def DiskText(self):
220		cl = self
221		s = ""
222		if cl.copied_from:
223			s += "Author: " + cl.copied_from + "\n\n"
224		if cl.private:
225			s += "Private: " + str(self.private) + "\n"
226		s += "Mailed: " + str(self.mailed) + "\n"
227		s += "Description:\n"
228		s += Indent(cl.desc, "\t")
229		s += "Files:\n"
230		for f in cl.files:
231			s += "\t" + f + "\n"
232		typecheck(s, str)
233		return s
234
235	def EditorText(self):
236		cl = self
237		s = _change_prolog
238		s += "\n"
239		if cl.copied_from:
240			s += "Author: " + cl.copied_from + "\n"
241		if cl.url != '':
242			s += 'URL: ' + cl.url + '	# cannot edit\n\n'
243		if cl.private:
244			s += "Private: True\n"
245		s += "Reviewer: " + JoinComma(cl.reviewer) + "\n"
246		s += "CC: " + JoinComma(cl.cc) + "\n"
247		s += "\n"
248		s += "Description:\n"
249		if cl.desc == '':
250			s += "\t<enter description here>\n"
251		else:
252			s += Indent(cl.desc, "\t")
253		s += "\n"
254		if cl.local or cl.name == "new":
255			s += "Files:\n"
256			for f in cl.files:
257				s += "\t" + f + "\n"
258			s += "\n"
259		typecheck(s, str)
260		return s
261
262	def PendingText(self, quick=False):
263		cl = self
264		s = cl.name + ":" + "\n"
265		s += Indent(cl.desc, "\t")
266		s += "\n"
267		if cl.copied_from:
268			s += "\tAuthor: " + cl.copied_from + "\n"
269		if not quick:
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"
274		s += "\tFiles:\n"
275		for f in cl.files:
276			s += "\t\t" + f + "\n"
277		typecheck(s, str)
278		return s
279
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())
287		f.close()
288		if sys.platform == "win32" and os.path.isfile(path):
289			os.remove(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)
295
296	def Delete(self, ui, repo):
297		dir = CodeReviewDir(ui, repo)
298		os.unlink(dir + "/cl." + self.name)
299
300	def Subject(self):
301		s = line1(self.desc)
302		if len(s) > 60:
303			s = s[0:55] + "..."
304		if self.name != "new":
305			s = "code review %s: %s" % (self.name, s)
306		typecheck(s, str)
307		return s
308
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")
315		os.chdir(repo.root)
316		form_fields = [
317			("content_upload", "1"),
318			("reviewers", JoinComma(self.reviewer)),
319			("cc", JoinComma(self.cc)),
320			("description", self.desc),
321			("base_hashes", ""),
322		]
323
324		if self.name != "new":
325			form_fields.append(("issue", self.name))
326		vcs = None
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"))
342			else:
343				uploaded_diff_file = [("data", "data.diff", data)]
344		else:
345			uploaded_diff_file = [("data", "data.diff", emptydiff)]
346
347		if vcs and self.name != "new":
348			form_fields.append(("subject", "diff -r " + vcs.base_rev + " " + ui.expandpath("default")))
349		else:
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)
354		patchset = None
355		msg = response_body
356		lines = msg.splitlines()
357		if len(lines) >= 2:
358			msg = lines[0]
359			patchset = lines[1].strip()
360			patches = [x.split(" ", 1) for x in lines[2:]]
361		if response_body.startswith("Issue updated.") and quiet:
362			pass
363		else:
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:]
369		self.name = issue
370		if not self.url:
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)
375		if vcs:
376			set_status("uploading base files")
377			vcs.UploadBaseFiles(issue, rpc, patches, patchset, upload_options, files)
378		if send_mail:
379			set_status("sending mail")
380			MySend("/" + issue + "/mail", payload="")
381		self.web = True
382		set_status("flushing changes to disk")
383		self.Flush(ui, repo)
384		return
385
386	def Mail(self, ui, repo):
387		pmsg = "Hello " + JoinComma(self.reviewer)
388		if self.cc:
389			pmsg += " (cc: %s)" % (', '.join(self.cc),)
390		pmsg += ",\n"
391		pmsg += "\n"
392		repourl = ui.expandpath("default")
393		if not self.mailed:
394			pmsg += "I'd like you to review this change to\n" + repourl + "\n"
395		else:
396			pmsg += "Please take another look.\n"
397		typecheck(pmsg, str)
398		PostMessage(ui, self.name, pmsg, subject=self.Subject())
399		self.mailed = True
400		self.Flush(ui, repo)
401
402def GoodCLName(name):
403	typecheck(name, str)
404	return re.match("^[0-9]+$", name)
405
406def ParseCL(text, name):
407	typecheck(text, str)
408	typecheck(name, str)
409	sname = None
410	lineno = 0
411	sections = {
412		'Author': '',
413		'Description': '',
414		'Files': '',
415		'URL': '',
416		'Reviewer': '',
417		'CC': '',
418		'Mailed': '',
419		'Private': '',
420	}
421	for line in text.split('\n'):
422		lineno += 1
423		line = line.rstrip()
424		if line != '' and line[0] == '#':
425			continue
426		if line == '' or line[0] == ' ' or line[0] == '\t':
427			if sname == None and line != '':
428				return None, lineno, 'text outside section'
429			if sname != None:
430				sections[sname] += line + '\n'
431			continue
432		p = line.find(':')
433		if p >= 0:
434			s, val = line[:p].strip(), line[p+1:].strip()
435			if s in sections:
436				sname = s
437				if val != '':
438					sections[sname] += val + '\n'
439				continue
440		return None, lineno, 'malformed section header'
441
442	for k in sections:
443		sections[k] = StripCommon(sections[k]).rstrip()
444
445	cl = CL(name)
446	if sections['Author']:
447		cl.copied_from = sections['Author']
448	cl.desc = sections['Description']
449	for line in sections['Files'].split('\n'):
450		i = line.find('#')
451		if i >= 0:
452			line = line[0:i].rstrip()
453		line = line.strip()
454		if line == '':
455			continue
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.
465		cl.mailed = True
466	if sections['Private'] in ('True', 'true', 'Yes', 'yes'):
467		cl.private = True
468	if cl.desc == '<enter description here>':
469		cl.desc = ''
470	return cl, 0, ''
471
472def SplitCommaSpace(s):
473	typecheck(s, str)
474	s = s.strip()
475	if s == "":
476		return []
477	return re.split(", *", s)
478
479def CutDomain(s):
480	typecheck(s, str)
481	i = s.find('@')
482	if i >= 0:
483		s = s[0:i]
484	return s
485
486def JoinComma(l):
487	for s in l:
488		typecheck(s, str)
489	return ", ".join(l)
490
491def ExceptionDetail():
492	s = str(sys.exc_info()[0])
493	if s.startswith("<type '") and s.endswith("'>"):
494		s = s[7:-2]
495	elif s.startswith("<class '") and s.endswith("'>"):
496		s = s[8:-2]
497	arg = str(sys.exc_info()[1])
498	if len(arg) > 0:
499		s += ": " + arg
500	return s
501
502def IsLocalCL(ui, repo, name):
503	return GoodCLName(name) and os.access(CodeReviewDir(ui, repo) + "/cl." + name, 0)
504
505# Load CL from disk and/or the web.
506def LoadCL(ui, repo, name, web=True):
507	typecheck(name, str)
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):
514		ff = open(path)
515		text = ff.read()
516		ff.close()
517		cl, lineno, err = ParseCL(text, name)
518		if err != "":
519			return None, "malformed CL data: "+err
520		cl.local = True
521	else:
522		cl = CL(name)
523	if web:
524		set_status("getting issue metadata from web")
525		d = JSONGet(ui, "/api/" + name + "?messages=true")
526		set_status(None)
527		if d is None:
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"
531		cl.dict = d
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.
539			pass
540		else:
541			cl.desc = d.get('description', "")
542		cl.url = server_url_base + name
543		cl.web = True
544		cl.private = d.get('private', False) != False
545		cl.lgtm = []
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))
551
552	set_status("loaded CL " + name)
553	return cl, ''
554
555class LoadCLThread(threading.Thread):
556	def __init__(self, ui, repo, dir, f, web):
557		threading.Thread.__init__(self)
558		self.ui = ui
559		self.repo = repo
560		self.dir = dir
561		self.f = f
562		self.web = web
563		self.cl = None
564	def run(self):
565		cl, err = LoadCL(self.ui, self.repo, self.f[3:], web=self.web)
566		if err != '':
567			self.ui.warn("loading "+self.dir+self.f+": " + err + "\n")
568			return
569		self.cl = cl
570
571# Load all the CLs from this repository.
572def LoadAllCL(ui, repo, web=True):
573	dir = CodeReviewDir(ui, repo)
574	m = {}
575	files = [f for f in os.listdir(dir) if f.startswith('cl.')]
576	if not files:
577		return m
578	active = []
579	first = True
580	for f in files:
581		t = LoadCLThread(ui, repo, dir, f, web)
582		t.start()
583		if web and first:
584			# first request: wait in case it needs to authenticate
585			# otherwise we get lots of user/password prompts
586			# running in parallel.
587			t.join()
588			if t.cl:
589				m[t.cl.name] = t.cl
590			first = False
591		else:
592			active.append(t)
593	for t in active:
594		t.join()
595		if t.cl:
596			m[t.cl.name] = t.cl
597	return m
598
599# Find repository root.  On error, ui.warn and return None
600def RepoDir(ui, repo):
601	url = repo.url();
602	if not url.startswith('file:'):
603		ui.warn("repository %s is not in local file system\n" % (url,))
604		return None
605	url = url[5:]
606	if url.endswith('/'):
607		url = url[:-1]
608	typecheck(url, str)
609	return url
610
611# Find (or make) code review directory.  On error, ui.warn and return None
612def CodeReviewDir(ui, repo):
613	dir = RepoDir(ui, repo)
614	if dir == None:
615		return None
616	dir += '/.hg/codereview/'
617	if not os.path.isdir(dir):
618		try:
619			os.mkdir(dir, 0700)
620		except:
621			ui.warn('cannot mkdir %s: %s\n' % (dir, ExceptionDetail()))
622			return None
623	typecheck(dir, str)
624	return dir
625
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.
630def TabsToSpaces(line):
631	i = 0
632	while i < len(line) and line[i] == '\t':
633		i += 1
634	return ' '*(8*i) + line[i:]
635
636# Strip maximal common leading white space prefix from text
637def StripCommon(text):
638	typecheck(text, str)
639	ws = None
640	for line in text.split('\n'):
641		line = line.rstrip()
642		if line == '':
643			continue
644		line = TabsToSpaces(line)
645		white = line[:len(line)-len(line.lstrip())]
646		if ws == None:
647			ws = white
648		else:
649			common = ''
650			for i in range(min(len(white), len(ws))+1):
651				if white[0:i] == ws[0:i]:
652					common = white[0:i]
653			ws = common
654		if ws == '':
655			break
656	if ws == None:
657		return text
658	t = ''
659	for line in text.split('\n'):
660		line = line.rstrip()
661		line = TabsToSpaces(line)
662		if line.startswith(ws):
663			line = line[len(ws):]
664		if line == '' and t == '':
665			continue
666		t += line + '\n'
667	while len(t) >= 2 and t[-2:] == '\n\n':
668		t = t[:-1]
669	typecheck(t, str)
670	return t
671
672# Indent text with indent.
673def Indent(text, indent):
674	typecheck(text, str)
675	typecheck(indent, str)
676	t = ''
677	for line in text.split('\n'):
678		t += indent + line + '\n'
679	typecheck(t, str)
680	return t
681
682# Return the first line of l
683def line1(text):
684	typecheck(text, str)
685	return text.split('\n')[0]
686
687_change_prolog = """# Change list.
688# Lines beginning with # are ignored.
689# Multi-line values should be indented.
690"""
691
692desc_re = '^(.+: |(tag )?(release|weekly)\.|fix build|undo CL)'
693
694desc_msg = '''Your CL description appears not to use the standard form.
695
696The first line of your change description is conventionally a
697one-line summary of the change, prefixed by the primary affected package,
698and is used as the subject for code review mail; the rest of the description
699elaborates.
700
701Examples:
702
703	encoding/rot13: new package
704
705	math: add IsInf, IsNaN
706
707	net: fix cname in LookupHost
708
709	unicode: update to Unicode 5.0.2
710
711'''
712
713def promptyesno(ui, msg):
714	return ui.promptchoice(msg, ["&yes", "&no"], 0) == 0
715
716def promptremove(ui, repo, f):
717	if promptyesno(ui, "hg remove %s (y/n)?" % (f,)):
718		if hg_commands.remove(ui, repo, 'path:'+f) != 0:
719			ui.warn("error removing %s" % (f,))
720
721def promptadd(ui, repo, f):
722	if promptyesno(ui, "hg add %s (y/n)?" % (f,)):
723		if hg_commands.add(ui, repo, 'path:'+f) != 0:
724			ui.warn("error adding %s" % (f,))
725
726def EditCL(ui, repo, cl):
727	set_status(None)	# do not show status
728	s = cl.EditorText()
729	while True:
730		s = ui.edit(s, ui.username())
731
732		# We can't trust Mercurial + Python not to die before making the change,
733		# so, by popular demand, just scribble the most recent CL edit into
734		# $(hg root)/last-change so that if Mercurial does die, people
735		# can look there for their work.
736		try:
737			f = open(repo.root+"/last-change", "w")
738			f.write(s)
739			f.close()
740		except:
741			pass
742
743		clx, line, err = ParseCL(s, cl.name)
744		if err != '':
745			if not promptyesno(ui, "error parsing change list: line %d: %s\nre-edit (y/n)?" % (line, err)):
746				return "change list not modified"
747			continue
748
749		# Check description.
750		if clx.desc == '':
751			if promptyesno(ui, "change list should have a description\nre-edit (y/n)?"):
752				continue
753		elif re.search('<enter reason for undo>', clx.desc):
754			if promptyesno(ui, "change list description omits reason for undo\nre-edit (y/n)?"):
755				continue
756		elif not re.match(desc_re, clx.desc.split('\n')[0]):
757			if promptyesno(ui, desc_msg + "re-edit (y/n)?"):
758				continue
759
760		# Check file list for files that need to be hg added or hg removed
761		# or simply aren't understood.
762		pats = ['path:'+f for f in clx.files]
763		changed = hg_matchPattern(ui, repo, *pats, modified=True, added=True, removed=True)
764		deleted = hg_matchPattern(ui, repo, *pats, deleted=True)
765		unknown = hg_matchPattern(ui, repo, *pats, unknown=True)
766		ignored = hg_matchPattern(ui, repo, *pats, ignored=True)
767		clean = hg_matchPattern(ui, repo, *pats, clean=True)
768		files = []
769		for f in clx.files:
770			if f in changed:
771				files.append(f)
772				continue
773			if f in deleted:
774				promptremove(ui, repo, f)
775				files.append(f)
776				continue
777			if f in unknown:
778				promptadd(ui, repo, f)
779				files.append(f)
780				continue
781			if f in ignored:
782				ui.warn("error: %s is excluded by .hgignore; omitting\n" % (f,))
783				continue
784			if f in clean:
785				ui.warn("warning: %s is listed in the CL but unchanged\n" % (f,))
786				files.append(f)
787				continue
788			p = repo.root + '/' + f
789			if os.path.isfile(p):
790				ui.warn("warning: %s is a file but not known to hg\n" % (f,))
791				files.append(f)
792				continue
793			if os.path.isdir(p):
794				ui.warn("error: %s is a directory, not a file; omitting\n" % (f,))
795				continue
796			ui.warn("error: %s does not exist; omitting\n" % (f,))
797		clx.files = files
798
799		cl.desc = clx.desc
800		cl.reviewer = clx.reviewer
801		cl.cc = clx.cc
802		cl.files = clx.files
803		cl.private = clx.private
804		break
805	return ""
806
807# For use by submit, etc. (NOT by change)
808# Get change list number or list of files from command line.
809# If files are given, make a new change list.
810def CommandLineCL(ui, repo, pats, opts, defaultcc=None):
811	if len(pats) > 0 and GoodCLName(pats[0]):
812		if len(pats) != 1:
813			return None, "cannot specify change number and file names"
814		if opts.get('message'):
815			return None, "cannot use -m with existing CL"
816		cl, err = LoadCL(ui, repo, pats[0], web=True)
817		if err != "":
818			return None, err
819	else:
820		cl = CL("new")
821		cl.local = True
822		cl.files = ChangedFiles(ui, repo, pats, taken=Taken(ui, repo))
823		if not cl.files:
824			return None, "no files changed"
825	if opts.get('reviewer'):
826		cl.reviewer = Add(cl.reviewer, SplitCommaSpace(opts.get('reviewer')))
827	if opts.get('cc'):
828		cl.cc = Add(cl.cc, SplitCommaSpace(opts.get('cc')))
829	if defaultcc:
830		cl.cc = Add(cl.cc, defaultcc)
831	if cl.name == "new":
832		if opts.get('message'):
833			cl.desc = opts.get('message')
834		else:
835			err = EditCL(ui, repo, cl)
836			if err != '':
837				return None, err
838	return cl, ""
839
840#######################################################################
841# Change list file management
842
843# Return list of changed files in repository that match pats.
844# The patterns came from the command line, so we warn
845# if they have no effect or cannot be understood.
846def ChangedFiles(ui, repo, pats, taken=None):
847	taken = taken or {}
848	# Run each pattern separately so that we can warn about
849	# patterns that didn't do anything useful.
850	for p in pats:
851		for f in hg_matchPattern(ui, repo, p, unknown=True):
852			promptadd(ui, repo, f)
853		for f in hg_matchPattern(ui, repo, p, removed=True):
854			promptremove(ui, repo, f)
855		files = hg_matchPattern(ui, repo, p, modified=True, added=True, removed=True)
856		for f in files:
857			if f in taken:
858				ui.warn("warning: %s already in CL %s\n" % (f, taken[f].name))
859		if not files:
860			ui.warn("warning: %s did not match any modified files\n" % (p,))
861
862	# Again, all at once (eliminates duplicates)
863	l = hg_matchPattern(ui, repo, *pats, modified=True, added=True, removed=True)
864	l.sort()
865	if taken:
866		l = Sub(l, taken.keys())
867	return l
868
869# Return list of changed files in repository that match pats and still exist.
870def ChangedExistingFiles(ui, repo, pats, opts):
871	l = hg_matchPattern(ui, repo, *pats, modified=True, added=True)
872	l.sort()
873	return l
874
875# Return list of files claimed by existing CLs
876def Taken(ui, repo):
877	all = LoadAllCL(ui, repo, web=False)
878	taken = {}
879	for _, cl in all.items():
880		for f in cl.files:
881			taken[f] = cl
882	return taken
883
884# Return list of changed files that are not claimed by other CLs
885def DefaultFiles(ui, repo, pats):
886	return ChangedFiles(ui, repo, pats, taken=Taken(ui, repo))
887
888#######################################################################
889# File format checking.
890
891def CheckFormat(ui, repo, files, just_warn=False):
892	set_status("running gofmt")
893	CheckGofmt(ui, repo, files, just_warn)
894	CheckTabfmt(ui, repo, files, just_warn)
895
896# Check that gofmt run on the list of files does not change them
897def CheckGofmt(ui, repo, files, just_warn):
898	files = gofmt_required(files)
899	if not files:
900		return
901	cwd = os.getcwd()
902	files = [RelativePath(repo.root + '/' + f, cwd) for f in files]
903	files = [f for f in files if os.access(f, 0)]
904	if not files:
905		return
906	try:
907		cmd = subprocess.Popen(["gofmt", "-l"] + files, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=sys.platform != "win32")
908		cmd.stdin.close()
909	except:
910		raise hg_util.Abort("gofmt: " + ExceptionDetail())
911	data = cmd.stdout.read()
912	errors = cmd.stderr.read()
913	cmd.wait()
914	set_status("done with gofmt")
915	if len(errors) > 0:
916		ui.warn("gofmt errors:\n" + errors.rstrip() + "\n")
917		return
918	if len(data) > 0:
919		msg = "gofmt needs to format these files (run hg gofmt):\n" + Indent(data, "\t").rstrip()
920		if just_warn:
921			ui.warn("warning: " + msg + "\n")
922		else:
923			raise hg_util.Abort(msg)
924	return
925
926# Check that *.[chys] files indent using tabs.
927def CheckTabfmt(ui, repo, files, just_warn):
928	files = [f for f in files if f.startswith('src/') and re.search(r"\.[chys]$", f) and not re.search(r"\.tab\.[ch]$", f)]
929	if not files:
930		return
931	cwd = os.getcwd()
932	files = [RelativePath(repo.root + '/' + f, cwd) for f in files]
933	files = [f for f in files if os.access(f, 0)]
934	badfiles = []
935	for f in files:
936		try:
937			for line in open(f, 'r'):
938				# Four leading spaces is enough to complain about,
939				# except that some Plan 9 code uses four spaces as the label indent,
940				# so allow that.
941				if line.startswith('    ') and not re.match('    [A-Za-z0-9_]+:', line):
942					badfiles.append(f)
943					break
944		except:
945			# ignore cannot open file, etc.
946			pass
947	if len(badfiles) > 0:
948		msg = "these files use spaces for indentation (use tabs instead):\n\t" + "\n\t".join(badfiles)
949		if just_warn:
950			ui.warn("warning: " + msg + "\n")
951		else:
952			raise hg_util.Abort(msg)
953	return
954
955#######################################################################
956# CONTRIBUTORS file parsing
957
958contributorsCache = None
959contributorsURL = None
960
961def ReadContributors(ui, repo):
962	global contributorsCache
963	if contributorsCache is not None:
964		return contributorsCache
965
966	try:
967		if contributorsURL is not None:
968			opening = contributorsURL
969			f = urllib2.urlopen(contributorsURL)
970		else:
971			opening = repo.root + '/CONTRIBUTORS'
972			f = open(repo.root + '/CONTRIBUTORS', 'r')
973	except:
974		ui.write("warning: cannot open %s: %s\n" % (opening, ExceptionDetail()))
975		return
976
977	contributors = {}
978	for line in f:
979		# CONTRIBUTORS is a list of lines like:
980		#	Person <email>
981		#	Person <email> <alt-email>
982		# The first email address is the one used in commit logs.
983		if line.startswith('#'):
984			continue
985		m = re.match(r"([^<>]+\S)\s+(<[^<>\s]+>)((\s+<[^<>\s]+>)*)\s*$", line)
986		if m:
987			name = m.group(1)
988			email = m.group(2)[1:-1]
989			contributors[email.lower()] = (name, email)
990			for extra in m.group(3).split():
991				contributors[extra[1:-1].lower()] = (name, email)
992
993	contributorsCache = contributors
994	return contributors
995
996def CheckContributor(ui, repo, user=None):
997	set_status("checking CONTRIBUTORS file")
998	user, userline = FindContributor(ui, repo, user, warn=False)
999	if not userline:
1000		raise hg_util.Abort("cannot find %s in CONTRIBUTORS" % (user,))
1001	return userline
1002
1003def FindContributor(ui, repo, user=None, warn=True):
1004	if not user:
1005		user = ui.config("ui", "username")
1006		if not user:
1007			raise hg_util.Abort("[ui] username is not configured in .hgrc")
1008	user = user.lower()
1009	m = re.match(r".*<(.*)>", user)
1010	if m:
1011		user = m.group(1)
1012
1013	contributors = ReadContributors(ui, repo)
1014	if user not in contributors:
1015		if warn:
1016			ui.warn("warning: cannot find %s in CONTRIBUTORS\n" % (user,))
1017		return user, None
1018
1019	user, email = contributors[user]
1020	return email, "%s <%s>" % (user, email)
1021
1022#######################################################################
1023# Mercurial helper functions.
1024# Read http://mercurial.selenic.com/wiki/MercurialApi before writing any of these.
1025# We use the ui.pushbuffer/ui.popbuffer + hg_commands.xxx tricks for all interaction
1026# with Mercurial.  It has proved the most stable as they make changes.
1027
1028hgversion = hg_util.version()
1029
1030# We require Mercurial 1.9 and suggest Mercurial 2.0.
1031# The details of the scmutil package changed then,
1032# so allowing earlier versions would require extra band-aids below.
1033# Ubuntu 11.10 ships with Mercurial 1.9.1 as the default version.
1034hg_required = "1.9"
1035hg_suggested = "2.0"
1036
1037old_message = """
1038
1039The code review extension requires Mercurial """+hg_required+""" or newer.
1040You are using Mercurial """+hgversion+""".
1041
1042To install a new Mercurial, use
1043
1044	sudo easy_install mercurial=="""+hg_suggested+"""
1045
1046or visit http://mercurial.selenic.com/downloads/.
1047"""
1048
1049linux_message = """
1050You may need to clear your current Mercurial installation by running:
1051
1052	sudo apt-get remove mercurial mercurial-common
1053	sudo rm -rf /etc/mercurial
1054"""
1055
1056if hgversion < hg_required:
1057	msg = old_message
1058	if os.access("/etc/mercurial", 0):
1059		msg += linux_message
1060	raise hg_util.Abort(msg)
1061
1062from mercurial.hg import clean as hg_clean
1063from mercurial import cmdutil as hg_cmdutil
1064from mercurial import error as hg_error
1065from mercurial import match as hg_match
1066from mercurial import node as hg_node
1067
1068class uiwrap(object):
1069	def __init__(self, ui):
1070		self.ui = ui
1071		ui.pushbuffer()
1072		self.oldQuiet = ui.quiet
1073		ui.quiet = True
1074		self.oldVerbose = ui.verbose
1075		ui.verbose = False
1076	def output(self):
1077		ui = self.ui
1078		ui.quiet = self.oldQuiet
1079		ui.verbose = self.oldVerbose
1080		return ui.popbuffer()
1081
1082def to_slash(path):
1083	if sys.platform == "win32":
1084		return path.replace('\\', '/')
1085	return path
1086
1087def hg_matchPattern(ui, repo, *pats, **opts):
1088	w = uiwrap(ui)
1089	hg_commands.status(ui, repo, *pats, **opts)
1090	text = w.output()
1091	ret = []
1092	prefix = to_slash(os.path.realpath(repo.root))+'/'
1093	for line in text.split('\n'):
1094		f = line.split()
1095		if len(f) > 1:
1096			if len(pats) > 0:
1097				# Given patterns, Mercurial shows relative to cwd
1098				p = to_slash(os.path.realpath(f[1]))
1099				if not p.startswith(prefix):
1100					print >>sys.stderr, "File %s not in repo root %s.\n" % (p, prefix)
1101				else:
1102					ret.append(p[len(prefix):])
1103			else:
1104				# Without patterns, Mercurial shows relative to root (what we want)
1105				ret.append(to_slash(f[1]))
1106	return ret
1107
1108def hg_heads(ui, repo):
1109	w = uiwrap(ui)
1110	hg_commands.heads(ui, repo)
1111	return w.output()
1112
1113noise = [
1114	"",
1115	"resolving manifests",
1116	"searching for changes",
1117	"couldn't find merge tool hgmerge",
1118	"adding changesets",
1119	"adding manifests",
1120	"adding file changes",
1121	"all local heads known remotely",
1122]
1123
1124def isNoise(line):
1125	line = str(line)
1126	for x in noise:
1127		if line == x:
1128			return True
1129	return False
1130
1131def hg_incoming(ui, repo):
1132	w = uiwrap(ui)
1133	ret = hg_commands.incoming(ui, repo, force=False, bundle="")
1134	if ret and ret != 1:
1135		raise hg_util.Abort(ret)
1136	return w.output()
1137
1138def hg_log(ui, repo, **opts):
1139	for k in ['date', 'keyword', 'rev', 'user']:
1140		if not opts.has_key(k):
1141			opts[k] = ""
1142	w = uiwrap(ui)
1143	ret = hg_commands.log(ui, repo, **opts)
1144	if ret:
1145		raise hg_util.Abort(ret)
1146	return w.output()
1147
1148def hg_outgoing(ui, repo, **opts):
1149	w = uiwrap(ui)
1150	ret = hg_commands.outgoing(ui, repo, **opts)
1151	if ret and ret != 1:
1152		raise hg_util.Abort(ret)
1153	return w.output()
1154
1155def hg_pull(ui, repo, **opts):
1156	w = uiwrap(ui)
1157	ui.quiet = False
1158	ui.verbose = True  # for file list
1159	err = hg_commands.pull(ui, repo, **opts)
1160	for line in w.output().split('\n'):
1161		if isNoise(line):
1162			continue
1163		if line.startswith('moving '):
1164			line = 'mv ' + line[len('moving '):]
1165		if line.startswith('getting ') and line.find(' to ') >= 0:
1166			line = 'mv ' + line[len('getting '):]
1167		if line.startswith('getting '):
1168			line = '+ ' + line[len('getting '):]
1169		if line.startswith('removing '):
1170			line = '- ' + line[len('removing '):]
1171		ui.write(line + '\n')
1172	return err
1173
1174def hg_push(ui, repo, **opts):
1175	w = uiwrap(ui)
1176	ui.quiet = False
1177	ui.verbose = True
1178	err = hg_commands.push(ui, repo, **opts)
1179	for line in w.output().split('\n'):
1180		if not isNoise(line):
1181			ui.write(line + '\n')
1182	return err
1183
1184def hg_commit(ui, repo, *pats, **opts):
1185	return hg_commands.commit(ui, repo, *pats, **opts)
1186
1187#######################################################################
1188# Mercurial precommit hook to disable commit except through this interface.
1189
1190commit_okay = False
1191
1192def precommithook(ui, repo, **opts):
1193	if commit_okay:
1194		return False  # False means okay.
1195	ui.write("\ncodereview extension enabled; use mail, upload, or submit instead of commit\n\n")
1196	return True
1197
1198#######################################################################
1199# @clnumber file pattern support
1200
1201# We replace scmutil.match with the MatchAt wrapper to add the @clnumber pattern.
1202
1203match_repo = None
1204match_ui = None
1205match_orig = None
1206
1207def InstallMatch(ui, repo):
1208	global match_repo
1209	global match_ui
1210	global match_orig
1211
1212	match_ui = ui
1213	match_repo = repo
1214
1215	from mercurial import scmutil
1216	match_orig = scmutil.match
1217	scmutil.match = MatchAt
1218
1219def MatchAt(ctx, pats=None, opts=None, globbed=False, default='relpath'):
1220	taken = []
1221	files = []
1222	pats = pats or []
1223	opts = opts or {}
1224
1225	for p in pats:
1226		if p.startswith('@'):
1227			taken.append(p)
1228			clname = p[1:]
1229			if clname == "default":
1230				files = DefaultFiles(match_ui, match_repo, [])
1231			else:
1232				if not GoodCLName(clname):
1233					raise hg_util.Abort("invalid CL name " + clname)
1234				cl, err = LoadCL(match_repo.ui, match_repo, clname, web=False)
1235				if err != '':
1236					raise hg_util.Abort("loading CL " + clname + ": " + err)
1237				if not cl.files:
1238					raise hg_util.Abort("no files in CL " + clname)
1239				files = Add(files, cl.files)
1240	pats = Sub(pats, taken) + ['path:'+f for f in files]
1241
1242	# work-around for http://selenic.com/hg/rev/785bbc8634f8
1243	if not hasattr(ctx, 'match'):
1244		ctx = ctx[None]
1245	return match_orig(ctx, pats=pats, opts=opts, globbed=globbed, default=default)
1246
1247#######################################################################
1248# Commands added by code review extension.
1249
1250# As of Mercurial 2.1 the commands are all required to return integer
1251# exit codes, whereas earlier versions allowed returning arbitrary strings
1252# to be printed as errors.  We wrap the old functions to make sure we
1253# always return integer exit codes now.  Otherwise Mercurial dies
1254# with a TypeError traceback (unsupported operand type(s) for &: 'str' and 'int').
1255# Introduce a Python decorator to convert old functions to the new
1256# stricter convention.
1257
1258def hgcommand(f):
1259	def wrapped(ui, repo, *pats, **opts):
1260		err = f(ui, repo, *pats, **opts)
1261		if type(err) is int:
1262			return err
1263		if not err:
1264			return 0
1265		raise hg_util.Abort(err)
1266	wrapped.__doc__ = f.__doc__
1267	return wrapped
1268
1269#######################################################################
1270# hg change
1271
1272@hgcommand
1273def change(ui, repo, *pats, **opts):
1274	"""create, edit or delete a change list
1275
1276	Create, edit or delete a change list.
1277	A change list is a group of files to be reviewed and submitted together,
1278	plus a textual description of the change.
1279	Change lists are referred to by simple alphanumeric names.
1280
1281	Changes must be reviewed before they can be submitted.
1282
1283	In the absence of options, the change command opens the
1284	change list for editing in the default editor.
1285
1286	Deleting a change with the -d or -D flag does not affect
1287	the contents of the files listed in that change.  To revert
1288	the files listed in a change, use
1289
1290		hg revert @123456
1291
1292	before running hg change -d 123456.
1293	"""
1294
1295	if codereview_disabled:
1296		return codereview_disabled
1297
1298	dirty = {}
1299	if len(pats) > 0 and GoodCLName(pats[0]):
1300		name = pats[0]
1301		if len(pats) != 1:
1302			return "cannot specify CL name and file patterns"
1303		pats = pats[1:]
1304		cl, err = LoadCL(ui, repo, name, web=True)
1305		if err != '':
1306			return err
1307		if not cl.local and (opts["stdin"] or not opts["stdout"]):
1308			return "cannot change non-local CL " + name
1309	else:
1310		name = "new"
1311		cl = CL("new")
1312		if repo[None].branch() != "default":
1313			return "cannot create CL outside default branch; switch with 'hg update default'"
1314		dirty[cl] = True
1315		files = ChangedFiles(ui, repo, pats, taken=Taken(ui, repo))
1316
1317	if opts["delete"] or opts["deletelocal"]:
1318		if opts["delete"] and opts["deletelocal"]:
1319			return "cannot use -d and -D together"
1320		flag = "-d"
1321		if opts["deletelocal"]:
1322			flag = "-D"
1323		if name == "new":
1324			return "cannot use "+flag+" with file patterns"
1325		if opts["stdin"] or opts["stdout"]:
1326			return "cannot use "+flag+" with -i or -o"
1327		if not cl.local:
1328			return "cannot change non-local CL " + name
1329		if opts["delete"]:
1330			if cl.copied_from:
1331				return "original author must delete CL; hg change -D will remove locally"
1332			PostMessage(ui, cl.name, "*** Abandoned ***", send_mail=cl.mailed)
1333			EditDesc(cl.name, closed=True, private=cl.private)
1334		cl.Delete(ui, repo)
1335		return
1336
1337	if opts["stdin"]:
1338		s = sys.stdin.read()
1339		clx, line, err = ParseCL(s, name)
1340		if err != '':
1341			return "error parsing change list: line %d: %s" % (line, err)
1342		if clx.desc is not None:
1343			cl.desc = clx.desc;
1344			dirty[cl] = True
1345		if clx.reviewer is not None:
1346			cl.reviewer = clx.reviewer
1347			dirty[cl] = True
1348		if clx.cc is not None:
1349			cl.cc = clx.cc
1350			dirty[cl] = True
1351		if clx.files is not None:
1352			cl.files = clx.files
1353			dirty[cl] = True
1354		if clx.private != cl.private:
1355			cl.private = clx.private
1356			dirty[cl] = True
1357
1358	if not opts["stdin"] and not opts["stdout"]:
1359		if name == "new":
1360			cl.files = files
1361		err = EditCL(ui, repo, cl)
1362		if err != "":
1363			return err
1364		dirty[cl] = True
1365
1366	for d, _ in dirty.items():
1367		name = d.name
1368		d.Flush(ui, repo)
1369		if name == "new":
1370			d.Upload(ui, repo, quiet=True)
1371
1372	if opts["stdout"]:
1373		ui.write(cl.EditorText())
1374	elif opts["pending"]:
1375		ui.write(cl.PendingText())
1376	elif name == "new":
1377		if ui.quiet:
1378			ui.write(cl.name)
1379		else:
1380			ui.write("CL created: " + cl.url + "\n")
1381	return
1382
1383#######################################################################
1384# hg code-login (broken?)
1385
1386@hgcommand
1387def code_login(ui, repo, **opts):
1388	"""log in to code review server
1389
1390	Logs in to the code review server, saving a cookie in
1391	a file in your home directory.
1392	"""
1393	if codereview_disabled:
1394		return codereview_disabled
1395
1396	MySend(None)
1397
1398#######################################################################
1399# hg clpatch / undo / release-apply / download
1400# All concerned with applying or unapplying patches to the repository.
1401
1402@hgcommand
1403def clpatch(ui, repo, clname, **opts):
1404	"""import a patch from the code review server
1405
1406	Imports a patch from the code review server into the local client.
1407	If the local client has already modified any of the files that the
1408	patch modifies, this command will refuse to apply the patch.
1409
1410	Submitting an imported patch will keep the original author's
1411	name as the Author: line but add your own name to a Committer: line.
1412	"""
1413	if repo[None].branch() != "default":
1414		return "cannot run hg clpatch outside default branch"
1415	return clpatch_or_undo(ui, repo, clname, opts, mode="clpatch")
1416
1417@hgcommand
1418def undo(ui, repo, clname, **opts):
1419	"""undo the effect of a CL
1420
1421	Creates a new CL that undoes an earlier CL.
1422	After creating the CL, opens the CL text for editing so that
1423	you can add the reason for the undo to the description.
1424	"""
1425	if repo[None].branch() != "default":
1426		return "cannot run hg undo outside default branch"
1427	return clpatch_or_undo(ui, repo, clname, opts, mode="undo")
1428
1429@hgcommand
1430def release_apply(ui, repo, clname, **opts):
1431	"""apply a CL to the release branch
1432
1433	Creates a new CL copying a previously committed change
1434	from the main branch to the release branch.
1435	The current client must either be clean or already be in
1436	the release branch.
1437
1438	The release branch must be created by starting with a
1439	clean client, disabling the code review plugin, and running:
1440
1441		hg update weekly.YYYY-MM-DD
1442		hg branch release-branch.rNN
1443		hg commit -m 'create release-branch.rNN'
1444		hg push --new-branch
1445
1446	Then re-enable the code review plugin.
1447
1448	People can test the release branch by running
1449
1450		hg update release-branch.rNN
1451
1452	in a clean client.  To return to the normal tree,
1453
1454		hg update default
1455
1456	Move changes since the weekly into the release branch
1457	using hg release-apply followed by the usual code review
1458	process and hg submit.
1459
1460	When it comes time to tag the release, record the
1461	final long-form tag of the release-branch.rNN
1462	in the *default* branch's .hgtags file.  That is, run
1463
1464		hg update default
1465
1466	and then edit .hgtags as you would for a weekly.
1467
1468	"""
1469	c = repo[None]
1470	if not releaseBranch:
1471		return "no active release branches"
1472	if c.branch() != releaseBranch:
1473		if c.modified() or c.added() or c.removed():
1474			raise hg_util.Abort("uncommitted local changes - cannot switch branches")
1475		err = hg_clean(repo, releaseBranch)
1476		if err:
1477			return err
1478	try:
1479		err = clpatch_or_undo(ui, repo, clname, opts, mode="backport")
1480		if err:
1481			raise hg_util.Abort(err)
1482	except Exception, e:
1483		hg_clean(repo, "default")
1484		raise e
1485	return None
1486
1487def rev2clname(rev):
1488	# Extract CL name from revision description.
1489	# The last line in the description that is a codereview URL is the real one.
1490	# Earlier lines might be part of the user-written description.
1491	all = re.findall('(?m)^http://codereview.appspot.com/([0-9]+)$', rev.description())
1492	if len(all) > 0:
1493		return all[-1]
1494	return ""
1495
1496undoHeader = """undo CL %s / %s
1497
1498<enter reason for undo>
1499
1500««« original CL description
1501"""
1502
1503undoFooter = """
1504»»»
1505"""
1506
1507backportHeader = """[%s] %s
1508
1509««« CL %s / %s
1510"""
1511
1512backportFooter = """
1513»»»
1514"""
1515
1516# Implementation of clpatch/undo.
1517def clpatch_or_undo(ui, repo, clname, opts, mode):
1518	if codereview_disabled:
1519		return codereview_disabled
1520
1521	if mode == "undo" or mode == "backport":
1522		# Find revision in Mercurial repository.
1523		# Assume CL number is 7+ decimal digits.
1524		# Otherwise is either change log sequence number (fewer decimal digits),
1525		# hexadecimal hash, or tag name.
1526		# Mercurial will fall over long before the change log
1527		# sequence numbers get to be 7 digits long.
1528		if re.match('^[0-9]{7,}$', clname):
1529			found = False
1530			for r in hg_log(ui, repo, keyword="codereview.appspot.com/"+clname, limit=100, template="{node}\n").split():
1531				rev = repo[r]
1532				# Last line with a code review URL is the actual review URL.
1533				# Earlier ones might be part of the CL description.
1534				n = rev2clname(rev)
1535				if n == clname:
1536					found = True
1537					break
1538			if not found:
1539				return "cannot find CL %s in local repository" % clname
1540		else:
1541			rev = repo[clname]
1542			if not rev:
1543				return "unknown revision %s" % clname
1544			clname = rev2clname(rev)
1545			if clname == "":
1546				return "cannot find CL name in revision description"
1547
1548		# Create fresh CL and start with patch that would reverse the change.
1549		vers = hg_node.short(rev.node())
1550		cl = CL("new")
1551		desc = str(rev.description())
1552		if mode == "undo":
1553			cl.desc = (undoHeader % (clname, vers)) + desc + undoFooter
1554		else:
1555			cl.desc = (backportHeader % (releaseBranch, line1(desc), clname, vers)) + desc + undoFooter
1556		v1 = vers
1557		v0 = hg_node.short(rev.parents()[0].node())
1558		if mode == "undo":
1559			arg = v1 + ":" + v0
1560		else:
1561			vers = v0
1562			arg = v0 + ":" + v1
1563		patch = RunShell(["hg", "diff", "--git", "-r", arg])
1564
1565	else:  # clpatch
1566		cl, vers, patch, err = DownloadCL(ui, repo, clname)
1567		if err != "":
1568			return err
1569		if patch == emptydiff:
1570			return "codereview issue %s has no diff" % clname
1571
1572	# find current hg version (hg identify)
1573	ctx = repo[None]
1574	parents = ctx.parents()
1575	id = '+'.join([hg_node.short(p.node()) for p in parents])
1576
1577	# if version does not match the patch version,
1578	# try to update the patch line numbers.
1579	if vers != "" and id != vers:
1580		# "vers in repo" gives the wrong answer
1581		# on some versions of Mercurial.  Instead, do the actual
1582		# lookup and catch the exception.
1583		try:
1584			repo[vers].description()
1585		except:
1586			return "local repository is out of date; sync to get %s" % (vers)
1587		patch1, err = portPatch(repo, patch, vers, id)
1588		if err != "":
1589			if not opts["ignore_hgpatch_failure"]:
1590				return "codereview issue %s is out of date: %s (%s->%s)" % (clname, err, vers, id)
1591		else:
1592			patch = patch1
1593	argv = ["hgpatch"]
1594	if opts["no_incoming"] or mode == "backport":
1595		argv += ["--checksync=false"]
1596	try:
1597		cmd = subprocess.Popen(argv, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=None, close_fds=sys.platform != "win32")
1598	except:
1599		return "hgpatch: " + ExceptionDetail() + "\nInstall hgpatch with:\n$ go get code.google.com/p/go.codereview/cmd/hgpatch\n"
1600
1601	out, err = cmd.communicate(patch)
1602	if cmd.returncode != 0 and not opts["ignore_hgpatch_failure"]:
1603		return "hgpatch failed"
1604	cl.local = True
1605	cl.files = out.strip().split()
1606	if not cl.files and not opts["ignore_hgpatch_failure"]:
1607		return "codereview issue %s has no changed files" % clname
1608	files = ChangedFiles(ui, repo, [])
1609	extra = Sub(cl.files, files)
1610	if extra:
1611		ui.warn("warning: these files were listed in the patch but not changed:\n\t" + "\n\t".join(extra) + "\n")
1612	cl.Flush(ui, repo)
1613	if mode == "undo":
1614		err = EditCL(ui, repo, cl)
1615		if err != "":
1616			return "CL created, but error editing: " + err
1617		cl.Flush(ui, repo)
1618	else:
1619		ui.write(cl.PendingText() + "\n")
1620
1621# portPatch rewrites patch from being a patch against
1622# oldver to being a patch against newver.
1623def portPatch(repo, patch, oldver, newver):
1624	lines = patch.splitlines(True) # True = keep \n
1625	delta = None
1626	for i in range(len(lines)):
1627		line = lines[i]
1628		if line.startswith('--- a/'):
1629			file = line[6:-1]
1630			delta = fileDeltas(repo, file, oldver, newver)
1631		if not delta or not line.startswith('@@ '):
1632			continue
1633		# @@ -x,y +z,w @@ means the patch chunk replaces
1634		# the original file's line numbers x up to x+y with the
1635		# line numbers z up to z+w in the new file.
1636		# Find the delta from x in the original to the same
1637		# line in the current version and add that delta to both
1638		# x and z.
1639		m = re.match('@@ -([0-9]+),([0-9]+) \+([0-9]+),([0-9]+) @@', line)
1640		if not m:
1641			return None, "error parsing patch line numbers"
1642		n1, len1, n2, len2 = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))
1643		d, err = lineDelta(delta, n1, len1)
1644		if err != "":
1645			return "", err
1646		n1 += d
1647		n2 += d
1648		lines[i] = "@@ -%d,%d +%d,%d @@\n" % (n1, len1, n2, len2)
1649
1650	newpatch = ''.join(lines)
1651	return newpatch, ""
1652
1653# fileDelta returns the line number deltas for the given file's
1654# changes from oldver to newver.
1655# The deltas are a list of (n, len, newdelta) triples that say
1656# lines [n, n+len) were modified, and after that range the
1657# line numbers are +newdelta from what they were before.
1658def fileDeltas(repo, file, oldver, newver):
1659	cmd = ["hg", "diff", "--git", "-r", oldver + ":" + newver, "path:" + file]
1660	data = RunShell(cmd, silent_ok=True)
1661	deltas = []
1662	for line in data.splitlines():
1663		m = re.match('@@ -([0-9]+),([0-9]+) \+([0-9]+),([0-9]+) @@', line)
1664		if not m:
1665			continue
1666		n1, len1, n2, len2 = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))
1667		deltas.append((n1, len1, n2+len2-(n1+len1)))
1668	return deltas
1669
1670# lineDelta finds the appropriate line number delta to apply to the lines [n, n+len).
1671# It returns an error if those lines were rewritten by the patch.
1672def lineDelta(deltas, n, len):
1673	d = 0
1674	for (old, oldlen, newdelta) in deltas:
1675		if old >= n+len:
1676			break
1677		if old+len > n:
1678			return 0, "patch and recent changes conflict"
1679		d = newdelta
1680	return d, ""
1681
1682@hgcommand
1683def download(ui, repo, clname, **opts):
1684	"""download a change from the code review server
1685
1686	Download prints a description of the given change list
1687	followed by its diff, downloaded from the code review server.
1688	"""
1689	if codereview_disabled:
1690		return codereview_disabled
1691
1692	cl, vers, patch, err = DownloadCL(ui, repo, clname)
1693	if err != "":
1694		return err
1695	ui.write(cl.EditorText() + "\n")
1696	ui.write(patch + "\n")
1697	return
1698
1699#######################################################################
1700# hg file
1701
1702@hgcommand
1703def file(ui, repo, clname, pat, *pats, **opts):
1704	"""assign files to or remove files from a change list
1705
1706	Assign files to or (with -d) remove files from a change list.
1707
1708	The -d option only removes files from the change list.
1709	It does not edit them or remove them from the repository.
1710	"""
1711	if codereview_disabled:
1712		return codereview_disabled
1713
1714	pats = tuple([pat] + list(pats))
1715	if not GoodCLName(clname):
1716		return "invalid CL name " + clname
1717
1718	dirty = {}
1719	cl, err = LoadCL(ui, repo, clname, web=False)
1720	if err != '':
1721		return err
1722	if not cl.local:
1723		return "cannot change non-local CL " + clname
1724
1725	files = ChangedFiles(ui, repo, pats)
1726
1727	if opts["delete"]:
1728		oldfiles = Intersect(files, cl.files)
1729		if oldfiles:
1730			if not ui.quiet:
1731				ui.status("# Removing files from CL.  To undo:\n")
1732				ui.status("#	cd %s\n" % (repo.root))
1733				for f in oldfiles:
1734					ui.status("#	hg file %s %s\n" % (cl.name, f))
1735			cl.files = Sub(cl.files, oldfiles)
1736			cl.Flush(ui, repo)
1737		else:
1738			ui.status("no such files in CL")
1739		return
1740
1741	if not files:
1742		return "no such modified files"
1743
1744	files = Sub(files, cl.files)
1745	taken = Taken(ui, repo)
1746	warned = False
1747	for f in files:
1748		if f in taken:
1749			if not warned and not ui.quiet:
1750				ui.status("# Taking files from other CLs.  To undo:\n")
1751				ui.status("#	cd %s\n" % (repo.root))
1752				warned = True
1753			ocl = taken[f]
1754			if not ui.quiet:
1755				ui.status("#	hg file %s %s\n" % (ocl.name, f))
1756			if ocl not in dirty:
1757				ocl.files = Sub(ocl.files, files)
1758				dirty[ocl] = True
1759	cl.files = Add(cl.files, files)
1760	dirty[cl] = True
1761	for d, _ in dirty.items():
1762		d.Flush(ui, repo)
1763	return
1764
1765#######################################################################
1766# hg gofmt
1767
1768@hgcommand
1769def gofmt(ui, repo, *pats, **opts):
1770	"""apply gofmt to modified files
1771
1772	Applies gofmt to the modified files in the repository that match
1773	the given patterns.
1774	"""
1775	if codereview_disabled:
1776		return codereview_disabled
1777
1778	files = ChangedExistingFiles(ui, repo, pats, opts)
1779	files = gofmt_required(files)
1780	if not files:
1781		return "no modified go files"
1782	cwd = os.getcwd()
1783	files = [RelativePath(repo.root + '/' + f, cwd) for f in files]
1784	try:
1785		cmd = ["gofmt", "-l"]
1786		if not opts["list"]:
1787			cmd += ["-w"]
1788		if os.spawnvp(os.P_WAIT, "gofmt", cmd + files) != 0:
1789			raise hg_util.Abort("gofmt did not exit cleanly")
1790	except hg_error.Abort, e:
1791		raise
1792	except:
1793		raise hg_util.Abort("gofmt: " + ExceptionDetail())
1794	return
1795
1796def gofmt_required(files):
1797	return [f for f in files if (not f.startswith('test/') or f.startswith('test/bench/')) and f.endswith('.go')]
1798
1799#######################################################################
1800# hg mail
1801
1802@hgcommand
1803def mail(ui, repo, *pats, **opts):
1804	"""mail a change for review
1805
1806	Uploads a patch to the code review server and then sends mail
1807	to the reviewer and CC list asking for a review.
1808	"""
1809	if codereview_disabled:
1810		return codereview_disabled
1811
1812	cl, err = CommandLineCL(ui, repo, pats, opts, defaultcc=defaultcc)
1813	if err != "":
1814		return err
1815	cl.Upload(ui, repo, gofmt_just_warn=True)
1816	if not cl.reviewer:
1817		# If no reviewer is listed, assign the review to defaultcc.
1818		# This makes sure that it appears in the
1819		# codereview.appspot.com/user/defaultcc
1820		# page, so that it doesn't get dropped on the floor.
1821		if not defaultcc:
1822			return "no reviewers listed in CL"
1823		cl.cc = Sub(cl.cc, defaultcc)
1824		cl.reviewer = defaultcc
1825		cl.Flush(ui, repo)
1826
1827	if cl.files == []:
1828		return "no changed files, not sending mail"
1829
1830	cl.Mail(ui, repo)
1831
1832#######################################################################
1833# hg p / hg pq / hg ps / hg pending
1834
1835@hgcommand
1836def ps(ui, repo, *pats, **opts):
1837	"""alias for hg p --short
1838	"""
1839	opts['short'] = True
1840	return pending(ui, repo, *pats, **opts)
1841
1842@hgcommand
1843def pq(ui, repo, *pats, **opts):
1844	"""alias for hg p --quick
1845	"""
1846	opts['quick'] = True
1847	return pending(ui, repo, *pats, **opts)
1848
1849@hgcommand
1850def pending(ui, repo, *pats, **opts):
1851	"""show pending changes
1852
1853	Lists pending changes followed by a list of unassigned but modified files.
1854	"""
1855	if codereview_disabled:
1856		return codereview_disabled
1857
1858	quick = opts.get('quick', False)
1859	short = opts.get('short', False)
1860	m = LoadAllCL(ui, repo, web=not quick and not short)
1861	names = m.keys()
1862	names.sort()
1863	for name in names:
1864		cl = m[name]
1865		if short:
1866			ui.write(name + "\t" + line1(cl.desc) + "\n")
1867		else:
1868			ui.write(cl.PendingText(quick=quick) + "\n")
1869
1870	if short:
1871		return
1872	files = DefaultFiles(ui, repo, [])
1873	if len(files) > 0:
1874		s = "Changed files not in any CL:\n"
1875		for f in files:
1876			s += "\t" + f + "\n"
1877		ui.write(s)
1878
1879#######################################################################
1880# hg submit
1881
1882def need_sync():
1883	raise hg_util.Abort("local repository out of date; must sync before submit")
1884
1885@hgcommand
1886def submit(ui, repo, *pats, **opts):
1887	"""submit change to remote repository
1888
1889	Submits change to remote repository.
1890	Bails out if the local repository is not in sync with the remote one.
1891	"""
1892	if codereview_disabled:
1893		return codereview_disabled
1894
1895	# We already called this on startup but sometimes Mercurial forgets.
1896	set_mercurial_encoding_to_utf8()
1897
1898	if not opts["no_incoming"] and hg_incoming(ui, repo):
1899		need_sync()
1900
1901	cl, err = CommandLineCL(ui, repo, pats, opts, defaultcc=defaultcc)
1902	if err != "":
1903		return err
1904
1905	user = None
1906	if cl.copied_from:
1907		user = cl.copied_from
1908	userline = CheckContributor(ui, repo, user)
1909	typecheck(userline, str)
1910
1911	about = ""
1912	if cl.reviewer:
1913		about += "R=" + JoinComma([CutDomain(s) for s in cl.reviewer]) + "\n"
1914	if opts.get('tbr'):
1915		tbr = SplitCommaSpace(opts.get('tbr'))
1916		cl.reviewer = Add(cl.reviewer, tbr)
1917		about += "TBR=" + JoinComma([CutDomain(s) for s in tbr]) + "\n"
1918	if cl.cc:
1919		about += "CC=" + JoinComma([CutDomain(s) for s in cl.cc]) + "\n"
1920
1921	if not cl.reviewer:
1922		return "no reviewers listed in CL"
1923
1924	if not cl.local:
1925		return "cannot submit non-local CL"
1926
1927	# upload, to sync current patch and also get change number if CL is new.
1928	if not cl.copied_from:
1929		cl.Upload(ui, repo, gofmt_just_warn=True)
1930
1931	# check gofmt for real; allowed upload to warn in order to save CL.
1932	cl.Flush(ui, repo)
1933	CheckFormat(ui, repo, cl.files)
1934
1935	about += "%s%s\n" % (server_url_base, cl.name)
1936
1937	if cl.copied_from:
1938		about += "\nCommitter: " + CheckContributor(ui, repo, None) + "\n"
1939	typecheck(about, str)
1940
1941	if not cl.mailed and not cl.copied_from:		# in case this is TBR
1942		cl.Mail(ui, repo)
1943
1944	# submit changes locally
1945	message = cl.desc.rstrip() + "\n\n" + about
1946	typecheck(message, str)
1947
1948	set_status("pushing " + cl.name + " to remote server")
1949
1950	if hg_outgoing(ui, repo):
1951		raise hg_util.Abort("local repository corrupt or out-of-phase with remote: found outgoing changes")
1952
1953	old_heads = len(hg_heads(ui, repo).split())
1954
1955	global commit_okay
1956	commit_okay = True
1957	ret = hg_commit(ui, repo, *['path:'+f for f in cl.files], message=message, user=userline)
1958	commit_okay = False
1959	if ret:
1960		return "nothing changed"
1961	node = repo["-1"].node()
1962	# push to remote; if it fails for any reason, roll back
1963	try:
1964		new_heads = len(hg_heads(ui, repo).split())
1965		if old_heads != new_heads and not (old_heads == 0 and new_heads == 1):
1966			# Created new head, so we weren't up to date.
1967			need_sync()
1968
1969		# Push changes to remote.  If it works, we're committed.  If not, roll back.
1970		try:
1971			hg_push(ui, repo)
1972		except hg_error.Abort, e:
1973			if e.message.find("push creates new heads") >= 0:
1974				# Remote repository had changes we missed.
1975				need_sync()
1976			raise
1977	except:
1978		real_rollback()
1979		raise
1980
1981	# We're committed. Upload final patch, close review, add commit message.
1982	changeURL = hg_node.short(node)
1983	url = ui.expandpath("default")
1984	m = re.match("(^https?://([^@/]+@)?([^.]+)\.googlecode\.com/hg/?)" + "|" +
1985		"(^https?://([^@/]+@)?code\.google\.com/p/([^/.]+)(\.[^./]+)?/?)", url)
1986	if m:
1987		if m.group(1): # prj.googlecode.com/hg/ case
1988			changeURL = "http://code.google.com/p/%s/source/detail?r=%s" % (m.group(3), changeURL)
1989		elif m.group(4) and m.group(7): # code.google.com/p/prj.subrepo/ case
1990			changeURL = "http://code.google.com/p/%s/source/detail?r=%s&repo=%s" % (m.group(6), changeURL, m.group(7)[1:])
1991		elif m.group(4): # code.google.com/p/prj/ case
1992			changeURL = "http://code.google.com/p/%s/source/detail?r=%s" % (m.group(6), changeURL)
1993		else:
1994			print >>sys.stderr, "URL: ", url
1995	else:
1996		print >>sys.stderr, "URL: ", url
1997	pmsg = "*** Submitted as " + changeURL + " ***\n\n" + message
1998
1999	# When posting, move reviewers to CC line,
2000	# so that the issue stops showing up in their "My Issues" page.
2001	PostMessage(ui, cl.name, pmsg, reviewers="", cc=JoinComma(cl.reviewer+cl.cc))
2002
2003	if not cl.copied_from:
2004		EditDesc(cl.name, closed=True, private=cl.private)
2005	cl.Delete(ui, repo)
2006
2007	c = repo[None]
2008	if c.branch() == releaseBranch and not c.modified() and not c.added() and not c.removed():
2009		ui.write("switching from %s to default branch.\n" % releaseBranch)
2010		err = hg_clean(repo, "default")
2011		if err:
2012			return err
2013	return None
2014
2015#######################################################################
2016# hg sync
2017
2018@hgcommand
2019def sync(ui, repo, **opts):
2020	"""synchronize with remote repository
2021
2022	Incorporates recent changes from the remote repository
2023	into the local repository.
2024	"""
2025	if codereview_disabled:
2026		return codereview_disabled
2027
2028	if not opts["local"]:
2029		err = hg_pull(ui, repo, update=True)
2030		if err:
2031			return err
2032	sync_changes(ui, repo)
2033
2034def sync_changes(ui, repo):
2035	# Look through recent change log descriptions to find
2036	# potential references to http://.*/our-CL-number.
2037	# Double-check them by looking at the Rietveld log.
2038	for rev in hg_log(ui, repo, limit=100, template="{node}\n").split():
2039		desc = repo[rev].description().strip()
2040		for clname in re.findall('(?m)^http://(?:[^\n]+)/([0-9]+)$', desc):
2041			if IsLocalCL(ui, repo, clname) and IsRietveldSubmitted(ui, clname, repo[rev].hex()):
2042				ui.warn("CL %s submitted as %s; closing\n" % (clname, repo[rev]))
2043				cl, err = LoadCL(ui, repo, clname, web=False)
2044				if err != "":
2045					ui.warn("loading CL %s: %s\n" % (clname, err))
2046					continue
2047				if not cl.copied_from:
2048					EditDesc(cl.name, closed=True, private=cl.private)
2049				cl.Delete(ui, repo)
2050
2051	# Remove files that are not modified from the CLs in which they appear.
2052	all = LoadAllCL(ui, repo, web=False)
2053	changed = ChangedFiles(ui, repo, [])
2054	for cl in all.values():
2055		extra = Sub(cl.files, changed)
2056		if extra:
2057			ui.warn("Removing unmodified files from CL %s:\n" % (cl.name,))
2058			for f in extra:
2059				ui.warn("\t%s\n" % (f,))
2060			cl.files = Sub(cl.files, extra)
2061			cl.Flush(ui, repo)
2062		if not cl.files:
2063			if not cl.copied_from:
2064				ui.warn("CL %s has no files; delete (abandon) with hg change -d %s\n" % (cl.name, cl.name))
2065			else:
2066				ui.warn("CL %s has no files; delete locally with hg change -D %s\n" % (cl.name, cl.name))
2067	return
2068
2069#######################################################################
2070# hg upload
2071
2072@hgcommand
2073def upload(ui, repo, name, **opts):
2074	"""upload diffs to the code review server
2075
2076	Uploads the current modifications for a given change to the server.
2077	"""
2078	if codereview_disabled:
2079		return codereview_disabled
2080
2081	repo.ui.quiet = True
2082	cl, err = LoadCL(ui, repo, name, web=True)
2083	if err != "":
2084		return err
2085	if not cl.local:
2086		return "cannot upload non-local change"
2087	cl.Upload(ui, repo)
2088	print "%s%s\n" % (server_url_base, cl.name)
2089	return
2090
2091#######################################################################
2092# Table of commands, supplied to Mercurial for installation.
2093
2094review_opts = [
2095	('r', 'reviewer', '', 'add reviewer'),
2096	('', 'cc', '', 'add cc'),
2097	('', 'tbr', '', 'add future reviewer'),
2098	('m', 'message', '', 'change description (for new change)'),
2099]
2100
2101cmdtable = {
2102	# The ^ means to show this command in the help text that
2103	# is printed when running hg with no arguments.
2104	"^change": (
2105		change,
2106		[
2107			('d', 'delete', None, 'delete existing change list'),
2108			('D', 'deletelocal', None, 'delete locally, but do not change CL on server'),
2109			('i', 'stdin', None, 'read change list from standard input'),
2110			('o', 'stdout', None, 'print change list to standard output'),
2111			('p', 'pending', None, 'print pending summary to standard output'),
2112		],
2113		"[-d | -D] [-i] [-o] change# or FILE ..."
2114	),
2115	"^clpatch": (
2116		clpatch,
2117		[
2118			('', 'ignore_hgpatch_failure', None, 'create CL metadata even if hgpatch fails'),
2119			('', 'no_incoming', None, 'disable check for incoming changes'),
2120		],
2121		"change#"
2122	),
2123	# Would prefer to call this codereview-login, but then
2124	# hg help codereview prints the help for this command
2125	# instead of the help for the extension.
2126	"code-login": (
2127		code_login,
2128		[],
2129		"",
2130	),
2131	"^download": (
2132		download,
2133		[],
2134		"change#"
2135	),
2136	"^file": (
2137		file,
2138		[
2139			('d', 'delete', None, 'delete files from change list (but not repository)'),
2140		],
2141		"[-d] change# FILE ..."
2142	),
2143	"^gofmt": (
2144		gofmt,
2145		[
2146			('l', 'list', None, 'list files that would change, but do not edit them'),
2147		],
2148		"FILE ..."
2149	),
2150	"^pending|p": (
2151		pending,
2152		[
2153			('s', 'short', False, 'show short result form'),
2154			('', 'quick', False, 'do not consult codereview server'),
2155		],
2156		"[FILE ...]"
2157	),
2158	"^ps": (
2159		ps,
2160		[],
2161		"[FILE ...]"
2162	),
2163	"^pq": (
2164		pq,
2165		[],
2166		"[FILE ...]"
2167	),
2168	"^mail": (
2169		mail,
2170		review_opts + [
2171		] + hg_commands.walkopts,
2172		"[-r reviewer] [--cc cc] [change# | file ...]"
2173	),
2174	"^release-apply": (
2175		release_apply,
2176		[
2177			('', 'ignore_hgpatch_failure', None, 'create CL metadata even if hgpatch fails'),
2178			('', 'no_incoming', None, 'disable check for incoming changes'),
2179		],
2180		"change#"
2181	),
2182	# TODO: release-start, release-tag, weekly-tag
2183	"^submit": (
2184		submit,
2185		review_opts + [
2186			('', 'no_incoming', None, 'disable initial incoming check (for testing)'),
2187		] + hg_commands.walkopts + hg_commands.commitopts + hg_commands.commitopts2,
2188		"[-r reviewer] [--cc cc] [change# | file ...]"
2189	),
2190	"^sync": (
2191		sync,
2192		[
2193			('', 'local', None, 'do not pull changes from remote repository')
2194		],
2195		"[--local]",
2196	),
2197	"^undo": (
2198		undo,
2199		[
2200			('', 'ignore_hgpatch_failure', None, 'create CL metadata even if hgpatch fails'),
2201			('', 'no_incoming', None, 'disable check for incoming changes'),
2202		],
2203		"change#"
2204	),
2205	"^upload": (
2206		upload,
2207		[],
2208		"change#"
2209	),
2210}
2211
2212#######################################################################
2213# Mercurial extension initialization
2214
2215def norollback(*pats, **opts):
2216	"""(disabled when using this extension)"""
2217	raise hg_util.Abort("codereview extension enabled; use undo instead of rollback")
2218
2219codereview_init = False
2220
2221def reposetup(ui, repo):
2222	global codereview_disabled
2223	global defaultcc
2224
2225	# reposetup gets called both for the local repository
2226	# and also for any repository we are pulling or pushing to.
2227	# Only initialize the first time.
2228	global codereview_init
2229	if codereview_init:
2230		return
2231	codereview_init = True
2232
2233	# Read repository-specific options from lib/codereview/codereview.cfg or codereview.cfg.
2234	root = ''
2235	try:
2236		root = repo.root
2237	except:
2238		# Yes, repo might not have root; see issue 959.
2239		codereview_disabled = 'codereview disabled: repository has no root'
2240		return
2241
2242	repo_config_path = ''
2243	p1 = root + '/lib/codereview/codereview.cfg'
2244	p2 = root + '/codereview.cfg'
2245	if os.access(p1, os.F_OK):
2246		repo_config_path = p1
2247	else:
2248		repo_config_path = p2
2249	try:
2250		f = open(repo_config_path)
2251		for line in f:
2252			if line.startswith('defaultcc:'):
2253				defaultcc = SplitCommaSpace(line[len('defaultcc:'):])
2254			if line.startswith('contributors:'):
2255				global contributorsURL
2256				contributorsURL = line[len('contributors:'):].strip()
2257	except:
2258		codereview_disabled = 'codereview disabled: cannot open ' + repo_config_path
2259		return
2260
2261	remote = ui.config("paths", "default", "")
2262	if remote.find("://") < 0:
2263		raise hg_util.Abort("codereview: default path '%s' is not a URL" % (remote,))
2264
2265	InstallMatch(ui, repo)
2266	RietveldSetup(ui, repo)
2267
2268	# Disable the Mercurial commands that might change the repository.
2269	# Only commands in this extension are supposed to do that.
2270	ui.setconfig("hooks", "precommit.codereview", precommithook)
2271
2272	# Rollback removes an existing commit.  Don't do that either.
2273	global real_rollback
2274	real_rollback = repo.rollback
2275	repo.rollback = norollback
2276
2277
2278#######################################################################
2279# Wrappers around upload.py for interacting with Rietveld
2280
2281from HTMLParser import HTMLParser
2282
2283# HTML form parser
2284class FormParser(HTMLParser):
2285	def __init__(self):
2286		self.map = {}
2287		self.curtag = None
2288		self.curdata = None
2289		HTMLParser.__init__(self)
2290	def handle_starttag(self, tag, attrs):
2291		if tag == "input":
2292			key = None
2293			value = ''
2294			for a in attrs:
2295				if a[0] == 'name':
2296					key = a[1]
2297				if a[0] == 'value':
2298					value = a[1]
2299			if key is not None:
2300				self.map[key] = value
2301		if tag == "textarea":
2302			key = None
2303			for a in attrs:
2304				if a[0] == 'name':
2305					key = a[1]
2306			if key is not None:
2307				self.curtag = key
2308				self.curdata = ''
2309	def handle_endtag(self, tag):
2310		if tag == "textarea" and self.curtag is not None:
2311			self.map[self.curtag] = self.curdata
2312			self.curtag = None
2313			self.curdata = None
2314	def handle_charref(self, name):
2315		self.handle_data(unichr(int(name)))
2316	def handle_entityref(self, name):
2317		import htmlentitydefs
2318		if name in htmlentitydefs.entitydefs:
2319			self.handle_data(htmlentitydefs.entitydefs[name])
2320		else:
2321			self.handle_data("&" + name + ";")
2322	def handle_data(self, data):
2323		if self.curdata is not None:
2324			self.curdata += data
2325
2326def JSONGet(ui, path):
2327	try:
2328		data = MySend(path, force_auth=False)
2329		typecheck(data, str)
2330		d = fix_json(json.loads(data))
2331	except:
2332		ui.warn("JSONGet %s: %s\n" % (path, ExceptionDetail()))
2333		return None
2334	return d
2335
2336# Clean up json parser output to match our expectations:
2337#   * all strings are UTF-8-encoded str, not unicode.
2338#   * missing fields are missing, not None,
2339#     so that d.get("foo", defaultvalue) works.
2340def fix_json(x):
2341	if type(x) in [str, int, float, bool, type(None)]:
2342		pass
2343	elif type(x) is unicode:
2344		x = x.encode("utf-8")
2345	elif type(x) is list:
2346		for i in range(len(x)):
2347			x[i] = fix_json(x[i])
2348	elif type(x) is dict:
2349		todel = []
2350		for k in x:
2351			if x[k] is None:
2352				todel.append(k)
2353			else:
2354				x[k] = fix_json(x[k])
2355		for k in todel:
2356			del x[k]
2357	else:
2358		raise hg_util.Abort("unknown type " + str(type(x)) + " in fix_json")
2359	if type(x) is str:
2360		x = x.replace('\r\n', '\n')
2361	return x
2362
2363def IsRietveldSubmitted(ui, clname, hex):
2364	dict = JSONGet(ui, "/api/" + clname + "?messages=true")
2365	if dict is None:
2366		return False
2367	for msg in dict.get("messages", []):
2368		text = msg.get("text", "")
2369		m = re.match('\*\*\* Submitted as [^*]*?([0-9a-f]+) \*\*\*', text)
2370		if m is not None and len(m.group(1)) >= 8 and hex.startswith(m.group(1)):
2371			return True
2372	return False
2373
2374def IsRietveldMailed(cl):
2375	for msg in cl.dict.get("messages", []):
2376		if msg.get("text", "").find("I'd like you to review this change") >= 0:
2377			return True
2378	return False
2379
2380def DownloadCL(ui, repo, clname):
2381	set_status("downloading CL " + clname)
2382	cl, err = LoadCL(ui, repo, clname, web=True)
2383	if err != "":
2384		return None, None, None, "error loading CL %s: %s" % (clname, err)
2385
2386	# Find most recent diff
2387	diffs = cl.dict.get("patchsets", [])
2388	if not diffs:
2389		return None, None, None, "CL has no patch sets"
2390	patchid = diffs[-1]
2391
2392	patchset = JSONGet(ui, "/api/" + clname + "/" + str(patchid))
2393	if patchset is None:
2394		return None, None, None, "error loading CL patchset %s/%d" % (clname, patchid)
2395	if patchset.get("patchset", 0) != patchid:
2396		return None, None, None, "malformed patchset information"
2397
2398	vers = ""
2399	msg = patchset.get("message", "").split()
2400	if len(msg) >= 3 and msg[0] == "diff" and msg[1] == "-r":
2401		vers = msg[2]
2402	diff = "/download/issue" + clname + "_" + str(patchid) + ".diff"
2403
2404	diffdata = MySend(diff, force_auth=False)
2405
2406	# Print warning if email is not in CONTRIBUTORS file.
2407	email = cl.dict.get("owner_email", "")
2408	if not email:
2409		return None, None, None, "cannot find owner for %s" % (clname)
2410	him = FindContributor(ui, repo, email)
2411	me = FindContributor(ui, repo, None)
2412	if him == me:
2413		cl.mailed = IsRietveldMailed(cl)
2414	else:
2415		cl.copied_from = email
2416
2417	return cl, vers, diffdata, ""
2418
2419def MySend(request_path, payload=None,
2420		content_type="application/octet-stream",
2421		timeout=None, force_auth=True,
2422		**kwargs):
2423	"""Run MySend1 maybe twice, because Rietveld is unreliable."""
2424	try:
2425		return MySend1(request_path, payload, content_type, timeout, force_auth, **kwargs)
2426	except Exception, e:
2427		if type(e) != urllib2.HTTPError or e.code != 500:	# only retry on HTTP 500 error
2428			raise
2429		print >>sys.stderr, "Loading "+request_path+": "+ExceptionDetail()+"; trying again in 2 seconds."
2430		time.sleep(2)
2431		return MySend1(request_path, payload, content_type, timeout, force_auth, **kwargs)
2432
2433# Like upload.py Send but only authenticates when the
2434# redirect is to www.google.com/accounts.  This keeps
2435# unnecessary redirects from happening during testing.
2436def MySend1(request_path, payload=None,
2437				content_type="application/octet-stream",
2438				timeout=None, force_auth=True,
2439				**kwargs):
2440	"""Sends an RPC and returns the response.
2441
2442	Args:
2443		request_path: The path to send the request to, eg /api/appversion/create.
2444		payload: The body of the request, or None to send an empty request.
2445		content_type: The Content-Type header to use.
2446		timeout: timeout in seconds; default None i.e. no timeout.
2447			(Note: for large requests on OS X, the timeout doesn't work right.)
2448		kwargs: Any keyword arguments are converted into query string parameters.
2449
2450	Returns:
2451		The response body, as a string.
2452	"""
2453	# TODO: Don't require authentication.  Let the server say
2454	# whether it is necessary.
2455	global rpc
2456	if rpc == None:
2457		rpc = GetRpcServer(upload_options)
2458	self = rpc
2459	if not self.authenticated and force_auth:
2460		self._Authenticate()
2461	if request_path is None:
2462		return
2463
2464	old_timeout = socket.getdefaulttimeout()
2465	socket.setdefaulttimeout(timeout)
2466	try:
2467		tries = 0
2468		while True:
2469			tries += 1
2470			args = dict(kwargs)
2471			url = "http://%s%s" % (self.host, request_path)
2472			if args:
2473				url += "?" + urllib.urlencode(args)
2474			req = self._CreateRequest(url=url, data=payload)
2475			req.add_header("Content-Type", content_type)
2476			try:
2477				f = self.opener.open(req)
2478				response = f.read()
2479				f.close()
2480				# Translate \r\n into \n, because Rietveld doesn't.
2481				response = response.replace('\r\n', '\n')
2482				# who knows what urllib will give us
2483				if type(response) == unicode:
2484					response = response.encode("utf-8")
2485				typecheck(response, str)
2486				return response
2487			except urllib2.HTTPError, e:
2488				if tries > 3:
2489					raise
2490				elif e.code == 401:
2491					self._Authenticate()
2492				elif e.code == 302:
2493					loc = e.info()["location"]
2494					if not loc.startswith('https://www.google.com/a') or loc.find('/ServiceLogin') < 0:
2495						return ''
2496					self._Authenticate()
2497				else:
2498					raise
2499	finally:
2500		socket.setdefaulttimeout(old_timeout)
2501
2502def GetForm(url):
2503	f = FormParser()
2504	f.feed(ustr(MySend(url)))	# f.feed wants unicode
2505	f.close()
2506	# convert back to utf-8 to restore sanity
2507	m = {}
2508	for k,v in f.map.items():
2509		m[k.encode("utf-8")] = v.replace("\r\n", "\n").encode("utf-8")
2510	return m
2511
2512def EditDesc(issue, subject=None, desc=None, reviewers=None, cc=None, closed=False, private=False):
2513	set_status("uploading change to description")
2514	form_fields = GetForm("/" + issue + "/edit")
2515	if subject is not None:
2516		form_fields['subject'] = subject
2517	if desc is not None:
2518		form_fields['description'] = desc
2519	if reviewers is not None:
2520		form_fields['reviewers'] = reviewers
2521	if cc is not None:
2522		form_fields['cc'] = cc
2523	if closed:
2524		form_fields['closed'] = "checked"
2525	if private:
2526		form_fields['private'] = "checked"
2527	ctype, body = EncodeMultipartFormData(form_fields.items(), [])
2528	response = MySend("/" + issue + "/edit", body, content_type=ctype)
2529	if response != "":
2530		print >>sys.stderr, "Error editing description:\n" + "Sent form: \n", form_fields, "\n", response
2531		sys.exit(2)
2532
2533def PostMessage(ui, issue, message, reviewers=None, cc=None, send_mail=True, subject=None):
2534	set_status("uploading message")
2535	form_fields = GetForm("/" + issue + "/publish")
2536	if reviewers is not None:
2537		form_fields['reviewers'] = reviewers
2538	if cc is not None:
2539		form_fields['cc'] = cc
2540	if send_mail:
2541		form_fields['send_mail'] = "checked"
2542	else:
2543		del form_fields['send_mail']
2544	if subject is not None:
2545		form_fields['subject'] = subject
2546	form_fields['message'] = message
2547
2548	form_fields['message_only'] = '1'	# Don't include draft comments
2549	if reviewers is not None or cc is not None:
2550		form_fields['message_only'] = ''	# Must set '' in order to override cc/reviewer
2551	ctype = "applications/x-www-form-urlencoded"
2552	body = urllib.urlencode(form_fields)
2553	response = MySend("/" + issue + "/publish", body, content_type=ctype)
2554	if response != "":
2555		print response
2556		sys.exit(2)
2557
2558class opt(object):
2559	pass
2560
2561def RietveldSetup(ui, repo):
2562	global force_google_account
2563	global rpc
2564	global server
2565	global server_url_base
2566	global upload_options
2567	global verbosity
2568
2569	if not ui.verbose:
2570		verbosity = 0
2571
2572	# Config options.
2573	x = ui.config("codereview", "server")
2574	if x is not None:
2575		server = x
2576
2577	# TODO(rsc): Take from ui.username?
2578	email = None
2579	x = ui.config("codereview", "email")
2580	if x is not None:
2581		email = x
2582
2583	server_url_base = "http://" + server + "/"
2584
2585	testing = ui.config("codereview", "testing")
2586	force_google_account = ui.configbool("codereview", "force_google_account", False)
2587
2588	upload_options = opt()
2589	upload_options.email = email
2590	upload_options.host = None
2591	upload_options.verbose = 0
2592	upload_options.description = None
2593	upload_options.description_file = None
2594	upload_options.reviewers = None
2595	upload_options.cc = None
2596	upload_options.message = None
2597	upload_options.issue = None
2598	upload_options.download_base = False
2599	upload_options.revision = None
2600	upload_options.send_mail = False
2601	upload_options.vcs = None
2602	upload_options.server = server
2603	upload_options.save_cookies = True
2604
2605	if testing:
2606		upload_options.save_cookies = False
2607		upload_options.email = "test@example.com"
2608
2609	rpc = None
2610
2611	global releaseBranch
2612	tags = repo.branchtags().keys()
2613	if 'release-branch.go10' in tags:
2614		# NOTE(rsc): This tags.sort is going to get the wrong
2615		# answer when comparing release-branch.go9 with
2616		# release-branch.go10.  It will be a while before we care.
2617		raise hg_util.Abort('tags.sort needs to be fixed for release-branch.go10')
2618	tags.sort()
2619	for t in tags:
2620		if t.startswith('release-branch.go'):
2621			releaseBranch = t
2622
2623#######################################################################
2624# http://codereview.appspot.com/static/upload.py, heavily edited.
2625
2626#!/usr/bin/env python
2627#
2628# Copyright 2007 Google Inc.
2629#
2630# Licensed under the Apache License, Version 2.0 (the "License");
2631# you may not use this file except in compliance with the License.
2632# You may obtain a copy of the License at
2633#
2634#	http://www.apache.org/licenses/LICENSE-2.0
2635#
2636# Unless required by applicable law or agreed to in writing, software
2637# distributed under the License is distributed on an "AS IS" BASIS,
2638# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
2639# See the License for the specific language governing permissions and
2640# limitations under the License.
2641
2642"""Tool for uploading diffs from a version control system to the codereview app.
2643
2644Usage summary: upload.py [options] [-- diff_options]
2645
2646Diff options are passed to the diff command of the underlying system.
2647
2648Supported version control systems:
2649	Git
2650	Mercurial
2651	Subversion
2652
2653It is important for Git/Mercurial users to specify a tree/node/branch to diff
2654against by using the '--rev' option.
2655"""
2656# This code is derived from appcfg.py in the App Engine SDK (open source),
2657# and from ASPN recipe #146306.
2658
2659import cookielib
2660import getpass
2661import logging
2662import mimetypes
2663import optparse
2664import os
2665import re
2666import socket
2667import subprocess
2668import sys
2669import urllib
2670import urllib2
2671import urlparse
2672
2673# The md5 module was deprecated in Python 2.5.
2674try:
2675	from hashlib import md5
2676except ImportError:
2677	from md5 import md5
2678
2679try:
2680	import readline
2681except ImportError:
2682	pass
2683
2684# The logging verbosity:
2685#  0: Errors only.
2686#  1: Status messages.
2687#  2: Info logs.
2688#  3: Debug logs.
2689verbosity = 1
2690
2691# Max size of patch or base file.
2692MAX_UPLOAD_SIZE = 900 * 1024
2693
2694# whitelist for non-binary filetypes which do not start with "text/"
2695# .mm (Objective-C) shows up as application/x-freemind on my Linux box.
2696TEXT_MIMETYPES = [
2697	'application/javascript',
2698	'application/x-javascript',
2699	'application/x-freemind'
2700]
2701
2702def GetEmail(prompt):
2703	"""Prompts the user for their email address and returns it.
2704
2705	The last used email address is saved to a file and offered up as a suggestion
2706	to the user. If the user presses enter without typing in anything the last
2707	used email address is used. If the user enters a new address, it is saved
2708	for next time we prompt.
2709
2710	"""
2711	last_email_file_name = os.path.expanduser("~/.last_codereview_email_address")
2712	last_email = ""
2713	if os.path.exists(last_email_file_name):
2714		try:
2715			last_email_file = open(last_email_file_name, "r")
2716			last_email = last_email_file.readline().strip("\n")
2717			last_email_file.close()
2718			prompt += " [%s]" % last_email
2719		except IOError, e:
2720			pass
2721	email = raw_input(prompt + ": ").strip()
2722	if email:
2723		try:
2724			last_email_file = open(last_email_file_name, "w")
2725			last_email_file.write(email)
2726			last_email_file.close()
2727		except IOError, e:
2728			pass
2729	else:
2730		email = last_email
2731	return email
2732
2733
2734def StatusUpdate(msg):
2735	"""Print a status message to stdout.
2736
2737	If 'verbosity' is greater than 0, print the message.
2738
2739	Args:
2740		msg: The string to print.
2741	"""
2742	if verbosity > 0:
2743		print msg
2744
2745
2746def ErrorExit(msg):
2747	"""Print an error message to stderr and exit."""
2748	print >>sys.stderr, msg
2749	sys.exit(1)
2750
2751
2752class ClientLoginError(urllib2.HTTPError):
2753	"""Raised to indicate there was an error authenticating with ClientLogin."""
2754
2755	def __init__(self, url, code, msg, headers, args):
2756		urllib2.HTTPError.__init__(self, url, code, msg, headers, None)
2757		self.args = args
2758		self.reason = args["Error"]
2759
2760
2761class AbstractRpcServer(object):
2762	"""Provides a common interface for a simple RPC server."""
2763
2764	def __init__(self, host, auth_function, host_override=None, extra_headers={}, save_cookies=False):
2765		"""Creates a new HttpRpcServer.
2766
2767		Args:
2768			host: The host to send requests to.
2769			auth_function: A function that takes no arguments and returns an
2770				(email, password) tuple when called. Will be called if authentication
2771				is required.
2772			host_override: The host header to send to the server (defaults to host).
2773			extra_headers: A dict of extra headers to append to every request.
2774			save_cookies: If True, save the authentication cookies to local disk.
2775				If False, use an in-memory cookiejar instead.  Subclasses must
2776				implement this functionality.  Defaults to False.
2777		"""
2778		self.host = host
2779		self.host_override = host_override
2780		self.auth_function = auth_function
2781		self.authenticated = False
2782		self.extra_headers = extra_headers
2783		self.save_cookies = save_cookies
2784		self.opener = self._GetOpener()
2785		if self.host_override:
2786			logging.info("Server: %s; Host: %s", self.host, self.host_override)
2787		else:
2788			logging.info("Server: %s", self.host)
2789
2790	def _GetOpener(self):
2791		"""Returns an OpenerDirector for making HTTP requests.
2792
2793		Returns:
2794			A urllib2.OpenerDirector object.
2795		"""
2796		raise NotImplementedError()
2797
2798	def _CreateRequest(self, url, data=None):
2799		"""Creates a new urllib request."""
2800		logging.debug("Creating request for: '%s' with payload:\n%s", url, data)
2801		req = urllib2.Request(url, data=data)
2802		if self.host_override:
2803			req.add_header("Host", self.host_override)
2804		for key, value in self.extra_headers.iteritems():
2805			req.add_header(key, value)
2806		return req
2807
2808	def _GetAuthToken(self, email, password):
2809		"""Uses ClientLogin to authenticate the user, returning an auth token.
2810
2811		Args:
2812			email:    The user's email address
2813			password: The user's password
2814
2815		Raises:
2816			ClientLoginError: If there was an error authenticating with ClientLogin.
2817			HTTPError: If there was some other form of HTTP error.
2818
2819		Returns:
2820			The authentication token returned by ClientLogin.
2821		"""
2822		account_type = "GOOGLE"
2823		if self.host.endswith(".google.com") and not force_google_account:
2824			# Needed for use inside Google.
2825			account_type = "HOSTED"
2826		req = self._CreateRequest(
2827				url="https://www.google.com/accounts/ClientLogin",
2828				data=urllib.urlencode({
2829						"Email": email,
2830						"Passwd": password,
2831						"service": "ah",
2832						"source": "rietveld-codereview-upload",
2833						"accountType": account_type,
2834				}),
2835		)
2836		try:
2837			response = self.opener.open(req)
2838			response_body = response.read()
2839			response_dict = dict(x.split("=") for x in response_body.split("\n") if x)
2840			return response_dict["Auth"]
2841		except urllib2.HTTPError, e:
2842			if e.code == 403:
2843				body = e.read()
2844				response_dict = dict(x.split("=", 1) for x in body.split("\n") if x)
2845				raise ClientLoginError(req.get_full_url(), e.code, e.msg, e.headers, response_dict)
2846			else:
2847				raise
2848
2849	def _GetAuthCookie(self, auth_token):
2850		"""Fetches authentication cookies for an authentication token.
2851
2852		Args:
2853			auth_token: The authentication token returned by ClientLogin.
2854
2855		Raises:
2856			HTTPError: If there was an error fetching the authentication cookies.
2857		"""
2858		# This is a dummy value to allow us to identify when we're successful.
2859		continue_location = "http://localhost/"
2860		args = {"continue": continue_location, "auth": auth_token}
2861		req = self._CreateRequest("http://%s/_ah/login?%s" % (self.host, urllib.urlencode(args)))
2862		try:
2863			response = self.opener.open(req)
2864		except urllib2.HTTPError, e:
2865			response = e
2866		if (response.code != 302 or
2867				response.info()["location"] != continue_location):
2868			raise urllib2.HTTPError(req.get_full_url(), response.code, response.msg, response.headers, response.fp)
2869		self.authenticated = True
2870
2871	def _Authenticate(self):
2872		"""Authenticates the user.
2873
2874		The authentication process works as follows:
2875		1) We get a username and password from the user
2876		2) We use ClientLogin to obtain an AUTH token for the user
2877				(see http://code.google.com/apis/accounts/AuthForInstalledApps.html).
2878		3) We pass the auth token to /_ah/login on the server to obtain an
2879				authentication cookie. If login was successful, it tries to redirect
2880				us to the URL we provided.
2881
2882		If we attempt to access the upload API without first obtaining an
2883		authentication cookie, it returns a 401 response (or a 302) and
2884		directs us to authenticate ourselves with ClientLogin.
2885		"""
2886		for i in range(3):
2887			credentials = self.auth_function()
2888			try:
2889				auth_token = self._GetAuthToken(credentials[0], credentials[1])
2890			except ClientLoginError, e:
2891				if e.reason == "BadAuthentication":
2892					print >>sys.stderr, "Invalid username or password."
2893					continue
2894				if e.reason == "CaptchaRequired":
2895					print >>sys.stderr, (
2896						"Please go to\n"
2897						"https://www.google.com/accounts/DisplayUnlockCaptcha\n"
2898						"and verify you are a human.  Then try again.")
2899					break
2900				if e.reason == "NotVerified":
2901					print >>sys.stderr, "Account not verified."
2902					break
2903				if e.reason == "TermsNotAgreed":
2904					print >>sys.stderr, "User has not agreed to TOS."
2905					break
2906				if e.reason == "AccountDeleted":
2907					print >>sys.stderr, "The user account has been deleted."
2908					break
2909				if e.reason == "AccountDisabled":
2910					print >>sys.stderr, "The user account has been disabled."
2911					break
2912				if e.reason == "ServiceDisabled":
2913					print >>sys.stderr, "The user's access to the service has been disabled."
2914					break
2915				if e.reason == "ServiceUnavailable":
2916					print >>sys.stderr, "The service is not available; try again later."
2917					break
2918				raise
2919			self._GetAuthCookie(auth_token)
2920			return
2921
2922	def Send(self, request_path, payload=None,
2923					content_type="application/octet-stream",
2924					timeout=None,
2925					**kwargs):
2926		"""Sends an RPC and returns the response.
2927
2928		Args:
2929			request_path: The path to send the request to, eg /api/appversion/create.
2930			payload: The body of the request, or None to send an empty request.
2931			content_type: The Content-Type header to use.
2932			timeout: timeout in seconds; default None i.e. no timeout.
2933				(Note: for large requests on OS X, the timeout doesn't work right.)
2934			kwargs: Any keyword arguments are converted into query string parameters.
2935
2936		Returns:
2937			The response body, as a string.
2938		"""
2939		# TODO: Don't require authentication.  Let the server say
2940		# whether it is necessary.
2941		if not self.authenticated:
2942			self._Authenticate()
2943
2944		old_timeout = socket.getdefaulttimeout()
2945		socket.setdefaulttimeout(timeout)
2946		try:
2947			tries = 0
2948			while True:
2949				tries += 1
2950				args = dict(kwargs)
2951				url = "http://%s%s" % (self.host, request_path)
2952				if args:
2953					url += "?" + urllib.urlencode(args)
2954				req = self._CreateRequest(url=url, data=payload)
2955				req.add_header("Content-Type", content_type)
2956				try:
2957					f = self.opener.open(req)
2958					response = f.read()
2959					f.close()
2960					return response
2961				except urllib2.HTTPError, e:
2962					if tries > 3:
2963						raise
2964					elif e.code == 401 or e.code == 302:
2965						self._Authenticate()
2966					else:
2967						raise
2968		finally:
2969			socket.setdefaulttimeout(old_timeout)
2970
2971
2972class HttpRpcServer(AbstractRpcServer):
2973	"""Provides a simplified RPC-style interface for HTTP requests."""
2974
2975	def _Authenticate(self):
2976		"""Save the cookie jar after authentication."""
2977		super(HttpRpcServer, self)._Authenticate()
2978		if self.save_cookies:
2979			StatusUpdate("Saving authentication cookies to %s" % self.cookie_file)
2980			self.cookie_jar.save()
2981
2982	def _GetOpener(self):
2983		"""Returns an OpenerDirector that supports cookies and ignores redirects.
2984
2985		Returns:
2986			A urllib2.OpenerDirector object.
2987		"""
2988		opener = urllib2.OpenerDirector()
2989		opener.add_handler(urllib2.ProxyHandler())
2990		opener.add_handler(urllib2.UnknownHandler())
2991		opener.add_handler(urllib2.HTTPHandler())
2992		opener.add_handler(urllib2.HTTPDefaultErrorHandler())
2993		opener.add_handler(urllib2.HTTPSHandler())
2994		opener.add_handler(urllib2.HTTPErrorProcessor())
2995		if self.save_cookies:
2996			self.cookie_file = os.path.expanduser("~/.codereview_upload_cookies_" + server)
2997			self.cookie_jar = cookielib.MozillaCookieJar(self.cookie_file)
2998			if os.path.exists(self.cookie_file):
2999				try:
3000					self.cookie_jar.load()
3001					self.authenticated = True
3002					StatusUpdate("Loaded authentication cookies from %s" % self.cookie_file)
3003				except (cookielib.LoadError, IOError):
3004					# Failed to load cookies - just ignore them.
3005					pass
3006			else:
3007				# Create an empty cookie file with mode 600
3008				fd = os.open(self.cookie_file, os.O_CREAT, 0600)
3009				os.close(fd)
3010			# Always chmod the cookie file
3011			os.chmod(self.cookie_file, 0600)
3012		else:
3013			# Don't save cookies across runs of update.py.
3014			self.cookie_jar = cookielib.CookieJar()
3015		opener.add_handler(urllib2.HTTPCookieProcessor(self.cookie_jar))
3016		return opener
3017
3018
3019def GetRpcServer(options):
3020	"""Returns an instance of an AbstractRpcServer.
3021
3022	Returns:
3023		A new AbstractRpcServer, on which RPC calls can be made.
3024	"""
3025
3026	rpc_server_class = HttpRpcServer
3027
3028	def GetUserCredentials():
3029		"""Prompts the user for a username and password."""
3030		# Disable status prints so they don't obscure the password prompt.
3031		global global_status
3032		st = global_status
3033		global_status = None
3034
3035		email = options.email
3036		if email is None:
3037			email = GetEmail("Email (login for uploading to %s)" % options.server)
3038		password = getpass.getpass("Password for %s: " % email)
3039
3040		# Put status back.
3041		global_status = st
3042		return (email, password)
3043
3044	# If this is the dev_appserver, use fake authentication.
3045	host = (options.host or options.server).lower()
3046	if host == "localhost" or host.startswith("localhost:"):
3047		email = options.email
3048		if email is None:
3049			email = "test@example.com"
3050			logging.info("Using debug user %s.  Override with --email" % email)
3051		server = rpc_server_class(
3052				options.server,
3053				lambda: (email, "password"),
3054				host_override=options.host,
3055				extra_headers={"Cookie": 'dev_appserver_login="%s:False"' % email},
3056				save_cookies=options.save_cookies)
3057		# Don't try to talk to ClientLogin.
3058		server.authenticated = True
3059		return server
3060
3061	return rpc_server_class(options.server, GetUserCredentials,
3062		host_override=options.host, save_cookies=options.save_cookies)
3063
3064
3065def EncodeMultipartFormData(fields, files):
3066	"""Encode form fields for multipart/form-data.
3067
3068	Args:
3069		fields: A sequence of (name, value) elements for regular form fields.
3070		files: A sequence of (name, filename, value) elements for data to be
3071					uploaded as files.
3072	Returns:
3073		(content_type, body) ready for httplib.HTTP instance.
3074
3075	Source:
3076		http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/146306
3077	"""
3078	BOUNDARY = '-M-A-G-I-C---B-O-U-N-D-A-R-Y-'
3079	CRLF = '\r\n'
3080	lines = []
3081	for (key, value) in fields:
3082		typecheck(key, str)
3083		typecheck(value, str)
3084		lines.append('--' + BOUNDARY)
3085		lines.append('Content-Disposition: form-data; name="%s"' % key)
3086		lines.append('')
3087		lines.append(value)
3088	for (key, filename, value) in files:
3089		typecheck(key, str)
3090		typecheck(filename, str)
3091		typecheck(value, str)
3092		lines.append('--' + BOUNDARY)
3093		lines.append('Content-Disposition: form-data; name="%s"; filename="%s"' % (key, filename))
3094		lines.append('Content-Type: %s' % GetContentType(filename))
3095		lines.append('')
3096		lines.append(value)
3097	lines.append('--' + BOUNDARY + '--')
3098	lines.append('')
3099	body = CRLF.join(lines)
3100	content_type = 'multipart/form-data; boundary=%s' % BOUNDARY
3101	return content_type, body
3102
3103
3104def GetContentType(filename):
3105	"""Helper to guess the content-type from the filename."""
3106	return mimetypes.guess_type(filename)[0] or 'application/octet-stream'
3107
3108
3109# Use a shell for subcommands on Windows to get a PATH search.
3110use_shell = sys.platform.startswith("win")
3111
3112def RunShellWithReturnCode(command, print_output=False,
3113		universal_newlines=True, env=os.environ):
3114	"""Executes a command and returns the output from stdout and the return code.
3115
3116	Args:
3117		command: Command to execute.
3118		print_output: If True, the output is printed to stdout.
3119			If False, both stdout and stderr are ignored.
3120		universal_newlines: Use universal_newlines flag (default: True).
3121
3122	Returns:
3123		Tuple (output, return code)
3124	"""
3125	logging.info("Running %s", command)
3126	p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
3127		shell=use_shell, universal_newlines=universal_newlines, env=env)
3128	if print_output:
3129		output_array = []
3130		while True:
3131			line = p.stdout.readline()
3132			if not line:
3133				break
3134			print line.strip("\n")
3135			output_array.append(line)
3136		output = "".join(output_array)
3137	else:
3138		output = p.stdout.read()
3139	p.wait()
3140	errout = p.stderr.read()
3141	if print_output and errout:
3142		print >>sys.stderr, errout
3143	p.stdout.close()
3144	p.stderr.close()
3145	return output, p.returncode
3146
3147
3148def RunShell(command, silent_ok=False, universal_newlines=True,
3149		print_output=False, env=os.environ):
3150	data, retcode = RunShellWithReturnCode(command, print_output, universal_newlines, env)
3151	if retcode:
3152		ErrorExit("Got error status from %s:\n%s" % (command, data))
3153	if not silent_ok and not data:
3154		ErrorExit("No output from %s" % command)
3155	return data
3156
3157
3158class VersionControlSystem(object):
3159	"""Abstract base class providing an interface to the VCS."""
3160
3161	def __init__(self, options):
3162		"""Constructor.
3163
3164		Args:
3165			options: Command line options.
3166		"""
3167		self.options = options
3168
3169	def GenerateDiff(self, args):
3170		"""Return the current diff as a string.
3171
3172		Args:
3173			args: Extra arguments to pass to the diff command.
3174		"""
3175		raise NotImplementedError(
3176				"abstract method -- subclass %s must override" % self.__class__)
3177
3178	def GetUnknownFiles(self):
3179		"""Return a list of files unknown to the VCS."""
3180		raise NotImplementedError(
3181				"abstract method -- subclass %s must override" % self.__class__)
3182
3183	def CheckForUnknownFiles(self):
3184		"""Show an "are you sure?" prompt if there are unknown files."""
3185		unknown_files = self.GetUnknownFiles()
3186		if unknown_files:
3187			print "The following files are not added to version control:"
3188			for line in unknown_files:
3189				print line
3190			prompt = "Are you sure to continue?(y/N) "
3191			answer = raw_input(prompt).strip()
3192			if answer != "y":
3193				ErrorExit("User aborted")
3194
3195	def GetBaseFile(self, filename):
3196		"""Get the content of the upstream version of a file.
3197
3198		Returns:
3199			A tuple (base_content, new_content, is_binary, status)
3200				base_content: The contents of the base file.
3201				new_content: For text files, this is empty.  For binary files, this is
3202					the contents of the new file, since the diff output won't contain
3203					information to reconstruct the current file.
3204				is_binary: True iff the file is binary.
3205				status: The status of the file.
3206		"""
3207
3208		raise NotImplementedError(
3209				"abstract method -- subclass %s must override" % self.__class__)
3210
3211
3212	def GetBaseFiles(self, diff):
3213		"""Helper that calls GetBase file for each file in the patch.
3214
3215		Returns:
3216			A dictionary that maps from filename to GetBaseFile's tuple.  Filenames
3217			are retrieved based on lines that start with "Index:" or
3218			"Property changes on:".
3219		"""
3220		files = {}
3221		for line in diff.splitlines(True):
3222			if line.startswith('Index:') or line.startswith('Property changes on:'):
3223				unused, filename = line.split(':', 1)
3224				# On Windows if a file has property changes its filename uses '\'
3225				# instead of '/'.
3226				filename = to_slash(filename.strip())
3227				files[filename] = self.GetBaseFile(filename)
3228		return files
3229
3230
3231	def UploadBaseFiles(self, issue, rpc_server, patch_list, patchset, options,
3232											files):
3233		"""Uploads the base files (and if necessary, the current ones as well)."""
3234
3235		def UploadFile(filename, file_id, content, is_binary, status, is_base):
3236			"""Uploads a file to the server."""
3237			set_status("uploading " + filename)
3238			file_too_large = False
3239			if is_base:
3240				type = "base"
3241			else:
3242				type = "current"
3243			if len(content) > MAX_UPLOAD_SIZE:
3244				print ("Not uploading the %s file for %s because it's too large." %
3245							(type, filename))
3246				file_too_large = True
3247				content = ""
3248			checksum = md5(content).hexdigest()
3249			if options.verbose > 0 and not file_too_large:
3250				print "Uploading %s file for %s" % (type, filename)
3251			url = "/%d/upload_content/%d/%d" % (int(issue), int(patchset), file_id)
3252			form_fields = [
3253				("filename", filename),
3254				("status", status),
3255				("checksum", checksum),
3256				("is_binary", str(is_binary)),
3257				("is_current", str(not is_base)),
3258			]
3259			if file_too_large:
3260				form_fields.append(("file_too_large", "1"))
3261			if options.email:
3262				form_fields.append(("user", options.email))
3263			ctype, body = EncodeMultipartFormData(form_fields, [("data", filename, content)])
3264			response_body = rpc_server.Send(url, body, content_type=ctype)
3265			if not response_body.startswith("OK"):
3266				StatusUpdate("  --> %s" % response_body)
3267				sys.exit(1)
3268
3269		# Don't want to spawn too many threads, nor do we want to
3270		# hit Rietveld too hard, or it will start serving 500 errors.
3271		# When 8 works, it's no better than 4, and sometimes 8 is
3272		# too many for Rietveld to handle.
3273		MAX_PARALLEL_UPLOADS = 4
3274
3275		sema = threading.BoundedSemaphore(MAX_PARALLEL_UPLOADS)
3276		upload_threads = []
3277		finished_upload_threads = []
3278
3279		class UploadFileThread(threading.Thread):
3280			def __init__(self, args):
3281				threading.Thread.__init__(self)
3282				self.args = args
3283			def run(self):
3284				UploadFile(*self.args)
3285				finished_upload_threads.append(self)
3286				sema.release()
3287
3288		def StartUploadFile(*args):
3289			sema.acquire()
3290			while len(finished_upload_threads) > 0:
3291				t = finished_upload_threads.pop()
3292				upload_threads.remove(t)
3293				t.join()
3294			t = UploadFileThread(args)
3295			upload_threads.append(t)
3296			t.start()
3297
3298		def WaitForUploads():
3299			for t in upload_threads:
3300				t.join()
3301
3302		patches = dict()
3303		[patches.setdefault(v, k) for k, v in patch_list]
3304		for filename in patches.keys():
3305			base_content, new_content, is_binary, status = files[filename]
3306			file_id_str = patches.get(filename)
3307			if file_id_str.find("nobase") != -1:
3308				base_content = None
3309				file_id_str = file_id_str[file_id_str.rfind("_") + 1:]
3310			file_id = int(file_id_str)
3311			if base_content != None:
3312				StartUploadFile(filename, file_id, base_content, is_binary, status, True)
3313			if new_content != None:
3314				StartUploadFile(filename, file_id, new_content, is_binary, status, False)
3315		WaitForUploads()
3316
3317	def IsImage(self, filename):
3318		"""Returns true if the filename has an image extension."""
3319		mimetype =  mimetypes.guess_type(filename)[0]
3320		if not mimetype:
3321			return False
3322		return mimetype.startswith("image/")
3323
3324	def IsBinary(self, filename):
3325		"""Returns true if the guessed mimetyped isnt't in text group."""
3326		mimetype = mimetypes.guess_type(filename)[0]
3327		if not mimetype:
3328			return False  # e.g. README, "real" binaries usually have an extension
3329		# special case for text files which don't start with text/
3330		if mimetype in TEXT_MIMETYPES:
3331			return False
3332		return not mimetype.startswith("text/")
3333
3334
3335class FakeMercurialUI(object):
3336	def __init__(self):
3337		self.quiet = True
3338		self.output = ''
3339
3340	def write(self, *args, **opts):
3341		self.output += ' '.join(args)
3342	def copy(self):
3343		return self
3344	def status(self, *args, **opts):
3345		pass
3346
3347	def formatter(self, topic, opts):
3348		from mercurial.formatter import plainformatter
3349		return plainformatter(self, topic, opts)
3350
3351	def readconfig(self, *args, **opts):
3352		pass
3353	def expandpath(self, *args, **opts):
3354		return global_ui.expandpath(*args, **opts)
3355	def configitems(self, *args, **opts):
3356		return global_ui.configitems(*args, **opts)
3357	def config(self, *args, **opts):
3358		return global_ui.config(*args, **opts)
3359
3360use_hg_shell = False	# set to True to shell out to hg always; slower
3361
3362class MercurialVCS(VersionControlSystem):
3363	"""Implementation of the VersionControlSystem interface for Mercurial."""
3364
3365	def __init__(self, options, ui, repo):
3366		super(MercurialVCS, self).__init__(options)
3367		self.ui = ui
3368		self.repo = repo
3369		self.status = None
3370		# Absolute path to repository (we can be in a subdir)
3371		self.repo_dir = os.path.normpath(repo.root)
3372		# Compute the subdir
3373		cwd = os.path.normpath(os.getcwd())
3374		assert cwd.startswith(self.repo_dir)
3375		self.subdir = cwd[len(self.repo_dir):].lstrip(r"\/")
3376		if self.options.revision:
3377			self.base_rev = self.options.revision
3378		else:
3379			mqparent, err = RunShellWithReturnCode(['hg', 'log', '--rev', 'qparent', '--template={node}'])
3380			if not err and mqparent != "":
3381				self.base_rev = mqparent
3382			else:
3383				out = RunShell(["hg", "parents", "-q"], silent_ok=True).strip()
3384				if not out:
3385					# No revisions; use 0 to mean a repository with nothing.
3386					out = "0:0"
3387				self.base_rev = out.split(':')[1].strip()
3388	def _GetRelPath(self, filename):
3389		"""Get relative path of a file according to the current directory,
3390		given its logical path in the repo."""
3391		assert filename.startswith(self.subdir), (filename, self.subdir)
3392		return filename[len(self.subdir):].lstrip(r"\/")
3393
3394	def GenerateDiff(self, extra_args):
3395		# If no file specified, restrict to the current subdir
3396		extra_args = extra_args or ["."]
3397		cmd = ["hg", "diff", "--git", "-r", self.base_rev] + extra_args
3398		data = RunShell(cmd, silent_ok=True)
3399		svndiff = []
3400		filecount = 0
3401		for line in data.splitlines():
3402			m = re.match("diff --git a/(\S+) b/(\S+)", line)
3403			if m:
3404				# Modify line to make it look like as it comes from svn diff.
3405				# With this modification no changes on the server side are required
3406				# to make upload.py work with Mercurial repos.
3407				# NOTE: for proper handling of moved/copied files, we have to use
3408				# the second filename.
3409				filename = m.group(2)
3410				svndiff.append("Index: %s" % filename)
3411				svndiff.append("=" * 67)
3412				filecount += 1
3413				logging.info(line)
3414			else:
3415				svndiff.append(line)
3416		if not filecount:
3417			ErrorExit("No valid patches found in output from hg diff")
3418		return "\n".join(svndiff) + "\n"
3419
3420	def GetUnknownFiles(self):
3421		"""Return a list of files unknown to the VCS."""
3422		args = []
3423		status = RunShell(["hg", "status", "--rev", self.base_rev, "-u", "."],
3424				silent_ok=True)
3425		unknown_files = []
3426		for line in status.splitlines():
3427			st, fn = line.split(" ", 1)
3428			if st == "?":
3429				unknown_files.append(fn)
3430		return unknown_files
3431
3432	def get_hg_status(self, rev, path):
3433		# We'd like to use 'hg status -C path', but that is buggy
3434		# (see http://mercurial.selenic.com/bts/issue3023).
3435		# Instead, run 'hg status -C' without a path
3436		# and skim the output for the path we want.
3437		if self.status is None:
3438			if use_hg_shell:
3439				out = RunShell(["hg", "status", "-C", "--rev", rev])
3440			else:
3441				fui = FakeMercurialUI()
3442				ret = hg_commands.status(fui, self.repo, *[], **{'rev': [rev], 'copies': True})
3443				if ret:
3444					raise hg_util.Abort(ret)
3445				out = fui.output
3446			self.status = out.splitlines()
3447		for i in range(len(self.status)):
3448			# line is
3449			#	A path
3450			#	M path
3451			# etc
3452			line = to_slash(self.status[i])
3453			if line[2:] == path:
3454				if i+1 < len(self.status) and self.status[i+1][:2] == '  ':
3455					return self.status[i:i+2]
3456				return self.status[i:i+1]
3457		raise hg_util.Abort("no status for " + path)
3458
3459	def GetBaseFile(self, filename):
3460		set_status("inspecting " + filename)
3461		# "hg status" and "hg cat" both take a path relative to the current subdir
3462		# rather than to the repo root, but "hg diff" has given us the full path
3463		# to the repo root.
3464		base_content = ""
3465		new_content = None
3466		is_binary = False
3467		oldrelpath = relpath = self._GetRelPath(filename)
3468		out = self.get_hg_status(self.base_rev, relpath)
3469		status, what = out[0].split(' ', 1)
3470		if len(out) > 1 and status == "A" and what == relpath:
3471			oldrelpath = out[1].strip()
3472			status = "M"
3473		if ":" in self.base_rev:
3474			base_rev = self.base_rev.split(":", 1)[0]
3475		else:
3476			base_rev = self.base_rev
3477		if status != "A":
3478			if use_hg_shell:
3479				base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath], silent_ok=True)
3480			else:
3481				base_content = str(self.repo[base_rev][oldrelpath].data())
3482			is_binary = "\0" in base_content  # Mercurial's heuristic
3483		if status != "R":
3484			new_content = open(relpath, "rb").read()
3485			is_binary = is_binary or "\0" in new_content
3486		if is_binary and base_content and use_hg_shell:
3487			# Fetch again without converting newlines
3488			base_content = RunShell(["hg", "cat", "-r", base_rev, oldrelpath],
3489				silent_ok=True, universal_newlines=False)
3490		if not is_binary or not self.IsImage(relpath):
3491			new_content = None
3492		return base_content, new_content, is_binary, status
3493
3494
3495# NOTE: The SplitPatch function is duplicated in engine.py, keep them in sync.
3496def SplitPatch(data):
3497	"""Splits a patch into separate pieces for each file.
3498
3499	Args:
3500		data: A string containing the output of svn diff.
3501
3502	Returns:
3503		A list of 2-tuple (filename, text) where text is the svn diff output
3504			pertaining to filename.
3505	"""
3506	patches = []
3507	filename = None
3508	diff = []
3509	for line in data.splitlines(True):
3510		new_filename = None
3511		if line.startswith('Index:'):
3512			unused, new_filename = line.split(':', 1)
3513			new_filename = new_filename.strip()
3514		elif line.startswith('Property changes on:'):
3515			unused, temp_filename = line.split(':', 1)
3516			# When a file is modified, paths use '/' between directories, however
3517			# when a property is modified '\' is used on Windows.  Make them the same
3518			# otherwise the file shows up twice.
3519			temp_filename = to_slash(temp_filename.strip())
3520			if temp_filename != filename:
3521				# File has property changes but no modifications, create a new diff.
3522				new_filename = temp_filename
3523		if new_filename:
3524			if filename and diff:
3525				patches.append((filename, ''.join(diff)))
3526			filename = new_filename
3527			diff = [line]
3528			continue
3529		if diff is not None:
3530			diff.append(line)
3531	if filename and diff:
3532		patches.append((filename, ''.join(diff)))
3533	return patches
3534
3535
3536def UploadSeparatePatches(issue, rpc_server, patchset, data, options):
3537	"""Uploads a separate patch for each file in the diff output.
3538
3539	Returns a list of [patch_key, filename] for each file.
3540	"""
3541	patches = SplitPatch(data)
3542	rv = []
3543	for patch in patches:
3544		set_status("uploading patch for " + patch[0])
3545		if len(patch[1]) > MAX_UPLOAD_SIZE:
3546			print ("Not uploading the patch for " + patch[0] +
3547				" because the file is too large.")
3548			continue
3549		form_fields = [("filename", patch[0])]
3550		if not options.download_base:
3551			form_fields.append(("content_upload", "1"))
3552		files = [("data", "data.diff", patch[1])]
3553		ctype, body = EncodeMultipartFormData(form_fields, files)
3554		url = "/%d/upload_patch/%d" % (int(issue), int(patchset))
3555		print "Uploading patch for " + patch[0]
3556		response_body = rpc_server.Send(url, body, content_type=ctype)
3557		lines = response_body.splitlines()
3558		if not lines or lines[0] != "OK":
3559			StatusUpdate("  --> %s" % response_body)
3560			sys.exit(1)
3561		rv.append([lines[1], patch[0]])
3562	return rv
3563