| #!/usr/bin/python |
| # Wrapper script around Rietveld's upload.py that groups files into |
| # changelists. |
| |
| import getpass |
| import linecache |
| import os |
| import random |
| import re |
| import string |
| import subprocess |
| import sys |
| import tempfile |
| import upload |
| import urllib2 |
| |
| CODEREVIEW_SETTINGS = { |
| # Default values. |
| "CODE_REVIEW_SERVER": "codereviews.googleopensourceprograms.com", |
| "CC_LIST": "melange-soc-dev@googlegroups.com", |
| "VIEW_VC": "http://code.google.com/p/soc/source/detail?r=", |
| } |
| |
| # Use a shell for subcommands on Windows to get a PATH search, and because svn |
| # may be a batch file. |
| use_shell = sys.platform.startswith("win") |
| |
| |
| # globals that store the root of the current repositary and the directory where |
| # we store information about changelists. |
| repository_root = "" |
| gcl_info_dir = "" |
| |
| |
| def GetSVNFileInfo(file, field): |
| """Returns a field from the svn info output for the given file.""" |
| output = RunShell(["svn", "info", file]) |
| for line in output.splitlines(): |
| search = field + ": " |
| if line.startswith(search): |
| return line[len(search):] |
| return "" |
| |
| |
| def GetRepositoryRoot(): |
| """Returns the top level directory of the current repository.""" |
| global repository_root |
| if not repository_root: |
| cur_dir_repo_root = GetSVNFileInfo(os.getcwd(), "Repository Root") |
| if not cur_dir_repo_root: |
| ErrorExit("gcl run outside of repository") |
| |
| repository_root = os.getcwd() |
| while True: |
| parent = os.path.dirname(repository_root) |
| if GetSVNFileInfo(parent, "Repository Root") != cur_dir_repo_root: |
| break |
| repository_root = parent |
| # Now read the code review settings for this repository. |
| settings_file = os.path.join(repository_root, "codereview.settings") |
| if os.path.exists(settings_file): |
| output = ReadFile(settings_file) |
| for line in output.splitlines(): |
| if not line or line.startswith("#"): |
| continue |
| key, value = line.split(": ", 1) |
| CODEREVIEW_SETTINGS[key] = value |
| return repository_root |
| |
| |
| def GetCodeReviewSetting(key): |
| """Returns a value for the given key for this repository.""" |
| return CODEREVIEW_SETTINGS.get(key, "") |
| |
| |
| def GetInfoDir(): |
| """Returns the directory where gcl info files are stored.""" |
| global gcl_info_dir |
| if not gcl_info_dir: |
| gcl_info_dir = os.path.join(GetRepositoryRoot(), '.svn', 'gcl_info') |
| return gcl_info_dir |
| |
| |
| def ErrorExit(msg): |
| """Print an error message to stderr and exit.""" |
| print >>sys.stderr, msg |
| sys.exit(1) |
| |
| |
| def RunShell(command, print_output=False): |
| """Executes a command and returns the output.""" |
| p = subprocess.Popen(command, stdout = subprocess.PIPE, |
| stderr = subprocess.STDOUT, shell = use_shell, |
| universal_newlines=True) |
| if print_output: |
| output_array = [] |
| while True: |
| line = p.stdout.readline() |
| if not line: |
| break |
| if print_output: |
| print line.strip('\n') |
| output_array.append(line) |
| output = "".join(output_array) |
| else: |
| output = p.stdout.read() |
| p.wait() |
| p.stdout.close() |
| return output |
| |
| |
| def ReadFile(filename): |
| """Returns the contents of a file.""" |
| file = open(filename, 'r') |
| result = file.read() |
| file.close() |
| return result |
| |
| |
| def WriteFile(filename, contents): |
| """Overwrites the file with the given contents.""" |
| file = open(filename, 'w') |
| file.write(contents) |
| file.close() |
| |
| |
| class ChangeInfo: |
| """Holds information about a changelist. |
| |
| issue: the Rietveld issue number, of "" if it hasn't been uploaded yet. |
| description: the description. |
| files: a list of 2 tuple containing (status, filename) of changed files, |
| with paths being relative to the top repository directory. |
| """ |
| def __init__(self, name="", issue="", description="", files=[]): |
| self.name = name |
| self.issue = issue |
| self.description = description |
| self.files = files |
| |
| def FileList(self): |
| """Returns a list of files.""" |
| return [file[1] for file in self.files] |
| |
| def Save(self): |
| """Writes the changelist information to disk.""" |
| data = SEPARATOR.join([self.issue, |
| "\n".join([f[0] + f[1] for f in self.files]), |
| self.description]) |
| WriteFile(GetChangelistInfoFile(self.name), data) |
| |
| def Delete(self): |
| """Removes the changelist information from disk.""" |
| os.remove(GetChangelistInfoFile(self.name)) |
| |
| def CloseIssue(self): |
| """Closes the Rietveld issue for this changelist.""" |
| data = [("description", self.description),] |
| ctype, body = upload.EncodeMultipartFormData(data, []) |
| SendToRietveld("/" + self.issue + "/close", body, ctype) |
| |
| def UpdateRietveldDescription(self): |
| """Sets the description for an issue on Rietveld.""" |
| data = [("description", self.description),] |
| ctype, body = upload.EncodeMultipartFormData(data, []) |
| SendToRietveld("/" + self.issue + "/description", body, ctype) |
| |
| |
| SEPARATOR = "\n-----\n" |
| # The info files have the following format: |
| # issue_id\n |
| # SEPARATOR\n |
| # filepath1\n |
| # filepath2\n |
| # . |
| # . |
| # filepathn\n |
| # SEPARATOR\n |
| # description |
| |
| |
| def GetChangelistInfoFile(changename): |
| """Returns the file that stores information about a changelist.""" |
| if not changename or re.search(r'\W', changename): |
| ErrorExit("Invalid changelist name: " + changename) |
| return os.path.join(GetInfoDir(), changename) |
| |
| |
| def LoadChangelistInfo(changename, fail_on_not_found=True, |
| update_status=False): |
| """Gets information about a changelist. |
| |
| Args: |
| fail_on_not_found: if True, this function will quit the program if the |
| changelist doesn't exist. |
| update_status: if True, the svn status will be updated for all the files |
| and unchanged files will be removed. |
| |
| Returns: a ChangeInfo object. |
| """ |
| info_file = GetChangelistInfoFile(changename) |
| if not os.path.exists(info_file): |
| if fail_on_not_found: |
| ErrorExit("Changelist " + changename + " not found.") |
| return ChangeInfo(changename) |
| data = ReadFile(info_file) |
| split_data = data.split(SEPARATOR, 2) |
| if len(split_data) != 3: |
| os.remove(info_file) |
| ErrorExit("Changelist file %s was corrupt and deleted" % info_file) |
| issue = split_data[0] |
| files = [] |
| for line in split_data[1].splitlines(): |
| status = line[:7] |
| file = line[7:] |
| files.append((status, file)) |
| description = split_data[2] |
| save = False |
| if update_status: |
| for file in files: |
| filename = os.path.join(GetRepositoryRoot(), file[1]) |
| status = RunShell(["svn", "status", filename])[:7] |
| if not status: # File has been reverted. |
| save = True |
| files.remove(file) |
| elif status != file[0]: |
| save = True |
| files[files.index(file)] = (status, file[1]) |
| change_info = ChangeInfo(changename, issue, description, files) |
| if save: |
| change_info.Save() |
| return change_info |
| |
| |
| def GetCLs(): |
| """Returns a list of all the changelists in this repository.""" |
| return os.listdir(GetInfoDir()) |
| |
| |
| def GenerateChangeName(): |
| """Generate a random changelist name.""" |
| random.seed() |
| current_cl_names = GetCLs() |
| while True: |
| cl_name = (random.choice(string.ascii_lowercase) + |
| random.choice(string.digits) + |
| random.choice(string.ascii_lowercase) + |
| random.choice(string.digits)) |
| if cl_name not in current_cl_names: |
| return cl_name |
| |
| |
| def GetModifiedFiles(): |
| """Returns a set that maps from changelist name to (status,filename) tuples. |
| |
| Files not in a changelist have an empty changelist name. Filenames are in |
| relation to the top level directory of the current repositary. Note that |
| only the current directory and subdirectories are scanned, in order to |
| improve performance while still being flexible. |
| """ |
| files = {} |
| |
| # Since the files are normalized to the root folder of the repositary, figure |
| # out what we need to add to the paths. |
| dir_prefix = os.getcwd()[len(GetRepositoryRoot()):].strip(os.sep) |
| |
| # Get a list of all files in changelists. |
| files_in_cl = {} |
| for cl in GetCLs(): |
| change_info = LoadChangelistInfo(cl) |
| for status, filename in change_info.files: |
| files_in_cl[filename] = change_info.name |
| |
| # Get all the modified files. |
| status = RunShell(["svn", "status"]) |
| for line in status.splitlines(): |
| if not len(line) or line[0] == "?": |
| continue |
| status = line[:7] |
| filename = line[7:] |
| if dir_prefix: |
| filename = os.path.join(dir_prefix, filename) |
| change_list_name = "" |
| if filename in files_in_cl: |
| change_list_name = files_in_cl[filename] |
| files.setdefault(change_list_name, []).append((status, filename)) |
| |
| return files |
| |
| |
| def GetFilesNotInCL(): |
| """Returns a list of tuples (status,filename) that aren't in any changelists. |
| |
| See docstring of GetModifiedFiles for information about path of files and |
| which directories are scanned. |
| """ |
| modified_files = GetModifiedFiles() |
| if "" not in modified_files: |
| return [] |
| return modified_files[""] |
| |
| |
| def SendToRietveld(request_path, payload=None, |
| content_type="application/octet-stream"): |
| """Send a POST/GET to Rietveld. Returns the response body.""" |
| def GetUserCredentials(): |
| """Prompts the user for a username and password.""" |
| email = raw_input("Email: ").strip() |
| password = getpass.getpass("Password for %s: " % email) |
| return email, password |
| |
| server = GetCodeReviewSetting("CODE_REVIEW_SERVER") |
| rpc_server = upload.HttpRpcServer(server, |
| GetUserCredentials, |
| host_override=server, |
| save_cookies=True) |
| return rpc_server.Send(request_path, payload, content_type) |
| |
| |
| def GetIssueDescription(issue): |
| """Returns the issue description from Rietveld.""" |
| return SendToRietveld("/" + issue + "/description") |
| |
| |
| def UnknownFiles(extra_args): |
| """Runs svn status and prints unknown files. |
| |
| Any args in |extra_args| are passed to the tool to support giving alternate |
| code locations. |
| """ |
| args = ["svn", "status"] |
| args += extra_args |
| p = subprocess.Popen(args, stdout = subprocess.PIPE, |
| stderr = subprocess.STDOUT, shell = use_shell) |
| while 1: |
| line = p.stdout.readline() |
| if not line: |
| break |
| if line[0] != '?': |
| continue # Not an unknown file to svn. |
| # The lines look like this: |
| # "? foo.txt" |
| # and we want just "foo.txt" |
| print line[7:].strip() |
| p.wait() |
| p.stdout.close() |
| |
| |
| def Opened(): |
| """Prints a list of modified files in the current directory down.""" |
| files = GetModifiedFiles() |
| cl_keys = files.keys() |
| cl_keys.sort() |
| for cl_name in cl_keys: |
| if cl_name: |
| note = "" |
| if len(LoadChangelistInfo(cl_name).files) != len(files[cl_name]): |
| note = " (Note: this changelist contains files outside this directory)" |
| print "\n--- Changelist " + cl_name + note + ":" |
| for file in files[cl_name]: |
| print "".join(file) |
| |
| |
| def Help(): |
| print ("GCL is a wrapper for Subversion that simplifies working with groups " |
| "of files.\n") |
| print "Basic commands:" |
| print "-----------------------------------------" |
| print " gcl change change_name" |
| print (" Add/remove files to a changelist. Only scans the current " |
| "directory and subdirectories.\n") |
| print (" gcl upload change_name [-r reviewer1@gmail.com," |
| "reviewer2@gmail.com,...] [--send_mail]") |
| print " Uploads the changelist to the server for review.\n" |
| print " gcl commit change_name" |
| print " Commits the changelist to the repository.\n" |
| print "Advanced commands:" |
| print "-----------------------------------------" |
| print " gcl delete change_name" |
| print " Deletes a changelist.\n" |
| print " gcl diff change_name" |
| print " Diffs all files in the changelist.\n" |
| print " gcl diff" |
| print (" Diffs all files in the current directory and subdirectories " |
| "that aren't in a changelist.\n") |
| print " gcl changes" |
| print " Lists all the the changelists and the files in them.\n" |
| print " gcl nothave [optional directory]" |
| print " Lists files unknown to Subversion.\n" |
| print " gcl opened" |
| print (" Lists modified files in the current directory and " |
| "subdirectories.\n") |
| print " gcl try change_name" |
| print (" Sends the change to the tryserver so a trybot can do a test" |
| " run on your code.\n") |
| |
| |
| def GetEditor(): |
| editor = os.environ.get("SVN_EDITOR") |
| if not editor: |
| editor = os.environ.get("EDITOR") |
| |
| if not editor: |
| if sys.platform.startswith("win"): |
| editor = "notepad" |
| else: |
| editor = "vi" |
| |
| return editor |
| |
| |
| def GenerateDiff(files): |
| """Returns a string containing the diff for the given file list.""" |
| diff = [] |
| for file in files: |
| # Use svn info output instead of os.path.isdir because the latter fails |
| # when the file is deleted. |
| if GetSVNFileInfo(file, "Node Kind") == "directory": |
| continue |
| # If the user specified a custom diff command in their svn config file, |
| # then it'll be used when we do svn diff, which we don't want to happen |
| # since we want the unified diff. Using --diff-cmd=diff doesn't always |
| # work, since they can have another diff executable in their path that |
| # gives different line endings. So we use a bogus temp directory as the |
| # config directory, which gets around these problems. |
| if sys.platform.startswith("win"): |
| parent_dir = tempfile.gettempdir() |
| else: |
| parent_dir = sys.path[0] # tempdir is not secure. |
| bogus_dir = os.path.join(parent_dir, "temp_svn_config") |
| if not os.path.exists(bogus_dir): |
| os.mkdir(bogus_dir) |
| diff.append(RunShell(["svn", "diff", "--config-dir", bogus_dir, file])) |
| return "".join(diff) |
| |
| |
| def UploadCL(change_info, args): |
| if not change_info.FileList(): |
| print "Nothing to upload, changelist is empty." |
| return |
| |
| upload_arg = ["upload.py", "-y", "-l"] |
| upload_arg.append("--server=" + GetCodeReviewSetting("CODE_REVIEW_SERVER")) |
| upload_arg.extend(args) |
| |
| desc_file = "" |
| if change_info.issue: # Uploading a new patchset. |
| upload_arg.append("--message=''") |
| upload_arg.append("--issue=" + change_info.issue) |
| else: # First time we upload. |
| handle, desc_file = tempfile.mkstemp(text=True) |
| os.write(handle, change_info.description) |
| os.close(handle) |
| |
| upload_arg.append("--cc=" + GetCodeReviewSetting("CC_LIST")) |
| upload_arg.append("--description_file=" + desc_file + "") |
| if change_info.description: |
| subject = change_info.description[:77] |
| if subject.find("\r\n") != -1: |
| subject = subject[:subject.find("\r\n")] |
| if subject.find("\n") != -1: |
| subject = subject[:subject.find("\n")] |
| if len(change_info.description) > 77: |
| subject = subject + "..." |
| upload_arg.append("--message=" + subject) |
| |
| # Change the current working directory before calling upload.py so that it |
| # shows the correct base. |
| os.chdir(GetRepositoryRoot()) |
| |
| # If we have a lot of files with long paths, then we won't be able to fit |
| # the command to "svn diff". Instead, we generate the diff manually for |
| # each file and concatenate them before passing it to upload.py. |
| issue = upload.RealMain(upload_arg, GenerateDiff(change_info.FileList())) |
| if issue and issue != change_info.issue: |
| change_info.issue = issue |
| change_info.Save() |
| |
| if desc_file: |
| os.remove(desc_file) |
| |
| |
| def TryChange(change_info, args): |
| """Create a diff file of change_info and send it to the try server.""" |
| try: |
| import trychange |
| except ImportError: |
| ErrorExit("You need to install trychange.py to use the try server.") |
| |
| trychange.TryChange(args, change_info.name, change_info.FileList()) |
| |
| |
| def Commit(change_info): |
| if not change_info.FileList(): |
| print "Nothing to commit, changelist is empty." |
| return |
| |
| commit_cmd = ["svn", "commit"] |
| filename = '' |
| if change_info.issue: |
| # Get the latest description from Rietveld. |
| change_info.description = GetIssueDescription(change_info.issue) |
| |
| commit_message = change_info.description.replace('\r\n', '\n') |
| if change_info.issue: |
| commit_message += ('\nReview URL: http://%s/%s' % |
| (GetCodeReviewSetting("CODE_REVIEW_SERVER"), |
| change_info.issue)) |
| |
| handle, commit_filename = tempfile.mkstemp(text=True) |
| os.write(handle, commit_message) |
| os.close(handle) |
| |
| handle, targets_filename = tempfile.mkstemp(text=True) |
| os.write(handle, "\n".join(change_info.FileList())) |
| os.close(handle) |
| |
| commit_cmd += ['--file=' + commit_filename] |
| commit_cmd += ['--targets=' + targets_filename] |
| # Change the current working directory before calling commit. |
| os.chdir(GetRepositoryRoot()) |
| output = RunShell(commit_cmd, True) |
| os.remove(commit_filename) |
| os.remove(targets_filename) |
| if output.find("Committed revision") != -1: |
| change_info.Delete() |
| |
| if change_info.issue: |
| revision = re.compile(".*?\nCommitted revision (\d+)", |
| re.DOTALL).match(output).group(1) |
| viewvc_url = GetCodeReviewSetting("VIEW_VC") |
| change_info.description = (change_info.description + |
| "\n\nCommitted: " + viewvc_url + revision) |
| change_info.CloseIssue() |
| |
| |
| def Change(change_info): |
| """Creates/edits a changelist.""" |
| if change_info.issue: |
| try: |
| description = GetIssueDescription(change_info.issue) |
| except urllib2.HTTPError, err: |
| if err.code == 404: |
| # The user deleted the issue in Rietveld, so forget the old issue id. |
| description = change_info.description |
| change_info.issue = "" |
| change_info.Save() |
| else: |
| ErrorExit("Error getting the description from Rietveld: " + err) |
| else: |
| description = change_info.description |
| |
| other_files = GetFilesNotInCL() |
| |
| separator1 = ("\n---All lines above this line become the description.\n" |
| "---Repository Root: " + GetRepositoryRoot() + "\n" |
| "---Paths in this changelist (" + change_info.name + "):\n") |
| separator2 = "\n\n---Paths modified but not in any changelist:\n\n" |
| text = (description + separator1 + '\n' + |
| '\n'.join([f[0] + f[1] for f in change_info.files]) + separator2 + |
| '\n'.join([f[0] + f[1] for f in other_files]) + '\n') |
| |
| handle, filename = tempfile.mkstemp(text=True) |
| os.write(handle, text) |
| os.close(handle) |
| |
| command = GetEditor() + " " + filename |
| os.system(command) |
| |
| result = ReadFile(filename) |
| os.remove(filename) |
| |
| if not result: |
| return |
| |
| split_result = result.split(separator1, 1) |
| if len(split_result) != 2: |
| ErrorExit("Don't modify the text starting with ---!\n\n" + result) |
| |
| new_description = split_result[0] |
| cl_files_text = split_result[1] |
| if new_description != description: |
| change_info.description = new_description |
| if change_info.issue: |
| # Update the Rietveld issue with the new description. |
| change_info.UpdateRietveldDescription() |
| |
| new_cl_files = [] |
| for line in cl_files_text.splitlines(): |
| if not len(line): |
| continue |
| if line.startswith("---"): |
| break |
| status = line[:7] |
| file = line[7:] |
| new_cl_files.append((status, file)) |
| change_info.files = new_cl_files |
| |
| change_info.Save() |
| print change_info.name + " changelist saved." |
| |
| |
| def Changes(): |
| """Print all the changlists and their files.""" |
| for cl in GetCLs(): |
| change_info = LoadChangelistInfo(cl, True, True) |
| print "\n--- Changelist " + change_info.name + ":" |
| for file in change_info.files: |
| print "".join(file) |
| |
| |
| def main(argv=None): |
| if argv is None: |
| argv = sys.argv |
| |
| if len(argv) == 1: |
| Help() |
| return 0; |
| |
| # Create the directory where we store information about changelists if it |
| # doesn't exist. |
| if not os.path.exists(GetInfoDir()): |
| os.mkdir(GetInfoDir()) |
| |
| command = argv[1] |
| if command == "opened": |
| Opened() |
| return 0 |
| if command == "nothave": |
| UnknownFiles(argv[2:]) |
| return 0 |
| if command == "changes": |
| Changes() |
| return 0 |
| if command == "diff" and len(argv) == 2: |
| files = GetFilesNotInCL() |
| print GenerateDiff([os.path.join(GetRepositoryRoot(), x[1]) for x in files]) |
| return 0 |
| |
| if len(argv) == 2: |
| if command == "change": |
| # Generate a random changelist name. |
| changename = GenerateChangeName() |
| elif command == "help": |
| Help() |
| return 0 |
| else: |
| ErrorExit("Need a changelist name.") |
| else: |
| changename = argv[2] |
| |
| fail_on_not_found = command != "change" |
| change_info = LoadChangelistInfo(changename, fail_on_not_found, True) |
| |
| if command == "change": |
| Change(change_info) |
| elif command == "upload": |
| UploadCL(change_info, argv[3:]) |
| elif command == "commit": |
| Commit(change_info) |
| elif command == "delete": |
| change_info.Delete() |
| elif command == "try": |
| TryChange(change_info, argv[3:]) |
| else: |
| # Everything else that is passed into gcl we redirect to svn, after adding |
| # the files. This allows commands such as 'gcl diff xxx' to work. |
| args =["svn", command] |
| root = GetRepositoryRoot() |
| args.extend([os.path.join(root, x) for x in change_info.FileList()]) |
| RunShell(args, True) |
| return 0 |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |