2 # Wrapper script around Rietveld's upload.py that groups files into
17 CODEREVIEW_SETTINGS
= {
19 "CODE_REVIEW_SERVER": "codereviews.googleopensourceprograms.com",
20 "CC_LIST": "melange-soc-dev@googlegroups.com",
21 "VIEW_VC": "http://code.google.com/p/soc/source/detail?r=",
24 # Use a shell for subcommands on Windows to get a PATH search, and because svn
25 # may be a batch file.
26 use_shell
= sys
.platform
.startswith("win")
29 # globals that store the root of the current repositary and the directory where
30 # we store information about changelists.
35 def GetSVNFileInfo(file, field
):
36 """Returns a field from the svn info output for the given file."""
37 output
= RunShell(["svn", "info", file])
38 for line
in output
.splitlines():
40 if line
.startswith(search
):
41 return line
[len(search
):]
45 def GetRepositoryRoot():
46 """Returns the top level directory of the current repository."""
47 global repository_root
48 if not repository_root
:
49 cur_dir_repo_root
= GetSVNFileInfo(os
.getcwd(), "Repository Root")
50 if not cur_dir_repo_root
:
51 ErrorExit("gcl run outside of repository")
53 repository_root
= os
.getcwd()
55 parent
= os
.path
.dirname(repository_root
)
56 if GetSVNFileInfo(parent
, "Repository Root") != cur_dir_repo_root
:
58 repository_root
= parent
59 # Now read the code review settings for this repository.
60 settings_file
= os
.path
.join(repository_root
, "codereview.settings")
61 if os
.path
.exists(settings_file
):
62 output
= ReadFile(settings_file
)
63 for line
in output
.splitlines():
64 if not line
or line
.startswith("#"):
66 key
, value
= line
.split(": ", 1)
67 CODEREVIEW_SETTINGS
[key
] = value
68 return repository_root
71 def GetCodeReviewSetting(key
):
72 """Returns a value for the given key for this repository."""
73 return CODEREVIEW_SETTINGS
.get(key
, "")
77 """Returns the directory where gcl info files are stored."""
80 gcl_info_dir
= os
.path
.join(GetRepositoryRoot(), '.svn', 'gcl_info')
85 """Print an error message to stderr and exit."""
86 print >>sys
.stderr
, msg
90 def RunShell(command
, print_output
=False):
91 """Executes a command and returns the output."""
92 p
= subprocess
.Popen(command
, stdout
= subprocess
.PIPE
,
93 stderr
= subprocess
.STDOUT
, shell
= use_shell
,
94 universal_newlines
=True)
98 line
= p
.stdout
.readline()
102 print line
.strip('\n')
103 output_array
.append(line
)
104 output
= "".join(output_array
)
106 output
= p
.stdout
.read()
112 def ReadFile(filename
):
113 """Returns the contents of a file."""
114 file = open(filename
, 'r')
120 def WriteFile(filename
, contents
):
121 """Overwrites the file with the given contents."""
122 file = open(filename
, 'w')
128 """Holds information about a changelist.
130 issue: the Rietveld issue number, of "" if it hasn't been uploaded yet.
131 description: the description.
132 files: a list of 2 tuple containing (status, filename) of changed files,
133 with paths being relative to the top repository directory.
135 def __init__(self
, name
="", issue
="", description
="", files
=[]):
138 self
.description
= description
142 """Returns a list of files."""
143 return [file[1] for file in self
.files
]
146 """Writes the changelist information to disk."""
147 data
= SEPARATOR
.join([self
.issue
,
148 "\n".join([f
[0] + f
[1] for f
in self
.files
]),
150 WriteFile(GetChangelistInfoFile(self
.name
), data
)
153 """Removes the changelist information from disk."""
154 os
.remove(GetChangelistInfoFile(self
.name
))
156 def CloseIssue(self
):
157 """Closes the Rietveld issue for this changelist."""
158 data
= [("description", self
.description
),]
159 ctype
, body
= upload
.EncodeMultipartFormData(data
, [])
160 SendToRietveld("/" + self
.issue
+ "/close", body
, ctype
)
162 def UpdateRietveldDescription(self
):
163 """Sets the description for an issue on Rietveld."""
164 data
= [("description", self
.description
),]
165 ctype
, body
= upload
.EncodeMultipartFormData(data
, [])
166 SendToRietveld("/" + self
.issue
+ "/description", body
, ctype
)
169 SEPARATOR
= "\n-----\n"
170 # The info files have the following format:
182 def GetChangelistInfoFile(changename
):
183 """Returns the file that stores information about a changelist."""
184 if not changename
or re
.search(r
'\W', changename
):
185 ErrorExit("Invalid changelist name: " + changename
)
186 return os
.path
.join(GetInfoDir(), changename
)
189 def LoadChangelistInfo(changename
, fail_on_not_found
=True,
190 update_status
=False):
191 """Gets information about a changelist.
194 fail_on_not_found: if True, this function will quit the program if the
195 changelist doesn't exist.
196 update_status: if True, the svn status will be updated for all the files
197 and unchanged files will be removed.
199 Returns: a ChangeInfo object.
201 info_file
= GetChangelistInfoFile(changename
)
202 if not os
.path
.exists(info_file
):
203 if fail_on_not_found
:
204 ErrorExit("Changelist " + changename
+ " not found.")
205 return ChangeInfo(changename
)
206 data
= ReadFile(info_file
)
207 split_data
= data
.split(SEPARATOR
, 2)
208 if len(split_data
) != 3:
210 ErrorExit("Changelist file %s was corrupt and deleted" % info_file
)
211 issue
= split_data
[0]
213 for line
in split_data
[1].splitlines():
216 files
.append((status
, file))
217 description
= split_data
[2]
221 filename
= os
.path
.join(GetRepositoryRoot(), file[1])
222 status
= RunShell(["svn", "status", filename
])[:7]
223 if not status
: # File has been reverted.
226 elif status
!= file[0]:
228 files
[files
.index(file)] = (status
, file[1])
229 change_info
= ChangeInfo(changename
, issue
, description
, files
)
236 """Returns a list of all the changelists in this repository."""
237 return os
.listdir(GetInfoDir())
240 def GenerateChangeName():
241 """Generate a random changelist name."""
243 current_cl_names
= GetCLs()
245 cl_name
= (random
.choice(string
.ascii_lowercase
) +
246 random
.choice(string
.digits
) +
247 random
.choice(string
.ascii_lowercase
) +
248 random
.choice(string
.digits
))
249 if cl_name
not in current_cl_names
:
253 def GetModifiedFiles():
254 """Returns a set that maps from changelist name to (status,filename) tuples.
256 Files not in a changelist have an empty changelist name. Filenames are in
257 relation to the top level directory of the current repositary. Note that
258 only the current directory and subdirectories are scanned, in order to
259 improve performance while still being flexible.
263 # Since the files are normalized to the root folder of the repositary, figure
264 # out what we need to add to the paths.
265 dir_prefix
= os
.getcwd()[len(GetRepositoryRoot()):].strip(os
.sep
)
267 # Get a list of all files in changelists.
270 change_info
= LoadChangelistInfo(cl
)
271 for status
, filename
in change_info
.files
:
272 files_in_cl
[filename
] = change_info
.name
274 # Get all the modified files.
275 status
= RunShell(["svn", "status"])
276 for line
in status
.splitlines():
277 if not len(line
) or line
[0] == "?":
282 filename
= os
.path
.join(dir_prefix
, filename
)
283 change_list_name
= ""
284 if filename
in files_in_cl
:
285 change_list_name
= files_in_cl
[filename
]
286 files
.setdefault(change_list_name
, []).append((status
, filename
))
291 def GetFilesNotInCL():
292 """Returns a list of tuples (status,filename) that aren't in any changelists.
294 See docstring of GetModifiedFiles for information about path of files and
295 which directories are scanned.
297 modified_files
= GetModifiedFiles()
298 if "" not in modified_files
:
300 return modified_files
[""]
303 def SendToRietveld(request_path
, payload
=None,
304 content_type
="application/octet-stream"):
305 """Send a POST/GET to Rietveld. Returns the response body."""
306 def GetUserCredentials():
307 """Prompts the user for a username and password."""
308 email
= raw_input("Email: ").strip()
309 password
= getpass
.getpass("Password for %s: " % email
)
310 return email
, password
312 server
= GetCodeReviewSetting("CODE_REVIEW_SERVER")
313 rpc_server
= upload
.HttpRpcServer(server
,
315 host_override
=server
,
317 return rpc_server
.Send(request_path
, payload
, content_type
)
320 def GetIssueDescription(issue
):
321 """Returns the issue description from Rietveld."""
322 return SendToRietveld("/" + issue
+ "/description")
325 def UnknownFiles(extra_args
):
326 """Runs svn status and prints unknown files.
328 Any args in |extra_args| are passed to the tool to support giving alternate
331 args
= ["svn", "status"]
333 p
= subprocess
.Popen(args
, stdout
= subprocess
.PIPE
,
334 stderr
= subprocess
.STDOUT
, shell
= use_shell
)
336 line
= p
.stdout
.readline()
340 continue # Not an unknown file to svn.
341 # The lines look like this:
343 # and we want just "foo.txt"
344 print line
[7:].strip()
350 """Prints a list of modified files in the current directory down."""
351 files
= GetModifiedFiles()
352 cl_keys
= files
.keys()
354 for cl_name
in cl_keys
:
357 if len(LoadChangelistInfo(cl_name
).files
) != len(files
[cl_name
]):
358 note
= " (Note: this changelist contains files outside this directory)"
359 print "\n--- Changelist " + cl_name
+ note
+ ":"
360 for file in files
[cl_name
]:
365 print ("GCL is a wrapper for Subversion that simplifies working with groups "
367 print "Basic commands:"
368 print "-----------------------------------------"
369 print " gcl change change_name"
370 print (" Add/remove files to a changelist. Only scans the current "
371 "directory and subdirectories.\n")
372 print (" gcl upload change_name [-r reviewer1@gmail.com,"
373 "reviewer2@gmail.com,...] [--send_mail]")
374 print " Uploads the changelist to the server for review.\n"
375 print " gcl commit change_name"
376 print " Commits the changelist to the repository.\n"
377 print "Advanced commands:"
378 print "-----------------------------------------"
379 print " gcl delete change_name"
380 print " Deletes a changelist.\n"
381 print " gcl diff change_name"
382 print " Diffs all files in the changelist.\n"
384 print (" Diffs all files in the current directory and subdirectories "
385 "that aren't in a changelist.\n")
387 print " Lists all the the changelists and the files in them.\n"
388 print " gcl nothave [optional directory]"
389 print " Lists files unknown to Subversion.\n"
391 print (" Lists modified files in the current directory and "
393 print " gcl try change_name"
394 print (" Sends the change to the tryserver so a trybot can do a test"
395 " run on your code.\n")
399 editor
= os
.environ
.get("SVN_EDITOR")
401 editor
= os
.environ
.get("EDITOR")
404 if sys
.platform
.startswith("win"):
412 def GenerateDiff(files
):
413 """Returns a string containing the diff for the given file list."""
416 # Use svn info output instead of os.path.isdir because the latter fails
417 # when the file is deleted.
418 if GetSVNFileInfo(file, "Node Kind") == "directory":
420 # If the user specified a custom diff command in their svn config file,
421 # then it'll be used when we do svn diff, which we don't want to happen
422 # since we want the unified diff. Using --diff-cmd=diff doesn't always
423 # work, since they can have another diff executable in their path that
424 # gives different line endings. So we use a bogus temp directory as the
425 # config directory, which gets around these problems.
426 if sys
.platform
.startswith("win"):
427 parent_dir
= tempfile
.gettempdir()
429 parent_dir
= sys
.path
[0] # tempdir is not secure.
430 bogus_dir
= os
.path
.join(parent_dir
, "temp_svn_config")
431 if not os
.path
.exists(bogus_dir
):
433 diff
.append(RunShell(["svn", "diff", "--config-dir", bogus_dir
, file]))
437 def UploadCL(change_info
, args
):
438 if not change_info
.FileList():
439 print "Nothing to upload, changelist is empty."
442 upload_arg
= ["upload.py", "-y", "-l"]
443 upload_arg
.append("--server=" + GetCodeReviewSetting("CODE_REVIEW_SERVER"))
444 upload_arg
.extend(args
)
447 if change_info
.issue
: # Uploading a new patchset.
448 upload_arg
.append("--message=''")
449 upload_arg
.append("--issue=" + change_info
.issue
)
450 else: # First time we upload.
451 handle
, desc_file
= tempfile
.mkstemp(text
=True)
452 os
.write(handle
, change_info
.description
)
455 upload_arg
.append("--cc=" + GetCodeReviewSetting("CC_LIST"))
456 upload_arg
.append("--description_file=" + desc_file
+ "")
457 if change_info
.description
:
458 subject
= change_info
.description
[:77]
459 if subject
.find("\r\n") != -1:
460 subject
= subject
[:subject
.find("\r\n")]
461 if subject
.find("\n") != -1:
462 subject
= subject
[:subject
.find("\n")]
463 if len(change_info
.description
) > 77:
464 subject
= subject
+ "..."
465 upload_arg
.append("--message=" + subject
)
467 # Change the current working directory before calling upload.py so that it
468 # shows the correct base.
469 os
.chdir(GetRepositoryRoot())
471 # If we have a lot of files with long paths, then we won't be able to fit
472 # the command to "svn diff". Instead, we generate the diff manually for
473 # each file and concatenate them before passing it to upload.py.
474 issue
= upload
.RealMain(upload_arg
, GenerateDiff(change_info
.FileList()))
475 if issue
and issue
!= change_info
.issue
:
476 change_info
.issue
= issue
483 def TryChange(change_info
, args
):
484 """Create a diff file of change_info and send it to the try server."""
488 ErrorExit("You need to install trychange.py to use the try server.")
490 trychange
.TryChange(args
, change_info
.name
, change_info
.FileList())
493 def Commit(change_info
):
494 if not change_info
.FileList():
495 print "Nothing to commit, changelist is empty."
498 commit_cmd
= ["svn", "commit"]
500 if change_info
.issue
:
501 # Get the latest description from Rietveld.
502 change_info
.description
= GetIssueDescription(change_info
.issue
)
504 commit_message
= change_info
.description
.replace('\r\n', '\n')
505 if change_info
.issue
:
506 commit_message
+= ('\nReview URL: http://%s/%s' %
507 (GetCodeReviewSetting("CODE_REVIEW_SERVER"),
510 handle
, commit_filename
= tempfile
.mkstemp(text
=True)
511 os
.write(handle
, commit_message
)
514 handle
, targets_filename
= tempfile
.mkstemp(text
=True)
515 os
.write(handle
, "\n".join(change_info
.FileList()))
518 commit_cmd
+= ['--file=' + commit_filename
]
519 commit_cmd
+= ['--targets=' + targets_filename
]
520 # Change the current working directory before calling commit.
521 os
.chdir(GetRepositoryRoot())
522 output
= RunShell(commit_cmd
, True)
523 os
.remove(commit_filename
)
524 os
.remove(targets_filename
)
525 if output
.find("Committed revision") != -1:
528 if change_info
.issue
:
529 revision
= re
.compile(".*?\nCommitted revision (\d+)",
530 re
.DOTALL
).match(output
).group(1)
531 viewvc_url
= GetCodeReviewSetting("VIEW_VC")
532 change_info
.description
= (change_info
.description
+
533 "\n\nCommitted: " + viewvc_url
+ revision
)
534 change_info
.CloseIssue()
537 def Change(change_info
):
538 """Creates/edits a changelist."""
539 if change_info
.issue
:
541 description
= GetIssueDescription(change_info
.issue
)
542 except urllib2
.HTTPError
, err
:
544 # The user deleted the issue in Rietveld, so forget the old issue id.
545 description
= change_info
.description
546 change_info
.issue
= ""
549 ErrorExit("Error getting the description from Rietveld: " + err
)
551 description
= change_info
.description
553 other_files
= GetFilesNotInCL()
555 separator1
= ("\n---All lines above this line become the description.\n"
556 "---Repository Root: " + GetRepositoryRoot() + "\n"
557 "---Paths in this changelist (" + change_info
.name
+ "):\n")
558 separator2
= "\n\n---Paths modified but not in any changelist:\n\n"
559 text
= (description
+ separator1
+ '\n' +
560 '\n'.join([f
[0] + f
[1] for f
in change_info
.files
]) + separator2
+
561 '\n'.join([f
[0] + f
[1] for f
in other_files
]) + '\n')
563 handle
, filename
= tempfile
.mkstemp(text
=True)
564 os
.write(handle
, text
)
567 command
= GetEditor() + " " + filename
570 result
= ReadFile(filename
)
576 split_result
= result
.split(separator1
, 1)
577 if len(split_result
) != 2:
578 ErrorExit("Don't modify the text starting with ---!\n\n" + result
)
580 new_description
= split_result
[0]
581 cl_files_text
= split_result
[1]
582 if new_description
!= description
:
583 change_info
.description
= new_description
584 if change_info
.issue
:
585 # Update the Rietveld issue with the new description.
586 change_info
.UpdateRietveldDescription()
589 for line
in cl_files_text
.splitlines():
592 if line
.startswith("---"):
596 new_cl_files
.append((status
, file))
597 change_info
.files
= new_cl_files
600 print change_info
.name
+ " changelist saved."
604 """Print all the changlists and their files."""
606 change_info
= LoadChangelistInfo(cl
, True, True)
607 print "\n--- Changelist " + change_info
.name
+ ":"
608 for file in change_info
.files
:
620 # Create the directory where we store information about changelists if it
622 if not os
.path
.exists(GetInfoDir()):
623 os
.mkdir(GetInfoDir())
626 if command
== "opened":
629 if command
== "nothave":
630 UnknownFiles(argv
[2:])
632 if command
== "changes":
635 if command
== "diff" and len(argv
) == 2:
636 files
= GetFilesNotInCL()
637 print GenerateDiff([os
.path
.join(GetRepositoryRoot(), x
[1]) for x
in files
])
641 if command
== "change":
642 # Generate a random changelist name.
643 changename
= GenerateChangeName()
644 elif command
== "help":
648 ErrorExit("Need a changelist name.")
652 fail_on_not_found
= command
!= "change"
653 change_info
= LoadChangelistInfo(changename
, fail_on_not_found
, True)
655 if command
== "change":
657 elif command
== "upload":
658 UploadCL(change_info
, argv
[3:])
659 elif command
== "commit":
661 elif command
== "delete":
663 elif command
== "try":
664 TryChange(change_info
, argv
[3:])
666 # Everything else that is passed into gcl we redirect to svn, after adding
667 # the files. This allows commands such as 'gcl diff xxx' to work.
668 args
=["svn", command
]
669 root
= GetRepositoryRoot()
670 args
.extend([os
.path
.join(root
, x
) for x
in change_info
.FileList()])
675 if __name__
== "__main__":